diff --git a/.claude/skills/app-coding-standards/SKILL.md b/.claude/skills/app-coding-standards/SKILL.md index 17ab10ef3c30..26c437f45caf 100644 --- a/.claude/skills/app-coding-standards/SKILL.md +++ b/.claude/skills/app-coding-standards/SKILL.md @@ -22,7 +22,7 @@ Coding standards for the Expensify App. Each standard is a standalone file in `r ### Performance - [PERF-1](rules/perf-1-no-spread-in-renderitem.md) — No spread in renderItem - [PERF-2](rules/perf-2-early-return.md) — Return early before expensive work -- [PERF-3](rules/perf-3-onyx-list-item-provider.md) — Use OnyxListItemProvider in renderItem +- [PERF-3](rules/perf-3-onyx-list-item-provider.md) — Shared data in list rows comes from OnyxListItemProvider - [PERF-5](rules/perf-5-shallow-comparison.md) — Shallow over deep comparisons - [PERF-6](rules/perf-6-derive-state-from-props.md) — Derive state from props - [PERF-7](rules/perf-7-reset-via-key-prop.md) — Reset via key prop @@ -38,6 +38,9 @@ Coding standards for the Expensify App. Each standard is a standalone file in `r - [PERF-17](rules/perf-17-pass-raw-index-on-demand.md) — Pass raw source, index on demand (no pre-built digest) - [PERF-18](rules/perf-18-use-pre-mount-destination.md) — Use usePreMountDestination for RHP pre-mounting - [PERF-19](rules/perf-19-scalar-collection-selectors.md) — Collection selectors return only what the consumer uses +- [PERF-20](rules/perf-20-no-ad-hoc-deferral.md) — Defer heavy work to happen after visual transitions +- [PERF-21](rules/perf-21-rows-read-own-data.md) — List rows read their own Onyx data +- [PERF-22](rules/perf-22-row-single-member-subscriptions.md) — List rows subscribe to single collection members ### Consistency - [CONSISTENCY-1](rules/consistency-1-no-platform-checks.md) — No platform-specific checks in components diff --git a/.claude/skills/app-coding-standards/rules/perf-11-optimize-data-selection.md b/.claude/skills/app-coding-standards/rules/perf-11-optimize-data-selection.md index 51f5817f0e33..6fdcbe071421 100644 --- a/.claude/skills/app-coding-standards/rules/perf-11-optimize-data-selection.md +++ b/.claude/skills/app-coding-standards/rules/perf-11-optimize-data-selection.md @@ -7,13 +7,15 @@ title: Optimize data selection and handling ### Reasoning -`useOnyx` supports an optional `selector` to narrow data before it reaches the component. Selectors control both **what** data the component receives and **how** Onyx detects changes: +`useOnyx` supports an optional `selector` to narrow data before it reaches the component. Onyx notifies subscribers per key: a write to a collection member notifies that member's subscribers and the collection's subscribers, and unchanged members keep their references. Selectors control both **what** data the component receives and **how** Onyx detects changes: - **With a selector**: Onyx runs `deepEqual` on the selector output to decide whether to re-render. This guards against unnecessary re-renders when unrelated data changes, but the comparison cost scales with the size of the output. -- **Without a selector**: Onyx uses `shallowEqual` on raw references, which is much cheaper. However, the component will re-render whenever **any** part of the subscribed data changes, since there is no narrowing to filter out irrelevant updates. +- **Without a selector**: Onyx compares by reference only, which is free. However, the component re-renders on **every** write to the key, even when the new value has the same contents. For a collection key, that means every write to any member. This means selectors are a double-edged sword. A well-written selector that returns a primitive or a small object is highly effective — it skips re-renders when unrelated data changes, and `deepEqual` on a small value is trivial. But a poorly-written selector that returns a large object, a full collection, or a non-plain type like `Set`/`Map` makes things worse — it forces an expensive `deepEqual` on every Onyx update with no re-render savings. +**Prefer a member key:** when the component needs one or a few members of a collection and knows their IDs, subscribe to `${ONYXKEYS.COLLECTION.X}${id}` instead of the collection. It needs no selector and wakes only for that member. In list rows this is required, see [PERF-22](perf-22-row-single-member-subscriptions.md). + **When to use a selector:** - To pick a few fields from a single Onyx key (reduces re-renders from unrelated field changes) - To compute a final scalar (boolean, number, string) from a larger dataset @@ -94,7 +96,10 @@ function UserProfile({userId}) { return {user?.name}; } -// GOOD: No selector on collection — shallowEqual is cheap, transform inline +// GOOD: The member is known, so read it by key instead of selecting it from the collection +const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + +// GOOD: The component needs every policy. No selector: the reference check is free, transform inline const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const mappedPolicies = Object.fromEntries( Object.entries(policies ?? {}).map(([key, policy]) => [ @@ -140,6 +145,7 @@ Flag ONLY when ANY of these are true: - The selector meaningfully reduces a large dataset to a small result (e.g., a primitive or a few items) by iterating over the subscribed collection itself — the `deepEqual` cost on a small result is negligible - The `useOnyx` call is on a single-item key (not a collection), and the selector picks specific fields - The data structure is static or the function requires the entire object for valid operations +- The code is a list row subscribing to a whole collection (`ONYXKEYS.COLLECTION.*`) or a derived map with an entry per record. Report [PERF-22](perf-22-row-single-member-subscriptions.md) instead. A row reading any other broad key, such as `BANK_ACCOUNT_LIST`, is still in scope here **Search Patterns** (hints for reviewers): - `useOnyx` diff --git a/.claude/skills/app-coding-standards/rules/perf-20-no-ad-hoc-deferral.md b/.claude/skills/app-coding-standards/rules/perf-20-no-ad-hoc-deferral.md new file mode 100644 index 000000000000..e056d4e8a130 --- /dev/null +++ b/.claude/skills/app-coding-standards/rules/perf-20-no-ad-hoc-deferral.md @@ -0,0 +1,184 @@ +--- +ruleId: PERF-20 +title: Defer heavy work to happen after visual transitions +--- + +## [PERF-20] Defer heavy work to happen after visual transitions + +### Reasoning + +Work that runs during a navigation, modal, or keyboard transition competes with the animation for the JS thread. `TransitionTracker` knows when these transitions start and end: screen push/pop/replace, modal open/close, and keyboard dismiss through `KeyboardUtils.dismiss`. The App has shared primitives built on it: + +- `usePreMountDestination` to mount the screen an RHP flow ends on while the RHP is still open +- `` to mount a subtree after tracked transitions settle +- `API.writeWhenReady` to apply an optimistic write once a barrier settles (by default, tracked transitions) +- for one-off imperative work: + - the `afterTransition` callback option on `Navigation` methods and `KeyboardUtils.dismiss` + - `Navigation.runAfterTransition` and `Navigation.runAfterUpcomingTransition` + - `TransitionTracker.runAfterTransitions` + +`TransitionTracker.runAfterTransitions({callback})` runs the callback synchronously when no transition is active. When it is called before the transition starts (for example, right after a navigation call), pass `waitForUpcomingTransition`. These primitives wait for tracked transitions to settle, with a fallback timeout if none starts. Two of them can also release earlier: + +- `KeyboardUtils.dismiss` calls `afterTransition` right away when the keyboard is not visible, and always on desktop web +- `API.writeWhenReady` runs the write right away when the app goes inactive or to the background + +See [DEFER_AFTER_PAINT.md](../../../../contributingGuides/DEFER_AFTER_PAINT.md) for which primitive fits which case, and for when `API.writeWhenReady` is not a safe replacement for `API.write`. + +### When to use + +Use one of these primitives when work triggered by an interaction is not needed for the first frame of the destination and would otherwise run while a navigation, modal, or keyboard transition is animating: a follow-up API write, focus or scroll, a follow-up navigation, or a heavy subtree on a freshly navigated screen. + +### When NOT to use + +- The delay is a UI duration that doesn't depend on a transition, such as a tooltip hover delay or how long a toast stays on screen +- The code needs a first frame or layout pass for a reason other than a transition (measuring, hydration) +- The destination needs the work to render its first frame +- None of the primitives fits. Flag it to a human, the pattern needs to be extended thoughtfully instead of worked around with inline timing + +### Incorrect + +The names below (`SOME_COMMAND`, `pendingWrite.ts`, `setPendingWrite`, etc...) are placeholders, not real App symbols. + +```tsx +// Pattern 1: timer or frame callback right after a navigation call +Navigation.dismissModal(); + +// Wait for the modal close animation before writing +setTimeout(() => { + API.write(WRITE_COMMANDS.SOME_COMMAND, params, onyxData); +}, 500); +``` + +```tsx +// Pattern 2: a screen runs another flow's pending work when it gains focus + +// pendingWrite.ts +let pendingWrite: (() => void) | undefined; +function setPendingWrite(write: () => void) { + pendingWrite = write; +} +function hasPendingWrite() { + return !!pendingWrite; +} +function flushPendingWrite() { + pendingWrite?.(); +} +function clearPendingWrite() { + pendingWrite = undefined; +} + +// Submit flow: queues the write, then navigates to ReportScreen +setPendingWrite(() => API.write(WRITE_COMMANDS.SOME_COMMAND, params, onyxData)); +Navigation.dismissModalWithReport({reportID}); + +// ReportScreen.tsx: runs the queued write on focus, so it doesn't compete with the dismiss animation +useFocusEffect( + useCallback(() => { + if (!hasPendingWrite()) { + return; + } + flushPendingWrite(); + clearPendingWrite(); + }, []), +); +``` + +```tsx +// Pattern 3: a hand-rolled "ready" flag that gates rendering +function SomeScreenHeader() { + // Render the actions only after the navigation animation, they are expensive + const [isReady, setIsReady] = useState(false); + useEffect(() => { + const id = requestAnimationFrame(() => setIsReady(true)); + return () => cancelAnimationFrame(id); + }, []); + + return isReady ? : null; +} +``` + +### Correct + +```tsx +// Pattern 1: the follow-up work runs once the dismiss transition settles +Navigation.dismissModal({afterTransition: () => doFollowUpWork()}); + +// Or, when the delayed work is a single API.write that can be deferred (see the caveats below) +const armed = API.armTransitionBarrier(); +Navigation.dismissModal(); +API.writeWhenReady(WRITE_COMMANDS.SOME_COMMAND, params, onyxData, armed.barrier); +``` + +```tsx +// Pattern 2: the flow that navigates owns the deferral, the destination screen has no flush +Navigation.dismissModalWithReport({reportID}, undefined, { + afterTransition: () => API.write(WRITE_COMMANDS.SOME_COMMAND, params, onyxData), +}); +``` + +```tsx +// Pattern 3 +}> + + +``` + +--- + +### Review Metadata + +**Only flag what the diff shows.** Every condition of a pattern must be visible in the added or changed lines or in the surrounding code shown in the diff. Do not infer behavior from a function's name alone. If a condition can't be confirmed from the diff, do not flag. Missing a case this way is intended because a false positive costs more than a miss. + +**Evidence of intent (patterns 1, 2 and 3).** Only flag when the code shows the delay exists to wait for a navigation, modal, or keyboard transition, for example a comment saying so or `CONST.ANIMATED_TRANSITION` as the delay. A navigation call before the timer is not enough on its own. + +Flag these patterns in added or changed lines: + +1. **Timer or frame after a navigation call.** In the same function body, `setTimeout` or `requestAnimationFrame` is called on a line after one of these calls. The timer must run on the same invocation of the function as the navigation call. Navigation calls include: + - `Navigation.navigate`, `Navigation.goBack`, `Navigation.dismissModal`, `Navigation.dismissModalWithReport`, etc... + - `KeyboardUtils.dismiss` + + Nested `requestAnimationFrame` calls count. Suggest the `afterTransition` option of that navigation call. + + Tell the author the fix must keep the old behavior: + - the navigation call still runs in the same cases as before, and the delayed work keeps the same guards. A guard that sat between the navigation call and the timer must not start skipping the navigation + - if the timer was cleared somewhere (for example on unmount), keep that. `afterTransition` has no cancel handle, so check a cancelled flag inside the callback. For `navigate`, `goBack` and `dismissModal` without `waitForTransition`, the work can instead be scheduled with `Navigation.runAfterUpcomingTransition` right after the call, and for `KeyboardUtils.dismiss` with `Navigation.runAfterTransition`. Then call `cancel()` on the returned handle in the cleanup + - some of the `Navigation` methods above can return early on some paths without calling `afterTransition`, while the old timer always ran. For example: `goBack` at the root of the stack, or when it falls back to resetting to the app root; `revealRouteBeforeDismissingModal` when navigation is not ready; `dismissToPreviousRHP` when there is nothing to dismiss. If the work must still run on those paths, it needs its own path + - the timing can change. `KeyboardUtils.dismiss` runs `afterTransition` synchronously when the keyboard is already hidden, and always on desktop web, so the work runs before the next line instead of later. When no transition starts, `afterTransition` waits up to `CONST.MAX_TRANSITION_START_WAIT_MS` (1000 ms), which can be later than the old timer + + Do not flag pattern 1 when the delay is a UI duration that doesn't depend on a transition, such as a tooltip hover delay or how long a toast stays on screen. A tooltip or toast delayed to wait for a navigation, modal or keyboard transition is not exempt. If the purpose is unclear, do not flag. + +2. **Pending work flushed on focus.** The evidence of intent is visible, and all of these are visible in the diff: + - a `useFocusEffect` callback checks a flag or value set outside this component: a module-level variable, or a getter from another file. The diff must show the getter and setter reading and writing the same variable, not just their names + - when the flag is set, the callback runs an API write, or a flush function whose body in the diff runs queued work + - the flag is set in the same function as a `Navigation.*` or `KeyboardUtils.dismiss` call (as in pattern 1), before that call + - the diff shows that the navigation goes to the screen with the focus callback: the same route or screen constant appears at the navigation call and in that screen, or a comment at the navigation call names that screen. If the diff doesn't show this, do not flag + + Suggest moving the deferred work into the `afterTransition` option of the flow's navigation call. + + Do not flag pattern 2 when the `useFocusEffect` callback only fetches or refreshes data, starts or stops a subscription, or sets focus, or when a comment says the work needs the destination screen itself (its data, its layout, or real focus). If the code that sets the flag is not in the diff, do not flag. + +3. **Hand-rolled ready flag gating a render.** The whole component is visible in the diff, the evidence of intent is visible, and: + - a `useState` boolean starts as `false` + - it is set to `true` once, from an effect that runs on mount (empty dependency array), inside a `setTimeout`, `requestAnimationFrame`, or `TransitionTracker.runAfterTransitions` callback + - its setter is not called anywhere else and not passed as a value to another component or function (calling it inside the scheduled callback, or inside `startTransition` there, is fine) + - the scheduled callback does nothing except set the flag (wrapping the setter in `startTransition` still counts as only setting the flag) + - the flag is only used to decide whether children render (not in effects, event handlers or props) + + Suggest ``, which mounts its children once after tracked transitions settle and yields to input with `startTransition`. It waits for an upcoming transition by default. Add a note that `waitForUpcomingTransition={false}` exists for components that can mount without a navigation transition (for example, remounted by a parent with a new `key`). Do not decide which value the component needs, since its mounting paths are usually not in the diff. + + Do not flag pattern 3 when the delay is a UI duration that doesn't depend on a transition (a tooltip hover delay, a toast that shows after N ms). A tooltip or toast delayed to wait for a navigation, modal or keyboard transition is not exempt. Also do not flag when a comment says the code needs a first frame or layout pass for a reason other than a transition (measuring, hydration). + +**Caveats for suggesting `API.writeWhenReady`:** only suggest it when the delayed work is a single `API.write` call and none of the cases in [Do not use `writeWhenReady` when](../../../../contributingGuides/DEFER_AFTER_PAINT.md#deferring-writes) apply. If the diff can't confirm that, suggest `afterTransition` instead. + +**DO NOT flag if:** + +- The delayed work being reviewed is itself scheduled with the `afterTransition` option, `API.writeWhenReady`, `Navigation.runAfterTransition`, `Navigation.runAfterUpcomingTransition`, or `TransitionTracker.runAfterTransitions`. A separate timer in the same function is still checked on its own. This covers patterns 1 and 2 only: pattern 3 still applies to a ready flag set inside `runAfterTransitions`. The body of an `afterTransition` callback is still checked, so a new navigation call followed by a timer inside it is pattern 1. +- The code is inside one of these files, which implement the primitives: `src/libs/Navigation/TransitionTracker.ts`, `src/libs/Navigation/Navigation.ts`, `src/libs/API/writeWhenReady.ts`, `src/components/NavigationDeferredMount.tsx`, `src/hooks/usePreMountDestination/`, `src/libs/Navigation/helpers/preMountBuffer.ts` +- The file is under `tests/` or its name ends in `.test.ts`, `.test.tsx`, `Test.ts` or `Test.tsx` + +**Search patterns** (starting points only, not the full definition of a match; generic arguments like `useState(false)` and whitespace do not change a match): + +- `setTimeout(` +- `requestAnimationFrame(` +- `useFocusEffect(` +- `useState(false)` diff --git a/.claude/skills/app-coding-standards/rules/perf-21-rows-read-own-data.md b/.claude/skills/app-coding-standards/rules/perf-21-rows-read-own-data.md new file mode 100644 index 000000000000..7d9869814708 --- /dev/null +++ b/.claude/skills/app-coding-standards/rules/perf-21-rows-read-own-data.md @@ -0,0 +1,81 @@ +--- +ruleId: PERF-21 +title: List rows read their own Onyx data +--- + +## [PERF-21] List rows read their own Onyx data + +### Reasoning + +If a list reads collections and passes members to its rows as props, or puts collections in FlashList `extraData`, then every write to those collections re-renders the list and every row, even rows whose data did not change. If the list passes only IDs and each row reads its own data, a write re-renders only the row that shows it, and the list does not re-render. + +### Incorrect + +```tsx +// BAD: the list reads collections for its rows and passes them down. +const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); +const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + + ( + + )} +/>; +``` + +### Correct + +```tsx +// GOOD: the list passes only the ID, and the row reads its own data. + } +/>; + +function ReportRow({reportID}: ReportRowProps) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`); + // ... +} +``` + +### Not a violation + +```tsx +// The list needs the whole collection to sort and filter, and passes only IDs to rows. +const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); +const reportIDs = getSortedReportIDs(reports); + + } +/>; +``` + +--- + +### Review Metadata + +**Flag when ALL of these are true:** + +- A list component reads an Onyx collection, or another key that holds data for every record. +- It passes members or the whole value to rows as props, or puts it in `extraData`. +- The row could read that data itself by key, from an ID it already receives. + +**DO NOT flag if:** + +- The list uses the collection only for its own work, such as sorting or filtering, and passes rows only IDs. +- The value is shared data that every row needs, not the row's own item. See [PERF-3](perf-3-onyx-list-item-provider.md). +- The list is not virtualized and renders only a few fixed items. + +**Related:** [PERF-22](perf-22-row-single-member-subscriptions.md) for what the row itself subscribes to. + +**Search Patterns** (hints for reviewers): +- `extraData` +- `renderItem` +- `useOnyx\(ONYXKEYS\.COLLECTION\.[A-Z_]+\)` diff --git a/.claude/skills/app-coding-standards/rules/perf-22-row-single-member-subscriptions.md b/.claude/skills/app-coding-standards/rules/perf-22-row-single-member-subscriptions.md new file mode 100644 index 000000000000..3d7be821521d --- /dev/null +++ b/.claude/skills/app-coding-standards/rules/perf-22-row-single-member-subscriptions.md @@ -0,0 +1,54 @@ +--- +ruleId: PERF-22 +title: List rows subscribe to single collection members +--- + +## [PERF-22] List rows subscribe to single collection members + +### Reasoning + +Onyx notifies subscribers per key. A write to a collection member notifies that member's subscribers and the collection's subscribers. A row that subscribes to a whole collection re-renders on a write to any member, so one write re-renders every visible row. A row that subscribes to its own member key re-renders only when its own data changes. + +### Incorrect + +```tsx +// BAD: every row subscribes to the whole report collection to get its own report. +function ReportRow({reportID}: ReportRowProps) { + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const report = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + // ... +} +``` + +### Correct + +```tsx +// GOOD: the row subscribes only to its own members. +function ReportRow({reportID}: ReportRowProps) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`); + // ... +} +``` + +--- + +### Review Metadata + +**Flag when ALL of these are true:** + +- The code is a list row component, or code that `renderItem` calls. +- The row subscribes to a whole collection (`ONYXKEYS.COLLECTION.*` without a member ID), or to a derived map with an entry per record, such as `REPORT_ATTRIBUTES`. +- The row uses only a few members of it, and their IDs come from the row's props or its own data. + +**DO NOT flag if:** + +- The row subscribes to a member key (`${ONYXKEYS.COLLECTION.X}${id}`), or uses a selector that returns only its own data. +- The row needs the whole collection, for example to search across all members. +- The code is not part of a list row. + +**Related:** [PERF-3](perf-3-onyx-list-item-provider.md) for shared data such as personal details, [PERF-21](perf-21-rows-read-own-data.md) when the list reads the data for its rows, [PERF-11](perf-11-optimize-data-selection.md) for selector cost. + +**Search Patterns** (hints for reviewers): +- `renderItem` +- `useOnyx\(ONYXKEYS\.COLLECTION\.[A-Z_]+\)` diff --git a/.claude/skills/app-coding-standards/rules/perf-3-onyx-list-item-provider.md b/.claude/skills/app-coding-standards/rules/perf-3-onyx-list-item-provider.md index cbf008f6724a..90795d706657 100644 --- a/.claude/skills/app-coding-standards/rules/perf-3-onyx-list-item-provider.md +++ b/.claude/skills/app-coding-standards/rules/perf-3-onyx-list-item-provider.md @@ -1,42 +1,61 @@ --- ruleId: PERF-3 -title: Use OnyxListItemProvider hooks instead of useOnyx in renderItem +title: Use OnyxListItemProvider hooks for shared data in list rows --- -## [PERF-3] Use OnyxListItemProvider hooks instead of useOnyx in renderItem +## [PERF-3] Use OnyxListItemProvider hooks for shared data in list rows ### Reasoning -Individual `useOnyx` calls in renderItem create separate subscriptions for each list item, causing memory overhead and update cascades. `OnyxListItemProvider` hooks provide optimized data access patterns specifically designed for list rendering performance. +Some data is not the row's own item but is the same for every row, such as the session, betas or the card list. `OnyxListItemProvider` reads each of these keys once above the list and shares it with every row through context, so each row does not need its own subscription. + +A provider hook returns the whole value, and every write to that key re-renders every row that uses it. That is fine for small, rarely written keys that the row needs whole. When the row needs only a few records of a large shared key, such as the people it shows, it reads only those records. ### Incorrect ```tsx -const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); +// BAD: each row subscribes to the session separately. +const [session] = useOnyx(ONYXKEYS.SESSION); +``` + +```tsx +// BAD: the row shows two people but reads the whole personal details list, +// so a write to any person re-renders every row. +const personalDetails = usePersonalDetails(); ``` ### Correct ```tsx -const personalDetails = usePersonalDetails(); +// GOOD: shared data that the row needs whole comes from the provider. +const session = useSession(); +``` + +```tsx +// GOOD: the row reads only the people it shows. +const [personalDetails] = usePersonalDetailsByIDs([report?.ownerAccountID, report?.managerID]); ``` --- ### Review Metadata -Flag ONLY when ALL of these are true: +**Flag when ALL of these are true:** -- Code is inside a renderItem function -- A `useOnyx` hook is being called -- A dedicated hook exists in OnyxListItemProvider for that data +- The code is a list row component, or code that `renderItem` calls. +- It reads data that is the same for every row, not the row's own item, and either: + - calls `useOnyx` on a key that has a hook in `OnyxListItemProvider`, or + - reads the whole personal details list (`usePersonalDetails`, `useAllPersonalDetails`) but uses only a few people whose IDs it knows. **DO NOT flag if:** -- Component is not inside renderItem -- No OnyxListItemProvider hook exists for the data -- Data structure is custom and requires raw Onyx access +- The code is not part of a list row. +- No `OnyxListItemProvider` hook exists for the key. +- The row needs the whole personal details list, for example to search across all people. +- The data is the row's own item. See [PERF-21](perf-21-rows-read-own-data.md) and [PERF-22](perf-22-row-single-member-subscriptions.md). **Search Patterns** (hints for reviewers): - `useOnyx` - `renderItem` +- `usePersonalDetails\(\)` +- `useAllPersonalDetails` diff --git a/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts b/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts index dd7bae51190b..25ac16ad11fb 100644 --- a/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts +++ b/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts @@ -84,8 +84,9 @@ async function run() { } core.setOutput('MOBILE_EXPENSIFY_PR_LIST', mobileExpensifyPRList); } catch (error) { - console.error((error as Error).message); - core.setFailed(error as Error); + const message = error instanceof Error ? error.message : String(error); + console.error(message); + core.setFailed(error instanceof Error ? error : message); } } diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index c6a083ec32cf..9fee4c2e6414 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -26716,8 +26716,9 @@ async function run() { } setOutput("MOBILE_EXPENSIFY_PR_LIST", mobileExpensifyPRList); } catch (error2) { - console.error(error2.message); - setFailed(error2); + const message = error2 instanceof Error ? error2.message : String(error2); + console.error(message); + setFailed(error2 instanceof Error ? error2 : message); } } if (import.meta.main) { diff --git a/.github/actions/javascript/validateReassureOutput/action.yml b/.github/actions/javascript/validateReassureOutput/action.yml index 9ef9e63316e7..e21b1a3e9bd1 100644 --- a/.github/actions/javascript/validateReassureOutput/action.yml +++ b/.github/actions/javascript/validateReassureOutput/action.yml @@ -1,9 +1,6 @@ name: 'Validate Regression Test Output' description: 'Validates the output of regression tests and determines if a test action should fail.' inputs: - DURATION_DEVIATION_PERCENTAGE: - description: Allowable percentage deviation for the mean duration in regression test results. - required: true COUNT_DEVIATION: description: Allowable deviation for the mean count in regression test results. required: true diff --git a/.github/actions/javascript/validateReassureOutput/index.js b/.github/actions/javascript/validateReassureOutput/index.js index e511f56ffd08..dd379f5b3565 100644 --- a/.github/actions/javascript/validateReassureOutput/index.js +++ b/.github/actions/javascript/validateReassureOutput/index.js @@ -19522,7 +19522,6 @@ import fs2 from "fs"; var run = () => { const regressionOutput = JSON.parse(fs2.readFileSync(".reassure/output.json", "utf8")); const countDeviation = Number(getInput("COUNT_DEVIATION", { required: true })); - const durationDeviation = Number(getInput("DURATION_DEVIATION_PERCENTAGE", { required: true })); if (regressionOutput.countChanged === void 0 || regressionOutput.countChanged.length === 0) { console.log("No countChanged data available. Exiting..."); return true; @@ -19543,13 +19542,6 @@ var run = () => { } else { console.log(`Render count difference ${renderCountDiff} is within the allowed deviation range of ${countDeviation}.`); } - const increasePercentage = (current.meanDuration - baseline.meanDuration) / baseline.meanDuration * 100; - if (increasePercentage > durationDeviation) { - setFailed(`Duration increase percentage exceeded the allowed deviation of ${durationDeviation}%. Current percentage: ${increasePercentage}%`); - break; - } else { - console.log(`Duration increase percentage ${increasePercentage}% is within the allowed deviation range of ${durationDeviation}%.`); - } } return true; }; diff --git a/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts b/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts index e2603ba6dabf..6e0819ffde7b 100644 --- a/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts +++ b/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts @@ -6,7 +6,6 @@ import fs from 'fs'; const run = (): boolean => { const regressionOutput = JSON.parse(fs.readFileSync('.reassure/output.json', 'utf8')) as CompareResult; const countDeviation = Number(core.getInput('COUNT_DEVIATION', {required: true})); - const durationDeviation = Number(core.getInput('DURATION_DEVIATION_PERCENTAGE', {required: true})); if (regressionOutput.countChanged === undefined || regressionOutput.countChanged.length === 0) { console.log('No countChanged data available. Exiting...'); @@ -34,14 +33,6 @@ const run = (): boolean => { } else { console.log(`Render count difference ${renderCountDiff} is within the allowed deviation range of ${countDeviation}.`); } - - const increasePercentage = ((current.meanDuration - baseline.meanDuration) / baseline.meanDuration) * 100; - if (increasePercentage > durationDeviation) { - core.setFailed(`Duration increase percentage exceeded the allowed deviation of ${durationDeviation}%. Current percentage: ${increasePercentage}%`); - break; - } else { - console.log(`Duration increase percentage ${increasePercentage}% is within the allowed deviation range of ${durationDeviation}%.`); - } } return true; diff --git a/.github/scripts/nitroSQLiteDatabaseLocation/HybridNitroSQLite.hpp b/.github/scripts/nitroSQLiteDatabaseLocation/HybridNitroSQLite.hpp new file mode 100644 index 000000000000..b3d59db537e3 --- /dev/null +++ b/.github/scripts/nitroSQLiteDatabaseLocation/HybridNitroSQLite.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +// OnLoad.mm only needs these storage paths from the HybridObject implementation. +namespace margelo::nitro::rnnitrosqlite { +class HybridNitroSQLite { +public: + static inline std::string docPath; + static inline std::string migrationDocPath; +}; +} // namespace margelo::nitro::rnnitrosqlite diff --git a/.github/scripts/nitroSQLiteDatabaseLocation/RNNitroSQLite-Swift-Cxx-Umbrella.hpp b/.github/scripts/nitroSQLiteDatabaseLocation/RNNitroSQLite-Swift-Cxx-Umbrella.hpp new file mode 100644 index 000000000000..f045b8704496 --- /dev/null +++ b/.github/scripts/nitroSQLiteDatabaseLocation/RNNitroSQLite-Swift-Cxx-Umbrella.hpp @@ -0,0 +1,3 @@ +#pragma once + +// The directory selector does not call any generated Swift bindings. diff --git a/.github/scripts/nitroSQLiteDatabaseLocation/verifyLocation.mm b/.github/scripts/nitroSQLiteDatabaseLocation/verifyLocation.mm new file mode 100644 index 000000000000..a0e64aff914a --- /dev/null +++ b/.github/scripts/nitroSQLiteDatabaseLocation/verifyLocation.mm @@ -0,0 +1,28 @@ +#import +#import +#include + +// Execute the iOS branch on the existing macOS CI runner. NitroSQLite's macOS +// default can differ from iOS and must not conceal a broken iOS plist setting. +#undef TARGET_OS_OSX +#define TARGET_OS_OSX 0 +#import NITRO_SQLITE_ONLOAD_PATH + +using margelo::nitro::rnnitrosqlite::HybridNitroSQLite; + +int main() { + @autoreleasepool { + // Given the host plist, when the installed native library runs +load. + NSString *expectedDirectory = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES).firstObject; + NSString *actualDirectory = [NSString stringWithUTF8String:HybridNitroSQLite::docPath.c_str()]; + // Then its database directory must remain Application Support. + if (actualDirectory == nil || ![actualDirectory.stringByResolvingSymlinksInPath isEqualToString:expectedDirectory.stringByResolvingSymlinksInPath]) { + fprintf(stderr, "NitroSQLite selected %s instead of Application Support. Check the installed dependency and the host plist.\n", + HybridNitroSQLite::docPath.c_str()); + return 1; + } + + puts("Native database location is Application Support."); + return 0; + } +} diff --git a/.github/scripts/verifyNitroSQLiteDatabaseLocation.sh b/.github/scripts/verifyNitroSQLiteDatabaseLocation.sh new file mode 100644 index 000000000000..37d8d851a1db --- /dev/null +++ b/.github/scripts/verifyNitroSQLiteDatabaseLocation.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TEST_DIR="$ROOT_DIR/.github/scripts/nitroSQLiteDatabaseLocation" +NITRO_SQLITE_DIR="${NITRO_SQLITE_DIR:-$ROOT_DIR/node_modules/react-native-nitro-sqlite}" +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +# Run the installed iOS directory selector, rather than copying its plist logic. +# Only the bridge headers are replaced; the native selector runs unchanged. +xcrun clang++ -std=c++20 -fobjc-arc -framework Foundation \ + -I "$TEST_DIR" \ + -DNITRO_SQLITE_ONLOAD_PATH="\"$NITRO_SQLITE_DIR/ios/OnLoad.mm\"" \ + "$TEST_DIR/verifyLocation.mm" -o "$WORK_DIR/verifyLocation" + +PLISTS=( + "$ROOT_DIR/ios/NewExpensify/Info.plist" +) +if [[ "$#" -gt 0 ]]; then + PLISTS=("$@") +fi + +for PLIST in "${PLISTS[@]}"; do + # Each plist runs in a fresh process because NitroSQLite selects its directory + # in Objective-C +load, before main() and before Onyx opens the database. + APP_DIR="$WORK_DIR/DatabaseLocation.app" + mkdir -p "$APP_DIR/Contents/MacOS" + cp "$WORK_DIR/verifyLocation" "$APP_DIR/Contents/MacOS/verifyLocation" + python3 - "$PLIST" "$APP_DIR/Contents/Info.plist" <<'PY' +import plistlib +import sys +from pathlib import Path + +info = plistlib.loads(Path(sys.argv[1]).read_bytes()) +# Bundle metadata contains build substitutions in the source host plists. +# Keep every database setting as supplied by the host, including unknown keys. +info.update(CFBundleExecutable='verifyLocation', CFBundleIdentifier='com.expensify.database-location-test', CFBundlePackageType='APPL') +Path(sys.argv[2]).write_bytes(plistlib.dumps(info)) +PY + echo "Checking native database location for $PLIST" + "$APP_DIR/Contents/MacOS/verifyLocation" +done diff --git a/.github/workflows/authorChecklist.yml b/.github/workflows/authorChecklist.yml index 2603542b47f8..46b690fe4815 100644 --- a/.github/workflows/authorChecklist.yml +++ b/.github/workflows/authorChecklist.yml @@ -16,7 +16,7 @@ jobs: if: | github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' - && github.actor != 'MelvinBot' + && github.event.pull_request.user.login != 'MelvinBot' steps: - name: Checkout uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 diff --git a/.github/workflows/buildAdHoc.yml b/.github/workflows/buildAdHoc.yml index 3633c87fb9cb..9aa04f3f7089 100644 --- a/.github/workflows/buildAdHoc.yml +++ b/.github/workflows/buildAdHoc.yml @@ -127,8 +127,8 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-adhoc-web-deploy-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} aws-region: us-east-1 - name: Deploy to S3 diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml index de8b5ff08840..8bdba2f7e5f3 100644 --- a/.github/workflows/buildAndroid.yml +++ b/.github/workflows/buildAndroid.yml @@ -156,8 +156,10 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + # Match the 6h job timeout because Rock does not refresh credentials before uploading + role-duration-seconds: 21600 aws-region: us-east-1 - name: Rock Remote Build - Android diff --git a/.github/workflows/buildIOS.yml b/.github/workflows/buildIOS.yml index 7cdb09c2d8a1..c79ca9aec984 100644 --- a/.github/workflows/buildIOS.yml +++ b/.github/workflows/buildIOS.yml @@ -237,8 +237,10 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + # Match the 6h job timeout because Rock does not refresh credentials before uploading + role-duration-seconds: 21600 aws-region: us-east-1 - name: Prepare provisioning profiles JSON diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index eeb9592a36f2..d2cebadd8ba8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -201,6 +201,8 @@ jobs: androidBuild: name: Build Android HybridApp needs: [prep] + # Preserve the default token scopes while adding OIDC access for the called workflow + permissions: write-all if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} uses: ./.github/workflows/buildAndroid.yml with: @@ -429,6 +431,8 @@ jobs: iosBuild: name: Build iOS HybridApp needs: [prep] + # Preserve the default token scopes while adding OIDC access for the called workflow + permissions: write-all if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} uses: ./.github/workflows/buildIOS.yml with: @@ -703,6 +707,8 @@ jobs: name: Deploy Web to S3 needs: [prep, webBuild, buildStorybook] if: ${{ always() && needs.webBuild.result == 'success' }} + # Preserve the default token scopes while adding OIDC access + permissions: write-all runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout @@ -735,8 +741,8 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-${{ env.DEPLOY_ENV }}-web-deploy-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} aws-region: us-east-1 - name: Deploy to S3 @@ -763,17 +769,19 @@ jobs: CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} HOST: ${{ env.DEPLOY_ENV == 'production' && vars.WEB_PRODUCTION_HOST || vars.WEB_STAGING_HOST }} + # The staging host is verified by webDeployStagingWorker, which deploys after this job. - name: Verify deploy + if: ${{ env.DEPLOY_ENV == 'production' }} run: | APP_VERSION=$(jq -r .version < package.json) ./.github/scripts/verifyDeploy.sh "$HOST" "$APP_VERSION" env: - HOST: ${{ env.DEPLOY_ENV == 'production' && vars.WEB_PRODUCTION_HOST || vars.WEB_STAGING_HOST }} + HOST: ${{ vars.WEB_PRODUCTION_HOST }} webDeployTestStaging: name: Deploy Web to test-staging.new.expensify.com - needs: [prep, webBuild] - if: ${{ needs.webBuild.result == 'success' && needs.prep.outputs.DEPLOY_ENV == 'staging' }} + needs: [prep, webBuild, buildStorybook] + if: ${{ always() && needs.webBuild.result == 'success' && needs.prep.outputs.DEPLOY_ENV == 'staging' }} # Trial hosting for the web build, so a failure here must not fail the staging deploy. continue-on-error: true runs-on: blacksmith-2vcpu-ubuntu-2404 @@ -801,6 +809,14 @@ jobs: - name: Extract web build run: tar -xzf "${{ needs.webBuild.outputs.TAR_FILENAME }}" + - name: Download storybook docs artifact + continue-on-error: true + # v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: storybook-docs-artifact + path: ./dist/docs + - name: Install Cloudflare Worker dependencies working-directory: web/cloudflare-worker run: npm ci @@ -816,7 +832,86 @@ jobs: - name: Verify deploy run: | APP_VERSION=$(jq -r .version < package.json) - ./.github/scripts/verifyDeploy.sh https://test-staging.new.expensify.com "$APP_VERSION" + ./.github/scripts/verifyDeploy.sh "$HOST" "$APP_VERSION" + env: + HOST: https://test-staging.new.expensify.com + + webDeployStagingWorker: + name: Deploy Web to the staging Cloudflare Worker + # After webDeploy, so the S3 copy of this build is in place before the Worker's version check. + needs: [prep, webBuild, buildStorybook, webDeploy] + if: ${{ always() && needs.webBuild.result == 'success' && needs.prep.outputs.DEPLOY_ENV == 'staging' }} + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 + with: + ref: ${{ needs.prep.outputs.DEPLOY_SHA }} + + - name: Setup Node + # v6.1.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version-file: '.nvmrc' + cache: npm + cache-dependency-path: web/cloudflare-worker/package-lock.json + + - name: Download web build artifact + # v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: web-build-tar-gz-artifact + path: ./ + + - name: Extract web build + run: tar -xzf "${{ needs.webBuild.outputs.TAR_FILENAME }}" + + # The Workers serve these to tabs still running this build after later deploys. + - name: Upload web build to the previous builds bucket + continue-on-error: true + run: | + if [[ -z "$AWS_ACCESS_KEY_ID" ]]; then + echo "::warning::CLOUDFLARE_R2_WEB_ASSETS_ACCESS_KEY_ID is not set, so this build is not available to future deploys." + exit 0 + fi + aws configure set default.s3.preferred_transfer_client classic + # Re-uploads every file, so files that stay the same across builds don't hit the bucket's 60-day expiry while still in use. + aws s3 cp dist s3://new-expensify-web-assets-staging/ --recursive \ + --endpoint-url "https://$CLOUDFLARE_ACCOUNT_ID.r2.cloudflarestorage.com" \ + --no-progress \ + --exclude '*.br' --exclude '*.map' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_WEB_ASSETS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_WEB_ASSETS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Download storybook docs artifact + continue-on-error: true + # v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: storybook-docs-artifact + path: ./dist/docs + + - name: Install Cloudflare Worker dependencies + working-directory: web/cloudflare-worker + run: npm ci + + - name: Deploy to Cloudflare Workers + working-directory: web/cloudflare-worker + run: npx wrangler deploy --env staging + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + WRANGLER_SEND_METRICS: false + + - name: Verify deploy + run: | + APP_VERSION=$(jq -r .version < package.json) + ./.github/scripts/verifyDeploy.sh "$HOST" "$APP_VERSION" + env: + HOST: ${{ vars.WEB_STAGING_HOST }} buildStorybook: name: Build storybook docs @@ -865,6 +960,7 @@ jobs: iosSubmit, webBuild, webDeploy, + webDeployStagingWorker, victoryChartRendererBuild, ] steps: @@ -880,7 +976,7 @@ jobs: name: Post a Slack message when the deploy is cancelled manually runs-on: blacksmith-2vcpu-ubuntu-2404 if: ${{ cancelled() }} - needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy] + needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy, webDeployStagingWorker] steps: - name: Check whether a newer deploy run exists id: check @@ -919,7 +1015,7 @@ jobs: ANDROID_RESULT: ${{ steps.platformResults.outputs.ANDROID_RESULT }} IOS_RESULT: ${{ steps.platformResults.outputs.IOS_RESULT }} WEB_RESULT: ${{ steps.platformResults.outputs.WEB_RESULT }} - needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy, victoryChartRendererBuild] + needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy, webDeployStagingWorker, victoryChartRendererBuild] if: ${{ always() }} steps: # Determine effective result for each platform @@ -954,9 +1050,11 @@ jobs: echo "IOS_RESULT=${{ needs.iosUploadTestflight.result }}" >> "$GITHUB_OUTPUT" fi - # Web: propagate build failure even when deploy is skipped + # Web: propagate build failure even when deploy is skipped. Staging is only deployed once its Worker is too. if [ "${{ needs.webBuild.result }}" == "failure" ]; then echo "WEB_RESULT=failure" >> "$GITHUB_OUTPUT" + elif [ "${{ env.DEPLOY_ENV }}" == "staging" ] && [ "${{ needs.webDeployStagingWorker.result }}" != "success" ]; then + echo "WEB_RESULT=${{ needs.webDeployStagingWorker.result }}" >> "$GITHUB_OUTPUT" else echo "WEB_RESULT=${{ needs.webDeploy.result }}" >> "$GITHUB_OUTPUT" fi diff --git a/.github/workflows/publishReactNativeArtifacts.yml b/.github/workflows/publishReactNativeArtifacts.yml index a385e36e4a37..fa134645b37e 100644 --- a/.github/workflows/publishReactNativeArtifacts.yml +++ b/.github/workflows/publishReactNativeArtifacts.yml @@ -41,6 +41,12 @@ on: - patches/react-native/@react-native+*.patch - Mobile-Expensify - scripts/artifacts-utils/compute-patches-hash.sh + # Keep in sync with the "Check if artifact publishing workflows changed" step below. + - .github/workflows/publishReactNativeArtifacts.yml + - .github/workflows/publishReactNativeiOSArtifacts.yml + - .github/workflows/publishReactNativeAndroidArtifacts.yml + - .github/actions/composite/fetchReactNativePrebuild/action.yml + - scripts/artifacts-utils/ios/publish/** jobs: verifyPatches: @@ -117,6 +123,24 @@ jobs: echo "::notice::Detected changes in Standalone NewDot patches (${{ steps.getOldPatchesHash.outputs.STANDALONE_APP_HASH }} -> ${{ steps.getNewPatchesHash.outputs.STANDALONE_APP_HASH }})" fi + - name: Check if artifact publishing workflows changed + if: ${{ github.event.after != '' }} + id: didWorkflowsChange + run: | + # Edits to the publishing workflows, the prebuild fetch action, or the iOS publish Gradle project + # change how artifacts are built without touching patches or the RN version. Keep in sync with `on.push.paths`. + if git diff --quiet '${{ github.event.before }}' '${{ github.event.after }}' -- \ + .github/workflows/publishReactNativeArtifacts.yml \ + .github/workflows/publishReactNativeiOSArtifacts.yml \ + .github/workflows/publishReactNativeAndroidArtifacts.yml \ + .github/actions/composite/fetchReactNativePrebuild/action.yml \ + scripts/artifacts-utils/ios/publish; then + echo "DID_WORKFLOWS_CHANGE=false" >> "$GITHUB_OUTPUT" + else + echo "DID_WORKFLOWS_CHANGE=true" >> "$GITHUB_OUTPUT" + echo "::notice::Detected changes in the artifact publishing workflows" + fi + - name: Get artifact build targets id: getArtifactBuildTargets run: | @@ -139,8 +163,8 @@ jobs: fi # push path: derive targets from what actually changed between github.event.before and github.event.after. - # Version change or standalone patch change forces a full rebuild of both hybrid and standalone. - if [[ '${{ steps.didVersionChange.outputs.DID_VERSION_CHANGE }}' == 'true' || '${{ steps.didPatchesChange.outputs.DID_STANDALONE_APP_PATCHES_CHANGE }}' == 'true' ]]; then + # Version change, standalone patch change or a change to the publishing workflows forces a full rebuild of both hybrid and standalone. + if [[ '${{ steps.didVersionChange.outputs.DID_VERSION_CHANGE }}' == 'true' || '${{ steps.didPatchesChange.outputs.DID_STANDALONE_APP_PATCHES_CHANGE }}' == 'true' || '${{ steps.didWorkflowsChange.outputs.DID_WORKFLOWS_CHANGE }}' == 'true' ]]; then echo "BUILD_TARGETS=[\"true\", \"false\"]" >> "$GITHUB_OUTPUT" elif [[ '${{ steps.didPatchesChange.outputs.DID_HYBRID_APP_PATCHES_CHANGE }}' == 'true' ]]; then echo "BUILD_TARGETS=[\"true\"]" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/reassurePerformanceTests.yml b/.github/workflows/reassurePerformanceTests.yml index 3eeb13a31bd5..00af550c2fe2 100644 --- a/.github/workflows/reassurePerformanceTests.yml +++ b/.github/workflows/reassurePerformanceTests.yml @@ -4,7 +4,57 @@ on: pull_request: types: [opened, synchronize] branches-ignore: [staging, production] - paths-ignore: [docs/**, help/**, .github/**, contributingGuides/**, tests/**, '**.md', '**.sh'] + # Ordered: the last matching pattern wins. + paths: + - '**' + # Native and OldDot: Jest never loads these. + - '!ios/**' + - '!android/**' + - '!vendor/**' + - '!fastlane/**' + - '!Mobile-Expensify' + - '!Gemfile' + - '!Gemfile.lock' + - '!.ruby-version' + - '!.java-version' + - '!.buckconfig' + - '!.watchmanconfig' + # Web and Storybook builds: nothing under src/, jest/ or tests/ imports these. + - '!web/**' + - '!.well-known/**' + - '!.storybook/**' + - '!config/rsbuild/**' + - '!config/repack/**' + - '!config/certificatePinning/**' + # Lint, format and repo tooling config. + - '!eslint-plugin-local-rules/**' + - '!config/eslint/**' + - '!eslint.config.mjs' + - '!.oxfmtrc.json' + - '!cspell.json' + - '!codecov.yml' + - '!.imgbotconfig' + - '!.mcp.json' + # Docs and agent tooling. + - '!docs/**' + - '!contributingGuides/**' + - '!.claude/**' + - '!evals/**' + - '!prompts/**' + # Tests: only the perf harness feeds the measurement. + - '!tests/**' + - 'tests/perf-test/**' + - 'tests/utils/**' + - 'tests/unit/currencyList.json' + - 'tests/__mocks__/**' + # CI: only this check, its baseline seed, and the actions it runs. + - '!.github/**' + - '.github/workflows/reassurePerformanceTests.yml' + - '.github/workflows/seedJestPerfCache.yml' + - '.github/actions/javascript/validateReassureOutput/**' + - '.github/actions/composite/setupNode/**' + - '!**.md' + - '!**.sh' # Cancel a run still measuring an earlier push to the same PR. pull_request-only, so github.ref is # refs/pull//merge and the group is one PR. @@ -212,5 +262,4 @@ jobs: id: validateReassureOutput uses: ./.github/actions/javascript/validateReassureOutput with: - DURATION_DEVIATION_PERCENTAGE: 20 COUNT_DEVIATION: 0 diff --git a/.github/workflows/remote-build-android.yml b/.github/workflows/remote-build-android.yml index 7462d1498d15..e24ff55b08b6 100644 --- a/.github/workflows/remote-build-android.yml +++ b/.github/workflows/remote-build-android.yml @@ -47,6 +47,8 @@ jobs: build: needs: [prep, resolveRefs] + # Preserve the default token scopes while adding OIDC access + permissions: write-all runs-on: ${{ github.repository_owner == 'Expensify' && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-2vcpu-ubuntu-2404' }} strategy: fail-fast: false @@ -82,8 +84,10 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + # Match the 6h job timeout because Rock does not refresh credentials before uploading + role-duration-seconds: 21600 aws-region: us-east-1 - name: Rock Remote Build - Android diff --git a/.github/workflows/remote-build-ios.yml b/.github/workflows/remote-build-ios.yml index 482f8a1d32f6..f246dcf43b0d 100644 --- a/.github/workflows/remote-build-ios.yml +++ b/.github/workflows/remote-build-ios.yml @@ -47,6 +47,8 @@ jobs: build: needs: [prep, resolveRefs] + # Preserve the default token scopes while adding OIDC access + permissions: write-all runs-on: ${{ github.repository_owner == 'Expensify' && 'blacksmith-12vcpu-macos-latest' || 'macos-latest' }} strategy: fail-fast: false @@ -94,8 +96,10 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + # Match the 6h job timeout because Rock does not refresh credentials before uploading + role-duration-seconds: 21600 aws-region: us-east-1 - name: Rock Remote Build - iOS diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index c58bffcfb075..a29336e8684b 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -63,6 +63,8 @@ jobs: buildApps: needs: [prep, resolveRefs] + # Preserve the default token scopes while adding OIDC access for the called workflow + permissions: write-all uses: ./.github/workflows/buildAdHoc.yml with: # Fall back to App main when nothing is resolved (test builds default to the latest NewDot). diff --git a/.github/workflows/testBuildOnPush.yml b/.github/workflows/testBuildOnPush.yml index b7f3d20e3d0b..286e2acfc44c 100644 --- a/.github/workflows/testBuildOnPush.yml +++ b/.github/workflows/testBuildOnPush.yml @@ -70,6 +70,8 @@ jobs: buildApps: needs: [prep] + # Preserve the default token scopes while adding OIDC access for the called workflow + permissions: write-all uses: ./.github/workflows/buildAdHoc.yml with: APP_REF: ${{ needs.prep.outputs.APP_REF }} diff --git a/.github/workflows/verifyHybridApp.yml b/.github/workflows/verifyHybridApp.yml index f16b9f77ac5d..762f08d95427 100644 --- a/.github/workflows/verifyHybridApp.yml +++ b/.github/workflows/verifyHybridApp.yml @@ -75,6 +75,10 @@ jobs: verify_android: name: Verify Android HybridApp builds on main + permissions: + contents: read + id-token: write + pull-requests: write if: ${{ !github.event.pull_request.head.repo.fork && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} cache-mode: read uses: ./.github/workflows/buildAndroid.yml @@ -85,6 +89,10 @@ jobs: verify_ios: name: Verify iOS HybridApp builds on main + permissions: + contents: read + id-token: write + pull-requests: write if: ${{ !github.event.pull_request.head.repo.fork && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} cache-mode: read uses: ./.github/workflows/buildIOS.yml diff --git a/.github/workflows/verifyPodfile.yml b/.github/workflows/verifyPodfile.yml index b9978711a9c3..e806fa0b1317 100644 --- a/.github/workflows/verifyPodfile.yml +++ b/.github/workflows/verifyPodfile.yml @@ -1,4 +1,4 @@ -name: Verify Podfile +name: Verify iOS native configuration on: pull_request: @@ -8,6 +8,10 @@ on: - ios/** - 'package.json' - 'package-lock.json' + - 'patches/react-native-nitro-sqlite/**' + - '.github/scripts/verifyNitroSQLiteDatabaseLocation.sh' + - '.github/scripts/nitroSQLiteDatabaseLocation/**' + - '.github/workflows/verifyPodfile.yml' jobs: verify: @@ -21,5 +25,8 @@ jobs: - name: Setup Node uses: ./.github/actions/composite/setupNode + - name: Verify native NitroSQLite database location + run: bash ./.github/scripts/verifyNitroSQLiteDatabaseLocation.sh + - name: Verify podfile run: ./.github/scripts/verifyPodfile.sh diff --git a/.nvmrc b/.nvmrc index 3eb1386fcc08..63f87c20ef9f 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -26.5.0 +26.12.1 diff --git a/.storybook/mockPaths.ts b/.storybook/mockPaths.ts index 2d97ca447146..960802b0d25d 100644 --- a/.storybook/mockPaths.ts +++ b/.storybook/mockPaths.ts @@ -8,6 +8,9 @@ const dirname = path.dirname(filename); /* eslint-disable @typescript-eslint/naming-convention */ export default { '@react-native-community/netinfo': path.resolve(dirname, '../__mocks__/@react-native-community/netinfo.ts'), - '@react-navigation/native': path.resolve(dirname, '../__mocks__/@react-navigation/native'), + // The static mock, plus a story-owned route stack so a whole flow can mount + '@react-navigation/native': path.resolve(dirname, './mocks/react-navigation-native.ts'), + // Modal backdrops read the stack card animation, which no story mounts + '@react-navigation/stack': path.resolve(dirname, './mocks/react-navigation-stack.ts'), }; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/.storybook/mocks/navigationStoryLoader.cjs b/.storybook/mocks/navigationStoryLoader.cjs new file mode 100644 index 000000000000..6e7bfe49cd2c --- /dev/null +++ b/.storybook/mocks/navigationStoryLoader.cjs @@ -0,0 +1,11 @@ +const path = require('path'); + +/** + * Rewrites the App's Navigation module for Storybook so navigate and goBack land on the story's route stack. + * A loader is used because `@libs` is a prefix alias that no alias entry for a single module can outrank. + */ +module.exports = function navigationStoryLoader(source) { + const routerPath = JSON.stringify(path.resolve(__dirname, './storyRouter.tsx')); + const renamed = source.replace(/export default \{/, 'const navigationForStorybook = {'); + return `import {withStoryRouter} from ${routerPath};\n${renamed}\nexport default withStoryRouter(navigationForStorybook);\n`; +}; diff --git a/.storybook/mocks/react-navigation-native.ts b/.storybook/mocks/react-navigation-native.ts new file mode 100644 index 000000000000..097840b9f798 --- /dev/null +++ b/.storybook/mocks/react-navigation-native.ts @@ -0,0 +1,86 @@ +import {useEffect} from 'react'; + +import {useStoryRouter} from './storyRouter'; + +const noop = () => {}; + +const staticRoute = {key: 'story', name: 'StoryFlow', params: {}}; +const staticNavigation = { + navigate: noop, + goBack: noop, + setParams: noop, + canGoBack: () => false, + getState: () => ({routes: []}), + isFocused: () => true, + addListener: () => noop, +}; + +/** Inside a StoryRouterProvider the route comes from the story's stack. Elsewhere no story mounts a navigator. */ +function useRoute() { + const router = useStoryRouter(); + return router ? {...staticRoute, params: router.current} : staticRoute; +} + +function useNavigation() { + const router = useStoryRouter(); + if (!router) { + return staticNavigation; + } + return {...staticNavigation, setParams: router.setParams, goBack: router.pop, canGoBack: () => true}; +} + +const useIsFocused = () => true; +const useTheme = () => ({}); +const useLocale = () => ({}); +const useNavigationState = () => undefined; +const usePreventRemove = noop; +const triggerTransitionEnd = noop; +const useFocusEffect = (callback: () => (() => void) | void) => { + useEffect(() => callback(), [callback]); +}; +function Link() { + return null; +} +function LinkingContext() { + return null; +} +function NavigationContainer() { + return null; +} +function ServerContainer() { + return null; +} +function ThemeProvider() { + return null; +} +const DarkTheme = {}; +const DefaultTheme = {}; +const useLinkBuilder = () => null; +const useLinkProps = () => null; +const useLinkTo = () => null; +const useScrollToTop = () => null; + +// Everything a story does not override comes from the navigation core +export * from '@react-navigation/core'; +export { + useRoute, + useNavigation, + useIsFocused, + useTheme, + useLocale, + useNavigationState, + usePreventRemove, + triggerTransitionEnd, + useFocusEffect, + Link, + LinkingContext, + NavigationContainer, + ServerContainer, + ThemeProvider, + DarkTheme, + DefaultTheme, + useLinkBuilder, + useLinkProps, + useLinkTo, + useScrollToTop, +}; diff --git a/.storybook/mocks/react-navigation-stack.ts b/.storybook/mocks/react-navigation-stack.ts new file mode 100644 index 000000000000..6ef3f6ec056f --- /dev/null +++ b/.storybook/mocks/react-navigation-stack.ts @@ -0,0 +1,27 @@ +// useCardAnimation returns React Native Animated values, not Reanimated ones +// eslint-disable-next-line no-restricted-imports +import {Animated} from 'react-native'; + +// The alias points the package name at this file, so the real module has to be reached by path, which has no declaration +// @ts-expect-error -- see above +// eslint-disable-next-line import/no-relative-packages +export * from '../../node_modules/@react-navigation/stack/lib/module/index.js'; + +const presented = new Animated.Value(1); +const idle = new Animated.Value(0); + +/** No story mounts a stack screen, so every modal is treated as fully presented */ +function useCardAnimation() { + return { + current: {progress: presented}, + next: undefined, + closing: idle, + swiping: idle, + inverted: presented, + index: 0, + layouts: {screen: {width: 0, height: 0}}, + insets: {top: 0, right: 0, bottom: 0, left: 0}, + }; +} + +export {useCardAnimation}; diff --git a/.storybook/mocks/storyRouter.tsx b/.storybook/mocks/storyRouter.tsx new file mode 100644 index 000000000000..41b38a883706 --- /dev/null +++ b/.storybook/mocks/storyRouter.tsx @@ -0,0 +1,106 @@ +import type {ReactNode} from 'react'; + +import React, {createContext, useContext, useEffect, useMemo, useRef, useState} from 'react'; + +type StoryRouteParams = {subPage?: string; action?: 'edit'}; + +type StoryRouter = { + current: StoryRouteParams; + push: (params: StoryRouteParams) => void; + replace: (params: StoryRouteParams) => void; + pop: () => boolean; + setParams: (params: StoryRouteParams) => void; +}; + +type ActiveStoryRouter = {routePrefix: string; api: StoryRouter}; + +const StoryRouterContext = createContext(undefined); + +let activeStoryRouter: ActiveStoryRouter | undefined; + +/** A one-screen stack for stories: the App's navigation hooks and helpers read and write it while a story is mounted */ +function StoryRouterProvider({routePrefix, initialParams = {}, children}: {routePrefix: string; initialParams?: StoryRouteParams; children: ReactNode}) { + const [stack, setStack] = useState([initialParams]); + const stackRef = useRef(stack); + useEffect(() => { + stackRef.current = stack; + }, [stack]); + + const api = useMemo( + () => ({ + current: stack.at(-1) ?? {}, + push: (params) => setStack((previous) => [...previous, params]), + replace: (params) => setStack((previous) => [...previous.slice(0, -1), params]), + pop: () => { + if (stackRef.current.length <= 1) { + return false; + } + setStack((previous) => previous.slice(0, -1)); + return true; + }, + setParams: (params) => setStack((previous) => [...previous.slice(0, -1), {...previous.at(-1), ...params}]), + }), + [stack], + ); + + useEffect(() => { + activeStoryRouter = {routePrefix, api}; + return () => { + activeStoryRouter = undefined; + }; + }, [routePrefix, api]); + + return {children}; +} + +function useStoryRouter(): StoryRouter | undefined { + return useContext(StoryRouterContext); +} + +function getActiveStoryRouter(): ActiveStoryRouter | undefined { + return activeStoryRouter; +} + +/** Reads `subPage` and `action` back out of a route the story's route builder produced */ +function parseStoryRoute(routePrefix: string, route: string): StoryRouteParams { + const path = route.split('?').at(0) ?? ''; + const rest = path.startsWith(routePrefix) ? path.slice(routePrefix.length) : path; + const [subPage, action] = rest.split('/').filter(Boolean); + return {subPage, action: action === 'edit' ? 'edit' : undefined}; +} + +type NavigationLike = { + navigate: (route: string, options?: {forceReplace?: boolean}) => void; + goBack: (backToRoute?: string, options?: unknown) => void; +}; + +/** Wraps the App's Navigation object so that, while a story router is mounted, navigation lands on its stack */ +function withStoryRouter(navigation: T): T { + const navigate: NavigationLike['navigate'] = (route, options) => { + const router = getActiveStoryRouter(); + if (!router) { + navigation.navigate(route, options); + return; + } + const params = parseStoryRoute(router.routePrefix, route); + if (options?.forceReplace) { + router.api.replace(params); + return; + } + router.api.push(params); + }; + const goBack: NavigationLike['goBack'] = (backToRoute, options) => { + const router = getActiveStoryRouter(); + if (!router) { + navigation.goBack(backToRoute, options); + return; + } + if (!router.api.pop() && backToRoute) { + router.api.replace(parseStoryRoute(router.routePrefix, backToRoute)); + } + }; + return {...navigation, navigate, goBack}; +} + +export {StoryRouterProvider, useStoryRouter, getActiveStoryRouter, parseStoryRoute, withStoryRouter}; +export type {StoryRouteParams}; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 8730a9acda24..da8a0da4930b 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,3 +1,4 @@ +import {CurrencyListContextProvider} from '@components/CurrencyListContextProvider'; import EnvironmentProvider from '@components/EnvironmentContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; @@ -9,7 +10,9 @@ import colors from '@styles/theme/colors'; import ComposeProviders from '@src/components/ComposeProviders'; import HTMLEngineProvider from '@src/components/HTMLEngineProvider'; +import KeyboardProvider from '@src/components/KeyboardProvider'; import {LocaleContextProvider} from '@src/components/LocaleContextProvider'; +import {ModalProvider} from '@src/components/Modal/Global/ModalContext'; import {KeyboardStateProvider} from '@src/components/withKeyboardState'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -22,12 +25,14 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import {SafeAreaProvider} from 'react-native-safe-area-context'; +import currencyList from '../tests/unit/currencyList.json'; import './fonts.css'; registerMiddlewares(); Onyx.init({ keys: ONYXKEYS, + initialKeyStates: {[ONYXKEYS.CURRENCY_LIST]: currencyList}, }); IntlStore.load(CONST.LOCALES.EN); @@ -38,9 +43,12 @@ const decorators = [ components={[ OnyxListItemProvider, LocaleContextProvider, + CurrencyListContextProvider, HTMLEngineProvider, SafeAreaProvider, PortalProvider, + KeyboardProvider, + ModalProvider, EnvironmentProvider, KeyboardStateProvider, SearchContextProvider, diff --git a/.storybook/rsbuild.config.ts b/.storybook/rsbuild.config.ts index 5f7bae2e98ad..4f7bdb02b54a 100644 --- a/.storybook/rsbuild.config.ts +++ b/.storybook/rsbuild.config.ts @@ -1,11 +1,15 @@ import type {RsbuildConfig} from '@rsbuild/core'; import {mergeRsbuildConfig} from '@rsbuild/core'; +import path from 'path'; +import {fileURLToPath} from 'url'; // Storybook 10 loads TS files directly and requires .ts extension for ESM imports // @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig import mockPaths from './mockPaths.ts'; +const dirname = path.dirname(fileURLToPath(import.meta.url)); + let envFile: string; switch (process.env.ENV) { case 'production': @@ -30,6 +34,15 @@ const rsbuildFinal = async (config: RsbuildConfig): Promise => { // they're already set in `shared.resolve.alias` and `mergeRsbuildConfig` keeps both. alias: mockPaths, }, + tools: { + rspack: (rspackConfig) => { + // `@libs` is a prefix alias no single-module alias can outrank, so the module is rewritten by a loader instead + rspackConfig.module?.rules?.push({ + test: /src\/libs\/Navigation\/Navigation\.ts$/, + use: [{loader: path.resolve(dirname, './mocks/navigationStoryLoader.cjs')}], + }); + }, + }, }); }; diff --git a/Mobile-Expensify b/Mobile-Expensify index 903b4645c4e9..31e1cb035552 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 903b4645c4e95b2a637d496db39b940292b84804 +Subproject commit 31e1cb035552ad475b8e512a74cdbf572e07c182 diff --git a/android/app/build.gradle b/android/app/build.gradle index 85d4aee072a0..f4cc74f78007 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009050804 - versionName "9.5.8-4" + versionCode 1009051601 + versionName "9.5.16-1" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/android/app/src/main/java/com/expensify/chat/AppStateTrackerModule.kt b/android/app/src/main/java/com/expensify/chat/AppStateTrackerModule.kt index 2f96578a4af3..5546916c38f9 100644 --- a/android/app/src/main/java/com/expensify/chat/AppStateTrackerModule.kt +++ b/android/app/src/main/java/com/expensify/chat/AppStateTrackerModule.kt @@ -4,6 +4,7 @@ import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.UiThreadUtil import com.expensify.chat.MainActivity @@ -25,4 +26,14 @@ class AppStateTrackerModule(reactContext: ReactApplicationContext) : ReactContex promise.resolve(false) } } + + /** + * Tells Android that NewDot finished its first screen, which records time to full display for this launch. + */ + @ReactMethod + fun reportFullyDrawn() { + UiThreadUtil.runOnUiThread { + reactApplicationContext.getCurrentActivity()?.reportFullyDrawn() + } + } } diff --git a/assets/animations/CustomAgents.lottie b/assets/animations/CustomAgents.lottie deleted file mode 100644 index 6f6b40b8c463..000000000000 Binary files a/assets/animations/CustomAgents.lottie and /dev/null differ diff --git a/assets/animations/ExpenseAssistant.lottie b/assets/animations/ExpenseAssistant.lottie deleted file mode 100644 index e2c0d9729eae..000000000000 Binary files a/assets/animations/ExpenseAssistant.lottie and /dev/null differ diff --git a/assets/animations/SpendAnalysis.lottie b/assets/animations/SpendAnalysis.lottie deleted file mode 100644 index f1cc7ab61cb5..000000000000 Binary files a/assets/animations/SpendAnalysis.lottie and /dev/null differ diff --git a/assets/emojis/types.ts b/assets/emojis/types.ts index a0dbeb41add0..54488f1c41d0 100644 --- a/assets/emojis/types.ts +++ b/assets/emojis/types.ts @@ -1,3 +1,4 @@ +import type en from '@src/languages/en'; import type IconAsset from '@src/types/utils/IconAsset'; type Emoji = { @@ -16,7 +17,7 @@ type Emoji = { type HeaderEmoji = { header: true; icon: IconAsset; - code: string; + code: keyof typeof en.emojiPicker.headers; }; type PickerEmoji = Emoji | HeaderEmoji; diff --git a/assets/images/integrationicons/exact-online-icon-square.svg b/assets/images/integrationicons/exact-online-icon-square.svg new file mode 100644 index 000000000000..104d1617da63 --- /dev/null +++ b/assets/images/integrationicons/exact-online-icon-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/product-illustrations/earth-with-coins.svg b/assets/images/product-illustrations/earth-with-coins.svg new file mode 100644 index 000000000000..eb9c97faaf63 --- /dev/null +++ b/assets/images/product-illustrations/earth-with-coins.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/user-pencil.svg b/assets/images/user-pencil.svg new file mode 100644 index 000000000000..7cc7518c5fe0 --- /dev/null +++ b/assets/images/user-pencil.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs index e899da2a5472..1194e9100074 100644 --- a/config/eslint/eslint.config.mjs +++ b/config/eslint/eslint.config.mjs @@ -619,6 +619,15 @@ const config = defineConfig([ }, rules: { '@typescript-eslint/no-deprecated': ['error', {allow: ['translateFn']}], + + // `ignorePrimitives: true` narrows this to the cases a type checker can decide: it reports + // `||` only when the left operand cannot be `''`, `0` or `false`, so `||` and `??` are + // provably equivalent. When the left operand can hold one of those, whether it counts as + // "missing" is domain knowledge the rule does not have — many API fields represent "unset" + // as an empty string rather than `null`, and several Onyx keys require `|| undefined` so an + // empty string never reaches a collection subscription. + // See contributingGuides/STYLE.md#accessing-object-properties-and-default-values + '@typescript-eslint/prefer-nullish-coalescing': ['error', {ignoreIfStatements: true, ignorePrimitives: true, ignoreTernaryTests: true}], }, }, @@ -643,7 +652,7 @@ const config = defineConfig([ }, { - files: ['src/libs/Navigation/types.ts'], + files: ['src/libs/Navigation/types.ts', 'src/libs/Navigation/paramLists/**/*.ts'], rules: { 'no-restricted-syntax': [ 'error', diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 968073a518b3..126425377a3a 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -14,7 +14,6 @@ "../../.github/actions/javascript/failureNotifier/failureNotifier.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../.github/actions/javascript/formatCodeCovComment/formatCodeCovComment.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../.github/actions/javascript/getAndroidRolloutPercentage/getAndroidRolloutPercentage.ts" "rulesdir/no-default-id-values" 1 -"../../.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../.github/actions/javascript/getPullRequestDetails/getPullRequestDetails.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../.github/actions/javascript/getPullRequestIncrementalChanges/getPullRequestIncrementalChanges.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 @@ -69,9 +68,8 @@ "../../src/components/AttachmentPicker/index.tsx" "react-hooks/refs" 1 "../../src/components/AttachmentPreview.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 -"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/GestureType" 3 +"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/GestureType" 2 "../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/LegacyComposedGesture" 1 -"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "react-hooks/refs" 3 "../../src/components/Attachments/AttachmentCarousel/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/Attachments/AttachmentCarousel/Pager/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Native" 2 @@ -84,16 +82,9 @@ "../../src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx" "react-hooks/refs" 1 "../../src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/AutoCompleteSuggestions/index.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/components/AutoSubmitModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/AutoUpdateTime.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 -"../../src/components/AvatarCropModal/AvatarCropView.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 -"../../src/components/AvatarCropModal/AvatarCropView.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 1 "../../src/components/AvatarCropModal/AvatarCropView.tsx" "no-restricted-imports" 1 -"../../src/components/AvatarCropModal/ImageCropView.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 -"../../src/components/AvatarCropModal/ImageCropView.tsx" "@typescript-eslint/no-deprecated/LegacyPanGesture" 1 "../../src/components/AvatarCropModal/ImageCropView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/AvatarCropModal/Slider.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 -"../../src/components/AvatarCropModal/Slider.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 1 "../../src/components/AvatarCropModal/Slider.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/AvatarSelector.tsx" "no-restricted-syntax" 2 "../../src/components/AvatarWithImagePicker.tsx" "react-hooks/set-state-in-effect" 2 @@ -102,14 +93,8 @@ "../../src/components/BookTravelButton.tsx" "no-restricted-imports" 1 "../../src/components/ButtonWithDropdownMenu/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/CategoryPicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/Charts/hooks/useChartInteractions.ts" "@typescript-eslint/no-deprecated/Gesture.Hover" 2 -"../../src/components/Charts/hooks/useChartInteractions.ts" "@typescript-eslint/no-deprecated/Gesture.Race" 2 -"../../src/components/Charts/hooks/useChartInteractions.ts" "@typescript-eslint/no-deprecated/Gesture.Tap" 2 "../../src/components/Charts/hooks/useChartLabelFormats.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Charts/hooks/useChartLabelMeasurements.ts" "rulesdir/no-raw-typography" 1 -"../../src/components/Charts/PieChart/PieChartContent.tsx" "@typescript-eslint/no-deprecated/Gesture.Hover" 2 -"../../src/components/Charts/PieChart/PieChartContent.tsx" "@typescript-eslint/no-deprecated/Gesture.Race" 2 -"../../src/components/Charts/PieChart/PieChartContent.tsx" "@typescript-eslint/no-deprecated/Gesture.Tap" 2 "../../src/components/Charts/utils/chartFontAssets.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 13 "../../src/components/Charts/utils/chartFontAssets.ts" "@typescript-eslint/no-unsafe-type-assertion" 13 "../../src/components/Charts/utils/chartFontsCache.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -118,10 +103,7 @@ "../../src/components/CheckboxWithLabel.tsx" "no-restricted-syntax" 1 "../../src/components/ColumnsSettingsList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ColumnsSettingsList.tsx" "no-restricted-imports" 1 -"../../src/components/Composer/implementation/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/Composer/implementation/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/Composer/implementation/index.tsx" "react-hooks/set-state-in-effect" 2 -"../../src/components/ConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ConfirmModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/ConnectionLayout.tsx" "no-restricted-imports" 1 "../../src/components/ConnectToMergeFlow/index.ios.tsx" "no-restricted-imports" 1 @@ -154,13 +136,8 @@ "../../src/components/DragAndDrop/Provider/index.tsx" "react-hooks/refs" 6 "../../src/components/DraggableList/SortableItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/DropZone/DropZoneWrapper.tsx" "react-hooks/refs" 1 -"../../src/components/DynamicForm/components/DynamicFormFieldRow.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 "../../src/components/EditableCell/usePopoverEditState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/EmojiPicker/CategoryShortcutButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/EmojiPicker/EmojiPicker.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/EmojiPicker/EmojiPicker.tsx" "react-hooks/refs" 3 -"../../src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/EmojiPicker/EmojiPickerMenu/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts" "react-hooks/set-state-in-effect" 2 "../../src/components/EmojiPicker/EmojiSkinToneList.tsx" "react-hooks/set-state-in-effect" 2 "../../src/components/EmptySelectionListContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -193,7 +170,6 @@ "../../src/components/HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx" "@typescript-eslint/no-deprecated/fontSize" 3 "../../src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/HTMLEngineProvider/HTMLRenderers/HTMLTableScroll/index.native.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 "../../src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -234,8 +210,6 @@ "../../src/components/Modal/BaseModal.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/Modal/Global/ConfirmModalWrapper.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/Modal/Global/ModalContext.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 -"../../src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx" "@typescript-eslint/no-deprecated/GestureType" 1 "../../src/components/Modal/ReanimatedModal/Container/index.web.tsx" "react-hooks/refs" 1 "../../src/components/MoneyReportHeader.tsx" "no-restricted-imports" 1 "../../src/components/MoneyReportHeaderActions/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -256,8 +230,6 @@ "../../src/components/MoneyRequestConfirmationListFooter/sections/ReceiptSection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MoneyRequestHeader.tsx" "no-restricted-imports" 1 "../../src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/MoneyRequestReportView/MoneyRequestViewReportFields.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 -"../../src/components/MoneyRequestReportView/MoneyRequestViewReportFields.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MultifactorAuthentication/components/Modals/createCancelConfirmModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/MultifactorAuthentication/components/OutcomeScreen/OutcomeScreenBase.tsx" "no-restricted-imports" 1 "../../src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -269,7 +241,7 @@ "../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Race" 2 "../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Simultaneous" 2 "../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/GestureRef" 1 -"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/GestureType" 2 +"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/GestureType" 1 "../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MultiGestureCanvas/index.tsx" "no-restricted-syntax" 1 "../../src/components/MultiGestureCanvas/index.tsx" "react-hooks/preserve-manual-memoization" 1 @@ -318,19 +290,13 @@ "../../src/components/RadioButtonWithLabel.tsx" "no-restricted-syntax" 1 "../../src/components/Reactions/AddReactionBubble.tsx" "react-hooks/refs" 1 "../../src/components/Reactions/MiniQuickEmojiReactions.tsx" "react-hooks/refs" 1 -"../../src/components/ReceiptCropView/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 4 -"../../src/components/ReceiptCropView/index.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 2 "../../src/components/ReceiptCropView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ReimbursementAccountLoadingIndicator.tsx" "no-restricted-imports" 1 "../../src/components/ReportActionAvatars/useReportPreviewSenderID.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/ReportActionItem/IssueCardMessage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/ReportActionItem/MoneyReportView.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 -"../../src/components/ReportActionItem/MoneyReportView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ReportActionItem/MoneyRequestReceiptView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ReportActionItem/MoneyRequestReceiptView.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/ReportActionItem/MoneyRequestView.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 5 -"../../src/components/ReportActionItem/receiptHoverUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ReportActionItem/SupportTicketView.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1 "../../src/components/ReportActionItem/TaskPreview.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ReportActionItem/TaskView.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1 @@ -371,7 +337,6 @@ "../../src/components/Search/index.tsx" "react-hooks/preserve-manual-memoization" 2 "../../src/components/Search/index.tsx" "react-hooks/refs" 5 "../../src/components/Search/index.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/components/Search/SearchAutocompleteInput.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/Search/SearchBulkActionsButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/Search/SearchContextDefinitions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -397,12 +362,10 @@ "../../src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchRouter/SearchRouter.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchRouter/SearchRouter.tsx" "no-restricted-imports" 1 -"../../src/components/Search/SearchRouter/SearchRouterUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchSingleSelectionPicker.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/SelectionButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/BaseSelectionList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/components/Footer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/SelectionList/hooks/useFlattenedSections.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/SelectionList/ListItem/SplitListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/utils/getListboxRole/index.web.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -423,7 +386,6 @@ "../../src/components/StatePicker/StateSelectorModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/StatePicker/StateSelectorModal.tsx" "no-restricted-imports" 1 "../../src/components/StateSelector.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 -"../../src/components/StatusBadge.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/SubStepForms/AddressStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/SubStepForms/ConfirmationStep.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 "../../src/components/SubStepForms/CountryFullStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -458,7 +420,6 @@ "../../src/components/TextPicker/TextSelectorModal.tsx" "no-restricted-imports" 1 "../../src/components/TextPicker/TextSelectorModal.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/TextWithTooltip/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/ThemeStylesContextProvider/default.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ThreeDotsMenu/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ThreeDotsMenu/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/TimeModalPicker.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 @@ -485,10 +446,6 @@ "../../src/components/VideoPlayer/BaseVideoPlayer.tsx" "react-hooks/refs" 13 "../../src/components/VideoPlayer/BaseVideoPlayer.tsx" "react-hooks/set-state-in-effect" 2 "../../src/components/VideoPlayer/useHandleNativeVideoControls/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 -"../../src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 1 -"../../src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 -"../../src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 1 "../../src/components/VideoPlayerContexts/PlaybackContext/playbackContextReportIDUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/WalletStatementModal/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/WideRHPContextProvider/useShouldRenderOverlay.ts" "react-hooks/set-state-in-effect" 1 @@ -526,15 +483,12 @@ "../../src/hooks/useCardFeedsForActivePolicies.ts" "no-restricted-imports" 1 "../../src/hooks/useCardFeedsForDisplay.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useCardFeedsForDisplay.ts" "no-restricted-imports" 1 -"../../src/hooks/useCreateReport.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useDebounce.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useDebouncedValue.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useDebounceNonReactive.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useDragAndDrop/types.ts" "@typescript-eslint/no-deprecated/React.MutableRefObject" 1 -"../../src/hooks/useDynamicBackPath.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useExportedToFilterOptions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useFilterFormValues.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/hooks/useHtmlPaste/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/hooks/useHydrateReportsFromSnapshot.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useImportSpreadsheetConfirmModal.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useInitial.ts" "react-hooks/refs" 4 @@ -545,7 +499,6 @@ "../../src/hooks/useKeyboardShortcut.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useLazyAsset.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/hooks/useLazyAsset.ts" "react-hooks/set-state-in-effect" 1 -"../../src/hooks/useListKeyboardNav.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/hooks/useMarkdownStyle.ts" "rulesdir/no-raw-typography" 3 "../../src/hooks/useNewTransactions.ts" "react-hooks/refs" 2 "../../src/hooks/useOnyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 @@ -574,11 +527,8 @@ "../../src/hooks/useStableIndexedHandler.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useStepFormSubmit.ts" "no-restricted-syntax" 1 "../../src/hooks/useSubPage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/hooks/useTackInputFocus/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/hooks/useTextWithMiddleEllipsis.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/hooks/useTransactionViolationOfWorkspace.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useViolations.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/hooks/useWindowDimensions/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/hooks/useWorkletStateMachine/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/languages/flattenObject.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/AccountingUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -598,8 +548,7 @@ "../../src/libs/actions/CompanyCards.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/CompanyCards.ts" "no-restricted-imports" 1 "../../src/libs/actions/CompanyCards.ts" "no-restricted-syntax" 4 -"../../src/libs/actions/connections/index.ts" "no-restricted-imports" 1 -"../../src/libs/actions/connections/index.ts" "no-restricted-syntax" 3 +"../../src/libs/actions/connections/index.ts" "no-restricted-syntax" 2 "../../src/libs/actions/connections/NetSuiteCommands.ts" "no-restricted-syntax" 2 "../../src/libs/actions/connections/QuickbooksDesktop.ts" "no-restricted-syntax" 2 "../../src/libs/actions/connections/QuickbooksOnline.ts" "no-restricted-syntax" 2 @@ -607,7 +556,6 @@ "../../src/libs/actions/connections/Xero.ts" "no-restricted-syntax" 2 "../../src/libs/actions/Delegate.ts" "no-restricted-syntax" 4 "../../src/libs/actions/Delegate.ts" "rulesdir/no-direct-personal-details-list" 1 -"../../src/libs/actions/Domain.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/actions/Domain.ts" "no-restricted-syntax" 1 "../../src/libs/actions/ExitSurvey.ts" "no-restricted-syntax" 1 "../../src/libs/actions/getCompanyCardBankConnection/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 @@ -625,29 +573,25 @@ "../../src/libs/actions/IOU/DeleteMoneyRequest.ts" "no-restricted-syntax" 1 "../../src/libs/actions/IOU/Duplicate.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../src/libs/actions/IOU/Duplicate.ts" "no-restricted-syntax" 2 -"../../src/libs/actions/IOU/Hold.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/actions/IOU/Hold.ts" "no-restricted-syntax" 2 "../../src/libs/actions/IOU/index.ts" "rulesdir/no-onyx-connect" 7 "../../src/libs/actions/IOU/MoneyRequest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/IOU/MoneyRequestBuilder.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 2 "../../src/libs/actions/IOU/MoneyRequestBuilder.ts" "@typescript-eslint/no-deprecated/translateLocal" 1 "../../src/libs/actions/IOU/MoneyRequestBuilder.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/actions/IOU/PayMoneyRequest.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 2 "../../src/libs/actions/IOU/PayMoneyRequest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/IOU/PayMoneyRequest.ts" "no-restricted-imports" 1 "../../src/libs/actions/IOU/PayMoneyRequest.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/IOU/PerDiem.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/actions/IOU/PerDiem.ts" "no-restricted-syntax" 2 "../../src/libs/actions/IOU/Receipt.ts" "no-restricted-syntax" 1 "../../src/libs/actions/IOU/RejectMoneyRequest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/IOU/RejectMoneyRequest.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/IOU/ReportWorkflow.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 3 +"../../src/libs/actions/IOU/ReportWorkflow.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 2 "../../src/libs/actions/IOU/ReportWorkflow.ts" "no-restricted-syntax" 1 "../../src/libs/actions/IOU/SendInvoice.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/IOU/SendInvoice.ts" "no-restricted-syntax" 1 "../../src/libs/actions/IOU/SendMoney.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/IOU/SendMoney.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/IOU/Split.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 1 "../../src/libs/actions/IOU/Split.ts" "@typescript-eslint/no-deprecated/Localize.translateLocal" 2 "../../src/libs/actions/IOU/Split.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/IOU/Split.ts" "no-restricted-syntax" 3 @@ -663,7 +607,6 @@ "../../src/libs/actions/Link.ts" "no-restricted-syntax" 2 "../../src/libs/actions/MapboxToken.ts" "no-restricted-syntax" 2 "../../src/libs/actions/MergeAccounts.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/MergeTransaction.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 1 "../../src/libs/actions/MergeTransaction.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/MergeTransaction.ts" "no-restricted-imports" 1 "../../src/libs/actions/MergeTransaction.ts" "no-restricted-syntax" 1 @@ -750,7 +693,6 @@ "../../src/libs/API/makeRequest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/AttendeeUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/BankAccountUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/canFocusInputOnScreenFocus/index.ts" "no-restricted-syntax" 1 "../../src/libs/CardFeedUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../src/libs/CardUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 12 "../../src/libs/CategoryOptionListUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -779,7 +721,6 @@ "../../src/libs/fileDownload/getImageResolution.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/fileDownload/index.android.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/fileDownload/index.ios.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/fileDownload/validateReceiptFile/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/filterArrayByMatch.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/focusComposerWithDelay/index.ts" "no-restricted-syntax" 1 "../../src/libs/focusComposerWithDelay/setTextInputSelection.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -790,7 +731,6 @@ "../../src/libs/hadTabNavigation.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/HttpUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/ImportOnyxStateUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 -"../../src/libs/InputUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/interceptAnonymousUser.ts" "no-restricted-syntax" 1 "../../src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/KeyboardShortcut/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 @@ -833,7 +773,6 @@ "../../src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx" "no-restricted-syntax" 1 "../../src/libs/Navigation/AppNavigator/Navigators/SearchFullscreenNavigator.tsx" "no-restricted-syntax" 1 "../../src/libs/Navigation/AppNavigator/Navigators/SettingsSplitNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx" "no-restricted-syntax" 1 "../../src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 @@ -848,7 +787,8 @@ "../../src/libs/Navigation/helpers/dynamicRoutesUtils/getDynamicRouteAdaptedState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/helpers/dynamicRoutesUtils/getDynamicRouteQueryParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/getAdaptedStateFromPath.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 +"../../src/libs/Navigation/helpers/dynamicRoutesUtils/stripDynamicRouteSuffixFromPath.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/Navigation/helpers/getAdaptedStateFromPath.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/libs/Navigation/helpers/getOnboardingRouteFromScreen.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/Navigation/helpers/getParamsFromRoute.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/Navigation/helpers/getPathFromState.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 @@ -870,7 +810,6 @@ "../../src/libs/Navigation/linkingConfig/config.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/Navigation/linkingConfig/RELATIONS/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/Navigation/Navigation.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 -"../../src/libs/Navigation/OnyxTabNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx" "@typescript-eslint/no-deprecated/NavigationContent" 1 "../../src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx" "@typescript-eslint/no-deprecated/NavigationContent" 1 "../../src/libs/Navigation/PlatformStackNavigation/navigationOptions/animation/withAnimation.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 @@ -897,14 +836,14 @@ "../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/PhoneNumber.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/PolicyUtils/connections.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/libs/PolicyUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/PolicyUtils/errors.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/PolicyUtils/rules.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/prepareRequestPayload/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../src/libs/prepareRequestPayload/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Pusher/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../src/libs/Pusher/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/QuickActionUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/ReceiptUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/ReportActionsUtils.ts" "@typescript-eslint/no-deprecated/reportAction.sequenceNumber" 1 "../../src/libs/ReportActionsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 109 "../../src/libs/ReportActionsUtils.ts" "rulesdir/no-onyx-connect" 2 @@ -914,11 +853,10 @@ "../../src/libs/ReportTitleUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/getPolicy" 27 "../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 16 -"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 8 +"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 7 "../../src/libs/ReportUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 31 "../../src/libs/ReportUtils.ts" "rulesdir/no-onyx-connect" 13 "../../src/libs/sanitizeLogParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/SearchAutocompleteUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/SearchQueryUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 48 "../../src/libs/SearchUIUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 71 "../../src/libs/SearchUIUtils.ts" "no-restricted-imports" 1 @@ -998,7 +936,6 @@ "../../src/pages/domain/Members/DomainMembersPage.tsx" "no-restricted-imports" 1 "../../src/pages/domain/Members/MoveUserBetweenGroupsPage.tsx" "no-restricted-imports" 1 "../../src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx" "no-restricted-imports" 1 -"../../src/pages/DynamicEditReportFieldPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/DynamicEditReportFieldPage.tsx" "no-restricted-imports" 1 "../../src/pages/DynamicFlagCommentPage.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1 "../../src/pages/DynamicFlagCommentPage.tsx" "no-restricted-imports" 1 @@ -1016,7 +953,6 @@ "../../src/pages/DynamicReportDetailsPage/ReportDetailsMenuPrivateNotesItem.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1 "../../src/pages/DynamicReportDetailsPage/ReportDetailsMenuSettingsItem.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1 "../../src/pages/DynamicReportDetailsPage/ReportDetailsMenuTrackExpenseItems.tsx" "@typescript-eslint/no-deprecated/MenuItem" 4 -"../../src/pages/DynamicReportDetailsPage/ReportDetailsNameSection.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 2 "../../src/pages/DynamicReportDetailsPage/ReportDetailsTitleSection.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 "../../src/pages/DynamicReportParticipantDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/DynamicReportParticipantDetailsPage.tsx" "no-restricted-imports" 1 @@ -1066,7 +1002,7 @@ "../../src/pages/inbox/report/LinkPreviewer.tsx" "@typescript-eslint/no-deprecated/fontSize" 3 "../../src/pages/inbox/report/ReactionList/HeaderReactionList.tsx" "no-restricted-syntax" 1 "../../src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 +"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "react-hooks/refs" 7 "../../src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx" "react-hooks/refs" 1 "../../src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1076,7 +1012,7 @@ "../../src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../src/pages/inbox/report/ReportActionItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/inbox/report/ReportActionItem.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "no-restricted-syntax" 1 "../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "react-hooks/refs" 5 "../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "react-hooks/set-state-in-effect" 1 @@ -1124,13 +1060,12 @@ "../../src/pages/iou/request/step/IOURequestEditReportCommon.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1 "../../src/pages/iou/request/step/IOURequestEditReportCommon.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/IOURequestStepAmount.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/iou/request/step/IOURequestStepConfirmation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/pages/iou/request/step/IOURequestStepConfirmation.tsx" "no-restricted-imports" 1 -"../../src/pages/iou/request/step/IOURequestStepConfirmation.tsx" "react-hooks/preserve-manual-memoization" 1 +"../../src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx" "no-restricted-imports" 1 +"../../src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx" "react-hooks/preserve-manual-memoization" 1 "../../src/pages/iou/request/step/IOURequestStepCurrencyModal.tsx" "no-restricted-imports" 1 "../../src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 2 "../../src/pages/iou/request/step/IOURequestStepDistanceMap.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 "../../src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/WebCamera.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1146,7 +1081,6 @@ "../../src/pages/iou/request/step/withFullTransactionOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/withWritableReportOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/LoadingPage.tsx" "no-restricted-imports" 1 -"../../src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx" "no-restricted-imports" 1 "../../src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx" "react-hooks/set-state-in-effect" 3 "../../src/pages/media/AttachmentModalScreen/AttachmentModalContext.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1249,14 +1183,14 @@ "../../src/pages/SetDefaultWorkspacePage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/AboutPage/AboutPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/AboutPage/AboutPage.tsx" "react-hooks/refs" 1 -"../../src/pages/settings/Agents/AddAgentPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Agents/AgentsPage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Agents/EditAgentPage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Agents/Fields/EditNamePage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Agents/Fields/EditPromptPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/settings/Agents/Fields/EditPromptPage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Agents/NewAgentPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/DynamicAddAgentPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/DynamicEditAgentPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/DynamicNewAgentPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/Fields/DynamicEditAgentAvatarPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/Fields/DynamicEditNamePage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/Fields/DynamicEditPromptPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/settings/Agents/Fields/DynamicEditPromptPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/AppDownloadLinks.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Copilot/CopilotPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/settings/Copilot/CopilotPage.tsx" "no-restricted-imports" 1 @@ -1613,8 +1547,6 @@ "../../src/pages/workspace/categories/DynamicDefaultCategorySelectorPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/categories/DynamicEditCategoryPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/categories/DynamicExpenseLimitTypeSelectorPage.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/categories/DynamicSpendCategorySelectorPage.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/categories/ImportedCategoriesPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/categories/WorkspaceCategoriesPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/categories/WorkspaceCategoriesPage.tsx" "react-hooks/set-state-in-effect" 1 @@ -1695,7 +1627,6 @@ "../../src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1 "../../src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 "../../src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/expensifyCard/issueNew/CardTypeStep.tsx" "@typescript-eslint/no-deprecated/MenuItem" 2 "../../src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1740,7 +1671,6 @@ "../../src/pages/workspace/members/ImportMembersPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/ImportMembersPage.tsx" "no-restricted-syntax" 1 "../../src/pages/workspace/members/ImportMembersPage.tsx" "rulesdir/no-default-id-values" 1 -"../../src/pages/workspace/members/WorkArrangementPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "react-hooks/set-state-in-effect" 1 @@ -1767,8 +1697,6 @@ "../../src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/receiptPartners/DynamicInviteReceiptPartnerPolicyPage.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1 -"../../src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/recruiting/approver/MergeATSApprovalModePage.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 "../../src/pages/workspace/recruiting/merge/filters/MergeATSFiltersPage.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 @@ -1802,7 +1730,6 @@ "../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/taxes/NamePage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/taxes/ValuePage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx" "no-restricted-imports" 1 @@ -1849,7 +1776,6 @@ "../../src/pages/workspace/WorkspaceMembersPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceNamePage.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/WorkspaceNewRoomPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/workspace/WorkspaceNewRoomPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceNewRoomPage.tsx" "react-hooks/set-state-in-effect" 3 "../../src/pages/workspace/WorkspaceOverviewClientIDPage.tsx" "no-restricted-imports" 1 diff --git a/contributingGuides/DEFER_AFTER_PAINT.md b/contributingGuides/DEFER_AFTER_PAINT.md new file mode 100644 index 000000000000..232ceb932352 --- /dev/null +++ b/contributingGuides/DEFER_AFTER_PAINT.md @@ -0,0 +1,68 @@ +# Defer after paint + +When an interaction navigates (submit an expense, open a report, switch a tab), the user waits for one thing: the destination on screen. Everything the destination does not need for that first frame should run after the navigation or modal transition ends, not during it. Work that lands mid-transition competes with the animation for the JS thread and shows up as dropped frames, a gap before the destination appears, or a flash of the previous page. + +The App has a small set of primitives for this, all waiting on the "transition finished" signal from `TransitionTracker` by default (`API.writeWhenReady` can accept custom barriers as well). Pick one from the table below instead of building a new mechanism. If none fits, flag a human and tell them the pattern needs to be thoughtfully extended. + +## Pick a primitive + +| Shape of the problem | Use | Details | +|---|---|---| +| Dismissing an RHP should show a fullscreen screen other than the one currently behind it (e.g. submit from Home lands on Spend), and that screen is known when the RHP mounts | `usePreMountDestination` | [NAVIGATION.md](NAVIGATION.md#pre-mounting-a-destination-behind-an-rhp), [PERF-18](../.claude/skills/app-coding-standards/rules/perf-18-use-pre-mount-destination.md) | +| A heavy subtree on a freshly navigated screen is not needed for first paint (dropdowns, secondary actions, heavy `useOnyx` consumers) | `` | [PERFORMANCE.md](PERFORMANCE.md#rendering-of-unnecessary-or-duplicated-child-components), [`NavigationDeferredMount.tsx`](../src/components/NavigationDeferredMount.tsx) | +| An API write whose optimistic data would re-render screens while a transition is still running | `API.writeWhenReady` with an armed transition barrier | [Deferring writes](#deferring-writes) below | +| One-off imperative work after a navigation, modal or keyboard transition (focus, scroll, a follow-up navigation) | `afterTransition` on `Navigation` methods and `KeyboardUtils.dismiss`, `Navigation.runAfterTransition` / `runAfterUpcomingTransition`, or `TransitionTracker.runAfterTransitions` | [INTERACTION_MANAGER.md](INTERACTION_MANAGER.md#when-to-use-runaftertransitions-directly) | + +The first two decide **what mounts when**. The last two decide **when work runs**. A single flow often combines them: the submit-expense flow pre-mounts the destination, reveals it, and only then applies the optimistic write through a barrier (see `SubmitExpenseOrchestrator.tsx`). + +## Deferring writes + +`API.writeWhenReady` works like `API.write`, but the optimistic update and the request both wait until a barrier settles, or until a safety timeout passes. After that, it runs the normal `write()` pipeline. + +Arm the barrier with `API.armTransitionBarrier()` when the interaction is triggered, before the navigation call, and pass it to the write once the write exists. A barrier attached later (after an `await`, a GPS lookup, or deep inside an action) can miss the transition. It then waits for the next one, which can be an unrelated keyboard or modal animation, or gives up after the start timeout (about 1s). One armed barrier can gate several writes from the same interaction. Call `cancel()` if the write never happens. + +```ts +const armed = API.armTransitionBarrier(); +Navigation.dismissModal(); +const location = await getCurrentLocation(); // placeholder: may finish before or after the dismiss transition ends +API.writeWhenReady(WRITE_COMMANDS.SOME_COMMAND, {...params, location}, onyxData, armed.barrier); +``` + +Arming isn't strictly required: when the write is built in the same function right after the navigation call, `API.createTransitionBarrier()` attaches in time too. Arming is the default because it doesn't depend on that timing. + +By default the barrier releases after the next transition of any kind. Arm it with `API.armTransitionBarrier('navigation')` when only a screen transition starting (or the ~1s start timeout) should end the start wait, for example when the same tap also dismisses the keyboard. + +A barrier is any `(signal: AbortSignal) => PromiseLike`, so a flow can wait on something other than a transition, for example Search's content layout in the submit flow. + +**Do not use `writeWhenReady` when:** + +- **The write may need to be cancelled.** A `writeWhenReady` call has no cancel handle once it is made. Use `afterTransition` with a cancelled flag instead. +- **The write is conditional.** A barrier decides _when_ the write happens, never _whether_. A rejected barrier still releases the write. Decide whether to write before calling it. +- **The write needs a `conflictResolver`.** A deferred request is not in the sequential queue until it executes, so conflict resolution can't see it. +- **Order matters across calls.** Two `writeWhenReady` calls can run in either order, because each waits on its own barrier. A plain `API.write` called after a `writeWhenReady` reaches the queue first, because `API.write` doesn't wait. +- **The barrier waits on a READ, without `{shouldClaimReadGate: false}`.** READs wait for pending deferred writes by default, so the READ waits for the write and the write waits for the READ until the safety timeout. Never let a barrier wait on a READ without passing that option (in the options object, the fifth argument of `writeWhenReady`). +- **Losing the write would be hard to recover from.** Pending writes are flushed when the app goes inactive or to the background, but a hard kill or crash loses them. Only defer writes the user can simply redo. + +## Rejected alternatives + +These were tried or proposed. Do not reintroduce them: + +- **Inline deferral in screen components** (`useState` flags plus a `useFocusEffect` flush, `setTimeout` with a guessed delay, `requestAnimationFrame` chains). This spreads flow-specific timing into screens that should not know about it. `ReportScreen` had a `useFocusEffect` flush for one submit case, which was removed when the submit flow moved to write barriers. `useDeferNonEssentials`, which `ReportScreen` uses to hold back non-essential UI after a submit, still defers inline, its migration is tracked in [#88596](https://github.com/Expensify/App/issues/88596). Use one of the primitives above. Flagged by [PERF-20](../.claude/skills/app-coding-standards/rules/perf-20-no-ad-hoc-deferral.md). +- **`deferredLayoutWrite`**, a channel that held writes until the destination screen flushed them on focus. Every destination screen had to flush it, and it was replaced by `API.writeWhenReady` barriers. +- **`InteractionManager.runAfterInteractions`**. Deprecated, see [INTERACTION_MANAGER.md](INTERACTION_MANAGER.md). +- **Compute-on-open for dropdowns and popovers** (subscribe to data only when the menu opens). It moves the cost to the moment the user taps, which hurts INP and makes the menu feel slow. Defer the mount with `NavigationDeferredMount` instead, so the work runs after the transition but before the tap. +- **A separate `scheduleAfterTransition(fn)` helper.** `TransitionTracker.runAfterTransitions` and the `afterTransition` option on `Navigation` already cover imperative call sites. + +## Telemetry + +Deferring work changes what "done" means, so a span has to pick which moment it measures: + +- **Perceived ready**: the destination is on screen, even if parts of it are placeholders. This is what the user waits for and what navigation spans should end on. `ManualSubmitToDestinationVisible` ends when the destination is on screen, for example when the report gains focus or the deferred Search skeleton lays out ([OBSERVABILITY_METRICS.md](OBSERVABILITY_METRICS.md#submit-expense-to-destination-visible)). +- **Fully mounted**: the deferred content is in. If this matters, measure it with a separate span instead of moving the end of the perceived-ready one. `Navigate to Reports Tab` has separate First Paint and Content Load spans for this reason. +- **INP**: deferred work still runs on the JS thread. If it lands while the user is interacting, it shows up as slow input instead of a slow navigation. Keep deferred renders in `startTransition`, which `NavigationDeferredMount` already does, so React can yield to input. + +Things to watch for: + +- **A pre-mounted destination has already laid out before the user submits.** A layout-only end trigger has nothing to fire on at reveal, so pair it with focus. `useSubmitToDestinationVisible` accepts `[FOCUS, LAYOUT]` for this. +- **A deferred write lands after the destination is visible.** If a span should include the optimistic data appearing, end it from the component that renders that data, not on focus. +- **Record which path ran.** The submit flow sets the `fast_path_handler` span attribute with `setFastPath`, so a regression can be traced to one path instead of looking like noise across all of them. diff --git a/contributingGuides/DYNAMIC_FORMS.md b/contributingGuides/DYNAMIC_FORMS.md new file mode 100644 index 000000000000..6ffae2d5ea1c --- /dev/null +++ b/contributingGuides/DYNAMIC_FORMS.md @@ -0,0 +1,142 @@ +# Dynamic Forms + +Some forms are described by data instead of JSX: the fields a Wise bank account needs depend on the country and currency, and the server sends them as a schema. `DynamicForm` renders such a schema with the regular form components, so these forms look and behave like every other form in the App. + +## Usage + +There are two ways in. + +### A whole flow: `DynamicFormFlow` + +`DynamicFormFlow` turns a schema into a multi-page flow, followed by a confirmation page with every answer. + +```tsx +import DynamicFormFlow from "@components/DynamicForm"; + + ROUTES.SOME_FLOW.getRoute(pageName, action)} + onSubmit={(answers) => submitSomeFlow(answers)} + onBack={() => Navigation.goBack()} +/>; +``` + +- `schema` is either `{pages}`, the pages as the server groups them, or `{fields}`, a flat list the App splits into pages (see [Pages](#pages)). +- The current page is kept in the route, through `useSubPage`. Pass `shouldReplaceRoute` for flows on dynamic routes. +- A new visit resumes on the first page its draft leaves incomplete, or on the last page when nothing is missing. +- `hasConfirmation` decides whether a confirmation page follows the last page. It is on by default; without it the last page's Confirm button submits. +- `layout` decides the step indicator: `auto` shows it at three or more shown pages, `stepper` always, `pages` never. +- Answers are saved to the form draft as the user types, and again, cleaned, when they leave a page with Next. +- `onPageSubmit` receives each page and its drafted answers, list entries included, when the user leaves it with Next, for flows that save page by page. Sensitive answers are not passed. +- `onSubmit` receives the answers to every visible field, sensitive ones included. Answers left on fields that were hidden later are not sent. +- `isSubmitting` and `submitError` show the confirmation page's Confirm button loading and the error from sending the answers. +- `onRefreshRequirements` is called when a field marked `refreshOnChange` changes, so the screen can fetch the schema again. Typed fields call it when the user leaves the input, not on every keystroke. +- `currency` is the currency of amount fields that let the user pick none. `maxFileSize` is the largest size of one file, in bytes, for file fields that set none. + +### One page: `DynamicFormFields` + +Render `DynamicFormFields` inside a `FormProvider` and validate the same content with `getDynamicFieldErrors`: + +```tsx +import DynamicFormFields from "@components/DynamicForm/components/DynamicFormFields"; +import getContentFields from "@components/DynamicForm/utils/getContentFields"; +import getDynamicFieldErrors from "@components/DynamicForm/utils/getDynamicFieldErrors"; + + getDynamicFieldErrors(getContentFields(content), values, translate)} + onSubmit={onSubmit} +> + {({ inputValues }) => } +; +``` + +Pass `inputValues` from the render prop, so `showWhen` and `dependsOn` react to the user's answers as they type. When `content` is one page of a bigger form, pass the whole form as `allFields`, since a field can depend on an answer asked on another page. Outside `DynamicFormFlow`, list entries are edited in a modal. + +## Pages + +A page is a heading with its own `content`: `key`, `title` or `titleKey`, `description` or `descriptionKey`, and an optional `showWhen`. Its `content` holds headings and fields in screen order. An item without a `type` is a heading; a heading shows only while its `showWhen` matches and a visible field follows it before the next heading. + +When the schema is a flat `{fields}` list, the App builds the pages: + +- Addresses, files, multi-selects, lists, dependent selects and selects with more than 10 options get a page of their own. +- Other fields share a page, up to 4 in a row. +- A field with `showWhen` joins the page of its controller and does not count toward that limit. + +A page is shown while its own `showWhen` matches and it has a visible field; a page that is not shown is skipped, and its fields are neither asked nor submitted. A page without a title is headed by its only question: the field's `question` or `questionKey` when it has one, else its label. An untitled page with several questions has no headline and no section name on the confirmation page, since no one question names it. A label never names a confirmation section, since the label is already on the row below it. Page keys become route segments, so they are made URL-safe and unique, and `confirm` is reserved for the confirmation page. + +## The field format + +`DynamicFormField` in `src/types/onyx/DynamicFormField.ts` is the contract with the server. Each `type` has its own variant, so a property such as `rule` or `currencyKey` only exists on the types that use it. + +| Type | Input | Type-specific properties | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `text` | `TextInput` | `regex`, `minLength`, `maxLength`, `example`, `keyboard`, `multiline`, `rule: 'legalName' \| 'phone'` | +| `number` | `TextInput` with the numeric keyboard | `regex`, `minLength`, `maxLength`, `example` | +| `select` | `ValuePicker`, or `PushRowWithModal` above 10 options | `values`, `dependsOn`, `presentation: 'tabs'` | +| `radio` | `RadioButtons` | `values`, `dependsOn`, `presentation: 'tabs'` | +| `multiselect` | `PushRowWithModal` with checkboxes and a Save button | `values`, `dependsOn` | +| `date` | `DatePicker` | `rule: 'dateOfBirth'` | +| `boolean` | Yes and No radio buttons, where No is an answer. With `presentation: 'checkbox'`, `CheckboxWithLabel`, which must be ticked when required. | `presentation: 'checkbox'` | +| `country` | `CountryPicker` | | +| `countryMultiselect` | `PushRowWithModal` with every country | | +| `currency` | `CurrencyPicker` | | +| `address` | The standard address form. The street is the field's value; the other parts are stored under `.city`, `.zipCode` and so on. | `rule: 'zipCode'` | +| `file` | `UploadFile` for PDF, JPG and PNG files, taking one file unless `maxFiles` allows more | `maxFiles`, `maxFileSize` | +| `amount` | `AmountForm`. With `currencyKey`, a `CurrencyPicker` above it saves the currency under that key. | `currencyKey` | +| `percent` | `PercentageForm`, from 1 to 100 | | +| `list` | Rows with Add, Edit and Remove. Each entry is edited on its own page in the flow, or in a modal outside it. | `itemFields`, `itemLabel`, `addItemDescription`, `minItems`, `maxItems` | + +Every type also takes: + +- `key`: unique within the form, and the field's draft key. A field without one is left out and logged. +- `label` or `labelKey`, and the same pair for `description`. A `*Key` is our translation and wins over the server's wording. +- `question` or `questionKey`: the headline of an untitled page where this is the only question, such as "What's your account number?". Without one, that page is headed by the label, which a text input then repeats. +- `required`. An optional address the user has started filling in is checked like a required one, so it is never sent in part. +- `readonly`: shown as a row with its value, never edited or validated +- `sensitive`: kept out of the form draft, see below +- `showWhen`: shown only while another answer is one of the listed values. A list answer counts when any chosen value matches. A field whose controlling field is hidden stays hidden. +- `refreshOnChange`: see `onRefreshRequirements` above + +A choice or list field that is the only question on its page is drawn as the page itself, as an inline list, with its question above it. A page without its own title takes the question as its headline, so the label is not repeated below it. Fields it reveals, such as an "Other" description, do not count, so the layout stays put when they appear. On a page with several questions, every choice, tabs included, shows its question above it. + +`maxFileSize` comes from the field when the schema sets it, else from the `maxFileSize` the screen passes. With neither, files of any size are taken. + +The server can send a type this App version does not know. The schema is typed `DynamicFormSchemaField` for that reason: such a field is left out and logged once, so a newer schema never blocks the form. + +## Privacy and accessibility + +- Every field row, read-only rows included, every confirmation row and the list entry editor are masked in session recordings. The mask sits on the row's `View`, so it also covers inputs that do not pass a FullStory class on, such as `CountryPicker`. +- The confirmation before removing a list entry names the entry only in its masked prompt, since the modal title cannot be masked. +- Radio buttons, Yes/No questions and tabs are named after their question for screen readers, and the bold labels above addresses, files and lists are headings. + +## Sensitive answers + +`sensitive` answers, such as SSNs, never reach the form draft. `DynamicFormFlow` keeps them in memory, in the RAM-only `ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS`, for one visit: + +- They are cleared when a new visit opens without a page in the route, and when the user leaves from the first page. +- They are cleared once `isSubmitting` turns false without `submitError`, or right after `onSubmit` when the screen passes no `isSubmitting`. +- They are never passed to `onPageSubmit`, since a page save would persist them in the request queue. +- The confirmation page shows only their last four characters, as in `•••-••-6789` for an SSN. + +## List fields + +A `list` field holds repeated entries, such as a company's owners. Its value is an array of entries, each holding its answers keyed by item field key and an `id` made on the device. + +- In the flow each entry is edited on its own page, using the `dynamicFormListItemForm` form, which the page fills with the entry's answers when it opens and empties when it closes. The modal outside the flow does the same. These pages come after the confirmation page and Next and Back skip them, so only Add, Edit or an entry's row opens them. +- Saving an entry drafts the list right away. Leaving the list page with Next drops the sensitive answers of entries the user removed. +- Sensitive entry answers are kept in memory per entry and are sent back with their entry on submit. They are checked with their entry, so one lost with the memory, such as after a restart, is asked for again. +- A list marked `sensitive` is kept in memory whole and never drafted. +- On the confirmation page each entry is a row of its own, with a letter avatar, that opens the entry. An empty list is a plain row that opens its page. +- `minItems`, `maxItems` and the first entry with a problem show as the list's error. + +## Adding a field type + +1. Add a variant to `DynamicFormField`. +2. Typecheck then points at every place that has to handle it: the renderers in `renderers/`, the validators in `utils/getDynamicFieldErrors.ts`, the supported types in `utils/isSupportedField.ts` and the input map in `DynamicFormFieldsTest.tsx`. +3. Map the field onto an existing input component. If the component's props do not fit a form input, add a thin adapter in `adapters/`: it takes `value`, `onInputChange` and `errorText`, renders one existing component, reads no Onyx and navigates nowhere, and passes `forwardedFSClass` on so the answer stays masked. If no component fits, add the input outside `DynamicForm` first, so other forms can use it too. diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md index 0880ce415641..478b1245f47b 100644 --- a/contributingGuides/FORMS.md +++ b/contributingGuides/FORMS.md @@ -341,6 +341,8 @@ InputWrapper component will automatically provide the following props to any inp ## Dynamic Form Inputs +For forms whose fields are described by data rather than JSX, use the schema-driven renderer in [DYNAMIC_FORMS.md](DYNAMIC_FORMS.md). + It's possible to conditionally render inputs (or more complex components with multiple inputs) inside a form. For example, an IdentityForm might be nested as input for a Form component. In order for Form to track the nested values properly, each field must have a unique identifier. It's not safe to use an index because adding or removing fields from the child Form component will not update these internal keys. Therefore, we will need to define keys and dynamically access the correlating child form data for validation/submission. diff --git a/contributingGuides/INTERACTION_MANAGER.md b/contributingGuides/INTERACTION_MANAGER.md index 2d5b10352e84..58cfc3f2b8cd 100644 --- a/contributingGuides/INTERACTION_MANAGER.md +++ b/contributingGuides/INTERACTION_MANAGER.md @@ -6,7 +6,7 @@ We used to carry patches that restored the real implementation (`react-native+…+restore-interaction-manager.patch`) and that opened interaction handles during screen transitions (`@react-navigation+native-stack+…+added-interaction-manager-integration.patch`). Both patches have been removed - the app no longer calls `InteractionManager` anywhere, so there was nothing left for them to serve. -`InteractionManager.runAfterInteractions` has been replaced with purpose-built alternatives that are more precise. +`InteractionManager.runAfterInteractions` has been replaced with purpose-built alternatives that are more precise. For how these fit together with pre-mounting, deferred mounts and deferred writes, see [DEFER_AFTER_PAINT.md](DEFER_AFTER_PAINT.md). ## Current state @@ -98,4 +98,4 @@ For reference, here's how the available timing primitives compare: | ---------------------- | ------------------------- | ------------------------- | --------------------- | | `rAF` | Next frame (~16ms) | None — just "next paint" | Web + RN | | `requestIdleCallback` | When idle (unpredictable) | None — "whenever free" | Web + RN (polyfilled) | -| `runAfterInteractions` | Same as `setImmediate` (stubbed) | None - nothing blocks the queue | RN only | +| `runAfterInteractions` | Native: same as `setImmediate` (stubbed). Web: after interaction handles clear, via `requestIdleCallback` | Native: none - nothing blocks the queue. Web: interaction handles | Web + RN | diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index ffffbbc2c49d..9e55f24f6257 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -372,7 +372,7 @@ const config: LinkingOptions['config'] = { }; ``` -3. Define types for a new screen in `src/libs/Navigation/types.ts`. +3. Define types for a new screen in `src/libs/Navigation/types.ts`. Screens of the Settings right-hand panel (`SettingsNavigatorParamList`) are defined in `src/libs/Navigation/paramLists/` instead: `AccountSettingsParamList.ts`, `WorkspaceSettingsParamList.ts` or `DomainSettingsParamList.ts`. ```ts type SettingsSplitNavigatorParamList = { diff --git a/contributingGuides/OBSERVABILITY_METRICS.md b/contributingGuides/OBSERVABILITY_METRICS.md index 3c457f3b127f..cf6dc3286803 100644 --- a/contributingGuides/OBSERVABILITY_METRICS.md +++ b/contributingGuides/OBSERVABILITY_METRICS.md @@ -143,6 +143,19 @@ This document lists all implemented telemetry metrics in the Expensify App. **Attributes**: `report_id`, `route_from` **Notes**: The flow enters through a native OS share surface (the iOS Share Extension or Android share intent) and continues in the loaded app’s participant selector. It is scoped to the submit flow only (route `SHARE_SUBMIT_DETAILS`); the shared selector's track/share flow (`SHARE_DETAILS`) is not instrumented. Abandoned attempts (user backs out before the screen renders) are canceled on unmount and tagged `canceled`. The existing `ShareExtensionOpenSubmitFlow` name is retained for Sentry historical continuity. +### Submit Expense to Destination Visible + +**Constant**: `CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE` +**Sentry Name**: `ManualSubmitToDestinationVisible` +**Threshold**: 400ms (P90) +**What's Measured**: Time from submitting an expense to its destination (the report or Search) being on screen +**Start**: The user submits an expense, from the confirmation step or a flow that skips it (`startTracking` in `src/libs/telemetry/submitFollowUpAction.ts`, called from `SubmitExpenseOrchestrator.tsx`, `submitWithDismissFirst.ts`, `SendMoney.ts` and `DynamicIOURequestStepCompanyInfo.tsx`) +**End**: +- User sees: The destination report or Search, possibly with deferred parts still showing placeholders +- Technical: Destination focus or layout, depending on the follow-up action (`useSubmitToDestinationVisible`, `useEndSubmitNavigationSpans`, the deferred Search skeleton layout in `SearchWithNavigationDeferredMount.tsx`). For `dismiss_modal_only`, some dismiss paths end the span in their `afterTransition` callback (`submitDismissStrategies.ts`, `dismissModalAndOpenReportInInboxTab.ts`). The rest, e.g. dismissing a super-wide RHP, leave it to the destination report, which ends it on focus or layout (`ReportScreen.tsx`, `SearchMoneyRequestReportPage.tsx`) +**Attributes**: `scenario`, `iou_type`, `iou_request_type`, `has_receipt`, `is_from_global_create`, `submit_follow_up_action`, `report_id`, `fast_path_handler`, `is_warm`, `was_list_empty`, `canceled` +**Notes**: Measures perceived ready, not fully mounted. See [DEFER_AFTER_PAINT.md](DEFER_AFTER_PAINT.md#telemetry) for how deferred and pre-mounted destinations affect it. Spans still open after 60s are canceled and not sent. + ### Send Message **Constant**: `CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE` diff --git a/contributingGuides/PAYMENT_VIA_EXPENSIFY.md b/contributingGuides/PAYMENT_VIA_EXPENSIFY.md index 0c91c02dade1..e2ef97984173 100644 --- a/contributingGuides/PAYMENT_VIA_EXPENSIFY.md +++ b/contributingGuides/PAYMENT_VIA_EXPENSIFY.md @@ -18,6 +18,7 @@ Include in the Description field a link to the payment confirmation posted by th **Important!:** If you’re new to NewDot payments, please double check your eligibility date against the date you were assigned to a GH issue. If you were assigned to a GH issue before your eligibility date, you must be paid via Upwork. +Once reports are submitted for payments, they often take a couple days to be approved, please wait at least 3 business days before commenting on the report. ## For issues with deposits If you're having an issue with the deposit, ie. a report gets _stuck_ in a state like Approved, start a chat with Concierge to ask for help. Provide as many details below as possible diff --git a/contributingGuides/PERFORMANCE.md b/contributingGuides/PERFORMANCE.md index 7bbffd894ab2..fc241fdb56bb 100644 --- a/contributingGuides/PERFORMANCE.md +++ b/contributingGuides/PERFORMANCE.md @@ -282,7 +282,7 @@ Since our codebase is very complex, it results in a large DOM tree rendered for One of the most common issues is related to modals, popovers, and tooltips — elements that may appear on the screen. The problem is that they are usually present in the DOM tree even when initially invisible. Because of this, the initial render time of a screen may increase, ultimately slowing down the app. -The solution is better control of invisible elements, making sure they are not included in the first render. This can be done, e.g., by a simple `return null`, smart usage of `lazy loading`, the `useTransition` hook, or the [``](/src/components/NavigationDeferredMount.tsx) component. `` gates a heavy subtree behind navigation transition completion via `TransitionTracker`, rendering a cheap `placeholder` until the nav animation has finished, then mounting its `children` inside `startTransition` — ideal for heavy subtrees (e.g. report headers, page-level secondary actions) mounted during navigation transitions that pull many `useOnyx` subscriptions or heavy hooks but don't need to be interactive on first render. +The solution is better control of invisible elements, making sure they are not included in the first render. This can be done, e.g., by a simple `return null`, smart usage of `lazy loading`, the `useTransition` hook, or the [``](/src/components/NavigationDeferredMount.tsx) component. `` gates a heavy subtree behind navigation transition completion via `TransitionTracker`, rendering a cheap `placeholder` until the nav animation has finished, then mounting its `children` inside `startTransition` — ideal for heavy subtrees (e.g. report headers, page-level secondary actions) mounted during navigation transitions that pull many `useOnyx` subscriptions or heavy hooks but don't need to be interactive on first render. For how it fits with the other ways of deferring work past a transition, see [DEFER_AFTER_PAINT.md](DEFER_AFTER_PAINT.md). Another issue worth mentioning is unnecessary code execution, especially for elements that are never shown on a specific platform. In theory, we separate the logic between platforms by using index.tsx/index.native.tsx files, but sometimes platform-specific logic may slip in, causing unnecessary execution. For example, this may happen when logic specific to a wide layout (applicable only for web) is included. @@ -368,7 +368,7 @@ ___ ## 6. Automated Tests & QA *Tests are required by default. If you cannot add them, explain why.* - [ ] **Unit Tests:** Added to prevent regression. -- [ ] **Reassure Tests:** Added (Required for execution time improvements). +- [ ] **Reassure Tests:** Added (Required for re-render improvements, since CI only checks render counts). For execution time improvements, add a `measureFunction` benchmark (`npm run perf-local`) and include the local before/after numbers from one machine in the PR description. - [ ] **Exception:** I cannot add automated tests because: _________________ - [ ] **Manual Verification:** I have included manual verification steps (Required). diff --git a/contributingGuides/PR_REVIEW_GUIDELINES.md b/contributingGuides/PR_REVIEW_GUIDELINES.md index 622e82512dd5..73f5c9ca74c4 100644 --- a/contributingGuides/PR_REVIEW_GUIDELINES.md +++ b/contributingGuides/PR_REVIEW_GUIDELINES.md @@ -58,6 +58,7 @@ For a comprehensive guide on standards and best practices for both PR authors an 1. Make sure any `setTimeout`s are absolutely necessary, not just a workaround for a situation that isn’t fully understood (e.g. a race condition). 2. Ensure Onyx is used appropriately. ([docs](https://github.com/expensify/react-native-onyx#merging-data)) +3. Work delayed until a navigation or modal transition ends should use the shared primitives, not inline state flushed on focus or a guessed `setTimeout`. See [DEFER_AFTER_PAINT.md](DEFER_AFTER_PAINT.md). ## Considerations around code reuse diff --git a/contributingGuides/REASSURE_PERFORMANCE_TEST.md b/contributingGuides/REASSURE_PERFORMANCE_TEST.md index be1f6bbcc2c6..0b614e0330c5 100644 --- a/contributingGuides/REASSURE_PERFORMANCE_TEST.md +++ b/contributingGuides/REASSURE_PERFORMANCE_TEST.md @@ -22,6 +22,9 @@ We use Reassure for monitoring performance regression. It helps us check if our - Identifying functions with heavy calculations. - Targeting functions that are frequently used throughout the app. +- `measureFunction` benchmarks only produce durations, so CI does not run them. Put new ones in `tests/perf-local/*.perf-local.ts`. +- Run them with `npm run perf-local -- --baseline` on the base and `npm run perf-local` on your branch, on the same machine, and put the numbers in the PR description. +- `perf-local` and `perf-test` share `.reassure/baseline.perf` and `current.perf`, so don't mix them. Pair `perf-local -- --baseline` with `perf-local`, and `perf-test -- --baseline` with `perf-test`, or every entry shows up as added or removed. ## Running tests locally @@ -35,7 +38,7 @@ We use Reassure for monitoring performance regression. It helps us check if our ## Metrics for Regression Detection - **Render count**: If the number of renders increases by one compared to the baseline, it will be considered a performance regression, leading to a failed test. This metric helps detect unexpected changes in component rendering behavior. *NOTE: sometimes regressions are intentional. For instance, if a new functionality is added to the tested component, causing an additional re-render, this regression is expected.* -- **Render duration**: A performance regression will occur if the measured rendering time is 20% higher than the baseline, resulting in a failed test. This threshold allows for reasonable fluctuations and accounts for changes that may lead to longer rendering times. +- **Render duration**: Durations are reported but not gated, because the baseline and the branch run on different machines. ## Tips for Performance Testing with Reassure diff --git a/contributingGuides/STYLE.md b/contributingGuides/STYLE.md index 98b3177b3c14..1e3144bb2b26 100644 --- a/contributingGuides/STYLE.md +++ b/contributingGuides/STYLE.md @@ -35,6 +35,7 @@ - [Object / Array Methods](#object--array-methods) - [Asynchronous code](#asynchronous-code) - [Accessing Object Properties and Default Values](#accessing-object-properties-and-default-values) + - [When `||` is the right operator](#when--is-the-right-operator) - [JSDocs](#jsdocs) - [Component props](#component-props) - [Destructuring](#destructuring) @@ -892,6 +893,33 @@ const name = user?.name || "default name"; const name = user?.name ?? "default name"; ``` +### When `||` is the right operator + +`??` only falls back on `null` and `undefined`. Use `||` when an empty string, `0` or `false` must fall back too, and leave a comment saying why. + +This comes up often because many API fields represent "unset" as an empty string rather than `null`: + +```ts +// GOOD - `exporter` is an empty string on a fresh connection, so it has to fall through +const exporter = exportConfig?.exporter || policy?.owner; +``` + +It is required when an ID is interpolated into an Onyx key, because an empty string subscribes to the whole collection instead of one record. Normalize it to `undefined` first — see [Default value for inexistent IDs](#default-value-for-inexistent-IDs): + +```ts +// GOOD - `|| undefined` stops an empty reportID subscribing to every report +const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID || undefined}`); +``` + +`||` is also correct when the expression is a boolean OR rather than a default, since `??` does not short circuit on `false`: + +```ts +// GOOD +return disabled || shouldBeDisabledByScreenReader || isExecuting; +``` + +[`@typescript-eslint/prefer-nullish-coalescing`](https://typescript-eslint.io/rules/prefer-nullish-coalescing/) only reports `||` where the left operand cannot be `''`, `0` or `false` — that is, where the two operators are provably equivalent. The cases above are judgment calls it cannot make, so they are not reported and do not need a suppression. + ## JSDocs - Omit comments that are redundant with TypeScript. Do not declare types in `@param` or `@return` blocks. Do not write `@implements`, `@enum`, `@private`, `@override`. eslint: [`jsdoc/no-types`](https://github.com/gajus/eslint-plugin-jsdoc/blob/main/.README/rules/no-types.md) diff --git a/contributingGuides/philosophies/SECURITY.md b/contributingGuides/philosophies/SECURITY.md index b452f0106a0b..208307a519b9 100644 --- a/contributingGuides/philosophies/SECURITY.md +++ b/contributingGuides/philosophies/SECURITY.md @@ -10,6 +10,7 @@ Updated rules for managing members across all types of chats in New Expensify. - Admins MUST NOT be able to leave or be removed from expense chats - Members MUST NOT be able to leave or be removed from the #announce room - Admins MUST NOT be able to leave or be removed from #admins +- Auditors MUST NOT be able to leave or be removed from #admins - Domain members MUST NOT be able to leave or be removed from their domain chat - Report submitters MUST NOT be able to leave or be removed from their reports - Report managers MUST NOT be able to leave or be removed from their reports @@ -32,50 +33,44 @@ Updated rules for managing members across all types of chats in New Expensify. - DM always has two participants. None of the participant can leave or be removed from the DM. Also no additional member can be invited to the chat. ### Workspace Rooms -#### Workspace -| | Creator | Member(Employee/User) | Admin | Auditor? | -| :----------------: | :-----: | :-------------------: | :---: | :------: | -| **Invite** | ✅ | ❌ | ✅ | ❌ | -| **Remove** | ✅ | ❌ | ✅ | ❌ | -| **Leave** | ❌ | ✅ | ❌ | ✅ | -| **Can be removed** | ❌ | ✅ | ✅ | ✅ | - -- Creator can't leave or be removed from their own workspace -- Admins can't leave from the workspace -- Admins can remove other workspace admins, as well as workspace members, and invited guests -- Creator can remove other workspace admins, as well as workspace members, and invited guests -- Members and Auditors cannot invite or remove anyone from the workspace - #### Workspace #announce room -| | Member(Employee/User) | Admin | Auditor? | -| :----------------: | :-------------------: | :---: | :------: | -| **Invite** | ❌ | ❌ | ❌ | -| **Remove** | ❌ | ❌ | ❌ | -| **Leave** | ❌ | ❌ | ❌ | -| **Can be removed** | ❌ | ❌ | ❌ | +| | Member(Employee/User) | Admin | Auditor | +| :----------------: | :-------------------: | :---: | :-----: | +| **Invite** | ❌ | ❌ | ❌ | +| **Remove** | ❌ | ❌ | ❌ | +| **Leave** | ❌ | ❌ | ❌ | +| **Can be removed** | ❌ | ❌ | ❌ | - No one can leave or be removed from the #announce room +- Auditors are added to #announce when they join the workspace, so they can't leave or be removed from it either +- Only workspace admins can post in #announce. Every other role, auditors included, sees the room as read-only -#### Workspace #admin room -| | Admin | -| :----------------: | :---: | -| **Invite** | ❌ | -| **Remove** | ❌ | -| **Leave** | ❌ | -| **Can be removed** | ❌ | +#### Workspace #admins room +| | Admin | Auditor | +| :----------------: | :---: | :-----: | +| **Invite** | ❌ | ❌ | +| **Remove** | ❌ | ❌ | +| **Leave** | ❌ | ❌ | +| **Can be removed** | ❌ | ❌ | - Admins can't leave or be removed from #admins +- Auditors are added to #admins when they are given the Auditor role, and membership follows the role rather than manual invites +- Auditors can read and comment in #admins. Unlike #announce, the room has no admins-only write capability +- No one can leave #admins, auditors included +- Auditors lose #admins when their role changes to one that has no #admins access. That is a role change, not a removal from the room #### Workspace rooms -| | Creator | Member | Guest(outside of the workspace) | -| :----------------: | :-----: | :----: | :-----------------------------: | -| **Invite** | ✅ | ✅ | ✅ | -| **Remove** | ✅ | ✅ | ❌ | -| **Leave** | ✅ | ✅ | ✅ | -| **Can be removed** | ✅ | ✅ | ✅ | +| | Creator | Member | Auditor | Guest(outside of the workspace) | +| :----------------: | :-----: | :----: | :-----: | :-----------------------------: | +| **Invite** | ✅ | ✅ | ✅ | ✅ | +| **Remove** | ✅ | ✅ | ✅ | ❌ | +| **Leave** | ✅ | ✅ | ✅ | ✅ | +| **Can be removed** | ✅ | ✅ | ✅ | ✅ | - Everyone can be removed/can leave from the room including creator - Guests are not able to remove anyone from the room +- Auditors are added to the workspace rooms that are not private when they are given the Auditor role. Private rooms are skipped, so they join those by invitation only +- Auditors are able to invite and remove other participants in the rooms that their role shared with them #### Expense chats | | Admin | Member(default) | Member(invited) | @@ -90,6 +85,9 @@ Updated rules for managing members across all types of chats in New Expensify. - Invited members(invited by members) are not able to invite or remove from the expense chat - Invited members(invited by members) are able to leave the expense chat - Default members and admins are able to remove invited members +- Auditors are added to every workspace expense chat, so they can see the expenses on the workspace, and they are able to comment +- Auditors are not able to be removed from an expense chat +- Auditors are able to leave another member's expense chat, but not their own ### Domain chat | | Member | @@ -100,12 +98,26 @@ Updated rules for managing members across all types of chats in New Expensify. - Domain members can't leave or be removed from their domain chat -4. ### Reports - | | Submitter | Manager | - | :----------------: | :-------: | :-----: | - | **Remove** | ❌ | ❌ | - | **Leave** | ❌ | ❌ | - | **Can be removed** | ❌ | ❌ | +### Reports +| | Submitter | Manager | +| :----------------: | :-------: | :-----: | +| **Remove** | ❌ | ❌ | +| **Leave** | ❌ | ❌ | +| **Can be removed** | ❌ | ❌ | - Report submitters can't leave or be removed from their reports (eg, if they are the report.accountID) - Report managers can't leave or be removed from their reports (eg, if they are the report.managerID) + +## Workspace membership +| | Creator | Member(Employee/User) | Admin | Auditor | +| :----------------: | :-----: | :-------------------: | :---: | :-----: | +| **Invite** | ✅ | ❌ | ✅ | ❌ | +| **Remove** | ✅ | ❌ | ✅ | ❌ | +| **Leave** | ❌ | ✅ | ❌ | ✅ | +| **Can be removed** | ❌ | ✅ | ✅ | ✅ | + +- Creator can't leave or be removed from their own workspace +- Admins can't leave from the workspace +- Admins can remove other workspace admins, as well as workspace members, and invited guests +- Creator can remove other workspace admins, as well as workspace members, and invited guests +- Members and Auditors cannot invite or remove anyone from the workspace diff --git a/cspell.json b/cspell.json index d8881a85216c..07b8cebe6eb5 100644 --- a/cspell.json +++ b/cspell.json @@ -733,6 +733,7 @@ "hybridapp", "iOSQRCode", "iaco", + "iban", "idempotently", "idfa", "ifdef", diff --git a/docs/articles/new-expensify/insights/View-the-Violations-by-submitter-report.md b/docs/Hidden/View-the-Violations-by-submitter-report.md similarity index 100% rename from docs/articles/new-expensify/insights/View-the-Violations-by-submitter-report.md rename to docs/Hidden/View-the-Violations-by-submitter-report.md diff --git a/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md b/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md index 19b1ed1d8b83..c3aa80b9b295 100644 --- a/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md +++ b/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md @@ -94,5 +94,5 @@ Only Domain Admins can access the Reconciliation tool. - Amount - Last four digits of the card number -**Note: Only posted transactions will be imported.** +**Note: Only posted transactions will be imported, except for cards connected through Plaid. Plaid cards also import pending transactions, which are marked as pending until they post.** diff --git a/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md b/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md index 39bd550f1bad..754345740a96 100644 --- a/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md +++ b/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md @@ -1,85 +1,90 @@ --- title: NS0068 Export Error in NetSuite Integration -description: Learn how to fix the NS0068 export error in NetSuite when the Created From field is not visible on the preferred transaction form. -keywords: NS0068, NetSuite Created From field not visible, permission to set value Created From, NetSuite transaction form visibility, Expensify Card journal entry error, Expensify NetSuite export error, Workspace Admin -internalScope: Audience is Workspace Admins using the NetSuite integration. Covers fixing the NS0068 export error caused by the Created From field not being visible on the preferred NetSuite form. Does not cover other NetSuite error codes. +description: Learn how to fix the NS0068 export error in NetSuite when Expensify can't set the Created From field (custbody_createdfrom_expensify) during export. +keywords: NS0068, NetSuite Created From field not visible, custbody_createdfrom_expensify, permission to set value Created From, Transaction Body Fields NetSuite, NetSuite transaction form visibility, Expensify Connect bundle, Expensify Card journal entry error, Expensify NetSuite export error, workspace admin +internalScope: Audience is workspace admins using the NetSuite integration. Covers fixing the NS0068 export error caused by the Created From field (custbody_createdfrom_expensify) not applying to the export record type, being hidden on the export form, not being editable by the Expensify role, or missing from NetSuite. Does not cover other NetSuite error codes. --- + + # NS0068 Export Error in NetSuite Integration If you see the error: -NS0068: You do not have permission to set value for element — 'Created From'. +NS0068: NetSuite won't let Expensify set the 'Created From' field (custbody_createdfrom_expensify). + +This means NetSuite is blocking Expensify from setting the **Created From** field during export. -This means the **Created From** field is not visible on the preferred transaction form in NetSuite. +The **Created From** field is a custom transaction body field with the ID `custbody_createdfrom_expensify`. The **Expensify Connect bundle** adds it to NetSuite. --- -## Why the NS0068 Export Error Happens in NetSuite +## Why the NS0068 export error happens in NetSuite The NS0068 error occurs when: +- The **Created From** field does not apply to the record type you're exporting to (vendor bills, expense reports, or journal entries). - The **Created From** field is hidden on the preferred export form. -- The form does not allow the integration to populate the Created From field. -- Expensify Card transactions are exported as journal entries and the Journal Entry form does not show the Created From field. +- The role used by the Expensify connection can't edit the **Created From** field. +- The **Created From** field is missing because the **Expensify Connect bundle** is not installed or is out of date. +- Expensify Card transactions are exported as journal entries and the journal entry form does not show the Created From field. Expensify must be able to write to the Created From field during export. --- -## How to Fix the NS0068 Export Error +## How to check the Created From field settings in NetSuite -### If Exporting Vendor Bills or Standard Transactions +1. Log in to **NetSuite** as an administrator. +2. Go to **Customization > Lists, Records, & Fields > Transaction Body Fields**. +3. Open the field with the ID **custbody_createdfrom_expensify**. +4. On the **Applies To** tab, confirm the option for your export type is selected: **Purchase** for vendor bills, **Expense Report** for expense reports, or **Journal** for journal entries. +5. On the **Access** tab, confirm **Default Access Level** is **Edit**. If the **Role** list includes the role used by the Expensify connection, such as **Expensify Integration**, confirm its access level is also **Edit**. +6. Select **Save**. -1. Log in to **NetSuite** as an Administrator. -2. Go to **Customization**. -3. Select **Forms**. -4. Click **Transaction Forms**. -5. Click **Edit** next to the form marked as **Preferred** for the export type. -6. Locate the **Created From** field. -7. Ensure the field is marked as **Show**. -8. Save the form. +If you can't find **custbody_createdfrom_expensify**, install or update the Expensify Connect bundle in NetSuite before continuing. [Learn how to install the Expensify Connect bundle in NetSuite](/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite). + +--- -Then in Expensify: +## How to show the Created From field on vendor bill and expense report forms -1. Go to **Settings**. -2. Select **Workspaces**. -3. Select your Workspace. -4. Click **Accounting**. -5. Click **Sync**. -6. Retry exporting the report. +1. Log in to **NetSuite** as an administrator. +2. Go to **Customization > Forms > Transaction Forms**. +3. Select **Edit** next to the form marked as **Preferred** for the export type. If you set a custom form ID for this export type in New Expensify, edit that form instead. +4. Go to **Screen Fields > Main**. +5. Confirm the **Created From** field is marked as **Show**. +6. Select **Save**. --- -### If Exporting Expensify Card Transactions as Journal Entries +## How to show the Created From field on Expensify Card journal entry forms + +1. Log in to **NetSuite** as an administrator. +2. Confirm **custbody_createdfrom_expensify** applies to journal entries, using the steps in **How to check the Created From field settings in NetSuite** above. +3. Go to **Customization > Forms > Transaction Forms**. +4. Select **Edit** next to the preferred **Journal Entry** form. +5. Go to **Screen Fields > Main**. +6. Confirm the **Created From** field is marked as **Show**. +7. Select **Save**. + +--- -1. Log in to **NetSuite**. -2. Go to **Customization**. -3. Select **Forms**. -4. Click **Transaction Forms**. -5. Click **Edit** next to the preferred **Journal Entry** form. -6. Go to **Screen Fields**. -7. Open the **Main** tab. -8. Ensure the **Created From** field is marked as **Show**. -9. Save the form. +## How to sync NetSuite and retry the export -Then in Expensify: +After updating NetSuite: -1. Go to **Settings**. -2. Select **Workspaces**. -3. Select your Workspace. -4. Click **Accounting**. -5. Click **Sync**. -6. Retry exporting the report. +1. Go to **Settings > Workspaces > [workspace name] > Accounting**. +2. Select **Sync Now**. +3. Retry exporting the report. --- # FAQ -## Does NS0068 Mean My Integration Is Broken? +## Does NS0068 mean my integration is broken? -No. This error is caused by form visibility settings in NetSuite. +No. This error is caused by the Created From field settings, form visibility, or role access in NetSuite. -## Do I Need to Reconnect NetSuite? +## Do I need to reconnect NetSuite? -No. Updating the form settings and running **Sync** is typically sufficient. +No. Updating the field and form settings and selecting **Sync Now** is typically sufficient. If the field is missing, install or update the Expensify Connect bundle first. diff --git a/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md new file mode 100644 index 000000000000..fd62cd6ec6db --- /dev/null +++ b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md @@ -0,0 +1,70 @@ +--- +title: INT133 Sync Error in Sage Intacct Integration +description: Learn what the INT133 sync error means in Sage Intacct and how to reactivate the web services user to restore your connection. +keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Expensify Sage Intacct sync error, PL04000076, Workspace Admin +internalScope: Audience is Workspace Admins using the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122). +--- + +# INT133 Sync Error in Sage Intacct Integration + +If you see the error: + +INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again. + +This means Sage Intacct rejected the connection because the web services user is set to **Inactive**, preventing the sync from completing. + +--- + +## Why the INT133 Sync Error Happens in Sage Intacct + +The INT133 error typically indicates: + +- The `xmlgateway_expensify` web services user was deactivated in Sage Intacct. +- An administrator changed the user's status to **Inactive** during a user cleanup or license review. +- The web services user that was used to connect the Workspace is no longer in use. + +Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first. + +--- + +## How to Fix the INT133 Sync Error + +This issue must be resolved in Sage Intacct and then retried in Expensify. + +### Reactivate the Web Services User in Sage Intacct + +1. Log in to Sage Intacct as an administrator. +2. Go to **Company > Admin > Web Services users**. +3. Locate the **xmlgateway_expensify** web services user. +4. Click **Edit**. +5. Change the **Status** to **Active**. +6. Click **Save**. + +If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials. + +--- + +### Retry the Sync in Expensify + +1. Go to **Settings > Workspaces**. +2. Select your Workspace. +3. Click **Accounting**. +4. Click **Sync Now**. + +Confirm the sync completes successfully. + +--- + +# FAQ + +## Do I Need Sage Intacct Admin Access? + +Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct. + +## Do I Need to Reenter My Credentials? + +Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in Expensify. + +## Does INT133 Mean the Integration Is Disconnected? + +Not necessarily. The connection settings are kept, but Sage Intacct rejects every sync until the web services user is active again. diff --git a/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md new file mode 100644 index 000000000000..fd62cd6ec6db --- /dev/null +++ b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md @@ -0,0 +1,70 @@ +--- +title: INT133 Sync Error in Sage Intacct Integration +description: Learn what the INT133 sync error means in Sage Intacct and how to reactivate the web services user to restore your connection. +keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Expensify Sage Intacct sync error, PL04000076, Workspace Admin +internalScope: Audience is Workspace Admins using the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122). +--- + +# INT133 Sync Error in Sage Intacct Integration + +If you see the error: + +INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again. + +This means Sage Intacct rejected the connection because the web services user is set to **Inactive**, preventing the sync from completing. + +--- + +## Why the INT133 Sync Error Happens in Sage Intacct + +The INT133 error typically indicates: + +- The `xmlgateway_expensify` web services user was deactivated in Sage Intacct. +- An administrator changed the user's status to **Inactive** during a user cleanup or license review. +- The web services user that was used to connect the Workspace is no longer in use. + +Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first. + +--- + +## How to Fix the INT133 Sync Error + +This issue must be resolved in Sage Intacct and then retried in Expensify. + +### Reactivate the Web Services User in Sage Intacct + +1. Log in to Sage Intacct as an administrator. +2. Go to **Company > Admin > Web Services users**. +3. Locate the **xmlgateway_expensify** web services user. +4. Click **Edit**. +5. Change the **Status** to **Active**. +6. Click **Save**. + +If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials. + +--- + +### Retry the Sync in Expensify + +1. Go to **Settings > Workspaces**. +2. Select your Workspace. +3. Click **Accounting**. +4. Click **Sync Now**. + +Confirm the sync completes successfully. + +--- + +# FAQ + +## Do I Need Sage Intacct Admin Access? + +Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct. + +## Do I Need to Reenter My Credentials? + +Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in Expensify. + +## Does INT133 Mean the Integration Is Disconnected? + +Not necessarily. The connection settings are kept, but Sage Intacct rejects every sync until the web services user is active again. diff --git a/docs/articles/expensify-classic/reports/Report-Approvals.md b/docs/articles/expensify-classic/reports/Report-Approvals.md index 1af13e89bf83..e6d41e32b1ab 100644 --- a/docs/articles/expensify-classic/reports/Report-Approvals.md +++ b/docs/articles/expensify-classic/reports/Report-Approvals.md @@ -263,7 +263,7 @@ Only **Domain Admins** have access. - Amount - The last four digits of the card -**Note:** Only posted credit card transactions will import. +**Note:** Only posted credit card transactions will import, except for cards connected through Plaid. Plaid cards also import pending transactions, which are marked as pending until they post. No. Concierge Receipt Audit is automatically enabled for all Control plan workspaces and cannot be turned off. @@ -282,7 +282,7 @@ Use the **Reconciliation tool** to find missing expenses: - Amount - Last four digits of the card number -**Note:** Only **posted transactions** will be imported. +**Note:** Only **posted transactions** will be imported, except for cards connected through Plaid. Plaid cards also import pending transactions, which are marked as pending until they post. ## Can admins delete expenses on behalf of members? diff --git a/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md b/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md index 75edd7314e20..4bb9b13535a7 100644 --- a/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md +++ b/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md @@ -52,6 +52,18 @@ To accomplish this, include phrases like: --- +## How to write instructions that unapprove expense reports + +**Goal:** Undoes the approval of an Approved expense report so it can be reviewed and approved again. The report returns to Outstanding (submitted), or to Draft if the submitter approved it themselves. Requires workspace admin role, unless the agent is the approver who gave the final approval. On a workspace that uses Dynamic External Workflow approvals, only a workspace admin can unapprove. A report that has already been paid through Expensify cannot be unapproved. + +To accomplish this, include phrases like: +- Unapprove reports over $10,000 +- Unapprove this report so it can be reviewed again +- Undo the approval on reports that were approved with violations +- Unapprove reports that were approved while an expense was missing a receipt + +--- + ## How to write instructions that reject expense reports **Goal:** Sends an entire Outstanding (submitted) expense report back to the submitter with a user-facing rejection comment explaining why. @@ -177,7 +189,7 @@ To accomplish this, include phrases like: ## How to write instructions that delete expense reports -**Goal:** Deletes an unsubmitted expense report the requester owns, after they confirm the deletion. Only reports that are still open or in draft can be deleted — a report that has been submitted, approved, reimbursed, or closed cannot be. Deleting an individual expense rather than a whole report is covered by the edit-expenses capability. +**Goal:** Deletes an unsubmitted expense report the requester owns, after they confirm the deletion. Only draft reports can be deleted — a report that has been submitted, approved, reimbursed, or closed cannot be. Deleting an individual expense rather than a whole report is covered by the edit-expenses capability. To accomplish this, include phrases like: - Delete this report @@ -236,7 +248,7 @@ To accomplish this, include phrases like: To accomplish this, include phrases like: - Send each employee a reminder to submit their expenses at the end of the month - Message the submitter directly when their report is rejected -- Notify individual workspace members of a workspace change +- Notify individual workspace members of an expense policy change - DM each person with outstanding reports a personalized nudge - Send a private message to the submitter when a receipt is missing - Reach out to each team member individually with their spending summary @@ -268,9 +280,21 @@ To accomplish this, include phrases like: --- +## How to write instructions that export a report as a PDF + +**Goal:** Generates a PDF of an expense report and attaches it to the chat as a downloadable file. When the chat is itself an expense report, it exports that report. From any other chat, such as a Concierge DM, the requester can choose a report by report ID, by submitter, or by what was bought. It does not change the report, and anyone who can view the report can ask for one. + +To accomplish this, include phrases like: +- Send me a PDF of this report +- Download this expense report as a PDF +- Export Alice's latest report to PDF +- Send me a PDF of my most recent report + +--- + ## How to write instructions that manage workspace settings and members -**Goal:** Inspects workspace configuration, answers membership questions, and — for workspace admins — adds or removes members, lists and assigns company cards from a connected card feed, and modifies workspace settings such as categories and approval rules. +**Goal:** Inspects workspace configuration, answers membership questions, reads the Expensify Card limit and remaining spend for anyone allowed to see the Expensify Card, and — for workspace admins — adds or removes members, lists and assigns company cards from a connected card feed, and modifies workspace settings such as categories and approval rules. To accomplish this, include phrases like: - Add new employees to the workspace when they join @@ -280,6 +304,7 @@ To accomplish this, include phrases like: - What is the current approval mode for this workspace? - Copy the category list from one workspace to another - Assign all unassigned company cards to the matching members +- How much of our Expensify Card limit is left? --- @@ -297,4 +322,4 @@ To accomplish this, include phrases like: --- -*This reference covers all 22 AI agent capabilities.* +*This reference covers all 24 AI agent capabilities.* diff --git a/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md b/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md index 35d0c3fdcd6c..4481d01a8a34 100644 --- a/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md +++ b/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md @@ -636,6 +636,12 @@ To react to this event, include phrases like: - When QuickBooks / NetSuite / Xero sync fails - When the accounting connection breaks +**A running count of how many of the workspace's reports are currently failing to export. There is one of these per workspace in its #admins room, kept up to date in place as reports start and stop failing, and addressed to the workspace's configured exporter.** + +To react to this event, include phrases like: +- When reports fail to export to accounting +- How many reports failed to export + **An accounting integration export failed for a specific report. This notification appears on the expense report itself, not in #admins.** To react to this event, include phrases like: @@ -920,4 +926,4 @@ To react to this event, include phrases like: --- -*This reference covers all 126 events AI agents can react to.* +*This reference covers all 127 events AI agents can react to.* diff --git a/docs/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Permissions.md b/docs/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Permissions.md index 1fb8f2825430..d70ec3a64667 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Permissions.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Permissions.md @@ -11,7 +11,7 @@ order: 2 # Learn About Billing Permissions -Every Workspace has a Workspace owner, sometimes called the billing owner. The Workspace owner is responsible for the Workspace's Expensify subscription and is the only person who can manage billing-related settings like plans and subscriptions. +Every Workspace has a Workspace owner, sometimes called the billing owner. The Workspace owner is responsible for the Workspace's Expensify subscription and is the only person who can manage billing-related settings like the subscription and payment method. Any Workspace Admin can change the Workspace's plan, which affects the Workspace owner's bill. To change the Workspace owner, see [Transfer Workspace Ownership](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Transfer-Workspace-Ownership). @@ -57,7 +57,7 @@ No. Each Workspace has one Workspace owner, although the same member can own mul ## Can Workspace Admins manage billing? -No. Workspace Admins can only manage workspace settings, but only the Workspace owner can manage billing and related settings. To learn more about the difference between Workspace Admin and Workspace owner, see [What member terms mean](/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Terms-and-Definitions). +Partly. Workspace Admins can manage Workspace settings and change the Workspace's plan, but only the Workspace owner can manage the subscription, payment method, and other billing settings. To learn more about changing the plan, see [Change Your Workspace Plan](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan). To learn more about the difference between Workspace Admin and Workspace owner, see [What member terms mean](/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Terms-and-Definitions). ## Who can view the Expensify billing receipts? diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md index 89d5a075c53c..f83efcdd6778 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md @@ -1,8 +1,8 @@ --- title: Change Your Workspace Plan description: Change your Workspace's Plan type to Collect or Control. -keywords: [New Expensify, change workspace plan, switch plan, Collect plan, Control plan, Submit plan, plan type, upgrade, downgrade, upgrade to Control, downgrade to Collect, upgrade Submit workspace, workspace admin] -internalScope: Audience is Workspace owners. Covers how to switch a Workspace between the Collect and Control plans, and how upgrading a free Submit workspace works. Does not cover the differences between plans or how to change a subscription. +keywords: [New Expensify, change workspace plan, switch plan, Collect plan, Control plan, Submit plan, plan type, upgrade, downgrade, upgrade to Control, downgrade to Collect, upgrade Submit workspace, Workspace Admin] +internalScope: Audience is Workspace Admins. Covers how to switch a Workspace between the Collect and Control plans, what happens in the #admins room after the plan changes, and how upgrading a free Submit workspace works. Does not cover the differences between plans or how to change a subscription. retrievalIntent: How to switch a Workspace plan between Collect and Control. contentType: task platform: new @@ -19,12 +19,23 @@ To learn which plan is right for your organization, see [Compare Collect and Con ## Who can change the workspace plan -Only the Workspace owner can change a workspace's plan. +Any Workspace Admin can change a Workspace's plan, including upgrading from Collect to Control and downgrading from Control to Collect. + +The Workspace owner pays for the Workspace, so plan changes made by any Workspace Admin affect the Workspace owner's bill. If you have an Annual subscription, changes to your plan may be restricted. To learn more, see [When You Can Change Your Plan](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management#when-you-can-change-your-plan). --- +## What happens after you change the Workspace plan + +When a Workspace Admin changes the plan, Expensify posts a message in the Workspace's **#admins** room. The message shows which Workspace Admin made the change, and the Workspace owner and all Workspace Admins can see it. + +- **Upgrade to Control:** The message says "[Admin name] upgraded this workspace to the Control plan." The Workspace Admin who upgraded also gets a private message in #admins with Control plan pricing. +- **Downgrade to Collect:** The message says "[Admin name] downgraded this workspace to the Collect plan." + +--- + ## How to switch to the Collect or Control plans 1. In the navigation tabs (on the left on web, at the bottom on mobile), go to **Workspaces > [workspace name]**. @@ -52,6 +63,14 @@ A free Submit workspace can be upgraded to Collect or Control. Once upgraded, it # FAQ +## Can a Workspace Admin change the Workspace plan? + +Yes. Any Workspace Admin can upgrade or downgrade the Workspace plan. Subscription settings, such as the subscription type and payment card, are still managed by the Workspace owner. + +## How will the Workspace owner know if a Workspace Admin changed the plan? + +Expensify posts every plan change in the Workspace's **#admins** room, which the Workspace owner and all Workspace Admins can see. The message shows which Workspace Admin upgraded or downgraded the Workspace. + ## Can I switch back to Control after downgrading to Collect? Yes. You can upgrade a workspace to Control at any time by returning to the **Plan type** setting. diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management.md index 390ec0b500d1..cd7a34adcdb2 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management.md @@ -1,8 +1,8 @@ --- title: Learn About Plan and Subscription Management description: Learn who can change a Workspace's plan or subscription in New Expensify and the conditions that apply, including when you can and can't downgrade. -keywords: [New Expensify, plan management, subscription management, change plan, change subscription, downgrade plan, workspace Owner, Collect, Control, Annual, Pay-per-use] -internalScope: Audience is Workspace owners. Covers who can change a Workspace's plan or subscription and the conditions that apply. Does not cover plan features or how to change a subscription. +keywords: [New Expensify, plan management, subscription management, change plan, change subscription, downgrade plan, workspace Owner, Workspace Admin, Collect, Control, Annual, Pay-per-use] +internalScope: Audience is Workspace owners and Workspace Admins. Covers who can change a Workspace's plan or subscription and the conditions that apply. Does not cover plan features or how to change a subscription. retrievalIntent: When are Workspace plan or subscription changes allowed. contentType: topic platform: new @@ -11,7 +11,7 @@ order: 1 # Learn About Plan and Subscription Management -Every paid Workspace has a plan and a subscription, and together they determine how billing is calculated. The Workspace owner can manage them, but changes may be restricted depending on your current subscription type. +Every paid Workspace has a plan and a subscription, and together they determine how billing is calculated. Any Workspace Admin can change the plan, and the Workspace owner manages the subscription. Changes may be restricted depending on your current subscription type. If you're new to Expensify billing, start with [Learn How Billing and Subscriptions Work](/articles/new-expensify/billing-and-subscriptions/Learn-How-Billing-and-Subscriptions-Work). @@ -19,9 +19,9 @@ If you're new to Expensify billing, start with [Learn How Billing and Subscripti ## Who can manage a plan or subscription -Only the Workspace owner can manage the Workspace's plan or subscription. Each workspace has exactly one owner, whose payment card is charged for the subscription. +Any Workspace Admin can change the Workspace's plan, including upgrading from Collect to Control and downgrading from Control to Collect. -Being a Workspace Admin doesn't include plan and subscription management permissions. Admins can manage members and workspace settings, but they can't change the Workspace's plan or subscription unless they first take over ownership. +Only the Workspace owner can manage the Workspace's subscription. Each Workspace has exactly one owner, whose payment card is charged for the subscription. Plan changes made by any Workspace Admin affect the Workspace owner's bill. To learn more, see [Learn About Billing Permissions](/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Permissions). @@ -57,7 +57,7 @@ Use these articles to complete common plan and subscription tasks: ## Can a Workspace Admin change the plan or subscription? -No. Only the Workspace owner can manage the Workspace's plan or subscription. If an admin needs to make these changes, they must first become the Workspace owner. [Learn how to transfer workspace ownership](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Transfer-Workspace-Ownership). +A Workspace Admin can change the plan, but not the subscription. Only the Workspace owner can manage the Workspace's subscription. If a Workspace Admin needs to change the subscription, they must first become the Workspace owner. [Learn how to transfer Workspace ownership](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Transfer-Workspace-Ownership). ## Why can't I switch from Control to Collect? diff --git a/docs/articles/new-expensify/concierge-ai/Concierge-Basics.md b/docs/articles/new-expensify/concierge-ai/Concierge-Basics.md index f89eb4ce7de1..0299cec75300 100644 --- a/docs/articles/new-expensify/concierge-ai/Concierge-Basics.md +++ b/docs/articles/new-expensify/concierge-ai/Concierge-Basics.md @@ -64,7 +64,7 @@ Yes! Just be clear in your message. Concierge can understand multi-step instruct # Can I talk to a real person instead of Concierge? -Absolutely. Just say you’d like to chat with a human, and we’ll connect you—24/7. +Absolutely. Just say you’d like to chat with a human, and we’ll connect you—24/7. You can also select **Account > Help > Talk to a human**. If you’re working with an onboarding specialist or account manager, Concierge can schedule a call with them for you. diff --git a/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md b/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md index 29403cce3bac..aa5d5e5eff58 100644 --- a/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md +++ b/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md @@ -41,6 +41,7 @@ If you haven’t set up a company card feed yet, learn how to set one up: ## What happens after you assign company cards - Posted transactions from the assigned card import automatically into the member's account as expenses. +- If the card is connected through Plaid, pending transactions also import and are marked as pending until they post. [Learn which connections import pending transactions](/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards#do-pending-transactions-import-from-a-direct-connection). - Expensify imports transactions from the **Transaction start date** onward. - The imported company card expenses are added to reports and submitted according to workspace rules. diff --git a/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md b/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md index 6ba11d39b978..17f47f5b2997 100644 --- a/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md +++ b/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md @@ -24,12 +24,14 @@ Anyone with an Expensify account can connect up to two personal cards for free. 2. Tap **Add personal card**. 3. Select your country from the country selector. 4. If you selected **United States**, choose your bank from the bank selector. - - If your bank is listed, you'll be prompted to connect via your bank's secure login (oAuth). + - If your bank is listed, you'll be prompted to directly connect via your bank's secure login. - If your bank is not listed, select **Other** to connect via Plaid. 5. Follow the prompts to log into your bank and authorize the connection. Once your card is connected, it will appear under **Cards** in the **Wallet**, and transactions will begin importing automatically. +If your card is connected through Plaid, pending transactions also import. They're marked as pending and update automatically when they post. Cards connected directly through your bank's secure login import transactions after they post. + --- ## What to do if you see a company card warning diff --git a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md index fe63c007544a..31606ab30014 100644 --- a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md +++ b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md @@ -1,7 +1,7 @@ --- title: Set up a Direct Connection for Company Cards description: Learn how to connect a company card account using a direct connection so its cards can be assigned to workspace members and their transactions imported into Expensify. -keywords: [New Expensify, set up direct company card connection, connect company cards Expensify, direct connection Expensify, direct feed Expensify, Plaid company cards] +keywords: [New Expensify, set up direct company card connection, connect company cards Expensify, direct connection Expensify, direct feed Expensify, Plaid company cards, pending company card transactions, Plaid pending transactions, company card current balance, company card remaining limit] internalScope: Audience is workspace admins and card admins. Covers connecting a company card account using a direct connection or Plaid. Does not cover assigning cards, managing cards, or troubleshooting connections. retrievalIntent: How to set up a direct connection to a bank or card provider for company cards. contentType: task @@ -48,6 +48,8 @@ To set up a direct connection, you must: - Cards with recent expenses appear as a list and can be assigned to workspace members. - After a card is assigned, posted transactions import into the assigned member's account automatically as expenses. +- If the card is connected through Plaid, pending transactions also import. They're marked as pending until they post. [Learn which connections import pending transactions](#do-pending-transactions-import-from-a-direct-connection). +- For connections made through Plaid, the **Company cards** page shows the account's **Current balance** and **Remaining limit** when your bank reports them. [Learn how to assign company cards](/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards). @@ -92,6 +94,20 @@ If you're still unable to connect to your bank, you can [import company card tra Yes, direct connections can be shared across workspaces. [Learn how to share a company card connection across workspaces](/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces). +## Do pending transactions import from a direct connection? + +Pending transactions import only for cards connected through Plaid (the **Other** option in the bank list). Cards connected to any other bank in the list import transactions after they post, which typically takes 1–3 business days. + +When a pending transaction imports: + +- It's marked as pending until it posts. +- It updates automatically when the transaction posts. +- It's removed if the bank cancels the transaction or it doesn't post. + +A report can't be submitted or approved while every expense on it is pending. + +If you delete a pending expense, the transaction may import again after it posts. + ## Can I import debit card transactions? Yes, debit card transactions can be imported by following the same steps for setting up a direct connection for company cards. diff --git a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md index 8b429d92725c..d86370751e60 100644 --- a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md +++ b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md @@ -52,6 +52,7 @@ To share a company card connection, you must: - Individual cards appear as a list and can be assigned to workspace members. - After a card is assigned, posted transactions import into the assigned member's account automatically as expenses. +- If the card is connected through Plaid, pending transactions also import and are marked as pending until they post. [Learn which connections import pending transactions](/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards#do-pending-transactions-import-from-a-direct-connection). [Learn how to assign company cards](/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards). diff --git a/docs/articles/new-expensify/connections/campfire/Configure-Campfire.md b/docs/articles/new-expensify/connections/campfire/Configure-Campfire.md index 49fb760cd1a7..d79213a477e3 100644 --- a/docs/articles/new-expensify/connections/campfire/Configure-Campfire.md +++ b/docs/articles/new-expensify/connections/campfire/Configure-Campfire.md @@ -39,7 +39,7 @@ On the Campfire connection, select **Import** to configure: - **Chart of accounts** – Imports your Campfire accounts as categories. This setting is always on. - **Enable newly imported accounts** – Choose whether new Campfire accounts arrive as enabled categories. This setting is off by default. - **Department** and Campfire dimensions – Under **All Campfire dimensions import as tags**, turn on each dimension you want to import. Each one you turn on becomes its own tag list. All dimensions are off by default. -- **Tax rates** – Import Campfire tax rates as workspace taxes. This option only appears when your Campfire subsidiary has tax rates. +- **Tax rates** – Import Campfire tax rates as workspace taxes. This option only appears when your Campfire entity has tax rates. --- diff --git a/docs/articles/new-expensify/connections/campfire/Connect-to-Campfire.md b/docs/articles/new-expensify/connections/campfire/Connect-to-Campfire.md index 115bcd07b846..504873da269b 100644 --- a/docs/articles/new-expensify/connections/campfire/Connect-to-Campfire.md +++ b/docs/articles/new-expensify/connections/campfire/Connect-to-Campfire.md @@ -1,14 +1,14 @@ --- title: Connect to Campfire -description: Learn how to connect Campfire to Expensify with an API key, choose a subsidiary, and reconnect or disconnect the integration. -keywords: [New Expensify, Campfire integration, connect Campfire, Campfire API key, Campfire subsidiary, Campfire accounting sync, Campfire authentication error, disconnect Campfire, Workspace Admin] -internalScope: Audience is Workspace Admins connecting Campfire as an accounting integration. Covers adding the Campfire connection, entering the API key, choosing a subsidiary, fixing an authentication error, and disconnecting. Does not cover configuring import, export, or advanced sync settings, or exporting reports. +description: Learn how to connect Campfire to Expensify with an API key, choose an entity, and reconnect or disconnect the integration. +keywords: [New Expensify, Campfire integration, connect Campfire, Campfire API key, Campfire entity, Campfire accounting sync, Campfire authentication error, disconnect Campfire, Workspace Admin] +internalScope: Audience is Workspace Admins connecting Campfire as an accounting integration. Covers adding the Campfire connection, entering the API key, choosing an entity, fixing an authentication error, and disconnecting. Does not cover configuring import, export, or advanced sync settings, or exporting reports. order: 1 --- # Connect to Campfire -Connect Campfire to your Expensify workspace to sync your accounting data. This article walks you through connecting Campfire with an API key, choosing a subsidiary, and completing the initial setup. +Connect Campfire to your Expensify workspace to sync your accounting data. This article walks you through connecting Campfire with an API key, choosing an entity, and completing the initial setup. Once connected, the integration imports: @@ -47,10 +47,10 @@ Expensify validates the API key and starts the first sync with Campfire. After the connection is established, the **Connections** section updates to show your connected Campfire integration, including: - The connection status and last sync timestamp. -- The selected subsidiary. +- The selected entity. - The **Import**, **Export**, and **Advanced** configuration tabs. -If your Campfire organization has multiple subsidiaries, you can choose which one to connect. If only one eligible subsidiary is available, Expensify selects it automatically. The selected subsidiary determines which accounting data is available in Expensify. +If your Campfire organization has multiple entities, you can choose which one to connect. If only one eligible entity is available, Expensify selects it automatically. The selected entity determines which accounting data is available in Expensify. After the first sync, you can configure how data imports and exports. Learn how to [configure Campfire import, export, and advanced settings](/articles/new-expensify/connections/campfire/Configure-Campfire). @@ -97,10 +97,10 @@ If your Campfire API key is rejected or later becomes invalid, the connection sh Yes. Expensify encrypts your API key and stores it securely on its servers. The key is never exposed in the app or stored on your device. -## Why can’t I select a Campfire subsidiary? +## Why can’t I select a Campfire entity? -**Subsidiary** is only selectable when your Campfire organization has more than one eligible subsidiary. Expensify only uses active entities and skips elimination and adjusting entities. If none are eligible, the sync fails. Add an active entity in Campfire, then sync the connection again. +**Entity** is only selectable when your Campfire organization has more than one eligible entity. Expensify only uses active entities and skips elimination and adjusting entities. If none are eligible, the sync fails. Add an active entity in Campfire, then sync the connection again. ## Why don’t I see tax rates after connecting to Campfire? -The **Tax rates** option only appears on the **Import** page when your Campfire subsidiary has tax rates set up. +The **Tax rates** option only appears on the **Import** page when your Campfire entity has tax rates set up. diff --git a/docs/articles/new-expensify/connections/campfire/Export-Reports-to-Campfire.md b/docs/articles/new-expensify/connections/campfire/Export-Reports-to-Campfire.md index ec8e46dfbb6b..67947ec9a326 100644 --- a/docs/articles/new-expensify/connections/campfire/Export-Reports-to-Campfire.md +++ b/docs/articles/new-expensify/connections/campfire/Export-Reports-to-Campfire.md @@ -47,7 +47,7 @@ The report history shows when the export starts and when it finishes. 3. In the selection bar, select **Export**. 4. Select **Campfire**. -**Note:** You can only export reports together when their workspaces connect to the same Campfire subsidiary. +**Note:** You can only export reports together when their workspaces connect to the same Campfire entity. --- diff --git a/docs/articles/new-expensify/connections/dualentry/Configure-DualEntry.md b/docs/articles/new-expensify/connections/dualentry/Configure-DualEntry.md index ca1fd7f15564..276b758e94ef 100644 --- a/docs/articles/new-expensify/connections/dualentry/Configure-DualEntry.md +++ b/docs/articles/new-expensify/connections/dualentry/Configure-DualEntry.md @@ -28,8 +28,6 @@ Each Workspace has its own DualEntry integration. To view or update the configur 2. Select **Accounting**. 3. On the DualEntry connection, select **Import**, **Export**, or **Advanced**. -![The Accounting page showing the connected DualEntry integration with the Import, Export, and Advanced settings visible.]({{site.url}}/assets/images/DualEntry_configure.png){:width="100%"} - --- ## How to configure DualEntry import settings diff --git a/docs/articles/new-expensify/connections/dualentry/Connect-to-DualEntry.md b/docs/articles/new-expensify/connections/dualentry/Connect-to-DualEntry.md index e889edf5515c..d6a1a1865471 100644 --- a/docs/articles/new-expensify/connections/dualentry/Connect-to-DualEntry.md +++ b/docs/articles/new-expensify/connections/dualentry/Connect-to-DualEntry.md @@ -2,13 +2,13 @@ title: Connect to DualEntry description: Learn how to connect DualEntry to Expensify with an API key to sync your accounting data. keywords: [New Expensify, DualEntry integration, connect DualEntry, DualEntry API key, accounting sync, DualEntry setup] -internalScope: Audience is Workspace Admins connecting DualEntry as an accounting integration. Covers adding the DualEntry connection, entering the API key, and selecting a subsidiary. Does not cover configuring import, export, or advanced sync settings. +internalScope: Audience is Workspace Admins connecting DualEntry as an accounting integration. Covers adding the DualEntry connection, entering the API key, and selecting a company. Does not cover configuring import, export, or advanced sync settings. order: 1 --- # Connect to DualEntry -Connect DualEntry to your Expensify workspace to sync your accounting data. This article walks you through connecting DualEntry, selecting a subsidiary, and completing the initial setup. +Connect DualEntry to your Expensify workspace to sync your accounting data. This article walks you through connecting DualEntry, selecting a company, and completing the initial setup. Once connected, the integration imports: @@ -35,7 +35,7 @@ To connect DualEntry, you must: 4. In the **DualEntry setup** pages, follow the on-screen instructions to generate an API key in DualEntry. 5. Paste the key into the **API key** field and select **Confirm**. -Expensify validates the API key, connects to your DualEntry organization, and retrieves your available subsidiaries. +Expensify validates the API key, connects to your DualEntry organization, and retrieves your available companies. **Note:** Your API key is encrypted and stored securely on Expensify's servers. It is never exposed in the app or stored on your device. @@ -48,10 +48,10 @@ Expensify validates the API key, connects to your DualEntry organization, and re After the connection is established, the **Connections** section updates to show your connected DualEntry integration, including: - The connection status and last sync timestamp. -- The selected subsidiary. +- The selected company. - The **Import**, **Export**, and **Advanced** configuration settings. -If your DualEntry organization has multiple subsidiaries, you can choose which one to connect. If only one eligible subsidiary is available, Expensify selects it automatically. The selected subsidiary determines which accounting data is available in Expensify. +If your DualEntry organization has multiple companies, you can choose which one to connect. If only one eligible company is available, Expensify selects it automatically. The selected company determines which accounting data is available in Expensify. After the initial sync completes, you can configure your import, export, and advanced accounting settings. Learn how to [configure your DualEntry import, export, and advanced settings](/articles/new-expensify/connections/dualentry/Configure-DualEntry). @@ -87,9 +87,9 @@ If your DualEntry API key is rejected or later becomes invalid, the connection s Tax rates are only available when DualEntry returns active VAT or GST rates for your organization. When available, tax rate import is off by default and can be enabled from the DualEntry **Import** settings. -## Can I change which subsidiary is used? +## Can I change which company is used? -Yes. If your DualEntry organization has multiple subsidiaries, you can select a different subsidiary at any time from the **Connections** section. Expensify will sync data from the newly selected subsidiary, including its accounts, dimensions, and tax rates. +Yes. If your DualEntry organization has multiple companies, you can select a different company at any time from the **Connections** section. Expensify will sync data from the newly selected company, including its accounts, dimensions, and tax rates. ## How can I disconnect DualEntry from a workspace? diff --git a/docs/articles/new-expensify/connections/dualentry/Manage-Vendor-Matching-for-DualEntry.md b/docs/articles/new-expensify/connections/dualentry/Manage-Vendor-Matching-for-DualEntry.md index b63936276903..042198847fce 100644 --- a/docs/articles/new-expensify/connections/dualentry/Manage-Vendor-Matching-for-DualEntry.md +++ b/docs/articles/new-expensify/connections/dualentry/Manage-Vendor-Matching-for-DualEntry.md @@ -1,8 +1,8 @@ --- title: Manage Vendor Matching for DualEntry -description: Learn how DualEntry vendor matching assigns vendors to non-reimbursable company card expenses before they export, including automatic matching, manual selection, subsidiary scoping, and default vendor behavior. +description: Learn how DualEntry vendor matching assigns vendors to non-reimbursable company card expenses before they export, including automatic matching, manual selection, company scoping, and default vendor behavior. keywords: [DualEntry, vendor matching, vendor, company card expenses, default vendor for all company cards, Credit Card Misc, company card export] -internalScope: Audience is workspace admins using the DualEntry connection for company card exports. Covers imported DualEntry vendors, subsidiary scoping, automatic and manual vendor assignment, default vendor behavior, the Credit Card Misc export fallback, and vendors that are no longer valid. Does not cover DualEntry connection setup, other DualEntry configuration settings, or vendor matching for other accounting connections. +internalScope: Audience is workspace admins using the DualEntry connection for company card exports. Covers imported DualEntry vendors, company scoping, automatic and manual vendor assignment, default vendor behavior, the Credit Card Misc export fallback, and vendors that are no longer valid. Does not cover DualEntry connection setup, other DualEntry configuration settings, or vendor matching for other accounting connections. order: 3 --- @@ -24,9 +24,9 @@ If your workspace isn't connected to DualEntry yet, learn how to [connect to Dua Expensify only imports the vendors a workspace can actually export to. A DualEntry vendor appears in Expensify when it is: - Active in DualEntry. - - Either assigned to the **Subsidiary** selected for the connection, or not assigned to any subsidiary. + - Either assigned to the **Company** selected for the connection, or not assigned to any company. -Vendors assigned to a different subsidiary are not available, so changing the **Subsidiary** on the connection changes which vendors you can select. +Vendors assigned to a different company are not available, so changing the **Company** on the connection changes which vendors you can select. The same list is used everywhere vendors appear in Expensify: automatic matching, the **Vendor** field on an expense, workspace merchant rules, and the **Default vendor for all company cards** setting. @@ -83,7 +83,7 @@ If no **Credit Card Misc** vendor exists in DualEntry, Expensify creates one on ## What happens when a DualEntry vendor is no longer valid -If a vendor assigned to an expense is deactivated in DualEntry, or is no longer available to the selected **Subsidiary**, the expense displays a **Vendor no longer valid** error, similar to category, tag, and tax violations. +If a vendor assigned to an expense is deactivated in DualEntry, or is no longer available to the selected **Company**, the expense displays a **Vendor no longer valid** error, similar to category, tag, and tax violations. Select an active vendor on the expense to clear the error. You can also select **None** to leave the **Vendor** field empty. @@ -105,11 +105,11 @@ Yes. Once a workspace admin manually assigns a vendor to an expense, Expensify p ## Why don't I see any vendors to choose from? -The vendor selector shows **No vendors found** when no DualEntry vendors are available to your workspace. This happens when your DualEntry vendor list is empty, when every vendor is inactive, or when every vendor belongs to a different subsidiary than the one selected for the connection. Add or activate the vendors in DualEntry, then sync the connection again. +The vendor selector shows **No vendors found** when no DualEntry vendors are available to your workspace. This happens when your DualEntry vendor list is empty, when every vendor is inactive, or when every vendor belongs to a different company than the one selected for the connection. Add or activate the vendors in DualEntry, then sync the connection again. -## Why did a vendor disappear after I changed the subsidiary? +## Why did a vendor disappear after I changed the company? -Vendors are scoped to the **Subsidiary** selected for the DualEntry connection. After you change the subsidiary, vendors that belong only to the previous subsidiary are no longer available, and expenses or merchant rules using them need an available vendor selected instead. +Vendors are scoped to the **Company** selected for the DualEntry connection. After you change the company, vendors that belong only to the previous company are no longer available, and expenses or merchant rules using them need an available vendor selected instead. ## How do I know why Expensify assigned a vendor automatically? diff --git a/docs/articles/new-expensify/connections/intuit-enterprise-suite/Configure-Intuit-Enterprise-Suite.md b/docs/articles/new-expensify/connections/intuit-enterprise-suite/Configure-Intuit-Enterprise-Suite.md new file mode 100644 index 000000000000..68fe4ed51d1b --- /dev/null +++ b/docs/articles/new-expensify/connections/intuit-enterprise-suite/Configure-Intuit-Enterprise-Suite.md @@ -0,0 +1,83 @@ +--- +title: Configure Intuit Enterprise Suite +description: Learn how to configure Intuit Enterprise Suite import, export, and advanced settings in Expensify, including importing custom dimensions as tags. +keywords: [New Expensify, Intuit Enterprise Suite, IES, Intuit Enterprise Suite configuration, Intuit Enterprise Suite import, Intuit Enterprise Suite export, custom dimensions, custom dimensions as tags] +internalScope: Audience is workspace admins configuring an existing Intuit Enterprise Suite connection. Covers import settings (including custom dimensions), export settings, and advanced settings. Does not cover the initial connection. +--- + +# Configure Intuit Enterprise Suite + +Configure how Expensify imports accounting data from Intuit Enterprise Suite and exports expenses back to your accounting system. + +If you haven't connected Intuit Enterprise Suite yet, learn how to [connect to Intuit Enterprise Suite](/articles/new-expensify/connections/intuit-enterprise-suite/Connect-to-Intuit-Enterprise-Suite). + +Once configured, you can: + +- Import categories and dimensions from Intuit Enterprise Suite. +- Export out-of-pocket and company card expenses. +- Automatically sync reports and reimbursements. + +--- + +## Where to find Intuit Enterprise Suite settings + +Each workspace has its own Intuit Enterprise Suite integration. To view or update the configuration settings: + +1. In the navigation tabs (on the left on web, on the bottom on mobile) select **Workspaces > [workspace name]**. +2. Select **Accounting**. +3. On the Intuit Enterprise Suite connection, choose **Import**, **Export**, or **Advanced**. + +![Accounting page showing the Intuit Enterprise Suite connection]({{site.url}}/assets/images/Accounting_Intuit-Enterprise-Suite.png){:width="100%"} + +--- + +## How to configure Intuit Enterprise Suite import settings + +The **Import** tab controls which accounting data is imported from Intuit Enterprise Suite into Expensify. + +- **Chart of accounts** – Imports as Categories. This setting is always enabled. +- **Dimensions** – Choose which Intuit Enterprise Suite custom dimensions to import as tags. Each available dimension can be enabled independently. + +--- + +## How to configure Intuit Enterprise Suite export settings + +The **Export** tab determines how Expensify sends data to Intuit Enterprise Suite. + +- **Preferred exporter** – Assign the workspace admin to automatically receive reports to export. +- **Export date** – Choose the date to use when exporting reports to Intuit Enterprise Suite. You can choose **Date of last expense**, **Export date**, or **Submitted date**. +- **Export out-of-pocket expenses as** – Choose whether reimbursable expenses export as **Check**, **Journal entry**, or **Vendor bill**. +- **Export invoices to** – Choose which account to use when exporting invoices to Intuit Enterprise Suite. +- **Export company card expenses as** – Choose whether non-reimbursable expenses export as **Credit card**, **Debit card**, or **Vendor bill**. +- **Export Expensify Card transactions as** – Expensify Card transactions export as **Credit card**. + +--- + +## How to configure Intuit Enterprise Suite advanced settings + +The **Advanced** tab controls settings like sync frequency, reimbursement, and settlement automation. + +- **Auto-sync** – Automatically sync Expensify and Intuit Enterprise Suite every day and export reports automatically using your selected export method. +- **Invite employees** – Choose whether to import Intuit Enterprise Suite employee records and automatically invite employees to the workspace. +- **Auto-create entities** – Choose whether Expensify automatically creates vendors in Intuit Enterprise Suite when they don't already exist, and creates customers when exporting invoices. +- **Sync reimbursed reports** – Automatically create bill payments in Intuit Enterprise Suite when reports are reimbursed in Expensify. + +--- + +# FAQ + +## Why don't I see my custom dimensions on the Import page? + +Custom dimensions only appear when: + +- The workspace is connected to Intuit Enterprise Suite, not QuickBooks Online. +- The custom dimension is active in the connected Intuit Enterprise Suite entity. +- The connection has synced since the custom dimension was created. + +## Can I import a custom dimension as report fields instead of tags? + +No. Custom dimensions always import as tags. + +## Who can configure Intuit Enterprise Suite settings? + +Workspace admins on the Control plan with **Accounting** enabled under **More features** and Intuit Enterprise Suite connected to the workspace. diff --git a/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-Intuit-Enterprise-Suite.md b/docs/articles/new-expensify/connections/intuit-enterprise-suite/Connect-to-Intuit-Enterprise-Suite.md similarity index 85% rename from docs/articles/new-expensify/connections/quickbooks-online/Connect-to-Intuit-Enterprise-Suite.md rename to docs/articles/new-expensify/connections/intuit-enterprise-suite/Connect-to-Intuit-Enterprise-Suite.md index d654711a7476..5cebd282751a 100644 --- a/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-Intuit-Enterprise-Suite.md +++ b/docs/articles/new-expensify/connections/intuit-enterprise-suite/Connect-to-Intuit-Enterprise-Suite.md @@ -1,8 +1,8 @@ --- title: Connect to Intuit Enterprise Suite description: Connect Intuit Enterprise Suite to your workspace in New Expensify to sync your accounting data automatically. -keywords: [New Expensify, Intuit Enterprise Suite, IES, connect Intuit Enterprise Suite, Intuit Enterprise Suite integration, QuickBooks Online, accounting sync, Control plan, Workspace Admin] -internalScope: Audience is Workspace Admins. Covers connecting Intuit Enterprise Suite to a workspace in New Expensify. Does not cover configuring import and export settings after connecting, connecting QuickBooks Online, or QuickBooks Desktop. +keywords: [New Expensify, Intuit Enterprise Suite, IES, connect Intuit Enterprise Suite, Intuit Enterprise Suite integration, QuickBooks Online, accounting sync, Control plan, workspace admin] +internalScope: Audience is workspace admins. Covers connecting Intuit Enterprise Suite to a workspace in New Expensify. Does not cover configuring import and export settings after connecting, connecting QuickBooks Online, or QuickBooks Desktop. --- # Connect to Intuit Enterprise Suite @@ -12,16 +12,18 @@ Connect Intuit Enterprise Suite to your Expensify workspace to sync your account Once connected, the integration imports: - Your chart of accounts as categories. - - Your dimensions, like Departments, Projects, and Classes, as tags. + - Your custom dimensions as tags (when enabled). - Tax rates (when enabled). +Custom dimensions are turned off until you enable each one. Learn how to [configure Intuit Enterprise Suite](/articles/new-expensify/connections/intuit-enterprise-suite/Configure-Intuit-Enterprise-Suite). + --- ## Who can connect Intuit Enterprise Suite to a workspace To connect Intuit Enterprise Suite, you must: -- Be a Workspace Admin with a workspace on the Control plan. +- Be a workspace admin with a workspace on the Control plan. - Have **Accounting** enabled on your workspace, under **More features**. - Have Intuit Enterprise Suite login credentials. @@ -56,7 +58,7 @@ The Intuit Enterprise Suite integration is only available on the Control plan. O ## Can I change which Entity is used? -Yes. If your Intuit Enterprise Suite organization has multiple entities, you can select which entity to sync with your Expensify Workspace under **Entities**. Expensify will sync data from the newly selected entity, including its chart of accounts, departments, projects, classes and tax rates. +Yes. If your Intuit Enterprise Suite organization has multiple entities, you can select which entity to sync with your Expensify workspace under **Entities**. Expensify will sync data from the newly selected entity, including its chart of accounts, departments, projects, classes and tax rates. ## Can I connect both QuickBooks Online and Intuit Enterprise Suite to the same workspace? diff --git a/docs/articles/new-expensify/connections/intuit-enterprise-suite/Manage-Vendor-Matching-for-Intuit-Enterprise-Suite.md b/docs/articles/new-expensify/connections/intuit-enterprise-suite/Manage-Vendor-Matching-for-Intuit-Enterprise-Suite.md new file mode 100644 index 000000000000..399fb352eba7 --- /dev/null +++ b/docs/articles/new-expensify/connections/intuit-enterprise-suite/Manage-Vendor-Matching-for-Intuit-Enterprise-Suite.md @@ -0,0 +1,93 @@ +--- +title: Manage Vendor Matching for Intuit Enterprise Suite +description: Learn how Intuit Enterprise Suite vendor matching assigns vendors to non-reimbursable company card expenses before they export, including automatic matching, manual selection, and default vendor behavior. +keywords: [Intuit Enterprise Suite, IES, vendor matching, vendor, company card expenses, default company card vendor, credit cards, company card export] +internalScope: Audience is workspace admins using the Intuit Enterprise Suite connection for company card exports. Covers imported Intuit Enterprise Suite vendors, automatic and manual vendor assignment, default company card vendor behavior, and vendors that are no longer valid. Does not cover Intuit Enterprise Suite connection setup, other Intuit Enterprise Suite configuration settings, or vendor matching for other accounting connections. +--- + +# Manage Vendor Matching for Intuit Enterprise Suite + +Intuit Enterprise Suite vendor matching lets workspace admins review and update the vendor assigned to non-reimbursable company card expenses before they export to Intuit Enterprise Suite. Expensify imports your Intuit Enterprise Suite vendor list, automatically matches vendors where possible, and lets admins set or update the **Vendor** field before export. This helps ensure expenses export with the correct vendor instead of requiring manual corrections in Intuit Enterprise Suite. + +## Who can use Intuit Enterprise Suite vendor matching + +This feature is available to workspace admins whose workspace: + + - Is connected to Intuit Enterprise Suite. + - Has **Export company card expenses as** set **Credit card** or **Debit card** in the Intuit Enterprise Suite configuration in Expensify. + +If your workspace isn't connected to Intuit Enterprise Suite yet, learn how to [connect to Intuit Enterprise Suite](/articles/new-expensify/connections/intuit-enterprise-suite/Connect-to-Intuit-Enterprise-Suite). + +## How vendors are matched to Intuit Enterprise Suite company card expenses + +Expensify assigns vendors automatically in the following order: + + - If a workspace merchant rule specifies a vendor, that vendor is assigned. + - Otherwise, Expensify automatically matches the merchant name against your imported Intuit Enterprise Suite vendor list. For example, **STARBUCKS #456 DOWNTOWN** matches **Starbucks**. + - If no match is found, the **Vendor** field remains empty until a workspace admin selects one. + +Whenever a vendor is assigned automatically, Concierge posts a system message on the expense indicating whether the vendor was set by a merchant rule or by vendor matching. + +The **Vendor** field appears only on non-reimbursable expenses. It isn't shown on reimbursable expenses or on invoices. + +## How to select an Intuit Enterprise Suite vendor on an expense + +1. Open the non-reimbursable expense. +2. Select **Vendor**. +3. Search for the vendor by name. +4. Select the vendor you want to assign. + +Once a vendor is selected manually, Expensify preserves that selection and won't overwrite it with automatic matching. + +## Where to find your imported Intuit Enterprise Suite vendors + +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [workspace name]**. +2. Select **Vendors**. +3. Use **Find vendor** to search the list by name. + +Vendors are managed in Intuit Enterprise Suite, so the list is read-only in Expensify and refreshes when the connection syncs. + +## How to set a default company card vendor for Intuit Enterprise Suite + +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [workspace name]**. +2. Select **Accounting**. +3. On the Intuit Enterprise Suite connection, select **Export**. +4. Select **Export company card expenses as**. +5. Select **Default vendor**. +6. Select a vendor from your imported Intuit Enterprise Suite vendor list. + +The default company card vendor is used only when an expense doesn't already have a vendor assigned. + +## How vendors export to Intuit Enterprise Suite + +When company card expenses are exported, Expensify assigns vendors in the following order: + +1. The vendor selected on the expense. +2. The **Default company card vendor** configured in the Intuit Enterprise Suite export settings. +3. **Credit Card Misc.** or **Debit Card Misc.**, if neither of the above is available. + +## How vendors that are no longer valid affect expenses + +If a vendor assigned to an expense is removed or deactivated in Intuit Enterprise Suite, the expense displays a **Vendor no longer valid** error, similar to category, tag, and tax violations. + +Select an active vendor on the expense to clear the error. + +# FAQ + +## Do I have to set a vendor? + +No. The **Vendor** field is optional. + +Expensify automatically attempts to match a vendor using your imported Intuit Enterprise Suite vendor list. If no match is found, the field can remain blank, and the expense exports using the **Default company card vendor** configured in your Intuit Enterprise Suite export settings. + +## Does manually assigning a vendor stop automatic matching? + +Yes. Once a workspace admin manually assigns a vendor to an expense, Expensify preserves that selection and won't replace it with automatic matching. + +## Why don't I see any vendors to choose from? + +If your Intuit Enterprise Suite vendor list is empty, you won't see any vendors to choose from. Add vendors in Intuit Enterprise Suite, then sync the connection again. + +## How do I know why Expensify assigned a vendor automatically? + +When Expensify automatically assigns a vendor, Concierge posts a system message on the expense indicating whether the vendor was assigned by a merchant rule or by vendor matching. diff --git a/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md b/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md index c46a0defe2d2..11eee5820a50 100644 --- a/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md +++ b/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md @@ -41,7 +41,7 @@ To control what data is imported from NetSuite: - Employee Defaults - **Customers and Projects** – Import as Tags or Report Fields - To support **cross-subsidiary data**, enable **Intercompany Time and Expense** under **Setup > Company > Enable Features > Advanced Features** -- **Tax** – Imports NetSuite Tax Groups into the **Taxes** tab +- **Tax** – Imports Tax Groups from legacy tax accounts or individual Tax Codes from SuiteTax accounts - **Custom Segments and Records** – Import as Tags or Report Fields - Use Field IDs from NetSuite’s **Transactions** tab - Do not use the “Filtered by” feature in NetSuite Custom Segments diff --git a/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md b/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md index d841ec715e4b..d990c2770358 100644 --- a/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md +++ b/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md @@ -38,7 +38,7 @@ Complete these steps in order. Each step has its own section below with detailed 7. Check your expense report transaction forms 8. Check your vendor bill transaction forms 9. Check your vendor credit transaction forms -10. Set up tax groups in NetSuite +10. Configure taxes in NetSuite 11. Connect Expensify to NetSuite using OAuth 2.0 If you're connecting with token-based authentication instead, follow the steps in **How to connect to NetSuite using token-based authentication** below. @@ -134,9 +134,29 @@ Expense categories map to Expensify categories and are linked to GL accounts so --- -## How to set up tax groups in NetSuite +## How to configure taxes in NetSuite -Expensify supports Tax Groups, not individual Tax Codes, except when you use SuiteTax. Under **Setup > Accounting > Set Up Taxes**, set **Tax Code Lists Include** to **Tax Groups And Tax Codes** or **Tax Groups Only**. In the UK and Ireland, make sure each Tax Group contains only one Tax Code. +SuiteTax accounts import individual Tax Codes. Legacy tax accounts import Tax Groups. + +**SuiteTax accounts** + +If the account uses SuiteTax, complete these steps: + +1. Go to **Setup > Users/Roles > Manage Roles**. +2. Edit the role used for the Expensify connection. +3. Under **Permissions > Lists**, set **Tax Details Tab** to **Full**. +4. Select **Save**. +5. Go to **Setup > SuiteTax Engine > Manage SuiteTax Engine Roles**. +6. Select **Assign SuiteTax Engine Permissions to Role**. +7. In the **Role** field, choose the role used for the Expensify connection. +8. On the **SuiteTax Engine Permissions** subtab, set each permission to **Full**. +9. Select **Save**. + +Do not complete the **Legacy tax accounts** steps. + +**Legacy tax accounts** + +If the account uses legacy taxes, under **Setup > Accounting > Set Up Taxes**, set **Tax Code Lists Include** to **Tax Groups And Tax Codes** or **Tax Groups Only**. In the UK and Ireland, make sure each Tax Group contains only one Tax Code. To create a Tax Group: @@ -198,7 +218,7 @@ Complete these steps in order. Steps 1 and 5–11 are the same as for an OAuth 2 8. Check your expense report transaction forms 9. Check your vendor bill transaction forms 10. Check your vendor credit transaction forms -11. Set up tax groups in NetSuite +11. Configure taxes in NetSuite 12. Connect Expensify to NetSuite using access tokens --- diff --git a/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0056-Export-Error.md b/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0056-Export-Error.md index 0a79d8edcef6..bdee8a586a0f 100644 --- a/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0056-Export-Error.md +++ b/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0056-Export-Error.md @@ -168,6 +168,8 @@ Only reports with a positive reimbursable amount can be exported. 4. Under **Permissions > Lists**, set **Tax Details Tab** to **Full**. 5. Click **Save**. +This permission remains required when **Export Foreign Currency Amount** is disabled. + --- # FAQ diff --git a/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md b/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md index ae03aac56e27..6f19e7d253bb 100644 --- a/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md +++ b/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md @@ -1,111 +1,114 @@ --- title: NS0068 Export Error in NetSuite Integration -description: Learn what the NS0068 export error means and how to make the Created From field visible on NetSuite transaction forms before exporting. -keywords: NS0068, NetSuite Created From field error, permission to set Created From NetSuite, transaction form visibility NetSuite, Expensify Card journal entry error, Expensify NetSuite integration, Workspace Admin -internalScope: Audience is Workspace Admins using the NetSuite integration. Covers resolving the NS0068 export error caused by the Created From field not being visible on NetSuite transaction forms. Does not cover role permission or token configuration issues. +description: Learn what the NS0068 export error means and how to configure the Created From field (custbody_createdfrom_expensify) in NetSuite so Expensify can set it during export. +keywords: NS0068, NetSuite Created From field error, custbody_createdfrom_expensify, permission to set Created From NetSuite, Transaction Body Fields NetSuite, transaction form visibility NetSuite, custom form ID NetSuite, Expensify Connect bundle, Expensify Card journal entry error, Expensify NetSuite integration, workspace admin +internalScope: Audience is workspace admins using the NetSuite integration. Covers resolving the NS0068 export error caused by the Created From field (custbody_createdfrom_expensify) not applying to the export record type, being hidden on the export form, not being editable by the Expensify role, or missing from NetSuite. Does not cover token configuration issues. --- + + # NS0068 Export Error in NetSuite Integration If you see the error: -NS0068 Export Error: You do not have permission to set value for element — 'Created From'. +NS0068 Export Error: NetSuite won't let Expensify set the 'Created From' field (custbody_createdfrom_expensify). -This means the **Created From** field is not visible on the preferred export form in NetSuite. +This means NetSuite is blocking Expensify from setting the **Created From** field during export. -The Workspace must be able to access and populate the Created From field during export. +The **Created From** field is a custom transaction body field with the ID `custbody_createdfrom_expensify`. The **Expensify Connect bundle** adds it to NetSuite. --- -## Why the NS0068 Export Error Happens in NetSuite +## Why the NS0068 export error happens in NetSuite The NS0068 error typically occurs when: -- The **Created From** field is hidden on the NetSuite transaction form used for exports. -- The preferred transaction form does not allow the field to be displayed. -- NetSuite blocks the integration from setting the field value because it is not visible. - -If the field is hidden, NetSuite prevents the integration from setting its value and the export fails. +- The **Created From** field does not apply to the record type you're exporting to (vendor bills, expense reports, or journal entries). +- The **Created From** field is hidden on the transaction form Expensify uses for your export type. +- The role used by the Expensify connection can't edit the **Created From** field. +- The **Created From** field is missing from NetSuite because the **Expensify Connect bundle** is not installed or is out of date. -This is a transaction form visibility issue, not a role permission or token configuration issue. +If any of these apply, NetSuite prevents the integration from setting the field value and the export fails. --- -## How to Fix the NS0068 Export Error +## How to fix the NS0068 export error -Follow the steps below to make the Created From field visible. +Follow the steps below to check the field setup, form visibility, and role access in NetSuite. --- -## Make the Created From Field Visible in NetSuite +## How to check the Created From field settings in NetSuite 1. Log in to NetSuite as an administrator. -2. Go to **Customization > Forms > Transaction Forms**. -3. Locate the transaction form marked as **Preferred** for your export type. -4. Click **Edit**. -5. Locate the **Created From** field. -6. Ensure the field is marked as **Show**. -7. Click **Save**. +2. Go to **Customization > Lists, Records, & Fields > Transaction Body Fields**. +3. Open the field with the ID **custbody_createdfrom_expensify**. +4. On the **Applies To** tab, confirm the option for your export type is selected: **Purchase** for vendor bills, **Expense Report** for expense reports, or **Journal** for journal entries. +5. On the **Access** tab, confirm **Default Access Level** is **Edit**. If the **Role** list includes the role used by the Expensify connection, such as **Expensify Integration**, confirm its access level is also **Edit**. +6. Select **Save**. -Confirm the field is visible on the preferred form before proceeding. +If you can't find **custbody_createdfrom_expensify**, update or reinstall the Expensify Connect bundle before continuing. [Learn how to update the Expensify Connect bundle in NetSuite](/articles/new-expensify/connections/netsuite/Update-Expensify-Connect-Bundle-in-NetSuite). --- -## Sync the Workspace in Expensify +## How to make the Created From field visible on the export form + +Expensify exports with the transaction form marked as **Preferred** in NetSuite. If you set a custom form under **Workspaces > [workspace name] > Accounting > Advanced > Custom form ID**, Expensify uses that form instead. -After updating the form: +1. Go to **Customization > Forms > Transaction Forms**. +2. Locate the transaction form Expensify uses for your export type. +3. Select **Edit**. +4. Go to **Screen Fields > Main**. +5. Confirm the **Created From** field is marked as **Show**. +6. Select **Save**. -On web: +--- -1. Go to the navigation tabs on the left and select **Workspaces**. -2. Select your Workspace. -3. Click **Accounting**. -4. Click the three-dot menu next to the NetSuite connection. -5. Click **Sync Now**. +## How to sync the workspace in Expensify -On mobile: +After updating NetSuite: -1. Tap the navigation tabs on the bottom and select **Workspaces**. -2. Select your Workspace. -3. Tap **Accounting**. -4. Tap the three-dot menu next to the NetSuite connection. -5. Tap **Sync Now**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name] > Accounting**. +2. Select **More (⋮)** next to the NetSuite connection. +3. Select **Sync now**. --- -## Retry the Export +## How to retry the export 1. Open the report. 2. Retry exporting to NetSuite. -If the Created From field is visible on the preferred form, the export should complete successfully. +If the Created From field applies to your export type, is visible on the export form, and is editable by the Expensify role, the export should complete successfully. --- -# Additional Steps for Expensify Card Journal Entries +## How to fix the NS0068 export error for Expensify Card journal entries -If you are exporting **Expensify Card transactions as journal entries**, confirm the Created From field is visible on the journal entry form. +If you are exporting **Expensify Card transactions as journal entries**, confirm the Created From field is set up for journal entries. -1. Go to **Customization > Forms > Transaction Forms**. -2. Click **Edit** next to the preferred journal entry form. -3. Go to **Screen Fields > Main**. -4. Confirm **Created From** is marked as **Show**. -5. Click **Save**. +1. Go to **Customization > Lists, Records, & Fields > Transaction Body Fields** and open **custbody_createdfrom_expensify**. +2. On the **Applies To** tab, confirm **Journal** is selected, then select **Save**. +3. Go to **Customization > Forms > Transaction Forms**. +4. Select **Edit** next to the preferred journal entry form. +5. Go to **Screen Fields > Main**. +6. Confirm **Created From** is marked as **Show**. +7. Select **Save**. -Then return to the Workspace and select **Sync Now** before retrying the export. +Then sync the workspace in Expensify before retrying the export. --- # FAQ -## Does the NS0068 Export Error Affect All Export Types? +## Does the NS0068 export error affect all export types? -It affects exports using a transaction form where the Created From field is hidden. +It affects any export type where the Created From field doesn't apply to the record, is hidden on the export form, or isn't editable by the Expensify role. -## Do I Need NetSuite Admin Access to Fix the NS0068 Export Error? +## Do I need NetSuite admin access to fix the NS0068 export error? -Yes. Updating transaction form field visibility requires NetSuite administrator permissions. +Yes. Updating custom field settings and transaction form visibility requires NetSuite administrator permissions. -## Do I Need to Reconnect the Integration? +## Do I need to reconnect the integration? -No. Making the Created From field visible and selecting **Sync Now** is typically sufficient. +No. Updating the Created From field settings and selecting **Sync now** is typically sufficient. If the field is missing, update or reinstall the Expensify Connect bundle first. diff --git a/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-QuickBooks-Online.md b/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-QuickBooks-Online.md index 7f22ed3ba93f..b26b52417db3 100644 --- a/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-QuickBooks-Online.md +++ b/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-QuickBooks-Online.md @@ -1,7 +1,7 @@ --- title: Connect to QuickBooks Online description: Integrate QuickBooks Online with Expensify -keywords: [New Expensify, QuickBooks Online, QBO integration, connect QuickBooks, reconnect QuickBooks, accounting sync, Expensify control plan, accounting sync, QuickBooks ISE, QuickBooks Essentials,Quickbooks Simple Start] +keywords: [New Expensify, QuickBooks Online, QBO integration, connect QuickBooks, reconnect QuickBooks, accounting sync, Expensify control plan, accounting sync, QuickBooks Essentials,Quickbooks Simple Start] internalScope: Audience is Workspace Admins. Covers connecting and reconnecting QuickBooks Online to Expensify and supported features. Does not cover QuickBooks Desktop or advanced accounting configuration. QuickBooks Online, QBO integration, connect QuickBooks Online, reconnect QuickBooks, QuickBooks Online red dot error, Intuit login error, accounting sync, Workspace Admin, automate expense syncing order: 1 --- @@ -9,8 +9,6 @@ order: 1 To use the QuickBooks Online connection, you must have a **QuickBooks Online account** and an Expensify **Collect** plan. -**Intuit Enterprise Suite** is a separate connection, listed directly below **QuickBooks Online** on the **Accounting** page, and it requires the **Control** plan. Learn how to [connect to Intuit Enterprise Suite](/articles/new-expensify/connections/quickbooks-online/Connect-to-Intuit-Enterprise-Suite). - The **QuickBooks Self-Employed** subscription is not supported. --- @@ -19,20 +17,20 @@ The **QuickBooks Self-Employed** subscription is not supported. Expensify's QuickBooks Online integration includes different features depending on your subscription level. While some options may appear in the app, you’ll see an error if your plan doesn’t support them. -| Feature | Simple Start | Essentials | Essentials Plus | Intuit Enterprise Suite | -|----------------------------|--------------|------------|-----------------|------------------------| -| Expense Reports | ✅ | ✅ | ✅ | ✅ | -| GL Accounts as Categories | ✅ | ✅ | ✅ | ✅ | -| Credit Card Transactions | ✅ | ✅ | ✅ | ✅ | -| Debit Card Transaction | | ✅ | ✅ | ✅ | -| Classes | | ✅ | ✅ | ✅ | -| Customers | | ✅ | ✅ | ✅ | -| Projects | | ✅ | ✅ | ✅ | -| Vendor Bills | | ✅ | ✅ | ✅ | -| Journal Entries | | ✅ | ✅ | ✅ | -| Tax | | ✅ | ✅ | ✅ | -| Billable | | | ✅ | ✅ | -| Location | | | ✅ | ✅ | +| Feature | Simple Start | Essentials | Essentials Plus | +|----------------------------|--------------|------------|-----------------| +| Expense Reports | ✅ | ✅ | ✅ | +| GL Accounts as Categories | ✅ | ✅ | ✅ | +| Credit Card Transactions | ✅ | ✅ | ✅ | +| Debit Card Transaction | | ✅ | ✅ | +| Classes | | ✅ | ✅ | +| Customers | | ✅ | ✅ | +| Projects | | ✅ | ✅ | +| Vendor Bills | | ✅ | ✅ | +| Journal Entries | | ✅ | ✅ | +| Tax | | ✅ | ✅ | +| Billable | | | ✅ | +| Location | | | ✅ | --- diff --git a/docs/articles/new-expensify/connections/rillet/Configure-Rillet.md b/docs/articles/new-expensify/connections/rillet/Configure-Rillet.md index 1eb68780ed2e..18ba4f9fb572 100644 --- a/docs/articles/new-expensify/connections/rillet/Configure-Rillet.md +++ b/docs/articles/new-expensify/connections/rillet/Configure-Rillet.md @@ -27,8 +27,6 @@ Each Workspace has its own Rillet integration. To view or update the configurati 2. Select **Accounting**. 3. On the Rillet connection, select **Import**, **Export**, or **Advanced**. -![Accounting page showing Rillet connection]({{site.url}}/assets/images/Accounting_Rillet_Configure.png){:width="100%"} - --- ## How to configure Rillet import settings @@ -40,7 +38,7 @@ On the Rillet integration, choose **Import** to configure: - **Chart of accounts** – Imports active Expense, Current Asset, and Fixed Asset accounts as Categories. This setting is always enabled. - **Enable newly imported accounts** – Choose whether newly imported accounts arrive as enabled or disabled categories. This defaults to off, so new accounts import as disabled categories. - **Dimensions** – Choose which Rillet dimensions to import as tags. Each available dimension (such as Departments, Projects, or Classes) can be enabled independently. -- **Tax rates** – Import tax rates from Rillet. This option only appears when tax rates are available for the selected subsidiary. +- **Tax rates** – Import tax rates from Rillet. This option only appears when tax rates are available for the selected entity. --- diff --git a/docs/articles/new-expensify/connections/rillet/Connect-to-Rillet.md b/docs/articles/new-expensify/connections/rillet/Connect-to-Rillet.md index 61eae1d34973..678f27ea6b9f 100644 --- a/docs/articles/new-expensify/connections/rillet/Connect-to-Rillet.md +++ b/docs/articles/new-expensify/connections/rillet/Connect-to-Rillet.md @@ -2,13 +2,13 @@ title: Connect to Rillet description: Learn how to connect Rillet to Expensify with an API key to sync your accounting data. keywords: [New Expensify, Rillet integration, connect Rillet, Rillet API key, accounting sync, Rillet setup] -internalScope: Audience is Workspace Admins connecting Rillet as an accounting integration. Covers adding the Rillet connection, entering the API key, and selecting a subsidiary. Does not cover configuring import, export, or advanced sync settings. +internalScope: Audience is Workspace Admins connecting Rillet as an accounting integration. Covers adding the Rillet connection, entering the API key, and selecting an entity. Does not cover configuring import, export, or advanced sync settings. order: 1 --- # Connect to Rillet -Connect Rillet to your Expensify workspace to sync your accounting data. This article walks you through connecting Rillet, selecting a subsidiary, and completing the initial setup. +Connect Rillet to your Expensify workspace to sync your accounting data. This article walks you through connecting Rillet, selecting an entity, and completing the initial setup. Once connected, the integration imports: @@ -35,7 +35,7 @@ To connect Rillet, you must: 3. In the **Connections** section next to **Rillet**, select **Connect**. 4. In the **Rillet setup** page that opens, follow the on-screen instructions to generate an API key in Rillet, paste it into the **API key** field, and click **Confirm**. -Expensify validates the API key, connects to your Rillet organization, and retrieves your available subsidiaries. +Expensify validates the API key, connects to your Rillet organization, and retrieves your available entities. **Note:** Your API key is encrypted and stored securely on Expensify's servers. It is never exposed in the app or stored on your device. @@ -48,11 +48,11 @@ Expensify validates the API key, connects to your Rillet organization, and retri After the connection is established, the **Connections** section updates to show your connected Rillet integration, including: - The connection status and last sync timestamp. -- The selected subsidiary. +- The selected entity. - The **Import**, **Export**, and **Advanced** configuration settings. - Additional integration options under **Other**. -If your Rillet organization has multiple subsidiaries, you can choose which one to connect. If only one eligible subsidiary is available, Expensify selects it automatically. The selected subsidiary determines which accounting data is available in Expensify. +If your Rillet organization has multiple entities, you can choose which one to connect. If only one eligible entity is available, Expensify selects it automatically. The selected entity determines which accounting data is available in Expensify. After the initial sync completes, you can configure your import, export, and advanced accounting settings. Learn how to [configure your Rillet import, export, and advanced settings](/articles/new-expensify/connections/rillet/Configure-Rillet). @@ -84,6 +84,6 @@ Yes. Expensify encrypts your API key and stores it securely on its servers. The Tax rates are only imported if your Rillet organization returns them. If no tax rates are available, tax rate options won't appear in Expensify. -## Can I change which subsidiary is used? +## Can I change which entity is used? -Yes. If your Rillet organization has multiple subsidiaries, you can select a different subsidiary at any time from the **Connections** section. Expensify will sync data from the newly selected subsidiary, including its accounts, dimensions, and tax rates. +Yes. If your Rillet organization has multiple entities, you can select a different entity at any time from the **Connections** section. Expensify will sync data from the newly selected entity, including its accounts, dimensions, and tax rates. diff --git a/docs/articles/new-expensify/connections/rillet/Manage-Vendor-Matching-for-Rillet.md b/docs/articles/new-expensify/connections/rillet/Manage-Vendor-Matching-for-Rillet.md index 752cbb9102fa..d804d48c84ab 100644 --- a/docs/articles/new-expensify/connections/rillet/Manage-Vendor-Matching-for-Rillet.md +++ b/docs/articles/new-expensify/connections/rillet/Manage-Vendor-Matching-for-Rillet.md @@ -3,8 +3,6 @@ title: Manage Vendor Matching for Rillet description: Learn how Rillet vendor matching assigns vendors to non-reimbursable company card expenses before they export, including automatic matching, manual selection, and default vendor behavior. keywords: [Rillet, vendor matching, vendor, company card expenses, default company card vendor, credit cards, company card export] internalScope: Audience is Workspace Admins using the Rillet connection for company card exports. Covers imported Rillet vendors, automatic and manual vendor assignment, default company card vendor behavior, and vendors that are no longer valid. Does not cover Rillet connection setup, other Rillet configuration settings, or vendor matching for other accounting connections. -noindex: true -sitemap: false --- # Manage Vendor Matching for Rillet diff --git a/docs/articles/new-expensify/connections/sage-intacct/Manage-Vendor-Matching-for-Sage-Intacct.md b/docs/articles/new-expensify/connections/sage-intacct/Manage-Vendor-Matching-for-Sage-Intacct.md index 6c4450529b7c..17f1d29be1b6 100644 --- a/docs/articles/new-expensify/connections/sage-intacct/Manage-Vendor-Matching-for-Sage-Intacct.md +++ b/docs/articles/new-expensify/connections/sage-intacct/Manage-Vendor-Matching-for-Sage-Intacct.md @@ -3,8 +3,6 @@ title: Manage Vendor Matching for Sage Intacct description: Learn how Sage Intacct vendor matching assigns vendors to non-reimbursable company card expenses before they export, including automatic matching, manual selection, and default vendor behavior. keywords: [Sage Intacct, vendor matching, vendor, company card expenses, default vendor, Credit Card Charges, credit card export] internalScope: Audience is Workspace Admins using the Sage Intacct connection with Credit Card Charges company card exports. Covers imported vendors, automatic and manual vendor assignment, default vendor behavior, and vendor export order. Does not cover Sage Intacct connection setup or other export types. -noindex: true -sitemap: false --- # Manage Vendor Matching for Sage Intacct diff --git a/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md new file mode 100644 index 000000000000..321331f5b379 --- /dev/null +++ b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md @@ -0,0 +1,75 @@ +--- +title: INT133 Sync Error in Sage Intacct Integration +description: Learn what the INT133 sync error means and how to reactivate the Sage Intacct web services user to restore the connection. +keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Sage Intacct sync authentication failure, PL04000076, Workspace Admin +internalScope: Audience is Workspace Admins managing the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122). +--- + +# INT133 Sync Error in Sage Intacct Integration + +If you see the error: + +INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again. + +This means Sage Intacct rejected the connection because the web services user is set to **Inactive**. + +When the web services user is inactive, syncing and exports will not work. + +--- + +## Why the INT133 Sync Error Happens in Sage Intacct + +The INT133 error typically occurs when: + +- The **xmlgateway_expensify** web services user was deactivated in Sage Intacct. +- An administrator changed the user's status to **Inactive** during a user cleanup or license review. +- The web services user that was used to connect the Workspace is no longer in use. + +Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first. + +--- + +## How to Fix the INT133 Sync Error + +Follow the steps below to reactivate the web services user and restore the connection. + +--- + +## Reactivate the Web Services User in Sage Intacct + +1. Log in to Sage Intacct as an administrator. +2. Go to **Company > Admin > Web Services users**. +3. Locate the **xmlgateway_expensify** web services user. +4. Click **Edit**. +5. Change the **Status** to **Active**. +6. Click **Save**. + +If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials. + +--- + +## Retry the Sync + +After reactivating the web services user: + +1. Go to **Workspaces > [Workspace Name] > Accounting**. +2. Select **Sync Now**. +3. Confirm the sync completes successfully. + +If the web services user is active, the sync should complete without errors. + +--- + +# FAQ + +## Do I Need Sage Intacct Admin Access to Fix This? + +Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct. + +## Do I Need to Reenter My Credentials? + +Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in the Workspace. + +## Does This Error Affect Exports? + +Yes. Exports and other integration features will not work until the web services user is active again. diff --git a/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md new file mode 100644 index 000000000000..321331f5b379 --- /dev/null +++ b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md @@ -0,0 +1,75 @@ +--- +title: INT133 Sync Error in Sage Intacct Integration +description: Learn what the INT133 sync error means and how to reactivate the Sage Intacct web services user to restore the connection. +keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Sage Intacct sync authentication failure, PL04000076, Workspace Admin +internalScope: Audience is Workspace Admins managing the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122). +--- + +# INT133 Sync Error in Sage Intacct Integration + +If you see the error: + +INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again. + +This means Sage Intacct rejected the connection because the web services user is set to **Inactive**. + +When the web services user is inactive, syncing and exports will not work. + +--- + +## Why the INT133 Sync Error Happens in Sage Intacct + +The INT133 error typically occurs when: + +- The **xmlgateway_expensify** web services user was deactivated in Sage Intacct. +- An administrator changed the user's status to **Inactive** during a user cleanup or license review. +- The web services user that was used to connect the Workspace is no longer in use. + +Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first. + +--- + +## How to Fix the INT133 Sync Error + +Follow the steps below to reactivate the web services user and restore the connection. + +--- + +## Reactivate the Web Services User in Sage Intacct + +1. Log in to Sage Intacct as an administrator. +2. Go to **Company > Admin > Web Services users**. +3. Locate the **xmlgateway_expensify** web services user. +4. Click **Edit**. +5. Change the **Status** to **Active**. +6. Click **Save**. + +If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials. + +--- + +## Retry the Sync + +After reactivating the web services user: + +1. Go to **Workspaces > [Workspace Name] > Accounting**. +2. Select **Sync Now**. +3. Confirm the sync completes successfully. + +If the web services user is active, the sync should complete without errors. + +--- + +# FAQ + +## Do I Need Sage Intacct Admin Access to Fix This? + +Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct. + +## Do I Need to Reenter My Credentials? + +Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in the Workspace. + +## Does This Error Affect Exports? + +Yes. Exports and other integration features will not work until the web services user is active again. diff --git a/docs/articles/new-expensify/domains/Manage-Domain-Members.md b/docs/articles/new-expensify/domains/Manage-Domain-Members.md index b5cb0976cde8..6afb1b123909 100644 --- a/docs/articles/new-expensify/domains/Manage-Domain-Members.md +++ b/docs/articles/new-expensify/domains/Manage-Domain-Members.md @@ -111,8 +111,9 @@ Domain Admins can assign or unassign a vacation delegate on behalf of a domain m 3. Click **Domain Members**. 4. Select the member. 5. Click **Vacation delegate**. -6. Select the member you want to assign as the delegate. -7. Click **Confirm**. +6. Select **Choose delegate**, then select the member you want to assign as the delegate. +7. Under **Clear after (recommended)**, choose the last day the delegate should cover (optional). +8. Select **Save**. --- diff --git a/docs/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-Pay-or-Google-Wallet.md b/docs/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-Pay-or-Google-Wallet.md index c139e8f82a42..d4d22ec15d00 100644 --- a/docs/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-Pay-or-Google-Wallet.md +++ b/docs/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-Pay-or-Google-Wallet.md @@ -8,6 +8,7 @@ internalScope: Audience is Expensify Cardholders. Covers adding an Expensify Car # Add the Expensify Card to Apple Pay or Google Wallet You can use your Expensify Card for contactless in-person payments by adding it to Apple Pay or Google Wallet. +Please note that Apple Pay and Google Wallet support is currently available for US cardholders only. Support for UK and EU cardholders is coming soon. --- diff --git a/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md b/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md index d9d34895a316..2b1478f80152 100644 --- a/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md +++ b/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md @@ -20,8 +20,7 @@ It gives you a quick way to see whether anything needs attention, get oriented, Home is the default view when you sign in. -- **Web:** Click the **Home** icon in the navigation tabs on the left to return to Home at any time. -- **Mobile:** Tap the **Home** icon in the navigation tabs on the bottom to return to Home. +To return to Home at any time, select **Home** in the navigation tabs (on the left on web, on the bottom on mobile). --- @@ -106,28 +105,27 @@ To review every trip instead of just the next 7 days, learn how to [view upcomin The **Recently added** section shows your most recently added expenses, so you can quickly get back to what you just created or imported. - Select any expense to open it. -- To see your full list of expenses, select the three dots **(⋮)** in the **Recently added** section, then select **View all expenses**. +- To see your full list of expenses, select **More (⋮)** in the **Recently added** section, then select **View all expenses**. --- -## How the Spend over time chart and insights work on Home +## How insights work on Home -The chart section on Home shows one spending insight at a time, starting with **Spend over time**, which displays a line chart of your workspace spending trends. +**Home** displays one insight at a time, with **Spend over time** selected by default. -Select the insight title (with the chevron next to it) to open the insight picker, then choose a different insight to show in the chart. The available insights match the ones you can open under **Spend**, and can include: +To display a different insight, select the insight title and choose another insight, such as: - **Spend over time** - **Top categories** - **Top merchants** - **Top spenders** -Your selection is saved, so the chart shows the same insight the next time you return to Home, even after you sign out and back in. +To view all available insights: -To open the selected insight in full, select the three dots **(⋮)**, then select **View**. This takes you to that insight on the **Spend** page. + - On web, select **Insights** in the navigation tabs on the left. + - On mobile, select **Spend** in the navigation tabs on the bottom,. -This section is available to Workspace Admins, Auditors, and approvers on paid workspaces. If an insight doesn't have enough data yet to draw a chart, the section still appears and shows a **Chart unavailable** message instead. If you don't hold one of these roles on any workspace, this section won't appear. - -To learn more about each insight, see [Learn how to use Insights in Expensify](/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify). +To learn more about each insight, see [Learn About Insights](/articles/new-expensify/insights/Learn-About-Insights). --- @@ -199,7 +197,7 @@ The **Time sensitive** section only appears when there is an urgent issue or lim ## Why don’t I see the Spend over time section? -The Home chart section only appears if you are a Workspace Admin, Auditor, or approver on a paid workspace. If you don't hold one of these roles on any workspace, this section won't be visible. If you do hold one of these roles but an insight doesn't have enough data yet, the section still appears and shows a **Chart unavailable** message rather than disappearing. You can switch which insight the chart shows using the insight title dropdown. +The Home chart section only appears if you're a member of a paid workspace. If you're on a paid workspace but an insight doesn't have enough data yet, the section still appears and shows a **Chart unavailable** message rather than disappearing. You can switch which insight the chart shows using the insight title dropdown. ## Why don’t I see the Getting started section? diff --git a/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md b/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md index 6564b3a6164b..ebc15df77a14 100644 --- a/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md +++ b/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md @@ -5,7 +5,7 @@ keywords: [New Expensify, join workspace, set up profile, add expense, enable tw --- -Welcome to Expensify! If you've received an invite to join your company's workspace, this guide walks you through setting up your account and getting started. +Welcome to Expensify! Whether you've received an invite to join your company's workspace or need to instruct your admin to send you one, this guide walks you through setting up your account and getting started. --- @@ -19,6 +19,14 @@ Ask one of your company's Expensify workspace admins to send you an invite using Now, check your email inbox for the invite to join your company's workspace. If you don't see it, check your **Spam** and **Archive** folders. +If you're creating your Expensify account, you can also find your company's workspace yourself: + +1. When asked **What do you want to do today?**, choose **Join my company workspace**. +2. If prompted, add your work email and enter the code sent to it. +3. Select **Join now** next to your company's workspace, or **Ask to join** if it needs admin approval. + +If you select **Skip** during these steps, Concierge sends you a task so you can finish later. + --- # Step 1: Access Expensify diff --git a/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md b/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md index f6098d22ba53..17aaaa8fd656 100644 --- a/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md +++ b/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md @@ -1,21 +1,21 @@ --- title: Navigating New Expensify -description: Learn how to move around New Expensify using the navigation tabs and Search, including Home, Inbox, Spend, Workspaces, and Account on web and mobile. -keywords: [New Expensify, navigation tabs, navigation, Home tab, Inbox tab, Inbox filters, Inbox tabs, All tab, Unread tab, To-dos, Mark all as read, mark chats as read, clear unread messages, unread badge, Spend tab, Workspaces tab, Account tab, Scan button, Create button, workspace filter, Search, go to, jump to destination] +description: Learn how to move around New Expensify using the navigation tabs and Search, including Home, Inbox, Spend, Insights, Workspaces, and Account on web and mobile. +keywords: [New Expensify, navigation tabs, navigation, Home tab, Inbox tab, Inbox filters, Inbox tabs, All tab, Unread tab, To-dos, Mark all as read, mark chats as read, clear unread messages, unread badge, Spend tab, Insights tab, Workspaces tab, Account, where did Account go, Account on mobile, profile picture, Scan button, Create button, workspace filter, Search, go to, jump to destination] internalScope: Audience is all Expensify members. Covers how to navigate Expensify using the navigation tabs, how to filter and clear the Inbox using the Inbox tabs, the primary action buttons, and Search navigation suggestions. Does not explain the detailed behavior of the Home tab sections. --- # Navigating New Expensify -New Expensify uses a consistent navigation layout across web and mobile so you can quickly move between Home, Inbox, Spend, Workspaces, and Account settings. +New Expensify uses a consistent navigation layout across web and mobile so you can quickly move between Home, Inbox, Spend, Insights, Workspaces, and Account settings. This guide explains where to find each tab and what it’s used for. If you'd like a guided walkthrough of the New Expensify experience, explore the interactive product tours: **For workspace admins:** -- [interactive web tour for Workspace Admins](https://expensify.storylane.io/share/rbncowjn0nav) -- [interactive mobile tour for Workspace Admins](https://expensify.storylane.io/share/em54uaq7wbgk) +- [interactive web tour for workspace admins](https://expensify.storylane.io/share/rbncowjn0nav) +- [interactive mobile tour for workspace admins](https://expensify.storylane.io/share/em54uaq7wbgk) **For submitters and approvers:** - [interactive web tour for submitters and approvers](https://expensify.storylane.io/share/rbncowjn0nav) @@ -26,18 +26,16 @@ You can find additional training resources in [How to Access Expensify Training] --- -## How to use navigation tabs +## How to use the navigation tabs -The **navigation tabs** are your main way to move through the app. +The navigation tabs are your main way to move through the app. They appear on the left on web and on the bottom on mobile. -- **Web:** Navigation tabs appear on the left side of the screen. -- **Mobile:** Navigation tabs appear on the bottom of the screen. - -Tabs include: +The navigation tabs include: - **Home** - **Inbox** - **Spend** +- **Insights** (web only) - **Workspaces** - **Account** @@ -92,13 +90,23 @@ From **Spend**, you can: --- +## What the Insights tab on web is used for + +**Insights** shows charts of your spending, such as **Spend over time**, **Top categories**, **Top merchants**, and **Top spenders**. + +Use Insights to see spending trends and what changed at a glance. From any chart, select **More (⋮)** to open matching expenses in **Spend**. + +Learn more about [Insights](/articles/new-expensify/insights/Learn-About-Insights). + +--- + ## What the Workspaces tab is used for The **Workspaces** tab displays all workspaces you are a member of. -Workspace Admins can open a workspace to manage workspace-level settings. +Workspace admins can open a workspace to manage workspace-level settings. -From **Workspaces**, Workspace Admins can: +From **Workspaces**, workspace admins can: - Create a new workspace - Manage members under **Members** @@ -111,11 +119,11 @@ Learn how to [create and set up a company workspace](/articles/new-expensify/get --- -## What the Account tab is used for +## What Account is used for -The **Account** tab contains personal and account-level settings. +**Account** contains personal and account-level settings. Select **Account** to open it. -From Account, you can manage: +From **Account**, you can manage: - **Profile** to update your personal details - **Subscription** to manage your Expensify subscription @@ -138,23 +146,21 @@ Use Account for personal settings and billing. ## How to find your assigned support contacts on the Help page -Paid Workspace Admins may have dedicated support contacts assigned to their account, such as an Account Manager, Partner Manager, or Account Executive. These contacts appear directly on the **Help** page so you can quickly start a conversation with them. +Workspace admins on a paid plan may have dedicated support contacts assigned to their account, such as an account manager, partner manager, or account executive. These contacts appear directly on the **Help** page so you can quickly start a conversation with them. -1. Click the navigation tabs (on the left on web, on the bottom on mobile). -2. Select **Settings**. -3. Select **Help**. -4. Below **Concierge**, look for your assigned support contacts. -5. Click a contact to open a direct message with them. +1. Select **Account > Help**. +2. Below **Concierge**, look for your assigned support contacts. +3. Select a contact to open a direct message with them. -If a support contact has a scheduling link set up, a **Book a call** button also appears next to them here. This works for your Account Manager, Partner Manager, and Guide. Learn [how to book a call with your support contact](#how-to-book-a-call-with-your-support-contact) below. +If a support contact has a scheduling link set up, a **Book a call** button also appears next to them here. This works for your account manager, partner manager, and guide. Learn [how to book a call with your support contact](#how-to-book-a-call-with-your-support-contact) below. --- ## How to book a call with your support contact -If your Account Manager, Partner Manager, or Guide has a scheduling link set up, you can book a call with them directly using the **Book a call** button. Clicking **Book a call** opens that person's calendar in a new tab so you can pick a time. The button appears in these places: +If your account manager, partner manager, or guide has a scheduling link set up, you can book a call with them directly using the **Book a call** button. Selecting **Book a call** opens that person's calendar in a new tab so you can pick a time. The button appears in these places: -- On the **Help** page, next to each support contact that has a scheduling link (Account Manager, Partner Manager, or Guide). +- On the **Help** page, next to each support contact that has a scheduling link (account manager, partner manager, or guide). - In the header of your direct message with that support contact. - In the header of your **Concierge** chat. @@ -182,6 +188,7 @@ If you’re not sure where to go: - Use **Home** to see what needs attention. - Use **Inbox** to manage conversations and approvals. - Use **Spend** to view or filter expense reports. +- Use **Insights** to see spending trends. - Use **Workspaces** to manage workspace settings. - Use **Account** to update personal settings or subscription details. @@ -191,8 +198,8 @@ If you’re not sure where to go: You can move to a top-level destination without using the navigation tabs by typing its name in **Search**. -1. Select **Search** (the magnifying glass icon). -2. Type the name of the destination you want to open: **Home**, **Inbox**, **Spend**, **Workspaces**, **Domains** or **Account**. You can also type **go** or **go to** before the name, such as **go to inbox**. +1. Select the **Search** icon. +2. Type the name of the destination you want to open: **Home**, **Inbox**, **Spend**, **Insights**, **Workspaces**, **Domains**, or **Account**. You can also type **go** or **go to** before the name, such as **go to inbox**. 3. Select the destination to open it. You can also use keyboard shortcuts on web to jump directly to specific items: @@ -206,31 +213,34 @@ You can also use keyboard shortcuts on web to jump directly to specific items: You can start common actions from **Search** instead of using the **➕ Create** button. -1. Select **Search** (the magnifying glass icon). +1. Select the **Search** icon. 2. Type the name of the action you want to start, such as **Create expense**, **Create report**, **Track distance**, **Start chat**, or **Book travel**. You can also type **go** or **go to** before the name, such as **go to book travel**. 3. Select the action to start it. Only the actions available to you appear. **Book travel** appears only when Expensify Travel is enabled on your active workspace, and it opens the same booking tool as the **Book travel** option in the **➕ Create** button. Learn how to [enable Expensify Travel on a workspace](/articles/travel/company-setup/Enable-Travel-on-a-Workspace). + +--- + ## How to jump to a domain page using Search -If you are a Domain Admin, **Search** also suggests the pages of each domain you administer, so you can open them without going through **Workspaces** > **Domains**. +If you are a domain admin, **Search** also suggests the pages of each domain you administer, so you can open them without going through **Workspaces > Domains**. -1. Select **Search** (the magnifying glass icon). +1. Select the **Search** icon. 2. Type the name of the page you want to open — **Domain members**, **Domain admins**, **Groups**, or **SAML** — or type the domain name, such as **example.com**. 3. Select the suggestion to open that page for that domain. Each domain suggestion shows the domain name on the right of the row, so you can tell the suggestions apart when you administer more than one domain. -These suggestions only appear for domains you administer. If you are not a Domain Admin of any domain, only **Go to Domains** appears. +These suggestions only appear for domains you administer. If you are not a domain admin of any domain, only **Go to Domains** appears. -Learn more about [what Domain Admins can manage](/articles/new-expensify/domains/Domain-Admins). +Learn more about [what domain admins can manage](/articles/new-expensify/domains/Domain-Admins). --- @@ -243,23 +253,25 @@ On web, navigation tabs appear on the left. On mobile, navigation tabs appear on ## Can I use Search to move between sections of Expensify? -Yes. Select **Search** (the magnifying glass icon), then type the name of a top-level destination: **Home**, **Inbox**, **Spend**, **Workspaces**, or **Account**. Select the **Go to [destination]** result to open it. You can also type **go** or **go to** before the name. +Yes. Select the **Search** icon, then type the name of a top-level destination: **Home**, **Inbox**, **Spend**, **Insights**, **Workspaces**, or **Account**. Select the **Go to [destination]** result to open it. You can also type **go** or **go to** before the name. ## Can I book travel from Search? -Yes. Select **Search** (the magnifying glass icon), type **Book travel**, then select the **Book travel** result. This opens the same booking tool as the **Book travel** option in the **➕ Create** button. +Yes. Select the **Search** icon, type **Book travel**, then select the **Book travel** result. This opens the same booking tool as the **Book travel** option in the **➕ Create** button. ## Why don’t I see Book travel in Search? -**Book travel** appears only when Expensify Travel is enabled on your active workspace. If a different workspace has Expensify Travel enabled, make that workspace active and search again. If no workspace has it enabled, ask a Workspace Admin to [enable Expensify Travel on a workspace](/articles/travel/company-setup/Enable-Travel-on-a-Workspace). +**Book travel** appears only when Expensify Travel is enabled on your active workspace. If a different workspace has Expensify Travel enabled, make that workspace active and search again. If no workspace has it enabled, ask a workspace admin to [enable Expensify Travel on a workspace](/articles/travel/company-setup/Enable-Travel-on-a-Workspace). ## Where do I manage workspace settings? -Open the **Workspaces** tab from the navigation tabs. +In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces**. ## Where do I update my personal profile or billing information? -Open the **Account** tab from the navigation tabs. +Personal information is managed under **Account** in the navigation tabs (on the left on web, on the bottom on mobile). + +Then select **Profile** to update your personal information, or **Subscription** to manage billing. ## What does the green Scan button do? @@ -273,18 +285,14 @@ The **➕ Create** button starts actions such as creating a report, sending mone Assigned support contacts only appear if: -- You are a Workspace Admin on a paid plan +- You are a workspace admin on a paid plan - A support contact has been assigned to your account If no contacts are assigned, these options will not appear on the **Help** page. -## How do I get an Account Manager assigned? - -Account Managers are typically assigned to accounts with subscriptions that include 10 or more members. Contact **Concierge** if you believe your account qualifies. - ## Why don't I see the Book a call button? -You'll only see the **Book a call** button if you have an assigned support contact (Account Manager, Partner Manager, or Guide) who has a scheduling link configured. If no support contact is assigned or none has a scheduling link set up, the button won't appear. In **Concierge**, only one **Book a call** button shows at a time. +You'll only see the **Book a call** button if you have an assigned support contact (account manager, partner manager, or guide) who has a scheduling link configured. If no support contact is assigned or none has a scheduling link set up, the button won't appear. In **Concierge**, only one **Book a call** button shows at a time. ## Are there keyboard shortcuts for opening reports or workspaces? diff --git a/docs/articles/new-expensify/getting-started/Troubleshoot-Adding-Your-Work-Email.md b/docs/articles/new-expensify/getting-started/Troubleshoot-Adding-Your-Work-Email.md index 1c6782b3a412..4011fb2d2882 100644 --- a/docs/articles/new-expensify/getting-started/Troubleshoot-Adding-Your-Work-Email.md +++ b/docs/articles/new-expensify/getting-started/Troubleshoot-Adding-Your-Work-Email.md @@ -83,7 +83,7 @@ No. Adding a work email is optional. On the **What’s your work email?** form, ## Can I try a different work email on the same account? -Yes, while the work email step is still open. If the message appears under the field, correct the address and click **Add work email** again. Once you click **Skip** or **Got it**, the step doesn’t appear again — add the address you want as a contact method after setup instead. +Yes, while the work email step is still open. If the message appears under the field, correct the address and click **Add work email** again. Once you click **Skip** or **Got it**, the step only appears again if you choose **Join my company workspace** during setup or open the **Add your work email** task from Concierge. Otherwise, add the address you want as a contact method after setup instead. ## Can I merge the existing account into my new account instead? diff --git a/docs/articles/new-expensify/insights/Explore-the-Spend-Over-Time-Insight.md b/docs/articles/new-expensify/insights/Explore-the-Spend-Over-Time-Insight.md new file mode 100644 index 000000000000..d8d7834eecfd --- /dev/null +++ b/docs/articles/new-expensify/insights/Explore-the-Spend-Over-Time-Insight.md @@ -0,0 +1,130 @@ +--- +title: Explore the Spend Over Time Insight +description: Learn what the Spend over time insight shows, how spending trends are calculated, and how to group expenses by different time periods. +keywords: [Spend over time, spending trends, spending over time, expense trends, spending chart, monthly spending, daily spending, weekly spending, quarterly spending, yearly spending, Group by, Insights, spending analysis] +internalScope: Audience is members who can access Insights. Covers what Spend over time shows, who can see it, how expenses are grouped by time period, and how to explore the underlying expenses in Spend. Does not cover detailed shared filtering instructions, exporting expenses, or other insights. +contentType: topic +platform: new-expensify +order: 3 +--- + +# Explore the Spend Over Time Insight + +**Spend over time** shows how total expenses change over a selected date range, helping you identify spending trends, increases, and decreases. + +You can use **Spend over time** to: + +- Monitor overall spending trends. +- Identify unusual increases or decreases in spending. +- Compare spending across days, weeks, months, quarters, or years. +- Spot recurring or seasonal spending patterns. +- Support budgeting and forecasting decisions. + +--- + +## What Spend over time shows + +**Spend over time** displays spending trends as a line chart, making it easy to see how spending changes throughout the selected date range. + +Each point on the chart represents a time period. You can view: + +- **Total spend:** The combined amount of expenses during that period. +- **Percentage of total spend:** The period's share of overall spending. +- **Number of expenses:** How many expenses contribute to the total. + +This makes it easier to identify periods with unusually high or low spending and compare spending patterns over time. + +![Spend over time insight showing monthly spending trends]({{site.url}}/assets/images/insights-spend-over-time.png){:width="100%"} + +--- + +## Who can see Spend over time + +**Spend over time** is available to all members. + +The expenses included depend on your permissions: + +- **Workspace admins and auditors** can see expenses across their workspace. +- **Approvers** can see expenses submitted to them. +- **Members** can see their own expenses. + +You won't see expenses you don't have permission to access. + +[Learn who can see insights](/articles/new-expensify/insights/Learn-About-Insights#who-can-see-insights). + +--- + +## How Spend over time is calculated + +**Spend over time** groups expenses by time period and calculates the total spending for each period. + +Results include expenses within the selected date range that you have permission to view. Amounts are displayed using the selected **Group currency**. + +**Spend over time** includes a **Group by** option that lets you change how expenses are grouped along the timeline. + +The available options are: + +- **Day:** View daily spending changes. +- **Week:** Compare spending across weeks. +- **Month:** Track monthly spending trends. +- **Quarter:** Compare spending across quarters. +- **Year:** Review annual spending trends. + +**Month** is selected by default. + +To change the grouping: + +1. On the **Spend over time** visualization, select **Group by**. +2. Choose **Day**, **Week**, **Month**, **Quarter**, or **Year**. +3. Select **Apply**. + +The chart updates to show spending grouped by your selected time period. + +The **Date**, **Workspace**, and **Group currency** filters are shared across all insights, allowing you to adjust which data appears. + +[Learn how to filter insights](/articles/new-expensify/insights/How-to-Use-Insights#how-to-filter-insights). + +--- + +## How to explore Spend over time + +**Spend over time** displays spending across the selected date range, grouped by your chosen time period. + +To explore the underlying expenses, select **More (⋮)** on the visualization, then select **View on Spend**. This opens the related results in **Spend**, where you can review individual expenses and analyze spending in more detail. + +[Learn how to explore expenses from an insight](/articles/new-expensify/insights/How-to-Use-Insights#how-to-explore-expenses-from-an-insight). + +--- + +## Related articles + +- [How to Use Insights](/articles/new-expensify/insights/How-to-Use-Insights) +- [Learn About Insights](/articles/new-expensify/insights/Learn-About-Insights) + +# FAQ + +## Can you export Spend over time? + +Grouped totals and chart summaries can't be exported directly from **Spend over time**, but you can export the underlying expenses from **Spend**. + +[Learn how to export expenses](/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses). + +## What other insights are available? + +Additional insights help you analyze spending from different perspectives: + +- **Top spenders** shows which members have the highest total spending. +- **Top merchants** shows which merchants account for the most spending. +- **Top categories** shows which expense categories account for the most spending. + +[Learn more about the available insights](/articles/new-expensify/insights/Learn-About-Insights#what-insights-are-available). + +## Why is an expense missing from Spend over time? + +An expense might not appear if: + +- You don't have permission to view it. +- It falls outside the selected date range. +- It belongs to a workspace excluded by the **Workspace** filter. + +To review the underlying expenses and apply additional filters, select **More (⋮)**, then **View on Spend**. diff --git a/docs/articles/new-expensify/insights/Explore-the-Top-Categories-Insight.md b/docs/articles/new-expensify/insights/Explore-the-Top-Categories-Insight.md new file mode 100644 index 000000000000..cee8248c3704 --- /dev/null +++ b/docs/articles/new-expensify/insights/Explore-the-Top-Categories-Insight.md @@ -0,0 +1,117 @@ +--- +title: Explore the Top Categories Insight +description: Learn what the Top categories insight shows, who can see it, how categories are ranked, and how to explore expenses by category. +keywords: [Top categories, top categories chart, category spending, spend by category, expense categories, highest spending categories, where money goes, Insights, donut chart, can't see Top categories] +internalScope: Audience is members who can access Insights on workspaces with categories enabled. Covers what Top categories shows, who can see it, how categories are ranked, which expenses are included, and how to explore category expenses in Spend. Does not cover detailed filtering instructions, exporting expenses, setting up categories, or other insights. +contentType: topic +platform: new-expensify +order: 6 +--- + +# Explore the Top Categories Insight + +**Top categories** shows which expense categories account for the highest total spending across expenses you have permission to view. + +You can use **Top categories** to: + +- Identify which expense types account for the most spending. +- Compare spending across categories such as Travel, Meals, or Office Supplies. +- Monitor spending patterns by category. +- Identify areas where you may need to adjust budgets. + +--- + +## What Top categories shows + +**Top categories** highlights the five expense categories with the highest total spending, ranked from highest to lowest. + +Each category is represented by a slice of a donut chart, making it easy to compare its share of spending. The table below the chart shows: + +- **Number of expenses:** How many expenses contribute to the category's total. +- **Total spend:** The combined amount of expenses in that category. +- **Percentage of total spend:** The category's share of overall spending. + +This makes it easier to understand which expense types account for the largest portion of spending. + +![Insights with Top categories highlighted]({{site.url}}/assets/images/insights-top-categories.png){:width="100%"} + +--- + +## Who can see Top categories + +**Top categories** only appears if a workspace you can access has categories enabled. + +The expenses included depend on your permissions: + +- **Workspace admins and auditors** can see expenses across their workspace. +- **Approvers** can see expenses submitted to them. +- **Members** can see their own expenses. + +If categories aren't enabled, a workspace admin can [learn how to create expense categories](/articles/new-expensify/workspaces/Create-expense-categories). + +[Learn who can see insights](/articles/new-expensify/insights/Learn-About-Insights#who-can-see-insights). + +--- + +## How Top categories is calculated + +**Top categories** groups expenses by category and ranks categories from highest to lowest total spend. + +Results include expenses within the selected date range that you have permission to view. Amounts are converted to the selected **Group currency**. + +The **Date**, **Workspace**, and **Group currency** filters are shared across all insights, allowing you to adjust which data appears. + +[Learn how to filter insights](/articles/new-expensify/insights/How-to-Use-Insights#how-to-filter-insights). + +--- + +## How to explore Top categories + +**Top categories** displays the five categories with the highest total spend for the selected date range. + +To see spending for every category, select **More (⋮)** on the visualization, then select **View on Spend**. This opens the related results in **Spend**, where you can explore spending beyond the top five categories. + +You can also select a slice of the donut chart to open **Spend** with the expenses in that category, listed from newest to oldest. + +[Learn how to explore expenses from an insight](/articles/new-expensify/insights/How-to-Use-Insights#how-to-explore-expenses-from-an-insight). + +--- + +## Related articles + +- [How to Use Insights](/articles/new-expensify/insights/How-to-Use-Insights) +- [Learn About Insights](/articles/new-expensify/insights/Learn-About-Insights) + +# FAQ + +## Can you export Top categories? + +Grouped totals and summary data can't be exported directly from **Top categories**, but you can export the underlying expenses from **Spend**. + +[Learn how to export expenses](/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses). + +## What other insights are available? + +Additional insights help you analyze spending from different perspectives: + +- **Spend over time** shows how total expenses change over a date range. +- **Top spenders** shows which members have the highest total spending. +- **Top merchants** shows which merchants account for the most spending. + +[Learn more about the available insights](/articles/new-expensify/insights/Learn-About-Insights#what-insights-are-available). + +## Why can't I see Top categories? + +**Top categories** only appears when at least one workspace you can access has categories enabled. + +If the insight isn't available, check whether categories are enabled for your workspace. A workspace admin can [create expense categories](/articles/new-expensify/workspaces/Create-expense-categories). + +## Why is an expense missing from Top categories? + +An expense might not appear if: + +- You don't have permission to view it. +- It falls outside the selected date range. +- Its category isn't among the five highest-spending categories shown in the visualization. + +To explore spending beyond the top five categories, select **More (⋮)**, then **View on Spend**. diff --git a/docs/articles/new-expensify/insights/Explore-the-Top-Merchants-Insight.md b/docs/articles/new-expensify/insights/Explore-the-Top-Merchants-Insight.md new file mode 100644 index 000000000000..9894c86aa498 --- /dev/null +++ b/docs/articles/new-expensify/insights/Explore-the-Top-Merchants-Insight.md @@ -0,0 +1,120 @@ +--- +title: Explore the Top Merchants Insight +description: Learn what the Top merchants insight shows, who can see it, how merchants are ranked, and how to explore merchant expenses. +keywords: [Top merchants, top merchants chart, vendor spending, merchant spending, highest spending merchants, spend by merchant, Insights, merchant names look different, top vendors] +internalScope: Audience is all members who can access Insights. Covers what Top merchants shows, who can see it, how merchants are ranked, which expenses are included, and how to explore merchant expenses in Spend. Does not cover detailed filtering instructions, exporting expenses, or other insights. +contentType: topic +platform: new-expensify +order: 5 +--- + +# Explore the Top Merchants Insight + +**Top merchants** shows which merchants account for the highest total spending across expenses you have permission to view. + +You can use **Top merchants** to: + +- Identify merchants with the highest spending. +- Monitor spending patterns by merchant. +- Find recurring vendor costs. +- Spot opportunities to negotiate better rates. + +--- + +## What Top merchants shows + +**Top merchants** highlights the five merchants with the highest total spending, ranked from highest to lowest. + +Each merchant is represented by a bar, making it easy to compare spending amounts. The table below the chart shows: + +- **Number of expenses:** How many expenses contribute to the merchant's total. +- **Total spend:** The combined amount of expenses for that merchant. +- **Percentage of total spend:** The merchant's share of overall spending. + +This makes it easier to compare spending across merchants and identify where the most money is being spent. + +![Insights with Top merchants highlighted]({{site.url}}/assets/images/insights-top-merchant.png){:width="100%"} + +--- + +## Who can see Top merchants + +**Top merchants** is available to all members. + +The expenses included depend on your permissions: + +- **Workspace admins and auditors** can see expenses across their workspace. +- **Approvers** can see expenses submitted to them. +- **Members** can see their own expenses. + +You won't see expenses you don't have permission to access. + +[Learn who can see insights](/articles/new-expensify/insights/Learn-About-Insights#who-can-see-insights). + +--- + +## How Top merchants is calculated + +**Top merchants** groups expenses by merchant and ranks merchants from highest to lowest total spend. + +Results include expenses within the selected date range that you have permission to view. Amounts are converted to the selected **Group currency**. + +Merchant names come from receipt data and card transactions, so the same vendor may appear under different names. Each merchant name is counted separately. + +The **Date**, **Workspace**, and **Group currency** filters are shared across all insights, allowing you to adjust which data appears. + +[Learn how to filter insights](/articles/new-expensify/insights/How-to-Use-Insights#how-to-filter-insights). + +--- + +## How to explore Top merchants + +**Top merchants** displays the five merchants with the highest total spend for the selected date range. + +To see spending for every merchant, select **More (⋮)** on the visualization, then select **View on Spend**. This opens the related results in **Spend**, where you can explore spending beyond the top five merchants. + +You can also select a merchant's bar to open **Spend** with the expenses for that merchant, listed from newest to oldest. + +[Learn how to explore expenses from an insight](/articles/new-expensify/insights/How-to-Use-Insights#how-to-explore-expenses-from-an-insight). + +--- + +## Related articles + +- [How to Use Insights](/articles/new-expensify/insights/How-to-Use-Insights) +- [Learn About Insights](/articles/new-expensify/insights/Learn-About-Insights) + +# FAQ + +## Can you export Top merchants? + +Grouped totals and summary data can't be exported directly from **Top merchants**, but you can export the underlying expenses from **Spend**. + +[Learn how to export expenses](/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses). + +## What other insights are available? + +Additional insights help you analyze spending from different perspectives: + +- **Spend over time** shows how total expenses change over a date range. +- **Top spenders** shows which members have the highest total spending. +- **Top categories** shows which expense categories account for the most spending. + +[Learn more about the available insights](/articles/new-expensify/insights/Learn-About-Insights#what-insights-are-available). + +## Why does the same merchant appear more than once in Top merchants? + +Merchant names come from receipts and card transactions, so the same vendor may appear under different names, such as "Amazon.com" and "AMZN Marketplace." + +Each name is counted separately. Select a merchant to review its expenses in **Spend** and confirm whether the expenses belong to the same vendor. + +## Why is an expense missing from Top merchants? + +An expense might not appear if: + +- You don't have permission to view it. +- It falls outside the selected date range. +- Its merchant isn't among the five highest-spending merchants shown in the visualization. +- It's recorded under a different merchant name. + +To explore spending beyond the top five merchants, select **More (⋮)**, then **View on Spend**. diff --git a/docs/articles/new-expensify/insights/Explore-the-Top-Spenders-Insight.md b/docs/articles/new-expensify/insights/Explore-the-Top-Spenders-Insight.md new file mode 100644 index 000000000000..fc9278369586 --- /dev/null +++ b/docs/articles/new-expensify/insights/Explore-the-Top-Spenders-Insight.md @@ -0,0 +1,107 @@ +--- +title: Explore the Top Spenders Insight +description: Learn what the Top spenders insight shows, who can see it, how members are ranked, and how to open a member's expenses. +keywords: [Top spenders, top spenders chart, visualization, highest spenders, employee spending, who spent the most, spend by member, Insights, workspace admin, auditor, approver, can't see Top spenders] +internalScope: Audience is workspace admins, auditors, and approvers. Covers what Top spenders shows, who can see it, how members are ranked, which expenses are included, and how to view all members in Spend. Does not cover detailed filtering instructions, exporting expenses, or other insights. +contentType: topic +platform: new-expensify +order: 4 +--- + +# Explore the Top Spenders Insight + +**Top spenders** shows which members submitted the highest total expenses across workspaces where you're a workspace admin, approver, or auditor. + +You can use **Top spenders** to: + +- Identify unusually high spending. +- Monitor spending patterns by member. +- Spot spending that may need a closer review for policy compliance. +- Support expense approval and reimbursement reviews. + +--- + +## What Top spenders shows + +**Top spenders** highlights the five members with the highest total spending, ranked from highest to lowest. + +Each member is represented by a bar, making it easy to compare spending amounts. For each member, you can see: + +- **Total spend:** The combined amount of their expenses. +- **Number of expenses:** How many expenses contribute to their total. + +This makes it easier to compare spending across members and identify expenses that may need further review. + +![Insights with Top spenders highlighted]({{site.url}}/assets/images/insights-top-spender.png){:width="100%"} + +--- + +## Who can see Top spenders + +**Top spenders** is available to workspace admins, auditors, and approvers on workspaces with at least two members. + +The expenses included depend on your permissions: + +- **Workspace admins and auditors** can see expenses across their workspace. +- **Approvers** can see expenses submitted to them. + +Members without these permissions won't see **Top spenders**. + +[Learn who can see insights](/articles/new-expensify/insights/Learn-About-Insights#who-can-see-insights). + +--- + +## How Top spenders is calculated + +**Top spenders** groups expenses by the member who submitted them and ranks members from highest to lowest total spend. + +Results include expenses within the selected date range from workspaces where you have the required permissions. Expenses submitted in individual chats instead of to a workspace aren't included. + +The **Date**, **Workspace**, and **Group currency** filters are shared across all insights, allowing you to adjust which data appears. + +[Learn how to filter insights](/articles/new-expensify/insights/How-to-Use-Insights#how-to-filter-insights). + +--- + +## How to explore Top spenders + +**Top spenders** displays the five members with the highest total spend for the selected date range. + +To see spend for every member, select **More (⋮)** on the visualization, then select **View on Spend**. This opens the related results in **Spend**, where you can explore spend beyond the top five members. + +[Learn how to explore expenses from an insight](/articles/new-expensify/insights/How-to-Use-Insights#how-to-explore-expenses-from-an-insight). + +--- + +## Related articles + +- [How to Use Insights](/articles/new-expensify/insights/How-to-Use-Insights) +- [Learn About Insights](/articles/new-expensify/insights/Learn-About-Insights) + +# FAQ + +## Can you export Top spenders? + +**Top spenders** totals can't be exported directly. However, if you expand each group to reveal the individual expenses, you can then select those expenses and use Export to CSV to download the raw data. + +[Learn how to export expenses](/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses). + +## What other insights are available? + +Additional insights help you analyze spending from different perspectives: + +- **Spend over time** shows how total expenses change over a date range. +- **Top merchants** shows which vendors received the most payments. +- **Top categories** shows the highest-spending expense types (e.g., Travel, Meals). + +[Learn more about the available insights](/articles/new-expensify/insights/Learn-About-Insights#what-insights-are-available) + +## Why is an expense missing from Top spenders? + +An expense might not appear if: +- It belongs to a workspace you don't have permission to view. +- It falls outside the selected date range. +- It was submitted in an individual chat rather than to a workspace. +- The member isn't among the five highest spenders shown in the visualization. + +To see spend for members outside the top five, select **More (⋮)**, then **View on Spend**. diff --git a/docs/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify.md b/docs/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify.md deleted file mode 100644 index a351ce79b304..000000000000 --- a/docs/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify.md +++ /dev/null @@ -1,225 +0,0 @@ ---- -title: How to Use Insights in Expensify -description: Learn how to use Insights to analyze spending trends and make data-driven financial decisions in New Expensify. -keywords: [New Expensify, Insights, spending analysis, expense reports, analytics, Top Spenders, Top Categories, Top Merchants, Spend over time, financial insights, budget analysis, expense trends] -internalScope: Audience is all workspace members. Covers how Insights reports work, how to access them, available report types, grouping logic, view modes, exporting data, and customization. Does not cover troubleshooting individual reports. ---- - -# How to Use Insights in Expensify - -Insights are pre-built reports that help you understand spending patterns and trends without exporting data or creating complex custom searches. Each Insight is a suggested search that groups and analyzes your expense data automatically. - ---- - -## Who can use Insights - -Anyone can use Insights. What you can see depends on your role in the workspace. - -- Regular members can view Insights that include their own submitted expenses. -- Approvers can view Insights that include their own expenses plus expenses submitted to them for approval. -- Workspace Admins can view Insights that include all workspace expenses. -- Auditors can view Insights that include all workspace expenses. - -Insights always respect workspace permissions. You will only see data you’re allowed to access. - -## Where to find Insights - -You can access Insights on both web and mobile. - -1. Click **Spend** in the navigation tabs (on the left on web, or on the bottom on mobile). -2. Scroll to **Insights**. -3. Select an Insight to open the report. - ---- - -## How to use Spend over time - -Spend over time shows how total expenses change across a selected date range. - -You can use Spend over time to: - - - Monitor overall spending trends - - Identify increases or decreases in expenses - - Compare spend across months or custom date ranges - - Track seasonal or recurring spending patterns - - Support budgeting and forecasting decisions - -[Learn more about the Spend over time report](https://help.expensify.com/articles/new-expensify/insights/View-the-Spend-over-time-report). - -## How to use Top Spenders - -Top Spenders shows which members submitted the highest total expenses in the previous calendar month, across the workspaces where you're a Workspace Admin, Approver, or Auditor. - -You can use Top Spenders to: - -- Identify unusually high spenders -- Monitor employee-level spending patterns -- Evaluate policy compliance -- Support reimbursement and approval reviews - -[Learn more about the Top Spenders report](https://help.expensify.com/articles/new-expensify/insights/View-the-Top-Spenders-report). - ---- - -## How to use Top Categories - -Top Categories shows which expense categories had the highest total spend in the previous calendar month. - -You can use Top Categories to: - -- Understand where most money is being spent by expense type -- Compare spending across categories like Travel, Meals, and Office Supplies -- Make budget allocation decisions - -[Learn more about the Top Categories report](https://help.expensify.com/articles/new-expensify/insights/View-the-Top-Categories-report) - ---- - -## How to use Top Merchants - -Top Merchants shows which merchants or vendors received the highest total payments in the previous calendar month. - -You can use Top Merchants to: - -- Track vendor spending trends -- Identify opportunities for volume discounts -- Monitor subscription and recurring expenses - -[Learn more about the Top Merchants report](https://help.expensify.com/articles/new-expensify/insights/View-the-Top-Merchants-report) - ---- - -## How to use Violations by submitter - -Violations by submitter shows which members submitted the most expenses that broke your workspace rules in the previous calendar month. It’s available to Workspace Admins and Auditors on Control workspaces that have Rules enabled. - -You can use Violations by submitter to: - -- See who is repeatedly submitting expenses with policy violations -- Spot compliance patterns without reviewing reports one by one -- Prioritize coaching or policy guidance for specific members - -[Learn more about the Violations by submitter report](https://help.expensify.com/articles/new-expensify/insights/View-the-Violations-by-submitter-report) - ---- - -## How Insights work - -Each Insight is powered by Expensify's search query engine using grouping filters and operators to: - -- Automatically group expenses by relevant dimensions (employee, category, merchant, time period) -- Calculate totals and counts -- Sort results to show the highest values first -- Update dynamically as new expenses are added - ---- - -## How to switch between views in Insights - -Insights support four viewing modes: - -**Bar View** -Displays grouped results as a bar chart. This is the default view for **Top categories** and makes comparisons easy at a glance. - -**Table View** -Displays grouped data in rows and columns for more detailed analysis. This is the default view for **Top spenders** and simplifies side-by-side comparisons. - -**Line View** -Displays grouped data in a line chart for analyzing trends. This is the default view for **Spend over time** and helps you monitor changes and patterns. - -**Pie view** -Displays grouped data as a pie chart to show proportional distribution. This is the default for **Top merchants** and helps you understand distribution instantly. - ---- - -To switch views: - -1. Open any Insight. -2. Click **View** in the top navigation. -3. Select **Table**, **Bar**, **Line** or **Pie**. - ---- - -## How to customize Insights - -While you can't modify the suggested Insight searches directly, you can: - -- Apply additional date filters -- Filter by workspace or member -- Expand groups to view individual expenses -- Export data to CSV -- Save a modified search - -## How to create a custom spending report using Insights - -If you want to build your own grouped report: - -1. Open an Insight as your starting point. -2. Modify the filters to match your needs. -3. Adjust the date range if needed. -4. Click **Save** to store your custom view. - -This allows you to automate recurring analysis without rebuilding the search each time. - -[Learn how to use search operators and grouping](/articles/new-expensify/reports-and-expenses/Using-search-operators) - ---- - -## How to export data from an Insight - -Insights cannot export grouped totals directly. However, you can export the underlying expenses. - -To export Insight data: - -1. Open the Insight. -2. Expand a group to show individual expenses. -3. Select the expenses you want to export. -4. Click **Export to CSV**. - -This is helpful for external reporting or sharing detailed data with finance teams. - ---- - -## Grouping options used in Insights - -Insights use grouping operators to summarize expenses. These include: - -- **group-by:from** - Group by employee (who submitted) -- **group-by:category** - Group by expense category -- **group-by:merchant** - Group by merchant or vendor -- **group-by:tag** - Group by expense tags -- **group-by:month** - Group by calendar month -- **group-by:week** - Group by calendar week -- **group-by:quarter** - Group by fiscal quarter -- **group-by:year** - Group by calendar year - -You can use these operators in custom searches to create your own insights and analyze spending from different perspectives. - -[View all available search operators](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-search-operators) - ---- - -# FAQ - -## Can I export grouped totals from Insights in Expensify? - -No. Grouped totals cannot be exported directly. You must expand each group and export the individual expenses as a CSV file. - ---- - -## How often do Insights update in Expensify? - -Insights update in realtime as expenses are created, edited, approved, or reimbursed. - ---- - -## Can I change the time period in Insights? - -Yes. Use date filters to adjust the time period. - -Common filters include: - -- `date:this-month` -- `date:last-month` -- `date:year-to-date` -- Custom date ranges diff --git a/docs/articles/new-expensify/insights/How-to-Use-Insights.md b/docs/articles/new-expensify/insights/How-to-Use-Insights.md new file mode 100644 index 000000000000..7bea3beadfbe --- /dev/null +++ b/docs/articles/new-expensify/insights/How-to-Use-Insights.md @@ -0,0 +1,88 @@ +--- +title: How to Use Insights +description: Learn how to use Insights in Expensify to filter spending data, explore expenses, change visualizations, and create custom spending reports. +keywords: [Insights, how to use Insights, filter Insights, customize Insights, spending analysis, change chart view, view expenses, export Insight data, save custom search, spending reports] +internalScope: Audience is members who can access Insights. Covers how to access and interact with Insights, adjust shared filters, explore underlying expenses, and use related reporting capabilities. Does not cover individual insight calculations, detailed search operators, or step-by-step export and saved-search workflows. +contentType: topic +platform: new-expensify +order: 2 +--- + +# How to Use Insights + +Insights help you analyze spending to spot changes and understand patterns. You can adjust which expenses are included, explore spending trends from different perspectives, and open the underlying expenses in **Spend** for further review. + +For an introduction to the available insights and what they show, see [Learn About Insights](/articles/new-expensify/insights/Learn-About-Insights). + +## Where to find Insights + + +Insights displays spending visualizations based on your permissions and workspace configuration. Where you access insights depends on your platform. + +On web: +In the navigation tabs on the left, select **Insights**. + +On mobile: +In the navigation tabs on the bottom, select **Spend**. Then, in the navigation menu, select the individual insight you want to view. + +## How to filter insights + +You can adjust the data shown across **Insights** using three shared filters: + +- **Date:** Choose the period you want to analyze. +- **Workspace:** Choose which workspace expenses to include. +- **Group currency:** Choose the currency used to group spending amounts. + +These filters apply across all insights, allowing you to compare spending trends, merchants, categories, and members using the same date range, workspaces, and currency. + +![Insights page with Date, Workspace, and Group currency filters highlighted]({{site.url}}/assets/images/insights-filters.png){:width="100%"} + +## How to explore expenses from an insight + +Insights summarize spending data, while **Spend** lets you review individual expenses and analyze spending in more detail. + +To explore the expenses behind an insight, select **More (⋮)** on the visualization, then select **View on Spend**. This opens the related results in **Spend**, where you can review individual expenses, apply additional filters, and use grouping options to analyze spending from different perspectives. + +[Learn how to use search operators to analyze spend](/articles/new-expensify/reports-and-expenses/Using-search-operators). + +## How to change how insight data is displayed + +Each insight uses a predefined visualization to highlight spending patterns. To explore the same expense data in a different format, open it in **Spend**, where you can switch between bar, line, pie, and table views. + +The available views are: + +- **Bar chart:** Helpful for comparing spending amounts across members, merchants, or categories. +- **Line chart:** Helpful for identifying changes and trends over time. +- **Pie chart:** Helpful for understanding how spending is distributed across groups. +- **Table:** Helpful for reviewing grouped spending data across rows and columns. + +To display the expense data in a different view: + +1. On the insight, select **More (⋮) > View on Spend**. +2. Select **Display > View**. +3. Choose the view you want, then select **Apply**. + +![Insight with View on Spend highlighted]({{site.url}}/assets/images/insight-view-on-spend.png){:width="100%"} + +## How to save a customized search from an insight + +If you regularly review the same spending data, you can save a customized search in **Spend**. Saved searches let you return to a particular combination of filters and grouping options without rebuilding the search each time. + +You can start with an insight, open its results in **Spend**, and customize the search to match your reporting needs. + +[Learn how to create a saved search](/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify). + +## How to export insight data + +Insights summarize expense data, but grouped totals and chart summaries cannot be exported directly. + +To export the underlying expenses, open the related results in **Spend** and select the individual expenses you want to export. This lets you download expense-level data for additional analysis or reporting outside Expensify. + +[Learn how to export expenses](/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses). + +## Related articles + +- [Explore the Spend Over Time Insight](/articles/new-expensify/insights/explore-the-spend-over-time-insight) +- [Explore the Top Spenders Insight](/articles/new-expensify/insights/explore-the-top-spenders-insight) +- [Explore the Top Merchants Insight](/articles/new-expensify/insights/explore-the-top-merchants-insight) +- [Explore the Top Categories Insight](/articles/new-expensify/insights/explore-the-top-categories-insight) diff --git a/docs/articles/new-expensify/insights/Learn-About-Insights.md b/docs/articles/new-expensify/insights/Learn-About-Insights.md new file mode 100644 index 000000000000..4b142c4552ac --- /dev/null +++ b/docs/articles/new-expensify/insights/Learn-About-Insights.md @@ -0,0 +1,60 @@ +--- +title: Learn About Insights +description: Learn what Insights are in Expensify, which insights are available, who can see them, and how they help you understand spending patterns. +keywords: [Insights, spending insights, spending analytics, spending trends, Spend over time, Top spenders, Top merchants, Top categories, who can see Insights, Insights vs Spend] +internalScope: Audience is all members. Covers what Insights are, the available spending insights, general visibility rules, and how Insights relates to Spend. Does not cover detailed filtering instructions, individual insight calculations, or saved-search and export workflows. +contentType: topic +platform: new-expensify +order: 1 +--- + +# Learn About Insights + +Insights help you understand spending patterns, track changes over time, and identify where money is being spent. They summarize expense data in visualizations so you can quickly see spending trends, compare expenses across members, merchants, and categories, and identify areas that may need closer review. + +## What insights are available + +**Insights** includes visualizations that help you understand spending from different perspectives: + +- **Spend over time** shows how total spending changes over a selected period, helping you identify trends and unusual increases or decreases. +- **Top spenders** shows which members have the highest total spending, helping you identify high spenders and monitor spending patterns by member. +- **Top merchants** shows which merchants account for the most spending, helping you understand where money is being spent and identify recurring vendor expenses. +- **Top categories** shows which expense categories account for the most spending, helping you understand spending by expense type and identify opportunities to adjust budgets. + +All insights share the same **Date**, **Workspace**, and **Group currency** filters, so you can compare spending across the same period, workspaces, and currency. + +![Insights page showing the Spend over time, Top spenders and Top merchant visualizations together.]({{site.url}}/assets/images/insights-page.png){:width="100%"} + +## Who can see Insights + +Insights are available to all members, but the data you see depends on your permissions in Expensify. + +- **Workspace admins and auditors** can see expenses across their workspaces. +- **Approvers** can see expenses submitted to them for approval. +- **Members** can see their own expenses. + +Some insights have additional requirements: + +- **Top spenders** is available to workspace admins, auditors, and approvers on workspaces with at least two members. +- **Top categories** appears only when a workspace you can access has categories enabled. + +Insights that don't meet these requirements won't appear. + +## How Insights and Spend work together + +**Insights** and **Spend** provide different ways to explore the same expense data. + +- **Insights** summarizes spending in visualizations, helping you identify trends and understand which members, merchants, or categories account for the most spend. +- **Spend** provides the underlying expense and report details, where you can search, review, and take action on specific expenses. + +You can open an insight's underlying results in **Spend** by selecting **More (⋮) > View on Spend** to investigate spending patterns, apply additional filters, or explore expenses beyond those displayed in a visualization. + +[Learn how to use Insights](/articles/new-expensify/insights/How-to-Use-Insights). + +## Related articles + +- [How to Use Insights](/articles/new-expensify/insights/How-to-Use-Insights) +- [Explore the Spend Over Time Insight](/articles/new-expensify/insights/explore-the-spend-over-time-insight) +- [Explore the Top Spenders Insight](/articles/new-expensify/insights/explore-the-top-spenders-insight) +- [Explore the Top Merchants Insight](/articles/new-expensify/insights/explore-the-top-merchants-insight) +- [Explore the Top Categories Insight](/articles/new-expensify/insights/explore-the-top-categories-insight) diff --git a/docs/articles/new-expensify/insights/View-the-Top-Categories-report.md b/docs/articles/new-expensify/insights/View-the-Top-Categories-report.md deleted file mode 100644 index c1f20a055b7b..000000000000 --- a/docs/articles/new-expensify/insights/View-the-Top-Categories-report.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -title: View the Top Categories report -description: Learn how to use the Top Categories report to understand category-level spending trends. -keywords: [New Expensify, Top Spenders, Top Categories, Top Merchants, Spend over time, expense categories, category spending, monthly spending, Workspace Admin, Approver, Auditor, category insights, expense analytics, report by category] -internalScope: Audience is Workspace Admins, Approvers, and Auditors. Covers using the Top Categories suggested search to view expense totals by category. Does not cover employee-level analysis or merchant-level grouping. ---- - -# View the Top Categories report - -The **Top Categories** report shows which expense categories had the highest total spend last month. It’s an easy way to: - -- Understand where the most money is being spent -- Spot trends without exporting data -- Make data-driven budget decisions with less effort - -This report is a pre-built suggested search that uses filters to group your expenses by category. - -![Top Categories bar chart]({{site.url}}/assets/images/top-categories.png){:width="100%"} - ---- - -## Who can use the Top Categories report - -The Top Categories report is available to all workspace members on web and mobile. - - **Workspace members** can view top categories for expenses on reports they’ve submitted or approved. - - **Workspace Admins** and **Auditors** can view top categories for all expenses on the workspace. - ---- - -## Where to find the Top Categories report - -**Web:** -Click **Spend** in the left navigation, then click **Top categories** under the **Insights** section - -**Mobile:** -Tap **Spend** from the navigation tabs on the bottom, then tap the hamburger menu in the top-right corner. Under **Insights**, tap **Top categories**. - ---- - -## What the Top Categories report shows - -The Top Categories report is powered by Expensify’s search query engine using grouping and filters. The report shows: - -- All **expense categories** sorted by total spend in the last month -- The **total amount spent** in each category -- The **number of expenses** in each category - ---- - -## How to interpret the Top Categories report - -Each category entry represents a single expense category, ordered from highest to lowest total spend. The report shows: - -- **Number of expenses** in the category -- **Total amount spent** in that category - -Select a category to review all expenses included in that grouping. - ---- - -## How to customize the Top Categories report - -You can adjust filters like status, expense type or employee to explore spending trends. - -The Top Categories report is a suggested search template. You cannot save changes directly to this report, but you can create a custom version. - -To create and save a custom report: - -1. Go to the **Spend** or **Expenses** tab. -2. Use filters to adjust the category grouping or timeframe. -3. Apply filters and click **Save** to save your custom view. - -[Learn how to create custom reports](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify#how-to-use-spend-search-query-commands) - ---- - -## How to switch views in the Top Categories report - -By default, the Top Categories report displays a **Bar** chart. You can also switch to **Line**, **Table** or **Pie** view depending on how you want to analyze your data. - -- **Line** shows spending trends over time. -- **Table** displays merchants in rows sorted by total spend. -- **Pie** shows how data is distributed. - -To change the view: - -1. Open the Top Categories report. -2. Click **View** in the top navigation. -3. Select **Line**, **Table** or **Pie**. - -The report will update instantly to reflect your selected view. - ---- - -## Ways to use the Top Categories report - -Workspace Admins and finance teams use the Top Categories report to: - -- Monitor which expense categories are driving most of the spend -- Quickly compare expense volume by category -- Make budget and policy decisions without needing spreadsheets - ---- - -# FAQ - -## Can I export the Top Categories report? - -The Top Categories report can’t be exported with its grouped totals or summary data directly. However, if you expand each group to reveal the individual expenses, you can then select those expenses and use Export to CSV to download the raw data. - -To export expenses: - -1. Go to the **Spend** or **Expenses** tab. -2. Use filters to group by category and apply the same timeframe. -3. Expand the category group, select the expenses, and click **Export to CSV**. - -## What’s the difference between the Top Categories report and other Insights reports? - -Each report provides a different view of your spending: - -- **Spend over time** shows how total expenses change over a date range. -- **Top Merchants** shows which vendors received the most payments. -- **Top Categories** shows the highest-spending expense types (e.g., Travel, Meals). -- **Top Spenders** shows which members submitted the most expenses. - -## How is the Top Categories report calculated? - -The report uses all expenses from the previous calendar month and groups them by category. It shows the **top 5 categories** based on total spend. - ---- diff --git a/docs/articles/new-expensify/insights/View-the-Top-Merchants-report.md b/docs/articles/new-expensify/insights/View-the-Top-Merchants-report.md deleted file mode 100644 index 19dffad6df3e..000000000000 --- a/docs/articles/new-expensify/insights/View-the-Top-Merchants-report.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -title: View the Top Merchants report -description: Learn how to use the Top Merchants report to understand merchant-level spending trends. -keywords: [New Expensify, Top Merchants, merchant spending, vendor analysis, monthly spending, Workspace Admin, Approver, Auditor, merchant insights, expense analytics, report by merchant] -internalScope: Audience is Workspace Admins, Approvers, and Auditors. Covers using the Top Merchants suggested search to view expense totals by merchant. Does not cover employee-level analysis or category-level grouping. ---- - -# View the Top Merchants report - -The **Top Merchants** report shows which merchants or vendors received the highest total spend last month. It's an easy way to: - -- Understand which vendors are receiving the most payments -- Identify recurring merchant relationships -- Spot trends without exporting data -- Make data-driven purchasing decisions with less effort - -This report is a pre-built suggested search that uses filters to group your expenses by merchant. - ---- - -## Who can use the Top Merchants report - -The Top Merchants report is available to all workspace members on web and mobile. - - **Workspace members** can view top merchants for expenses on reports they've submitted or approved. - - **Workspace Admins** and **Auditors** can view top merchants for all expenses on the workspace. - ---- - -## Where to find the Top Merchants report - -**Web:** -Click **Spend** in the left navigation, then click **Top merchants** under the **Insights** section - -**Mobile:** -Tap **Spend** from the navigation tabs on the bottom, then tap the hamburger menu in the top-right corner. Under **Insights**, tap **Top merchants**. - ---- - -## What the Top Merchants report shows - -The Top Merchants report is powered by Expensify's search query engine using grouping and filters. The report shows: - -- All **merchants** sorted by total spend in the last month -- The **total amount spent** at each merchant -- The **number of expenses** for each merchant - ---- - -## How to interpret the Top Merchants report - -Each merchant entry represents a single vendor or merchant, ordered from highest to lowest total spend. The report shows: - -- **Number of expenses** for that merchant -- **Total amount spent** at that merchant - -Select a merchant to review all expenses included in that grouping. - ---- - -## How to customize the Top Merchants report - -You can adjust filters like status, expense type, or employee to explore spending trends. - -The Top Merchants report is a suggested search template. You cannot save changes directly to this report, but you can create a custom version. - -To create and save a custom report: - -1. Go to the **Spend** or **Expenses** tab. -2. Use filters to adjust the merchant grouping or timeframe. -3. Apply filters and click **Save** to save your custom view. - -[Learn how to create custom reports](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify#how-to-use-spend-search-query-commands) - ---- - -## How to switch views in the Top Merchants report - -By default, the Top Merchants report displays a **Pie** chart. You can also switch to **Table**, **Bar** or **Line** view depending on how you want to analyze your data. - -- **Table** displays merchants in rows sorted by total spend. -- **Bar** shows merchant totals visually for quick comparison. -- **Line** shows spending trends over time. - -To change the view: - -1. Open the Top Merchants report. -2. Click **View** in the top navigation. -3. Select **Table**, **Bar**, or **Line**. - -The report will update instantly to reflect your selected view. - ---- - -## Ways to use the Top Merchants report - -Workspace Admins and finance teams use the Top Merchants report to: - -- Monitor vendor spending patterns -- Identify opportunities for volume discounts or contract negotiations -- Quickly compare expense volume by vendor -- Track subscription and recurring service costs -- Make purchasing decisions without needing spreadsheets - ---- - -# FAQ - -## Can I export the Top Merchants report? - -The Top Merchants report can't be exported with its grouped totals or summary data directly. However, if you expand each group to reveal the individual expenses, you can then select those expenses and use Export to CSV to download the raw data. - -To export expenses: - -1. Go to the **Spend** or **Expenses** tab. -2. Use filters to group by merchant and apply the same timeframe. -3. Expand the merchant group, select the expenses, and click **Export to CSV**. - -## What's the difference between the Top Merchants report and other insights reports? - -Each report provides a different view of your spending: - -- **Spend over time** shows how total expenses change over a date range. -- **Top Categories** shows the highest-spending expense types (e.g., Travel, Meals). -- **Top Spenders** shows which members submitted the most expenses. - -All reports help identify trends from different perspectives to support better financial decisions. - -## How is the Top Merchants report calculated? - -The report uses all expenses from the previous calendar month and groups them by merchant. It shows the **top 5 merchants** based on total spend. - -## Why do some merchant names look different? - -Merchant names are captured from receipt data and card transactions. The same vendor might appear under different names (e.g., "Amazon.com" vs "AMZN Marketplace"). You may need to review the individual expenses to confirm they're from the same merchant. - ---- diff --git a/docs/articles/new-expensify/insights/View-the-Top-Spenders-report.md b/docs/articles/new-expensify/insights/View-the-Top-Spenders-report.md deleted file mode 100644 index fa4b3de39a50..000000000000 --- a/docs/articles/new-expensify/insights/View-the-Top-Spenders-report.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -title: View the Top Spenders report -description: Learn how Workspace Admins, Approvers, and Auditors can use the Top Spenders report to understand spending trends. -keywords: [New Expensify, Top Spenders, Top Categories, Top Merchants, Spend over time, employee spending, high spenders, expense trends, Workspace Admin, Approver, Auditor, monthly spending, spending insights, virtual CFO, analytics, insight, budget] -internalScope: Audience is Workspace Admins, Approvers, and Auditors. Covers using the Top Spenders suggested search to view employee-level spending, and which expenses are included in the results. Does not cover custom reports, exporting data, or grouping by category or merchant. ---- - -# View the Top Spenders report - -The Top Spenders report shows which employees submitted the highest total expenses last month. It’s a fast way to: - -- Identify high or unusual spenders -- Spot trends without exporting data -- Make data-backed decisions with minimal effort - -This report is a pre-built suggested search so you can review spending activity at a glance. - -![Viewing the Top Spenders report]({{site.url}}/assets/images/top-spender-2.png{:width="100%"} - ---- - -## Who can use the Top Spenders report - -The Top Spenders report is available to Workspace Admins, Approvers, and Auditors on both web and mobile. - -**Note:** Exporting data is only available on web. - ---- - -## Where to find the Top Spenders report - -**Web:** -Use the navigation tabs on the left and select **Spend**, then click **Top spenders** under the **Insights** section. - -**Mobile:** -Tap **Spend** from the navigation tabs on the bottom, then tap the hamburger menu in the top-right corner. Under **Insights**, tap **Top spenders**. - ---- - -## What information the Top Spenders report displays - -The Top Spenders report is powered by a saved search query using Expensify's grouping and filtering engine. The report shows: - -- The **top expense submitters** for the last month -- The **total amount spent** by each person -- The **number of expenses** by each person - -Results cover only the workspaces where you're a Workspace Admin, Approver, or Auditor. Expenses submitted in an individual chat instead of to a workspace aren't included. - ---- - -## How to interpret the Top Spenders report - -Each row represents an individual employee, sorted in descending order by amount. Columns include: - -- **Number of expenses submitted** -- **Total amount spent** - -Click any row to view that member's individual expenses. - ---- - -## Can you customize the Top Spenders report? - -Yes - you can adjust filters like date range, workspace, or employee to explore spending trends. However, the Top Spenders report is a built-in suggested search, so you can’t save changes to it directly. - -To create and save a custom report: - -1. Go to the **Spend** or **Expenses** tab. -2. Use filters to adjust grouping and timeframes. -3. Apply filters and save your custom search. - -[Learn how to create custom reports](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify#how-to-use-spend-search-query-commands). - ---- - -## Ways to use the Top Spenders report - -Workspace Admins and finance teams use the Top Spenders report to: - -- Identify high or unusual spenders at a glance -- Spot spending trends without exporting data -- Make data-backed decisions quickly - ---- - -## How to switch views in the Top Spenders report - -By default, the Top Spenders report displays a **Table**. You can also switch to **Bar**, **Line** or **Pie** view depending on how you want to analyze your data. - - - **Bar** shows totals for easy comparison. - - **Line** shows trends over time. - - **Pie** shows how data is distributed. - -To change the view: - -1. Open the Top Spenders report. -2. Click **View** in the top navigation. -3. Select **Bar**, **Line**, or **Pie**. - -The report will update instantly to reflect your selected view. - ---- - -# FAQ - -## Can you export the Top Spenders report? - -Not directly — the Top Spenders report can’t be exported with its grouped totals or summary data. However, if you expand each group to reveal the individual expenses, you can then select those expenses and use Export to CSV to download the raw data. - -1. Go to the **Spend** tab. -2. Apply filters to create your own view. -3. Click **Export to CSV**. - -## What other Insights reports are available? - -Additional Insights reports help you analyze spending from different perspectives: - -- **Spend over time** shows how total expenses change over a date range. -- **Top Merchants** shows which vendors received the most payments. -- **Top Categories** shows the highest-spending expense types (e.g., Travel, Meals). - -[Learn more about all available Insights](https://help.expensify.com/articles/new-expensify/insights/Insights-Overview) - -## Can other people see my Top Spenders report? - -Only Workspace Admins, Approvers, and Auditors can view a workspace's **Top Spenders** report. Regular members do not have access. - -## How is the Top Spenders report calculated? - -The Top Spenders report uses expenses from the previous calendar month that belong to the workspaces where you're a Workspace Admin, Approver, or Auditor, and groups them by submitter (employee). It shows the top 5 people by total amount spent. - -## Why is an expense missing from the Top Spenders report? - -The expense is left out when it doesn't belong to one of your workspaces. An expense submitted in an individual chat isn't part of a workspace, so it never appears in **Top spenders**. Expenses dated outside the previous calendar month are also excluded. diff --git a/docs/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts.md b/docs/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts.md index 3bf0ecdd4980..1b5cae7f2dc9 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts.md +++ b/docs/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts.md @@ -2,29 +2,29 @@ title: Accounting Search Shortcuts description: Use the Accounting search shortcuts on the Spend page to review expense status, prepare for export, and reconcile transactions with your accounting records. keywords: [New Expensify, accounting expensify, reconcile expenses, awaiting export, cash accruals, card accruals, card statements, bank reconciliation, month-end close, search shortcut, accounting shortcut] -internalScope: Audience is Workspace Admins and Auditors. Covers using the Accounting section to review expense states and support reconciliation workflows. Does not cover exporting or performing reconciliation in detail. +internalScope: Audience is workspace admins and auditors. Covers using the Accounting section to review expense states and support reconciliation workflows. Does not cover exporting or performing reconciliation in detail. --- # Accounting Search Shortcuts The **Accounting** search shortcuts on the **Spend** page bring together the expense views you need to reconcile your records and close your books. It helps you identify what’s ready to export, what still needs attention, and what should be matched against statements or bank data. -Workspace Admins and Auditors can use these shortcuts to validate totals, track liabilities, and ensure your expenses align with accounting records before and after export. +Workspace admins and auditors can use these shortcuts to validate totals, track liabilities, and ensure your expenses align with accounting records before and after export. --- ## Who can use Accounting search shortcuts -Workspace Admins and Auditors with: -- Approvals turned on +Workspace admins and auditors with: +- Approvals enabled - Either reimbursements enabled, at least one card feed connected, or Expensify Cards enabled --- ## How to use the Accounting search shortcuts -1. In the navigation tabs (on the left on web, on the bottom on mobile) select **Spend**. -2. In the **Accounting** section, choose a search shortcut based on what you need to review. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Spend**. +2. In the **Accounting** section, select a search shortcut based on what you need to review. 3. Use filters like **Date**, **Feed**, or **Posted** to refine your view. 4. Review the **Total** in the footer to understand the amount for that view. @@ -54,7 +54,7 @@ Once you’ve reviewed the expenses, you can export them to a CSV if you need to # FAQ ## How can I create an aging report? -An aging report is a breakdown of expenses by how long they have been in a given status. You can create an aging report by using the **Cash accruals** or **Card accruals** shortcut and changing **Display** to group by month, week, quarter or year. [Learn how to use Insights grouping operators](/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify#grouping-options-used-in-insights). +An aging report is a breakdown of expenses by how long they have been in a given status. You can create an aging report by using the **Cash accruals** or **Card accruals** shortcut and changing **Display** to group by month, week, quarter or year. [Learn how to group results using search operators](/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze#how-to-group-and-visualize-results-using-search-operators). ## Why don’t I see all Accounting search shortcuts? diff --git a/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md index eb0f3ddc1f7d..6af9bfc99e0d 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md @@ -168,7 +168,7 @@ The outdated row is removed from the report. The expense itself is not deleted # FAQ ## Why can't I action a pending expense? -Expensify Card expenses show as pending until the merchant posts them. This can take 1–3 business days. Hotel or rental car holds may take longer (up to 31 days for hotels). Only posted expenses can be approved. +Expensify Card expenses, and expenses from cards connected through Plaid, show as pending until the merchant posts them. This can take 1–3 business days. Hotel or rental car holds may take longer (up to 31 days for hotels). Only posted expenses can be approved. ## Why can’t I see Bypass approvers? Only Workspace admins can bypass the prescribed approval workflow. If **Prevent Self-Approval** is enabled, an admin cannot bypass approvals to approve their own report. diff --git a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md index cc0b8a686ee0..d7d4cb2a50f2 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md +++ b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md @@ -229,9 +229,9 @@ Unsubmitted reports will remain in your Open reports section. You can go back an The **Submit** button only appears once your report includes at least one valid expense. If the report has already been submitted, the button will no longer be visible. If your workspace has no approval workflow, the button will say **Mark as done** instead of **Submit**. -## Why can’t I submit a report with pending Expensify Card transactions? +## Why can’t I submit a report with pending card transactions? -If all transactions on your report are pending Expensify Card transactions, the report can't be submitted yet. The **Submit** button won't appear, and the report isn't counted in your **Submit** to-do. Pending transactions may take a few days to post. After at least one transaction has posted, the **Submit** button appears again and you can submit the report. +If all transactions on a report are pending, the report can’t be submitted yet. This applies to Expensify Card transactions and to company or personal cards connected through Plaid. You can submit the report once at least one transaction posts. ## What happens to held expenses when I submit a report? diff --git a/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md b/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md index 8df4e57bc7d8..a7309ebdd5d4 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md +++ b/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md @@ -162,7 +162,7 @@ How long **Cancel payment** stays available depends on how the report was paid: | **Unapprove** | After approval, before payment | Approver | Reverts the approval if the report hasn’t been re-approved or paid | | **Mark as exported** | On approved/paid reports when integrated to an accounting system| Admin, Exporter | Marks the report as manually exported for tracking purposes | | **Export to [accounting system]** | When an accounting system is connected | Admin, Exporter | Sends the report to an external system such as Xero or QuickBooks Online | -| **Duplicate expense** | On non-card expenses in any state | Submitter | Creates a copy of the expense on your primary workspace with the same details. Date is set to today and receipts are not copied | +| **Duplicate expense** | On non-card expenses in any state | Submitter | Creates a copy of the expense with the same details. The copy is added to the same report if it can still accept expenses, or to your primary workspace if not. Date is set to today and receipts are not copied | | **Duplicate report** | On reports the submitter owns, in any state | Submitter | Creates a copy of the report and its non-card expenses. Date is set to today and receipts are not copied | | **Download report** | Any report state | All roles on reports that aren't Draft. Submitter only on Draft reports, including reports rejected back to the submitter | Downloads a copy of the report as a PDF | | **Print** | Any report status except Draft | All roles | Opens the report in a printable format and triggers the browser's print dialog | diff --git a/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md b/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md index 11cbde863e99..7b3a17fa9d48 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md +++ b/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md @@ -12,12 +12,12 @@ The Spend page in New Expensify gives you a full list of your reports and relate Report previews are summaries shown directly in your workspace chat in the **Inbox**. They let you quickly check a report’s status without opening it. -## Visibility of Report Previews +## Who can see report previews - Visible to **all members** in the workspace chat. - Only the **report creator** sees interactive options like **Add expense**. - **Workspace admins** can approve and pay reports, as well as reject and hold reports. -## Preview Details +## What report previews show - Report title and status (e.g., Approved, Paid) - Up to 10 expense previews with: - Date and category @@ -27,7 +27,7 @@ Report previews are summaries shown directly in your workspace chat in the **Inb - **+X more** link if there are over 10 expenses - Action button: Submit, Approve, Pay, etc., depending on the role -## Preview Behavior and Updates +## How report previews update - Updates in **real time** as expenses are added. - If a report is deleted, the preview is removed. @@ -48,16 +48,16 @@ Each row represents an expense and includes: - Description - Tags (if enabled) -## Available Actions on the Table -- **Click an expense** to view or edit it in the right-hand panel -- **Edit a cell directly:** Hover over **date**, **merchant**, **description**, **category**, **tag**, or **amount** and click the pencil icon to edit the value inline without opening the expense details. +## What you can do in the report table +- **Select an expense** to view or edit it in the right-hand panel +- **Edit a cell directly:** Hover over **date**, **merchant**, **description**, **category**, **tag**, or **amount** and select the edit icon to edit the value inline without opening the expense details. - **Select multiple expenses** using checkboxes, then apply bulk actions such as: - **Move to another report:** When you need to have multiple reports or need to break up expenses across multiple weeks or months. - **Download:** For exporting to a CSV file for analysis or to share with your accountant. - **Hold:** Use this when you need to temporarily pause the approval of an expense until all required information is provided. - **Delete:** To remove expenses from the expense report. -**Note:** The selection bar appears after selecting at least one expense. +**Note:** The selection bar appears after you select at least one expense. ## How to move expenses between reports @@ -74,8 +74,8 @@ Each row represents an expense and includes: **From the expense details** -1. Click the expense to edit it in the right-hand panel. -2. Click **Move to report** from the bulk action menu. +1. Select the expense to edit it in the right-hand panel. +2. Select **Move to report**. 3. In the right-hand panel, select from the following options: - Create report - An existing draft or submitted report @@ -105,11 +105,11 @@ The total is shown in your default currency until you choose a different one. ### How to change the total spend currency 1. On the **Spend** page, optionally select the rows you want to total using the checkboxes. Leave everything unselected to convert the whole search result. -2. In the footer, click the currency shown next to the **Total spend** amount. +2. In the footer, select the currency shown next to the **Total spend** amount. 3. In the currency picker, search for and select the currency you want. -4. Click **Apply**. +4. Select **Apply**. -The **Total spend** amount updates to show the converted value in the currency you selected. Click **Reset** in the picker to return to your default currency. +The **Total spend** amount updates to show the converted value in the currency you selected. Select **Reset** in the picker to return to your default currency. Conversion works across the different ways you can view and select expenses: @@ -132,15 +132,15 @@ The currency picker can't be opened in these cases: On a wide screen: 1. On the **Spend** page, select **Expenses**. -2. Click the **Amount** column header to sort from largest to smallest. -3. Click the **Amount** column header again to reverse the order. +2. Select the **Amount** column header to sort from largest to smallest. +3. Select the **Amount** column header again to reverse the order. On mobile, or in a narrow browser window, the column headers aren't shown, so sort from the **Display** menu instead: 1. On the **Spend** page, select **Expenses**. -2. Tap the **Display** icon. -3. Tap **Sort by**, select **Amount**, then tap **Apply**. -4. To change the direction, tap the **Display** icon again, tap **Sort by**, tap **Sort order**, select **Descending** (largest to smallest) or **Ascending** (smallest to largest), then tap **Apply**. +2. Select the **Display** icon. +3. Select **Sort by**, choose **Amount**, then select **Apply**. +4. To change the direction, select the **Display** icon again, select **Sort by > Sort order**, choose **Descending** (largest to smallest) or **Ascending** (smallest to largest), then select **Apply**. The **Amount** column is only sortable when your results are shown as a table. When you group your results, this column reads **Total** instead of **Amount**. @@ -179,26 +179,26 @@ A -$4,000 credit therefore sorts below a $10 expense rather than next to a $4,00 ## How to apply filters in New Expensify -Web: +On web: -1. In the navigation tabs on the left, click **Spend**. -2. Click **Filters**. -3. Click **Type** and select **Expense**, **Expense Report**, **Chat**, **Invoice**, **Trip**, or **Task**. -5. Select the filters you want to apply. Available filters vary based on the selected **Type**. +1. In the navigation tabs on the left, select **Spend**. +2. Select **Filters**. +3. Select **Type** and choose **Expense**, **Expense Report**, **Chat**, **Invoice**, **Trip**, or **Task**. +4. Select the filters you want to apply. Available filters vary based on the selected **Type**. Selected filter values are applied to the list immediately. -Mobile: +On mobile: -1. In the navigation tabs on the bottom, tap **Spend**. -2. Tap the **Filters** icon next to the search box. -3. Tap **Type** and select **Expense**, **Expense Report**, **Chat**, **Invoice**, **Trip**, or **Task**. -4. Tap **Save**. +1. In the navigation tabs on the bottom, select **Spend**. +2. Select the **Filters** icon next to the search field. +3. Select **Type** and choose **Expense**, **Expense Report**, **Chat**, **Invoice**, **Trip**, or **Task**. +4. Select **Save**. 5. Select the filters you want to apply. Available filters vary based on the selected **Type**. -6. Tap **Save** after selecting each filter. -7. Tap **View Results**. +6. Select **Save** after selecting each filter. +7. Select **View results**. -For more advanced filtering, you can enter search operators directly into the search box on the **Spend** page. [Learn how to use search operators to filter and analyze](/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze). +For more advanced filtering, you can enter search operators directly into the search field on the **Spend** page. [Learn how to use search operators to filter and analyze](/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze). ## How filtering works on the Spend page @@ -244,7 +244,10 @@ If you create a draft report and have unreported expenses in your SelfDM, a prom # FAQ ## Can I export reports from the Spend page? -Yes! Choose the **Current view** option within the **Export** menu to download a CSV of exactly what you're seeing—your active filters, columns, and ordering are all preserved. You can also use a template-based export for a standard set of columns. Learn more about [exporting from the Spend page](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses). +Yes! Choose the **Current view** option within the **Export** menu to download a CSV of exactly what you're seeing—your active filters, columns, and ordering are all preserved. You can also use a template-based export for a standard set of columns. Learn more about [exporting from the Spend page](/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses). + +## Where did the Insights on the Spend page go? +The **Spend over time**, **Top categories**, **Top merchants**, and **Top spenders** charts moved from the **Insights** section of the Spend page to the **Insights** page. In the navigation tabs (on the left on web, on the bottom on mobile), select **Insights** to see them. For more details about insights, see [Learn About Insights](/articles/new-expensify/insights/Learn-About-Insights). ## Do smart suggestions work on mobile? Yes, smart suggestions also appear in the mobile Inbox and draft reports. diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md index 8d2d2be6b2b7..d40c67ed40f7 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md @@ -7,7 +7,7 @@ internalScope: Audience is all members. Covers how to duplicate a single expense # How to duplicate an expense -You can create a copy of one or more expenses using **Duplicate expense**. This generates new expenses with the same details as the originals, including the merchant, amount, category, tags, and tax. The date is set to today and receipt images are not copied. An expense that is on a report is copied onto your primary workspace, while an Unreported expense is copied onto your personal space. +You can create a copy of one or more expenses using **Duplicate expense**. This generates new expenses with the same details as the originals, including the merchant, amount, category, tags, and tax. The date is set to today and receipt images are not copied. If you duplicate a single expense that is on a report still able to accept expenses (such as a Draft report), the copy stays on that report. Otherwise, an expense on a report is copied to your primary workspace, and an Unreported expense is copied to your personal space. This is useful when you need to create similar expenses quickly without re-entering all the details manually. You can duplicate a single expense or select multiple expenses and duplicate them all at once. @@ -66,7 +66,7 @@ Purpose: Helps the user identify the bulk duplicate option on the selection bar. The duplicate expense keeps the original expense's merchant, amount, category, tags, tax, and billable or reimbursable status. The date is set to today and receipt images are not included. -The duplicate matches the reported state of the original. If the original expense is on a report, the copy is created on your primary workspace. If the original is an Unreported expense, the copy is created on your personal space and is not added to a report. +The duplicate matches the reported state of the original. If you duplicate a single expense on a report that can still accept expenses (such as a Draft report), the copy stays on that report. Otherwise, if the original expense is on a report that cannot accept new expenses, such as an Approved or Paid report, the copy is created on your primary workspace. If the original is an Unreported expense, the copy is created on your personal space and is not added to a report. If the original expense's coding (such as categories or tags) is not available on your primary workspace, the duplicate expense will still be created. You will be prompted to correct the coding on the expense. diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md index 53f91d30e6fa..7793fbec6871 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md @@ -27,7 +27,8 @@ If you want to learn why duplicate expenses happen, see [Why Expenses Duplicate] You can merge duplicate expenses if you can edit both expenses. - Submitters can merge their own unreported expenses, expenses on Draft reports, and expenses on Outstanding reports. -- Approvers and Workspace Admins can merge expenses on the same Draft or Outstanding report. +- Approvers can merge expenses on the same Draft or Outstanding report. +- Workspace admins can merge a member's expenses across that member's Draft and Outstanding reports. You can't merge expenses that are Approved or Paid. Learn more about [expense statuses](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Understanding-Report-Statuses-and-Actions). diff --git a/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md b/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md index e770c6e3be6c..8bf9fc80993e 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md +++ b/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md @@ -1,7 +1,7 @@ --- title: Use Search Operators to Filter and Analyze description: Learn how to use search operators, filters, and grouping to find, organize, and analyze expenses, chats, reports, and tasks in Expensify. -keywords: [New Expensify, search operators, advanced filters, search rules, expense search, report search, chat filters, advanced search, group-by, view, chart, search syntax, bank account filter, custom field, international reimbursement IDs, transaction status filter, pending card expenses, posted card expenses, paid by filter, paid-by] +keywords: [New Expensify, search operators, advanced filters, search rules, expense search, report search, chat filters, advanced search, group-by, view, chart, search syntax, bank account filter, custom field, international reimbursement IDs, transaction status filter, pending card expenses, posted card expenses, paid by filter, paid-by, vendor filter, rejected filter, declined] internalScope: Audience is all Expensify members. Covers search operator syntax for filtering, grouping, and chart views. Does not cover saved search management or Search page UI navigation. --- @@ -70,6 +70,7 @@ You can use the following operators to filter expenses: | `merchant:` | Filter by expense merchant name | `merchant:"Delta Air Lines"` | | `category:` | Filter by expense category label | `category:"Travel"` | | `tag:` | Filter by one or multiple tags | `tag:"Client A"` | +| `vendor:` | Filter by the vendor assigned to an expense. Available only when one of your workspaces uses vendor matching. | `vendor:"Starbucks"` | | `amount:` / `purchase-amount:` | Filter by amount using `=`, `>`, `<`, `>=`, or `<=` | `amount:>100` | | `status:` | Filter by expense status (`unreported`, `draft`, `outstanding`, `approved`, `paid`, `done`) | `status:approved` | | `date:` | Filter by expense date using relative dates or comparison operators for a date range | `date:this-month` | @@ -78,6 +79,7 @@ You can use the following operators to filter expenses: | `receipt-type:` | Filter by receipt type (`ereceipt`, `itemized`) | `receipt-type:itemized` | | `reimbursable:` | Filter by whether an expense is reimbursable (`yes` or `no`) | `reimbursable:yes` | | `billable:` | Filter by whether an expense is billable (`yes` or `no`) | `billable:no` | +| `declined:` | Filter by whether an expense was rejected (`yes` or `no`). Matches the **Rejected** filter | `declined:yes` | | `attendee:` | Filter by expense attendee using an email, phone number, display name, or `me` | `attendee:"Jason Mills"` | | `posted:` | Filter by the date a credit card expense was posted | `posted:last-statement` | | `transaction-status:` | Filter by whether a card expense is still `pending` or has `posted`. Accepts one value at a time, and only card expenses are returned. Available only when you have at least one card. | `transaction-status:pending` | @@ -106,6 +108,7 @@ You can use the following operators to filter reports: | `withdrawn:` | Filter by ACH withdrawal date | `withdrawn:2024-01-15` | | `withdrawal-type:` | Filter by withdrawal type (`reimbursement`, `expensify-card`, `central-travel-invoicing`) | `withdrawal-type:reimbursement` | | `paid-status:` | Filter by how the report was paid (`markedAsPaid`, `withdrawing`, `confirmed`). Combine multiple values with commas | `paid-status:markedAsPaid,confirmed` | +| `declined:` | Filter by whether a report was rejected (`yes` or `no`). Matches the **Rejected** filter | `declined:yes` | | `paid-by:` | Filter by the member who paid the report (email, phone, display name, or `me`). Combine multiple members with commas. Reports that have not been paid never match. This operator also works on expense, invoice, and trip searches | `paid-by:me,alice@acme.com` | | `action:` | Filter by blocking report action | `action:approve` | | `submitter-user-id:` | Filter by the Custom field 1 value set for the report submitter | `submitter-user-id:12345` | @@ -180,7 +183,6 @@ Use `group-by:` to group results by a specific dimension. | **Syntax** | **Description** | **Example** | |---|---|---| -| `group-by:report` | Group results by report | `type:expense group-by:report` | | `group-by:from` | Group results by submitter | `type:expense group-by:from` | | `group-by:card` | Group results by card | `type:expense group-by:card` | | `group-by:withdrawal-id` | Group results by withdrawal ID | `type:expense group-by:withdrawal-id` | diff --git a/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md b/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md index 5dd2c097d3c4..c14c4fb2ee4d 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md +++ b/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md @@ -35,14 +35,14 @@ Expensify automatically merges a SmartScanned receipt with a matching card trans - The transaction dates match - The currencies match or fall within the allowed foreign exchange tolerance - Only one possible match exists -- The card transaction posts within seven days of the receipt being SmartScanned +- The card transaction posts within seven days of the receipt being SmartScanned. For cards connected through Plaid, the pending transaction can merge before it posts. **Expenses will not automatically merge if:** - The receipt was not SmartScanned or SmartScan did not complete -- Expense details were edited before the card transaction posted +- Expense details were edited before the card transaction posted (or, for cards connected through Plaid, before the pending transaction imported) - The amount, date, or currency differs beyond tolerance -- The card transaction posts after seven days +- The card transaction posts after seven days (or, for cards connected through Plaid, the pending transaction imports after seven days) - The foreign exchange difference exceeds five percent - The receipt is already matched to another expense - Both expenses are card transactions @@ -94,7 +94,7 @@ If both expenses are card transactions, they cannot be merged. Instead, [delete This happens when the receipt and card transaction did not merge into a single expense. This is usually due to differences in amount, date, currency, or timing. ## Why didn’t my receipt merge with my card transaction? -SmartScan requires a close match in amount, date, and currency. If those details differ, or if the card transaction has not posted within seven days, the expenses will not merge automatically. +SmartScan requires a close match in amount, date, and currency. If those details differ, or if the card transaction has not posted within seven days (for cards connected through Plaid, imported as pending within seven days), the expenses will not merge automatically. ## Can uploading the same receipt twice create duplicates? diff --git a/docs/articles/new-expensify/settings/Account-Settings.md b/docs/articles/new-expensify/settings/Account-Settings.md index 5873b0e72242..5e28d24743f3 100644 --- a/docs/articles/new-expensify/settings/Account-Settings.md +++ b/docs/articles/new-expensify/settings/Account-Settings.md @@ -2,11 +2,14 @@ title: Account Settings description: Learn how to update your profile settings, preferences, and notifications in Expensify. keywords: [New Expensify, profile, account preferences, notifications, language, theme, high contrast mode, timezone, personal information, phone number, security code] +internalScope: Audience is all members. Covers how to update profile, status, pronouns, timezone, language, theme, notification, and personal information settings in Account. Does not cover security, wallet, or subscription settings. --- # Account Settings -Expensify allows you to personalize your experience by customizing your profile, preferences, and notifications. This guide shows you how to update your photo, language, timezone, notification settings, and more—all from the left-hand navigation menu. +Expensify allows you to personalize your experience by customizing your profile, preferences, and notifications. This guide shows you how to update your photo, language, timezone, notification settings, and more—all from **Account**. + +You can access **Account** in the navigation tabs (on the left on web, on the bottom on mobile). --- @@ -14,15 +17,15 @@ Expensify allows you to personalize your experience by customizing your profile, You can update the following profile settings: -- **Profile Photo** – Upload or change your photo. -- **Customized Avatar** – Select an avatar instead of a profile photo. +- **Profile photo** – Upload or change your photo. +- **Custom avatar** – Select an avatar instead of a profile photo. - **Status** – Add a custom message and emoji to show your current status. - **Pronouns** – Display your preferred pronouns on your profile. - **Language** – Update your account to your preferred language. - **Timezone** – Set the correct timezone for expense and report timestamps. -- **Theme** – Toggle between light mode, dark mode, or follow device settings, and enable high contrast mode. +- **Theme** – Switch between light mode, dark mode, or your device settings, and enable high contrast mode. - **Notifications** – Customize how and when you receive email and app notifications. -- **Personal Information** – Add or update your legal name, date of birth, phone number, and address. +- **Personal information** – Add or update your legal name, date of birth, phone number, and address. --- @@ -30,9 +33,10 @@ You can update the following profile settings: To upload or update a profile photo: -1. In the navigation tabs, click **Account > Profile**. -2. Click the pencil icon next to your profile image. -3. Select **Upload Image** and choose a new photo. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**. +2. Select **Profile**. +3. Select the edit icon next to your profile image. +4. Select **Upload photo** and choose a new photo. --- @@ -40,11 +44,12 @@ To upload or update a profile photo: To use an avatar instead of a profile photo: -1. In the navigation tabs, click **Account > Profile**. -2. Click the pencil icon next to your profile image. -3. Scroll down to **Or choose a custom avatar**. -4. Select an avatar in your desired colored palette. -5. Click **Save**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**. +2. Select **Profile**. +3. Select the edit icon next to your profile image. +4. Scroll down to **Or choose a custom avatar**. +5. Choose an avatar in your preferred color palette. +6. Select **Save**. --- @@ -52,12 +57,11 @@ To use an avatar instead of a profile photo: To manually set or update your timezone: -1. Go to **Account > Profile**. -2. Click **Timezone**. -3. Turn off **Automatically determine your location**. -4. Click **Timezone** -5. Select the your time zone from the list. -6. Click **Save**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**. +2. Select **Profile > Time zone**. +3. Disable **Automatically determine your location**. +4. Choose your timezone from the list. +5. Select **Save**. --- @@ -65,11 +69,11 @@ To manually set or update your timezone: To set or change your language: -1. In the navigation tabs, select **Account > Preferences**. -2. Tap or click on **Language**. +1. On web, select **Account** in the navigation tabs. On mobile, select your profile icon. +2. Select **Preferences > Language**. 3. Choose your preferred language from the list. -4. The checkmark will confirm the selected language. -5. Your account will update automatically to reflect the new language setting. + +A checkmark confirms the selected language, and your account updates automatically to the new language. **Note:** Language changes affect your personal account view only. Workspace members must update their own preferences individually. @@ -97,8 +101,9 @@ Expensify supports the following languages: To manage notification settings: -1. Go to **Account > Preferences > App preferences**. -2. Adjust the notification toggles: +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**. +2. Select **Preferences**. +3. Under **App preferences**, enable or disable these toggles: - **Receive relevant feature updates and Expensify news** - **Mute all sounds from Expensify** @@ -108,11 +113,12 @@ To manage notification settings: Set your profile status with an emoji and optional message. -1. Go to **Account > Profile**. -2. Click **Status** and enter your custom message. -3. (Optional) Add an emoji. -4. Choose when to clear the status (e.g., 1 hour, 1 day, or Never). -5. Click **Save**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**. +2. Select **Profile > Status**. +3. Enter your custom message. +4. (Optional) Add an emoji. +5. Under **Clear after**, choose when to clear the status, such as **1 hour**, **Today**, or **Never**. +6. Select **Save**. --- @@ -120,11 +126,11 @@ Set your profile status with an emoji and optional message. To set or update your pronouns: -1. Go to **Account > Profile**. -2. Click **Pronouns**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**. +2. Select **Profile > Pronouns**. 3. Type a word or letters into the **Search to see options** field. No pronouns are listed until you type a search term. 4. Select your pronouns from the search results. -5. Click **Save**. +5. Select **Save**. --- @@ -132,12 +138,10 @@ To set or update your pronouns: To change the app’s appearance: -1. Go to **Account > Preferences**. -2. Click **Theme** and choose from: - - **Light** - - **Dark** - - **Use device settings** -3. Click **Save**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**. +2. Select **Preferences > Theme**. +3. Choose **Light**, **Dark**, or **Use device settings**. +4. Select **Save**. --- @@ -145,10 +149,10 @@ To change the app’s appearance: High contrast mode increases the visual contrast of UI elements, making text and controls easier to distinguish. -1. Go to **Account > Preferences**. -2. Click **Theme**. -3. Toggle **High contrast mode** on. -4. Click **Save**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**. +2. Select **Preferences > Theme**. +3. Enable **High contrast mode**. +4. Select **Save**. You can also enable high contrast mode from the [login page](https://new.expensify.com/) by selecting **Enable high contrast**. @@ -157,12 +161,14 @@ You can also enable high contrast mode from the [login page](https://new.expensi ## How to update your personal information To update your legal name, date of birth, phone number, or address: -1. Go to **Account > Profile**. -2. Scroll to the **Private** section. -3. Click **Legal Name**, **Date of Birth**, **Phone Number**, or **Address**. -4. Enter the updated information. -5. Click **Save**. -6. Enter the security code sent to your email or phone to verify the changes. + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**. +2. Select **Profile**. +3. Scroll to the **Private** section. +4. Select **Legal name**, **Date of birth**, **Phone number**, or **Address**. +5. Enter the updated information. +6. Select **Save**. +7. Enter the security code sent to your email or phone to verify the changes. When typing in the **Address line 1** field, a list of address suggestions appears. Select a suggestion to auto-populate the address fields including city, state, ZIP code, and country. @@ -173,14 +179,18 @@ If no address is saved, the **Country** field is pre-filled based on your curren ## How to update your display name or legal name To edit your display name: -1. Go to **Account > Profile**. -2. Click **Display Name**, enter your preferred name, and click **Save**. + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**. +2. Select **Profile > Display name**. +3. Enter your preferred name and select **Save**. To edit your legal name: -1. Go to **Account > Profile**. -2. Scroll to **Private** and click **Legal Name**. -3. Enter the updated information and click **Save**. -4. Enter the security code sent to you to verify the change. + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**. +2. Select **Profile**. +3. Scroll to **Private** and select **Legal name**. +4. Enter the updated information and select **Save**. +5. Enter the security code sent to you to verify the change. --- @@ -193,7 +203,11 @@ Your photo helps teammates identify you easily in chats, reports, and notificati No. It only changes the language of your user interface, not the content of your reports. ## Can I disable all Expensify app notifications? -Yes. From the navigation tabs, go to **Account > Preferences** and toggle off both update and sound notifications. +Yes. To mute notifications: + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**. +2. Select **Preferences**. +3. Disable **Receive relevant feature updates and Expensify news** and enable **Mute all sounds from Expensify**. ## Can I disable email notifications for reports that I approve for reimbursement? No. To comply with regulations in several US states, Expensify is required to send you an email whenever you send money to another individual. @@ -202,5 +216,4 @@ No. To comply with regulations in several US states, Expensify is required to se Yes! Your chosen theme and high contrast mode setting apply across web, mobile, and desktop versions of Expensify. ## Can I set a permanent status message? -Yes. After entering your status, choose **Never** under “Clear After” to keep it active indefinitely. - +Yes. After entering your status, choose **Never** under **Clear after** to keep it active indefinitely. 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 eef91bd6fa0e..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 @@ -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 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. @@ -17,19 +17,20 @@ 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. 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. +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 -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 +50,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/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md index 7179adb64976..8164bc74d89f 100644 --- a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md +++ b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md @@ -1,7 +1,7 @@ --- title: Managing Workspace Members in New Expensify description: Learn how to add, remove, filter, and manage user roles in a New Expensify workspace. -keywords: [New Expensify, workspace members, add user, remove user, user roles, workspace admin, card admin, people admin, payments admin, invite users, filter members, role filter] +keywords: [New Expensify, workspace members, add user, remove user, user roles, workspace admin, card admin, people admin, payments admin, guest, invite users, filter members, role filter, member display name] --- @@ -12,9 +12,10 @@ A **Workspace member** is anyone added to a workspace with one of the following - **Member** – Can submit and/or approve expenses. - **Workspace admin** – Can manage members, roles, workflows, and settings. - **Card admin** – Can manage the Expensify Card and Company cards, including issuing cards, setting limits, and assigning company cards. Has read-only access to Overview and Members, and all other workspace settings pages are hidden. (Control workspaces only) -- **People admin** – Can manage workspace membership and configure approval workflows. People admins can add and remove Members and Auditors only, and cannot add or remove admins or other scoped admin roles. They have read-only access to the workspace overview and edit access to Members and the Approvals section of Workflows. All other workspace editor pages are hidden. (Control workspaces only) +- **People admin** – Can manage workspace membership and configure approval workflows. People admins can add and remove Members, Guests, and Auditors only, and cannot add or remove admins or other scoped admin roles. They have read-only access to the workspace overview and edit access to Members and the Approvals section of Workflows. All other workspace editor pages are hidden. (Control workspaces only) - **Payments admin** – Can manage workspace payment settings in the **Payments** section of **Workflows**, including business bank accounts, the authorized expense payer, and payment methods. Payment permissions are otherwise unchanged, so paying a report still depends on the relevant bank account being shared with the payer. Has read-only access to the workspace overview and Members, and all other workspace editor pages are hidden. (Control workspaces only) - **Auditor** – Can view and comment on reports and has read-only access to all workspace editor pages (such as Categories, Tags, Rules, Workflows, and Accounting). Auditors cannot change any workspace settings or take workflow actions such as approving, paying, or taking control. (Control workspaces only) +- **Guest** – Can submit reports with limited visibility. Guests can’t view the workspace member list or create rooms. (Control workspaces only) - **Editor** – Can edit workspace settings, but cannot see all expenses associated with the workspace. They cannot add admins to the workspace or change other members' roles. (Submit workspaces only) --- @@ -32,7 +33,7 @@ To invite someone to your workspace: **Tip:** You can also invite members under **Settings > Profile** by clicking **Share** to send the workspace’s URL or QR code. -**Note:** Workspace admins and People admins can invite members. People admins can only invite members as **Members** or **Auditors** — they cannot assign admin roles. +**Note:** Workspace admins and People admins can invite members. People admins can only invite members as **Members**, **Guests**, or **Auditors** — they cannot assign admin roles. --- @@ -49,6 +50,7 @@ You can filter the member list by role to quickly find specific groups of member - **People Admins** – Members with the People Admin role (Control workspaces only). - **Payments admins** – Shows only members with the Payments admin role (Control workspaces only). - **Auditors** – Members with the Auditor role (Control workspaces only). + - **Guests** – Members with the Guest role (Control workspaces only). - **Editors** – Members with the Editor role (Submit workspaces only). - **Members** – Members with the Member role. @@ -64,7 +66,7 @@ If no members match the selected filters, an empty state is displayed with the m If someone no longer needs access to your workspace, you can remove them individually or in bulk to keep your member list up to date. -**Note:** Workspace admins and People admins can remove members. People admins can only remove **Members** and **Auditors** — they cannot remove admins or other scoped admin roles. +**Note:** Workspace admins and People admins can remove members. People admins can only remove **Members**, **Guests**, and **Auditors** — they cannot remove admins or other scoped admin roles. **Note:** If you remove a member who is an approver on an Outstanding report, the workspace owner replaces them as the approver. Learn more about [automatic approver reassignment](/articles/new-expensify/workspaces/Add-Approvals). @@ -94,12 +96,25 @@ If someone no longer needs access to your workspace, you can remove them individ On the web, you can also change a member’s role directly from the members list. Hover over the member’s **Role** and click the pencil icon that appears to select a new role. -**Note:** People admins can change a member’s role between **Member** and **Auditor** only. Granting or removing the admin role or any scoped admin role (such as Card admin, People admin, or Payments admin) requires a Workspace admin. +**Note:** People admins can change a member’s role between **Member**, **Guest**, and **Auditor** only. Granting or removing the admin role or any scoped admin role (such as Card admin, People admin, or Payments admin) requires a Workspace admin. **Note:** You can’t change the role of the member set as the workspace’s authorized payer. Their **Role** can’t be edited and role-change options are hidden until you assign a different payer. To change their role, first go to **Workspaces > Workflows**, open **Payments**, and set a different admin as the **Payer**. --- +# How to Set a Member’s Display Name + +Workspace admins can set a display name for a member who hasn’t added a name of their own. + +1. Go to **Workspaces > [Workspace Name] > Members**. +2. Click the member’s name. +3. In the right-hand panel, click **Profile**. +4. Click **Display name**, enter a first and last name, and click **Save**. + +The name applies in every workspace the member belongs to. After it’s saved, only the member can change it. + +--- + # Transfer Workspace Ownership To transfer ownership to another admin: diff --git a/docs/assets/images/Accounting_Intuit-Enterprise-Suite.png b/docs/assets/images/Accounting_Intuit-Enterprise-Suite.png new file mode 100644 index 000000000000..33df8fbd5bc1 Binary files /dev/null and b/docs/assets/images/Accounting_Intuit-Enterprise-Suite.png differ diff --git a/docs/assets/images/DualEntry_accounting.png b/docs/assets/images/DualEntry_accounting.png new file mode 100644 index 000000000000..508ab4895d37 Binary files /dev/null and b/docs/assets/images/DualEntry_accounting.png differ diff --git a/docs/assets/images/Rillet_accounting.png b/docs/assets/images/Rillet_accounting.png new file mode 100644 index 000000000000..2a3088631908 Binary files /dev/null and b/docs/assets/images/Rillet_accounting.png differ diff --git a/docs/assets/images/home.png b/docs/assets/images/home.png index 37c95e644a31..988294af8e4a 100644 Binary files a/docs/assets/images/home.png and b/docs/assets/images/home.png differ diff --git a/docs/assets/images/home2.png b/docs/assets/images/home2.png index b2bb5c1ef908..7662e67dce34 100644 Binary files a/docs/assets/images/home2.png and b/docs/assets/images/home2.png differ diff --git a/docs/assets/images/insight-view-on-spend.png b/docs/assets/images/insight-view-on-spend.png new file mode 100644 index 000000000000..aa9063fdea84 Binary files /dev/null and b/docs/assets/images/insight-view-on-spend.png differ diff --git a/docs/assets/images/insights-filters.png b/docs/assets/images/insights-filters.png new file mode 100644 index 000000000000..030b959c3f73 Binary files /dev/null and b/docs/assets/images/insights-filters.png differ diff --git a/docs/assets/images/insights-page.png b/docs/assets/images/insights-page.png new file mode 100644 index 000000000000..e199f4997bc8 Binary files /dev/null and b/docs/assets/images/insights-page.png differ diff --git a/docs/assets/images/insights-spend-over-time.png b/docs/assets/images/insights-spend-over-time.png new file mode 100644 index 000000000000..2f81a0653b2a Binary files /dev/null and b/docs/assets/images/insights-spend-over-time.png differ diff --git a/docs/assets/images/insights-top-categories.png b/docs/assets/images/insights-top-categories.png new file mode 100644 index 000000000000..961f5b92b206 Binary files /dev/null and b/docs/assets/images/insights-top-categories.png differ diff --git a/docs/assets/images/insights-top-merchant.png b/docs/assets/images/insights-top-merchant.png new file mode 100644 index 000000000000..cc4eb59c79b8 Binary files /dev/null and b/docs/assets/images/insights-top-merchant.png differ diff --git a/docs/assets/images/insights-top-spender.png b/docs/assets/images/insights-top-spender.png new file mode 100644 index 000000000000..00270a92e176 Binary files /dev/null and b/docs/assets/images/insights-top-spender.png differ diff --git a/docs/new-expensify/hubs/connections/intuit-enterprise-suite.html b/docs/new-expensify/hubs/connections/intuit-enterprise-suite.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/new-expensify/hubs/connections/intuit-enterprise-suite.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/docs/redirects.csv b/docs/redirects.csv index baee3b41b63f..ae9d7ff07781 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -1033,7 +1033,13 @@ https://help.expensify.com/articles/Unlisted/Configure-Rillet,https://help.expen https://help.expensify.com/articles/Unlisted/Connect-to-Rillet,https://help.expensify.com/articles/new-expensify/connections/rillet/Connect-to-Rillet https://help.expensify.com/articles/Unlisted/Manage-Vendor-Matching-for-Intacct,https://help.expensify.com/articles/new-expensify/connections/sage-intacct/Manage-Vendor-Matching-for-Sage-Intacct https://help.expensify.com/articles/Unlisted/Manage-Vendor-Matching-for-Xero,https://help.expensify.com/articles/new-expensify/connections/xero/Manage-Vendor-Matching-for-Xero +https://help.expensify.com/articles/new-expensify/connections/quickbooks-online/Connect-to-Intuit-Enterprise-Suite,https://help.expensify.com/articles/new-expensify/connections/intuit-enterprise-suite/Connect-to-Intuit-Enterprise-Suite https://help.expensify.com/articles/new-expensify/getting-started/Understanding-the-For-you-Section,https://help.expensify.com/articles/new-expensify/getting-started/Understanding-the-To-dos-Section https://help.expensify.com/articles/new-expensify/ai-agents/Create-Agent-Rules,https://help.expensify.com/articles/new-expensify/ai-agents/Automate-Workflows-with-Agent-Rules https://help.expensify.com/articles/new-expensify/ai-agents/Create-and-Use-Custom-Agents,https://help.expensify.com/articles/new-expensify/ai-agents/Create-and-Use-Agents https://help.expensify.com/articles/new-expensify/ai-agents/How-to-Write-Agent-Rules,https://help.expensify.com/articles/new-expensify/ai-agents/How-to-Write-Agent-Instructions +https://help.expensify.com/articles/new-expensify/insights/View-the-Top-Spenders-report,https://help.expensify.com/articles/new-expensify/insights/Explore-the-Top-Spenders-Insight +https://help.expensify.com/articles/new-expensify/insights/View-the-Top-Categories-report,https://help.expensify.com/articles/new-expensify/insights/Explore-the-Top-Categories-Insight +https://help.expensify.com/articles/new-expensify/insights/View-the-Top-Merchants-report,https://help.expensify.com/articles/new-expensify/insights/Explore-the-Top-Merchants-Insight +https://help.expensify.com/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify,https://help.expensify.com/articles/new-expensify/insights/How-to-Use-Insights +https://help.expensify.com/articles/new-expensify/insights/View-the-Violations-by-submitter-report,https://help.expensify.com/Hidden/View-the-Violations-by-submitter-report diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index e09fb2b24b29..bab3003cb63c 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.5.8 + 9.5.16 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.5.8.4 + 9.5.16.1 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 22acf7c996d6..8484d8799e04 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.5.8 + 9.5.16 CFBundleVersion - 9.5.8.4 + 9.5.16.1 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile b/ios/Podfile index 12d17f06cc44..d9fef99b568f 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -3,6 +3,9 @@ require File.join(File.dirname(`node --print "require.resolve('expo/package.json # This value is used by $RNMapboxMaps $RNMapboxMapsImpl = 'mapbox' $VCDisableFrameProcessors = true +# Compiles react-native-screens' experimental (gamma) iOS code, which ScrollViewMarker needs. +# Without it, rendering ScrollViewMarker crashes on iOS. +ENV['RNS_GAMMA_ENABLED'] = '1' def node_require(script) # Resolve script with node to allow for hoisting diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b1f52d4f86b6..b02b59ac794b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -4116,7 +4116,7 @@ SPEC CHECKSUMS: RNPermissions: 32bd3b977f28442af0900b102f4458f0f9f9b77c RNReactNativeHapticFeedback: 6c8677db48f62a635e6a2065f5d2bd02498d7a73 RNReanimated: e75c988a6527ec651ea2957b93891ddb64d530d4 - RNScreens: 8708989f700bdc3dc5abc3c77aa3b5f3e73ce2b2 + RNScreens: 433b8c24dd1dfeaa3b61f72d55052cb64fd19c5f RNSentry: 3dcc26572ed12d8ee7edbe80d198dda70ac10d4c RNShare: eb378c57de30a83b0253eed74762ee99249f47d7 RNSVG: 394cfd0518613b144c65ffe69045f827b78a7a7c @@ -4134,6 +4134,6 @@ SPEC CHECKSUMS: VisionCamera: 30b358b807324c692064f78385e9a732ce1bebfe Yoga: c30c859b7e9b75e547b600b486fe33165f60de00 -PODFILE CHECKSUM: 64b30fca02abc208520075c2e1275b24533fa805 +PODFILE CHECKSUM: c937d1b7b774ce019c0c848e4ad1e850f08c0e90 COCOAPODS: 1.16.2 diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index b8b01434878f..77fe0db48495 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.5.8 + 9.5.16 CFBundleVersion - 9.5.8.4 + 9.5.16.1 NSExtension NSExtensionAttributes diff --git a/jest.config.js b/jest.config.js index 61fe030d49f2..7aebdba5e6f2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,11 +1,11 @@ const testFileExtension = 'ts?(x)'; -// Reassure runs only the performance tests, matched by the `.perf-test` suffix / `__perf__` folder, +// Reassure runs only the performance tests, matched by the `.perf-test` suffix / `__perf__` folder (or the `.perf-local` suffix for local benchmarks), // which it passes to Jest as `--testMatch`. We detect that from the test-runner argv so we can leave // `performance` real for perf runs only: enabling Jest's modern fake timers globally also fakes // `performance`, and React's Scheduler captures that (frozen) clock once at module load, which zeroes // every React Profiler render duration — and thus every Reassure `[render]` measurement. -const isPerfTestRun = process.argv.some((arg) => arg.includes('perf-test') || arg.includes('__perf__')); +const isPerfTestRun = process.argv.some((arg) => /perf-(test|local)|__perf__/.test(arg)); // With `--coverage`, Jest also instruments every file matched by `collectCoverageFrom` that no test loaded, so // untested files show up at 0%. Under `--shard=N/M` each shard would repeat that pass over all of `src/**` @@ -62,6 +62,8 @@ module.exports = { testEnvironment: 'jsdom', setupFiles: ['/jest/setup.ts', './node_modules/@react-native-google-signin/google-signin/jest/build/setup.js'], setupFilesAfterEnv: ['/jest/setupAfterEnv.ts', '/tests/perf-test/setupAfterEnv.ts'], + // Restarts a worker between suites once it holds this much memory, so long-lived CI workers don't get OOM killed. + workerIdleMemoryLimit: '2GB', cacheDirectory: '/.jest-cache', coverageReporters: ['json', 'lcov', 'text-summary'], moduleNameMapper: { diff --git a/jest/setup.ts b/jest/setup.ts index d7e3eec8321e..4589474f054c 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -350,6 +350,10 @@ jest.mock('@shopify/react-native-skia', () => ({ listFontFamilies: jest.fn(() => []), })); +// The native tab bar icons are drawn with Skia's imperative API, which the mock above leaves out, so the tabs get no icons. +jest.mock('@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer', () => jest.fn(() => undefined)); +jest.mock('@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage', () => jest.fn(() => undefined)); + jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ isSensorAvailable: jest.fn(() => Promise.resolve({available: false})), createKeys: jest.fn(() => Promise.resolve({publicKey: ''})), @@ -365,6 +369,8 @@ jest.mock('victory-native', () => ({ CartesianChart: jest.fn( ({children}: {children?: (args: Record) => ReactNode}) => children?.({points: {y: []}, chartBounds: {left: 0, right: 0, top: 0, bottom: 0}}) ?? null, ), + Pie: {Chart: jest.fn(() => null)}, + PolarChart: jest.fn(() => null), })); // Provide a default global fetch mock for tests that do not explicitly set it up diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts index 49701c9c77bb..10955574c996 100644 --- a/jest/setupAfterEnv.ts +++ b/jest/setupAfterEnv.ts @@ -10,11 +10,6 @@ import mockUseSingleExecution from '../tests/utils/mockUseSingleExecution'; jest.useRealTimers(); -jest.mock('@hooks/useAIFeaturesPromoModal', () => ({ - __esModule: true, - default: jest.fn(), -})); - // The real hook relies on real navigation transitions to settle `isExecuting` (see // `runAfterPredictedTransition`/`TransitionTracker`), which don't happen in unit tests and would // otherwise leave buttons stuck disabled. Mocked globally so no test needs to import the navigation diff --git a/knip.json b/knip.json index 0f5564cf69bf..730d221fe72e 100644 --- a/knip.json +++ b/knip.json @@ -41,6 +41,7 @@ "ignore": [ ".github/actions/**/index.js", "tests/perf-test/**", + "tests/perf-local/**", "web/snippets/gib.js", "web/cloudflare-worker/**", "src/libs/actions/connections/FinancialForce.ts", diff --git a/package-lock.json b/package-lock.json index 7ab9deb59a10..7a9cc581a99e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.5.8-4", + "version": "9.5.16-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.5.8-4", + "version": "9.5.16-1", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -152,7 +152,7 @@ "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", "scheduler": "0.27.0", - "victory-native": "^41.21.0", + "victory-native": "^42.0.1", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "devDependencies": { @@ -166,9 +166,9 @@ "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-transform-export-namespace-from": "^7.27.1", "@callstack/reassure-compare": "^1.0.0-rc.4", - "@callstack/repack": "5.3.0", - "@callstack/repack-plugin-expo-modules": "5.3.0", - "@callstack/repack-plugin-reanimated": "5.3.0", + "@callstack/repack": "5.4.1", + "@callstack/repack-plugin-expo-modules": "5.4.1", + "@callstack/repack-plugin-reanimated": "5.4.1", "@dword-design/eslint-plugin-import-alias": "^5.0.0", "@fullstory/babel-plugin-annotate-react": "^2.4.0", "@fullstory/babel-plugin-react-native": "^1.7.1", @@ -198,6 +198,7 @@ "@rsdoctor/rspack-plugin": "^1.5.17", "@rspack/core": "^2.1.2", "@rspack/plugin-react-refresh": "^2.0.2", + "@sentry/browser-utils": "10.75.0", "@sentry/cli": "3.3.0", "@sentry/webpack-plugin": "5.2.0", "@storybook/addon-a11y": "10.4.6", @@ -295,8 +296,8 @@ }, "engines": { "bun": "1.3.14", - "node": "26.5.0", - "npm": "11.17.0" + "node": "26.12.1", + "npm": "11.20.0" } }, "modules/background-task": { @@ -4027,13 +4028,13 @@ } }, "node_modules/@callstack/repack": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@callstack/repack/-/repack-5.3.0.tgz", - "integrity": "sha512-/5pnG6bz+nkV8DkVZfRDpRSVMUU+znLEfKy49HQgxsJx5vxlxL3MV7Pn2+APetitB9NEGFP+GQApj5hesUKQCw==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@callstack/repack/-/repack-5.4.1.tgz", + "integrity": "sha512-vlpXdlVLiu9mi2ca3TMBv1MydnTGX789EHTDQ7RMBZO43DHx6RDrWh5snzbWclhy3+HmvdUkKA4eyYduRoDWUg==", "dev": true, "license": "MIT", "dependencies": { - "@callstack/repack-dev-server": "5.3.0", + "@callstack/repack-dev-server": "5.4.1", "@discoveryjs/json-ext": "^0.5.7", "@rspack/plugin-react-refresh": "1.0.0", "babel-loader": "^9.2.1", @@ -4081,9 +4082,9 @@ } }, "node_modules/@callstack/repack-dev-server": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@callstack/repack-dev-server/-/repack-dev-server-5.3.0.tgz", - "integrity": "sha512-WW5onAddhO12wM59VCCpLQYn7NIPZc2HvM+mmcTvJsi8k3ggeW61pYsOnnX6GMNWmy/2Wu4AiecouQa4+zGuwA==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@callstack/repack-dev-server/-/repack-dev-server-5.4.1.tgz", + "integrity": "sha512-aRXPBx1ZRWuO9m3JVI5RWXO9GqxTLRX1Z3Ca+T724CQqqj/VK7q91+uEc7ReqiqHKrCMYwTY2CM8lNpZDKggUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4183,22 +4184,22 @@ } }, "node_modules/@callstack/repack-plugin-expo-modules": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@callstack/repack-plugin-expo-modules/-/repack-plugin-expo-modules-5.3.0.tgz", - "integrity": "sha512-4tnnb4ujLckWqYQOAzPIeOM9vWvVPdzS4SD+y5kjiFckVu0N6COzF29gl3EYZfJwKLU4tGkPqUqfvIa60HzQaQ==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@callstack/repack-plugin-expo-modules/-/repack-plugin-expo-modules-5.4.1.tgz", + "integrity": "sha512-YCCQXbjdnI3euHZYcpCMSngwBVovNFNUfD3HOsntAzCEyClpU1DVg2kWdI9wM9ZsTUPRr/eH49L2KZ1xyyzzOA==", "dev": true, "license": "MIT", "engines": { "node": ">=18" }, "peerDependencies": { - "@callstack/repack": "^5.3.0" + "@callstack/repack": "^5.4.1" } }, "node_modules/@callstack/repack-plugin-reanimated": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@callstack/repack-plugin-reanimated/-/repack-plugin-reanimated-5.3.0.tgz", - "integrity": "sha512-TtuVhhAlvLdg+wrHf8k15Q5zNLiemOuFavsFiOQQhQP5vX299eEp2lw/1tr25vYsdoGvdc0Na0BsePRIF4ujGg==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@callstack/repack-plugin-reanimated/-/repack-plugin-reanimated-5.4.1.tgz", + "integrity": "sha512-qwiiLlqb1xWf3K2Qv3yVO/ujnkaTOkGSg2gPq4+x8IcFqZRftiW3qlZYTRN913OXqB7e3ZajOepwDUUMWmZJCg==", "dev": true, "license": "MIT", "dependencies": { @@ -4209,7 +4210,7 @@ }, "peerDependencies": { "@babel/core": "^7.20", - "@callstack/repack": "^5.3.0" + "@callstack/repack": "^5.4.1" } }, "node_modules/@callstack/repack-plugin-reanimated/node_modules/semver": { @@ -8613,9 +8614,9 @@ "license": "MIT" }, "node_modules/@fastify/proxy-addr": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", - "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.1.tgz", + "integrity": "sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==", "dev": true, "funding": [ { @@ -8634,9 +8635,9 @@ } }, "node_modules/@fastify/sensible": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-6.0.5.tgz", - "integrity": "sha512-v4mGejwT6EJA2zd2rS6XwlyUSva2ILsqzG0aGkyrlS7l2jIpUgFACclSPN3kDijX//cf8+GutRLhJVKx3ae2Cw==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-6.0.6.tgz", + "integrity": "sha512-2OhR9prEDmjeVSrBUBXgP9e595WWrBGQxjo/JMweA7DgJ5gseotN0OhRWZLom1nnmFkLywkccdQc10ihFtT6fw==", "dev": true, "funding": [ { @@ -22645,7 +22646,9 @@ } }, "node_modules/bytes": { - "version": "3.0.0", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -23347,15 +23350,18 @@ } }, "node_modules/compression": { - "version": "1.7.4", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", "license": "MIT", "dependencies": { - "accepts": "~1.3.5", - "bytes": "3.0.0", - "compressible": "~2.0.16", + "bytes": "3.1.2", + "compressible": "~2.0.18", "debug": "2.6.9", - "on-headers": "~1.0.2", - "safe-buffer": "5.1.2", + "destroy": "1.2.0", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", "vary": "~1.1.2" }, "engines": { @@ -23373,6 +23379,35 @@ "version": "2.0.0", "license": "MIT" }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "devOptional": true, @@ -27204,9 +27239,9 @@ } }, "node_modules/fast-uri": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", - "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz", + "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==", "dev": true, "funding": [ { @@ -27262,9 +27297,9 @@ } }, "node_modules/fastify": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.3.tgz", - "integrity": "sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==", + "version": "5.12.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz", + "integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==", "dev": true, "funding": [ { @@ -33806,9 +33841,9 @@ } }, "node_modules/mathjs": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.1.1.tgz", - "integrity": "sha512-rM668DTtpSzMVoh/cKAllyQVEbBApM5g//IMGD8vD7YlrIz9ITRr3SrdhjaDxcBNTdyETWwPebj2unZyHD7ZdA==", + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.2.0.tgz", + "integrity": "sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -35031,7 +35066,9 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -42208,9 +42245,9 @@ } }, "node_modules/victory-native": { - "version": "41.21.0", - "resolved": "https://registry.npmjs.org/victory-native/-/victory-native-41.21.0.tgz", - "integrity": "sha512-qyldgowkkiAWzN765sODzp4REh2MxBoex+DF5LerIpssTXpxGYEF21bIrhAoQJLDXiZ2rrCj1Dye+MNVYJd7vw==", + "version": "42.0.1", + "resolved": "https://registry.npmjs.org/victory-native/-/victory-native-42.0.1.tgz", + "integrity": "sha512-W3dNBMeRtZ8AQGBkV1PrYYKLFSstTy6uulmlJJFch4hvPX/R1xH1VJXzZopK6GGbcq/ejcr8WwntWwhqTo0lzA==", "license": "MIT", "dependencies": { "d3-scale": "^4.0.2", @@ -42220,11 +42257,11 @@ "react-fast-compare": "^3.2.2" }, "peerDependencies": { - "@shopify/react-native-skia": ">=1.2.3 <3.0.0", + "@shopify/react-native-skia": ">=2.6.0 <3.0.0", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", - "react-native-reanimated": ">=3.0.0" + "react-native-reanimated": ">=3.19.1" } }, "node_modules/vlq": { diff --git a/package.json b/package.json index 69ac415da967..37db67de4dde 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.5.8-4", + "version": "9.5.16-1", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -48,6 +48,7 @@ "test:verbose": "TZ=utc NODE_OPTIONS=\"--experimental-vm-modules --max_old_space_size=4096\" JEST_VERBOSE=true jest", "test:debug": "TZ=utc NODE_OPTIONS='--inspect-brk --experimental-vm-modules' jest --runInBand", "perf-test": "NODE_OPTIONS=--experimental-vm-modules npx reassure", + "perf-local": "NODE_OPTIONS=--experimental-vm-modules npx reassure --testMatch '/tests/perf-local/**/*.perf-local.ts'", "typecheck": "bun scripts/typecheck.ts", "lint": "bun scripts/lint/index.ts", "lint-changed": "./scripts/lintChanged.sh", @@ -228,7 +229,7 @@ "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", "scheduler": "0.27.0", - "victory-native": "^41.21.0", + "victory-native": "^42.0.1", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "devDependencies": { @@ -242,9 +243,9 @@ "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-transform-export-namespace-from": "^7.27.1", "@callstack/reassure-compare": "^1.0.0-rc.4", - "@callstack/repack": "5.3.0", - "@callstack/repack-plugin-expo-modules": "5.3.0", - "@callstack/repack-plugin-reanimated": "5.3.0", + "@callstack/repack": "5.4.1", + "@callstack/repack-plugin-expo-modules": "5.4.1", + "@callstack/repack-plugin-reanimated": "5.4.1", "@dword-design/eslint-plugin-import-alias": "^5.0.0", "@fullstory/babel-plugin-annotate-react": "^2.4.0", "@fullstory/babel-plugin-react-native": "^1.7.1", @@ -274,6 +275,7 @@ "@rsdoctor/rspack-plugin": "^1.5.17", "@rspack/core": "^2.1.2", "@rspack/plugin-react-refresh": "^2.0.2", + "@sentry/browser-utils": "10.75.0", "@sentry/cli": "3.3.0", "@sentry/webpack-plugin": "5.2.0", "@storybook/addon-a11y": "10.4.6", @@ -459,7 +461,7 @@ }, "engines": { "bun": "1.3.14", - "node": "26.5.0", - "npm": "11.17.0" + "node": "26.12.1", + "npm": "11.20.0" } } diff --git a/patches/@callstack/repack/@callstack+repack+5.3.0+001+clamp-dev-server-progress.patch b/patches/@callstack/repack/@callstack+repack+5.3.0+001+clamp-dev-server-progress.patch deleted file mode 100644 index a9ea9ca1aa0e..000000000000 --- a/patches/@callstack/repack/@callstack+repack+5.3.0+001+clamp-dev-server-progress.patch +++ /dev/null @@ -1,48 +0,0 @@ -diff --git a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js -index 0121929..7c2856b 100644 ---- a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js -+++ b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js -@@ -17,6 +17,7 @@ class Compiler { - statsCache = {}; - resolvers = {}; - progressSenders = {}; -+ lastProgress = {}; - isCompilationInProgress = false; - // late-init - -@@ -24,7 +25,9 @@ class Compiler { - this.reporter = reporter; - this.rootDir = rootDir; - const handler = (platform, value) => { -- const percentage = Math.floor(value * 100); -+ const rawPercentage = Math.floor(value * 100); -+ const percentage = Math.max(rawPercentage, this.lastProgress[platform] ?? 0); -+ this.lastProgress[platform] = percentage; - this.progressSenders[platform]?.forEach(sendProgress => { - sendProgress({ - completed: percentage, -@@ -36,7 +39,7 @@ class Compiler { - message: [{ - progress: { - platform, -- value -+ value: percentage / 100 - } - }], - timestamp: Date.now(), -@@ -78,6 +81,7 @@ class Compiler { - setupCompiler() { - this.compiler.hooks.watchRun.tap('repack:watch', () => { - this.isCompilationInProgress = true; -+ this.lastProgress = {}; - this.platforms.forEach(platform => { - if (platform === 'android') { - void (0, _index2.runAdbReverse)({ -@@ -96,6 +100,7 @@ class Compiler { - }); - this.compiler.hooks.invalid.tap('repack:invalid', () => { - this.isCompilationInProgress = true; -+ this.lastProgress = {}; - this.platforms.forEach(platform => { - this.devServerContext.notifyBuildStart(platform); - this.devServerContext.broadcastToHmrClients({ diff --git a/patches/@callstack/repack/@callstack+repack+5.3.0+002+lazy-platform-compilation.patch b/patches/@callstack/repack/@callstack+repack+5.3.0+002+lazy-platform-compilation.patch deleted file mode 100644 index 9865f8152739..000000000000 --- a/patches/@callstack/repack/@callstack+repack+5.3.0+002+lazy-platform-compilation.patch +++ /dev/null @@ -1,358 +0,0 @@ -diff --git a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.d.ts b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.d.ts -index 26d726c..d8999a0 100644 ---- a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.d.ts -+++ b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.d.ts -@@ -13,8 +13,11 @@ export declare class Compiler { - statsCache: Record; - resolvers: Record void>>; - progressSenders: Record; -- isCompilationInProgress: boolean; -+ isCompilationInProgress: Record; - devServerContext: Server.DelegateContext; -+ private pendingCompilations; -+ private activePlatforms; -+ private isClosed; - constructor(configs: MultiRspackOptions, reporter: Reporter, rootDir: string); - get devServerOptions(): import("@rspack/core").DevServer; - get watchOptions(): import("@rspack/core").WatchOptions; -@@ -23,7 +26,10 @@ export declare class Compiler { - private removeProgressSender; - setDevServerContext(ctx: Server.DelegateContext): void; - private setupCompiler; -+ private setupChildCompilerHooks; -+ private activatePlatform; - start(): void; -+ close(callback?: (error?: Error | null) => void): void; - getAsset(filename: string, platform: string, sendProgress?: SendProgress): Promise; - getSource(filename: string, platform: string | undefined, sendProgress?: SendProgress): Promise; - getSourceMap(filename: string, platform: string | undefined): Promise; -diff --git a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js -index 7c2856b..cabce76 100644 ---- a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js -+++ b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js -@@ -18,13 +18,20 @@ class Compiler { - resolvers = {}; - progressSenders = {}; - lastProgress = {}; -- isCompilationInProgress = false; -+ isCompilationInProgress = {}; - // late-init - -+ pendingCompilations = new Map(); -+ activePlatforms = new Set(); -+ isClosed = false; -+ - constructor(configs, reporter, rootDir) { - this.reporter = reporter; - this.rootDir = rootDir; - const handler = (platform, value) => { -+ // An unrequested platform is parked, so its progress must not reach the terminal or -+ // the device: it would drive a second progress bar beside the platform in use. -+ if (!this.activePlatforms.has(platform)) return; - const rawPercentage = Math.floor(value * 100); - const percentage = Math.max(rawPercentage, this.lastProgress[platform] ?? 0); - this.lastProgress[platform] = percentage; -@@ -79,43 +86,66 @@ class Compiler { - this.devServerContext = ctx; - } - setupCompiler() { -- this.compiler.hooks.watchRun.tap('repack:watch', () => { -- this.isCompilationInProgress = true; -- this.lastProgress = {}; -- this.platforms.forEach(platform => { -- if (platform === 'android') { -- void (0, _index2.runAdbReverse)({ -- port: this.devServerContext.options.port, -- logger: this.devServerContext.log -- }); -+ // rspack's MultiCompiler fires the aggregated `done` only when every child has reported -+ // (@rspack/core: `doneCompilers === this.compilers.length`). A parked child never reports, so -+ // that event stays pending and the server never settles. Upstream moves this bookkeeping onto -+ // the child compilers for the same reason. -+ for (const childCompiler of this.compiler.compilers) { -+ this.setupChildCompilerHooks(childCompiler); -+ } -+ } -+ setupChildCompilerHooks(childCompiler) { -+ const platform = childCompiler.options.name; -+ childCompiler.hooks.watchRun.tapAsync('repack:lazy-compilation', (_compiler, done) => { -+ if (this.activePlatforms.has(platform)) { -+ done(); -+ return; -+ } -+ this.pendingCompilations.set(platform, () => { -+ // Exclude time spent waiting for the platform to be requested and -+ // avoid replaying file changes that happened before that request. -+ if (childCompiler.watching) { -+ const startTime = Date.now(); -+ childCompiler.watching.startTime = startTime; -+ childCompiler.watching.lastWatcherStartTime = startTime; - } -- this.devServerContext.notifyBuildStart(platform); -- this.devServerContext.broadcastToHmrClients({ -- action: 'compiling', -- body: { -- name: platform -- } -- }); -+ done(); - }); - }); -- this.compiler.hooks.invalid.tap('repack:invalid', () => { -- this.isCompilationInProgress = true; -- this.lastProgress = {}; -- this.platforms.forEach(platform => { -- this.devServerContext.notifyBuildStart(platform); -- this.devServerContext.broadcastToHmrClients({ -- action: 'compiling', -- body: { -- name: platform -- } -+ childCompiler.hooks.watchRun.tap('repack:watch', () => { -+ if (!this.activePlatforms.has(platform)) return; -+ this.isCompilationInProgress[platform] = true; -+ this.lastProgress[platform] = 0; -+ if (platform === 'android') { -+ void (0, _index2.runAdbReverse)({ -+ port: this.devServerContext.options.port, -+ logger: this.devServerContext.log - }); -+ } -+ this.devServerContext.notifyBuildStart(platform); -+ this.devServerContext.broadcastToHmrClients({ -+ action: 'compiling', -+ body: { -+ name: platform -+ } - }); - }); -- this.compiler.hooks.done.tap('repack:done', multiStats => { -- const stats = multiStats.toJson({ -+ childCompiler.hooks.invalid.tap('repack:invalid', () => { -+ if (!this.activePlatforms.has(platform)) return; -+ this.isCompilationInProgress[platform] = true; -+ this.lastProgress[platform] = 0; -+ this.devServerContext.notifyBuildStart(platform); -+ this.devServerContext.broadcastToHmrClients({ -+ action: 'compiling', -+ body: { -+ name: platform -+ } -+ }); -+ }); -+ childCompiler.hooks.done.tap('repack:done', stats => { -+ const childStats = stats.toJson({ - all: false, - assets: true, -- children: true, - outputPath: true, - timings: true, - hash: true, -@@ -123,49 +153,46 @@ class Compiler { - warnings: true - }); - try { -- stats.children.map(childStats => { -- const platform = childStats.name; -- this.devServerContext.broadcastToHmrClients({ -- action: 'hash', -- body: { -- name: platform, -- hash: childStats.hash -- } -- }); -- this.statsCache[platform] = childStats; -- const assets = childStats.assets; -- this.assetsCache[platform] = assets.filter(asset => asset.type === 'asset').reduce((acc, { -- name, -+ this.devServerContext.broadcastToHmrClients({ -+ action: 'hash', -+ body: { -+ name: platform, -+ hash: childStats.hash -+ } -+ }); -+ this.statsCache[platform] = childStats; -+ const assets = childStats.assets; -+ this.assetsCache[platform] = assets.filter(asset => asset.type === 'asset').reduce((acc, { -+ name, -+ info, -+ size -+ }) => { -+ const assetPath = _nodePath.default.join(childStats.outputPath, name); -+ const data = this.filesystem.readFileSync(assetPath); -+ const asset = { -+ data, - info, - size -- }) => { -- const assetPath = _nodePath.default.join(childStats.outputPath, name); -- const data = this.filesystem.readFileSync(assetPath); -- const asset = { -- data, -- info, -- size -- }; -- acc[(0, _index.adaptFilenameToPlatform)(name)] = asset; -- if (info.related?.sourceMap) { -- const sourceMapName = Array.isArray(info.related.sourceMap) ? info.related.sourceMap[0] : info.related.sourceMap; -- const sourceMapPath = _nodePath.default.join(childStats.outputPath, sourceMapName); -- const sourceMapData = this.filesystem.readFileSync(sourceMapPath); -- const sourceMapAsset = { -- data: sourceMapData, -- info: { -- hotModuleReplacement: info.hotModuleReplacement, -- size: sourceMapData.length -- }, -+ }; -+ acc[(0, _index.adaptFilenameToPlatform)(name)] = asset; -+ if (info.related?.sourceMap) { -+ const sourceMapName = Array.isArray(info.related.sourceMap) ? info.related.sourceMap[0] : info.related.sourceMap; -+ const sourceMapPath = _nodePath.default.join(childStats.outputPath, sourceMapName); -+ const sourceMapData = this.filesystem.readFileSync(sourceMapPath); -+ const sourceMapAsset = { -+ data: sourceMapData, -+ info: { -+ hotModuleReplacement: info.hotModuleReplacement, - size: sourceMapData.length -- }; -- acc[(0, _index.adaptFilenameToPlatform)(sourceMapName)] = sourceMapAsset; -- } -- return acc; -- }, -- // keep old assets -- this.assetsCache[platform] ?? {}); -- }); -+ }, -+ size: sourceMapData.length -+ }; -+ acc[(0, _index.adaptFilenameToPlatform)(sourceMapName)] = sourceMapAsset; -+ } -+ return acc; -+ }, -+ // keep old assets -+ this.assetsCache[platform] ?? {}); - } catch (error) { - this.reporter.process({ - type: 'error', -@@ -174,32 +201,41 @@ class Compiler { - message: ['An error occured while processing assets from compilation:', String(error)] - }); - } -- this.isCompilationInProgress = false; -- stats.children?.forEach(childStats => { -- const platform = childStats.name; -- const time = childStats.time; -- this.callPendingResolvers(platform); -- this.devServerContext.notifyBuildEnd(platform); -- this.devServerContext.broadcastToHmrClients({ -- action: 'ok', -- body: { -- name: platform -+ this.isCompilationInProgress[platform] = false; -+ this.callPendingResolvers(platform); -+ this.devServerContext.notifyBuildEnd(platform); -+ this.devServerContext.broadcastToHmrClients({ -+ action: 'ok', -+ body: { -+ name: platform -+ } -+ }); -+ this.reporter.process({ -+ issuer: 'DevServer', -+ message: [{ -+ progress: { -+ platform, -+ time: childStats.time - } -- }); -- this.reporter.process({ -- issuer: 'DevServer', -- message: [{ -- progress: { -- platform, -- time -- } -- }], -- timestamp: Date.now(), -- type: 'progress' -- }); -+ }], -+ timestamp: Date.now(), -+ type: 'progress' - }); - }); - } -+ activatePlatform(platform) { -+ if (!this.platforms.includes(platform)) { -+ throw new _index.CLIError(`Unrecognized platform: ${platform}`); -+ } -+ if (this.activePlatforms.has(platform)) return; -+ this.activePlatforms.add(platform); -+ this.isCompilationInProgress[platform] = true; -+ const resumeCompilation = this.pendingCompilations.get(platform); -+ if (resumeCompilation) { -+ this.pendingCompilations.delete(platform); -+ resumeCompilation(); -+ } -+ } - start() { - this.compiler.watch(this.watchOptions, error => { - if (!error) return; -@@ -208,14 +244,33 @@ class Compiler { - }); - }); - } -+ close(callback = () => {}) { -+ this.isClosed = true; -+ const error = new Error('Compiler closed before compilation completed'); -+ this.platforms.forEach(platform => { -+ this.callPendingResolvers(platform, error); -+ }); -+ -+ // Resume pending compilations so the Watching instances can be closed -+ for (const resumeCompilation of this.pendingCompilations.values()) { -+ resumeCompilation(); -+ } -+ this.pendingCompilations.clear(); -+ this.compiler.close(callback); -+ } - async getAsset(filename, platform, sendProgress) { -+ if (this.isClosed) { -+ throw new Error('Compiler closed before compilation completed'); -+ } -+ this.activatePlatform(platform); -+ - // Return file from assetsCache if exists - const fileFromCache = this.assetsCache[platform]?.[filename]; - if (fileFromCache) { - return fileFromCache; - } - this.addProgressSender(platform, sendProgress); -- if (!this.isCompilationInProgress) { -+ if (!this.isCompilationInProgress[platform]) { - this.removeProgressSender(platform, sendProgress); - return Promise.reject(new Error(`File ${filename} for ${platform} not found in compilation assets (no compilation in progress)`)); - } -diff --git a/node_modules/@callstack/repack/dist/commands/rspack/start.js b/node_modules/@callstack/repack/dist/commands/rspack/start.js -index cc57d82..6ae7930 100644 ---- a/node_modules/@callstack/repack/dist/commands/rspack/start.js -+++ b/node_modules/@callstack/repack/dist/commands/rspack/start.js -@@ -191,7 +191,15 @@ async function start(_, cliConfig, args) { - return { - stop: async () => { - reporter.stop(); -- await stop(); -+ try { -+ // A platform nobody asked for is parked mid watch-run; resume it so the Watching -+ // instances can shut down instead of hanging (callstack/repack#1430). -+ await new Promise((resolve, reject) => { -+ compiler.close(error => (error ? reject(error) : resolve())); -+ }); -+ } finally { -+ await stop(); -+ } - } - }; - } -\ No newline at end of file diff --git a/patches/@callstack/repack/@callstack+repack+5.4.1+001+clamp-dev-server-progress.patch b/patches/@callstack/repack/@callstack+repack+5.4.1+001+clamp-dev-server-progress.patch new file mode 100644 index 000000000000..1e29ef76c8e9 --- /dev/null +++ b/patches/@callstack/repack/@callstack+repack+5.4.1+001+clamp-dev-server-progress.patch @@ -0,0 +1,46 @@ +--- a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js ++++ b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js +@@ -17,6 +17,7 @@ + statsCache = {}; + resolvers = {}; + progressSenders = {}; ++ lastProgress = {}; + isCompilationInProgress = {}; + // late-init + +@@ -28,7 +29,9 @@ + this.rootDir = rootDir; + const handler = (platform, value) => { + if (!this.activePlatforms.has(platform)) return; +- const percentage = Math.floor(value * 100); ++ const rawPercentage = Math.floor(value * 100); ++ const percentage = Math.max(rawPercentage, this.lastProgress[platform] ?? 0); ++ this.lastProgress[platform] = percentage; + this.progressSenders[platform]?.forEach(sendProgress => { + sendProgress({ + completed: percentage, +@@ -40,7 +43,7 @@ + message: [{ + progress: { + platform, +- value ++ value: percentage / 100 + } + }], + timestamp: Date.now(), +@@ -104,6 +107,7 @@ + childCompiler.hooks.watchRun.tap('repack:watch', () => { + if (!this.activePlatforms.has(platform)) return; + this.isCompilationInProgress[platform] = true; ++ this.lastProgress[platform] = 0; + if (platform === 'android') { + void (0, _index2.runAdbReverse)({ + port: this.devServerContext.options.port, +@@ -121,6 +125,7 @@ + childCompiler.hooks.invalid.tap('repack:invalid', () => { + if (!this.activePlatforms.has(platform)) return; + this.isCompilationInProgress[platform] = true; ++ this.lastProgress[platform] = 0; + this.devServerContext.notifyBuildStart(platform); + this.devServerContext.broadcastToHmrClients({ + action: 'compiling', diff --git a/patches/@callstack/repack/details.md b/patches/@callstack/repack/details.md index 66ab25dec30b..ad585c4c8c31 100644 --- a/patches/@callstack/repack/details.md +++ b/patches/@callstack/repack/details.md @@ -1,6 +1,6 @@ # `@callstack/repack` patches -### [@callstack+repack+5.3.0+001+clamp-dev-server-progress.patch](@callstack+repack+5.3.0+001+clamp-dev-server-progress.patch) +### [@callstack+repack+5.4.1+001+clamp-dev-server-progress.patch](@callstack+repack+5.4.1+001+clamp-dev-server-progress.patch) - Reason: @@ -12,32 +12,12 @@ and resets it on the `watchRun` and `invalid` hooks when a new compilation starts. ``` -- Upstream PR/issue: https://github.com/callstack/repack/issues/1459 - filed with a standalone reproduction. Delete this patch once a release clamps the forwarded percentage. +- Rewritten against 5.4.1 on the bump from 5.3.0. The 5.3.0 version of this patch targeted the pre-lazy-compilation `Compiler.js` and no longer applies: upstream released per-platform lazy compilation, so the progress handler now sits behind an `activePlatforms` guard and `isCompilationInProgress` is a per-platform record. Same clamp, new anchors. +- Upstream PR/issue: https://github.com/callstack/repack/issues/1459 - filed with a standalone reproduction; closed as will-not-fix, so this stays until upstream changes its mind. - E/App issue: None. Reported during review of the PR below. - PR Introducing Patch: https://github.com/Expensify/App/pull/99407 -### [@callstack+repack+5.3.0+002+lazy-platform-compilation.patch](@callstack+repack+5.3.0+002+lazy-platform-compilation.patch) +Removed on the bump to `@callstack/repack@5.4.1`: -- Reason: - - ``` - Back-port of upstream lazy platform compilation. On 5.3.0 the dev server starts every configured - platform compiling immediately, so a machine running one app also pays for a full compile of the - other platform (measured: iOS 188.3s and Android 190.7s, both compiled although only iOS was ever - requested). Narrowing the server to one platform does not fix it: that server cannot serve the - other platform's bundle at all, so a second app reusing the running server gets nothing. - - This patch keeps every platform configured and parks each child compiler at its first watch run - until something actually asks for that platform's bundle. A platform is activated by - `getAsset`, i.e. by a bundle request, and only activated platforms emit progress and build - signals, so one progress stream serves the platform in use. Bookkeeping moved from the - MultiCompiler hooks to the child compilers because rspack only fires the aggregated `done` once - every child has reported, and a parked child never reports. `close()` releases parked compilations - before the compiler closes, as upstream does. - ``` - -- Also updates `dist/commands/rspack/Compiler.d.ts`: its declarations describe the pre-patch shape (`isCompilationInProgress: boolean`) and omit the new members. -- Depends on `001`: both patches edit the progress handling in `dist/commands/rspack/Compiler.js`, and this one is authored against the post-`001` file. Keep them in numeric order. -- Upstream PR/issue: https://github.com/callstack/repack/pull/1430 - merged into `main`, not in any release yet; staged for 5.4.0 by https://github.com/callstack/repack/pull/1441. Delete this patch when a release containing it is pinned. -- E/App issue: None. Found while answering review on the PR below. -- PR Introducing Patch: https://github.com/Expensify/App/pull/99407 +- `002+lazy-platform-compilation` — upstream released the feature (`dist/commands/rspack/Compiler.js` now parks each child compiler until `getAsset` requests that platform). Was https://github.com/callstack/repack/pull/1430. +- `003+android-fonts-raw` and `004+android-keep-xml` — https://github.com/callstack/repack/issues/1472 fixed both: the assets loader emits `require()`'d font files to `res/raw` as well as `res/font` (`dist/loaders/assetsLoader/assetsLoader.js`), and the assets copy step writes `res/raw/keep.xml` with `tools:keep` for every copied resource (`dist/plugins/utils/AssetsCopyProcessor.js`), matching Metro. Verified against the published 5.4.1 tarball, and on device: Expensify/App#103243 shipped an AdHoc build on the 5.4.1 canary where fonts resolve from `raw` and all 765 `raw` assets plus 22 drawables survive `shrinkResources=true`. E/App issue: https://github.com/Expensify/App/issues/102619. diff --git a/patches/@rnmapbox/maps/@rnmapbox+maps+10.3.2+001+immediate-follow-camera-transition.patch b/patches/@rnmapbox/maps/@rnmapbox+maps+10.3.2+001+immediate-follow-camera-transition.patch index c572e6089d3e..8ae94b426eb1 100644 --- a/patches/@rnmapbox/maps/@rnmapbox+maps+10.3.2+001+immediate-follow-camera-transition.patch +++ b/patches/@rnmapbox/maps/@rnmapbox+maps+10.3.2+001+immediate-follow-camera-transition.patch @@ -125,6 +125,18 @@ index c8e61a0..d3e50b9 100644 followUserMode: followUserMode, followZoomLevel: followZoomLevel, followPitch: followPitch, +diff --git a/node_modules/@rnmapbox/maps/lib/module/specs/RNMBXCameraNativeComponent.ts b/node_modules/@rnmapbox/maps/lib/module/specs/RNMBXCameraNativeComponent.ts +index 79fb0d0..acd20c1 100644 +--- a/node_modules/@rnmapbox/maps/lib/module/specs/RNMBXCameraNativeComponent.ts ++++ b/node_modules/@rnmapbox/maps/lib/module/specs/RNMBXCameraNativeComponent.ts +@@ -28,6 +28,7 @@ export interface NativeProps extends ViewProps { + userTrackingMode?: OptionalProp; + + followUserLocation?: OptionalProp; ++ followUserLocationUseImmediateTransition?: OptionalProp; + followUserMode?: OptionalProp; + followZoomLevel?: OptionalProp; + followPitch?: OptionalProp; diff --git a/node_modules/@rnmapbox/maps/src/components/Camera.tsx b/node_modules/@rnmapbox/maps/src/components/Camera.tsx index c8e61a0..d3e50b9 100644 --- a/node_modules/@rnmapbox/maps/src/components/Camera.tsx diff --git a/patches/@shopify/react-native-skia/details.md b/patches/@shopify/react-native-skia/details.md index b4741c128c44..d09ae410b58d 100644 --- a/patches/@shopify/react-native-skia/details.md +++ b/patches/@shopify/react-native-skia/details.md @@ -156,9 +156,9 @@ Untransformed canvases keep the previous ratio, so nothing else changes. ``` -- Upstream PR/issue: +- Upstream PR/issue: https://github.com/Shopify/react-native-skia/issues/4084 was closed as not a Skia bug: the maintainer considers a canvas painted under a CSS transform an app concern, and said neither https://github.com/Shopify/react-native-skia/pull/4085 (this approach) nor https://github.com/Shopify/react-native-skia/pull/4105 (an opt-in pixelDensity prop) is the way to fix it. No upstream fix is planned, so this patch has to be carried across Skia upgrades unless the charts stop being fitted with a transform. - E/App issue: https://github.com/Expensify/App/issues/95221 -- PR introducing patch: +- PR introducing patch: https://github.com/Expensify/App/pull/101291 ### [@shopify+react-native-skia+2.11.2+005+load-skia-web-fail-closed.patch](@shopify+react-native-skia+2.11.2+005+load-skia-web-fail-closed.patch) diff --git a/patches/react-native-gesture-handler/details.md b/patches/react-native-gesture-handler/details.md new file mode 100644 index 000000000000..2cac998214b0 --- /dev/null +++ b/patches/react-native-gesture-handler/details.md @@ -0,0 +1,17 @@ +# `react-native-gesture-handler` patches + +### [react-native-gesture-handler+3.3.0+001+start-hover-on-pointer-move.patch](react-native-gesture-handler+3.3.0+001+start-hover-on-pointer-move.patch) + +- Reason: + + ``` + On web the hover gesture begins only on the DOM pointerenter event. The Gesture Handler 3 hook detector attaches + its listeners in an effect, after paint, so when the view mounts under a pointer that is already there (e.g. quickly + switching charts in the Insights card), pointerenter fires before the listeners exist and the hover never begins + until the pointer leaves the view and enters it again. The patch begins the hover on the first pointer move over + the view when it has not begun yet. + ``` + +- Upstream PR/issue: https://github.com/software-mansion/react-native-gesture-handler/pull/4624 +- E/App issue: https://github.com/Expensify/App/issues/102782 +- PR introducing patch: https://github.com/Expensify/App/pull/103457 diff --git a/patches/react-native-gesture-handler/react-native-gesture-handler+3.3.0+001+start-hover-on-pointer-move.patch b/patches/react-native-gesture-handler/react-native-gesture-handler+3.3.0+001+start-hover-on-pointer-move.patch new file mode 100644 index 000000000000..74729022523b --- /dev/null +++ b/patches/react-native-gesture-handler/react-native-gesture-handler+3.3.0+001+start-hover-on-pointer-move.patch @@ -0,0 +1,35 @@ +diff --git a/node_modules/react-native-gesture-handler/lib/module/web/handlers/HoverGestureHandler.js b/node_modules/react-native-gesture-handler/lib/module/web/handlers/HoverGestureHandler.js +index 7ba7b7b..6f0567f 100644 +--- a/node_modules/react-native-gesture-handler/lib/module/web/handlers/HoverGestureHandler.js ++++ b/node_modules/react-native-gesture-handler/lib/module/web/handlers/HoverGestureHandler.js +@@ -33,6 +33,12 @@ export default class HoverGestureHandler extends GestureHandler { + this.end(); + } + onPointerMove(event) { ++ // `pointerenter` is missed when the pointer is already over the view before the listeners are attached ++ // (the v3 detector attaches them in an effect, after paint), so start hovering on the first move instead. ++ if (this.state === State.UNDETERMINED) { ++ this.onPointerMoveOver(event); ++ return; ++ } + this.tracker.track(event); + this.stylusData = event.stylusData; + super.onPointerMove(event); +diff --git a/node_modules/react-native-gesture-handler/src/web/handlers/HoverGestureHandler.ts b/node_modules/react-native-gesture-handler/src/web/handlers/HoverGestureHandler.ts +index 0f13b55..46967aa 100644 +--- a/node_modules/react-native-gesture-handler/src/web/handlers/HoverGestureHandler.ts ++++ b/node_modules/react-native-gesture-handler/src/web/handlers/HoverGestureHandler.ts +@@ -49,6 +49,13 @@ export default class HoverGestureHandler extends GestureHandler { + } + + protected override onPointerMove(event: AdaptedEvent): void { ++ // `pointerenter` is missed when the pointer is already over the view before the listeners are attached ++ // (the v3 detector attaches them in an effect, after paint), so start hovering on the first move instead. ++ if (this.state === State.UNDETERMINED) { ++ this.onPointerMoveOver(event); ++ return; ++ } ++ + this.tracker.track(event); + this.stylusData = event.stylusData; + diff --git a/patches/react-native-pdf/details.md b/patches/react-native-pdf/details.md index af4fe90697b7..8f00de232f70 100644 --- a/patches/react-native-pdf/details.md +++ b/patches/react-native-pdf/details.md @@ -77,3 +77,20 @@ - Upstream PR/issue: https://github.com/wonday/react-native-pdf/issues/674 - E/App issue: https://github.com/Expensify/App/issues/101895 - PR introducing patch: https://github.com/Expensify/App/pull/102389 + + +### [react-native-pdf+7.0.2+006+fix-scale-changed-on-resize.patch](react-native-pdf+7.0.2+006+fix-scale-changed-on-resize.patch) + +- Reason: + + ``` + This patch stops onScaleChanged on Android from reporting a zoom change when the PDF view is resized, e.g. when rotating the device between portrait and landscape. + + Android reported the scale as the drawn page width divided by the page width of the first draw. A resize changes the page width, so after rotating the scale was reported as e.g. 2 or 0.5 even though the user didn't zoom. The patch reports the viewer's zoom (getZoom()) instead, which is relative to the page size fitted to the current view size, and only emits the event when the zoom changes. This matches iOS, which recalculates its base scale factor for the new view size on every resize. + + In the attachment carousel a scale different from 1 is treated as zoomed in, which hid the carousel arrows and disabled swiping between attachments after rotating. + ``` + +- Upstream PR/issue: https://github.com/wonday/react-native-pdf/pull/1050, https://github.com/wonday/react-native-pdf/issues/1049 +- E/App issue: https://github.com/Expensify/App/issues/103207 +- PR introducing patch: https://github.com/Expensify/App/pull/103781 diff --git a/patches/react-native-pdf/react-native-pdf+7.0.2+006+fix-scale-changed-on-resize.patch b/patches/react-native-pdf/react-native-pdf+7.0.2+006+fix-scale-changed-on-resize.patch new file mode 100644 index 000000000000..54f813c6333b --- /dev/null +++ b/patches/react-native-pdf/react-native-pdf+7.0.2+006+fix-scale-changed-on-resize.patch @@ -0,0 +1,47 @@ +diff --git a/node_modules/react-native-pdf/android/src/main/java/org/wonday/pdf/PdfView.java b/node_modules/react-native-pdf/android/src/main/java/org/wonday/pdf/PdfView.java +--- a/node_modules/react-native-pdf/android/src/main/java/org/wonday/pdf/PdfView.java ++++ b/node_modules/react-native-pdf/android/src/main/java/org/wonday/pdf/PdfView.java +@@ -86,9 +86,7 @@ + private boolean scrollEnabled = true; + private boolean enableRTL = false; + +- private float originalWidth = 0; +- private float lastPageWidth = 0; +- private float lastPageHeight = 0; ++ private float lastZoom = 0; + + // used to store the parameters for `super.onSizeChanged` + private int oldW = 0; +@@ -256,17 +254,16 @@ + + @Override + public void onLayerDrawn(Canvas canvas, float pageWidth, float pageHeight, int displayedPage){ +- if (originalWidth == 0) { +- originalWidth = pageWidth; +- } +- +- if (lastPageWidth>0 && lastPageHeight>0 && (pageWidth!=lastPageWidth || pageHeight!=lastPageHeight)) { ++ // Report the viewer's zoom instead of the page width relative to the first drawn page width. ++ // The page width also changes when the view is resized (e.g. on rotation), which isn't a zoom change. ++ float zoom = getZoom(); ++ if (lastZoom>0 && zoom!=lastZoom) { + // maybe change by other instance, restore zoom setting + Constants.Pinch.MINIMUM_ZOOM = this.minScale; + Constants.Pinch.MAXIMUM_ZOOM = this.maxScale; + + WritableMap event = Arguments.createMap(); +- event.putString("message", "scaleChanged|"+(pageWidth/originalWidth)); ++ event.putString("message", "scaleChanged|"+zoom); + ThemedReactContext context = (ThemedReactContext) getContext(); + EventDispatcher dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, getId()); + int surfaceId = UIManagerHelper.getSurfaceId(this); +@@ -284,8 +281,7 @@ + // ); + } + +- lastPageWidth = pageWidth; +- lastPageHeight = pageHeight; ++ lastZoom = zoom; + } + + @Override diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index babf9982cb42..d903512dcac5 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -67,3 +67,40 @@ - Upstream PR/issue: https://github.com/software-mansion/react-native-screens/issues/4156, https://github.com/software-mansion/react-native-screens/pull/4157 - E/App issue: https://github.com/Expensify/App/issues/102046 - PR introducing patch: https://github.com/Expensify/App/pull/103126 + +### [react-native-screens+4.28.0+002+animate-hiding-the-native-tab-bar.patch](react-native-screens+4.28.0+002+animate-hiding-the-native-tab-bar.patch) + +- Reason: The App hides the native tab bar on screens that are not a tab root, which React Navigation forwards to RNScreens as `tabBarHidden` (it derives the flag from `tabBarStyle.display === 'none'`). RNScreens calls UIKit's `setTabBarHidden:animated:` with `animated:NO`, so the bar blinks in and out instead of travelling with the screen that hid it. The patch animates the change once the bar is in a window, which lets UIKit run its own show/hide animation without animating the bar out on the first render. The pre-iOS 18 branch is left alone: it assigns `tabBar.hidden` directly and has no animated counterpart. **This patch can be removed on the bump to react-native-screens 4.29.0**, the first release with the upstream fix linked below. +- Upstream PR/issue: fixed upstream by [software-mansion/react-native-screens#4632](https://github.com/software-mansion/react-native-screens/pull/4632), which animates by default behind a new `ios.tabBarHiddenAnimationEnabled` prop, and [#4673](https://github.com/software-mansion/react-native-screens/pull/4673), which skips the animation on the first render. Both are merged after the 4.28.0 release and labeled for the v4 backport. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) + +### [react-native-screens+4.28.0+003+android-tab-label-typography.patch](react-native-screens+4.28.0+003+android-tab-label-typography.patch) + +- Reason: Two changes to the Android tab labels, both in `TabsAppearanceApplicator`, which React Navigation has no option for. + - Bold selected label. The Android tab bar takes one font weight for every label, while the design marks the selected tab with a bold label, the same way the JS side bar does. Material's `BottomNavigationView` draws each item with two labels, a small one shown while unselected and a large one shown while selected. `updateFontStyles` sets the large label's typeface with `Typeface.BOLD`, the way Material applies its own bold, so Android picks the bold face of the same font family. Material's `setItemTextAppearanceActiveBoldEnabled` is not used for this: it re-applies Material's 12sp text size to the large label alone and recomputes the selected item's offset from the difference between the two labels' sizes, so every selected tab would sit higher than the others. + - Letter spacing. `updateFontStyles` replaces the typeface and size of Material's tab labels but keeps the rest of Material 3's `LabelMedium` text appearance, which tracks letters 0.5sp apart. With Expensify Neue the labels read too spread out next to the rest of the app, so the patch sets the letter spacing of both labels to 0. +- Upstream PR/issue: not reported, because both changes are the App's typography (a bold selected label and its letter spacing) rather than a library defect, and RNScreens has no option for either. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) + +### [react-native-screens+4.28.0+004+no-android-tab-icon-tint.patch](react-native-screens+4.28.0+004+no-android-tab-icon-tint.patch) + +- Reason: The Android account tab shows the user's avatar. `TabsAppearanceApplicator` assigns `bottomNavigationView.itemIconTintList` unconditionally, and a `ColorStateList` tint is `SRC_IN`, so it flattens the avatar to a solid silhouette in the tint color. React Navigation's `tinted: false` only reaches iOS, since `getPlatformIcon` in `@react-navigation/bottom-tabs` always hands Android a plain `imageSource`. The patch drops the icon tint list so Android draws every icon bitmap as supplied. The App recolors the glyphs off-screen in Skia for both selection states, so they keep the design's colors. Label colors are untouched and keep coming from `tabBarItemTitleFontColor`. +- Upstream PR/issue: not reported, because dropping the tint for every icon fits only a bar whose icons all arrive pre-colored, as the App's do. The disabled and focused icon colors go with it, which the App does not use. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) + +### [react-native-screens+4.28.0+005+hidden-android-tab-bar-touches.patch](react-native-screens+4.28.0+005+hidden-android-tab-bar-touches.patch) + +- Reason: On Android, a hidden native tab bar is set to `View.GONE`. `TabsContainer` is a `FrameLayout`, which skips `GONE` children during layout, so the bar keeps the bounds it had while visible. React Native's `TouchTargetHelper` walks native children without checking visibility, so a touch inside those stale bounds targets the hidden bar instead of the React views drawn there. On a chat, that swallowed taps on the composer's emoji and + buttons while the keyboard was closed. The touch target search skips a child whose transform is non-invertible, so the patch scales the hidden bar to zero on the Y axis and back to one when it is shown. Its layout is left alone, so the bar's height and the insets derived from it do not change. +- Upstream PR/issue: TBD +- E/App issue: [#104226](https://github.com/Expensify/App/issues/104226) +- PR introducing patch: [#104238](https://github.com/Expensify/App/pull/104238) + +### [react-native-screens+4.28.0+006+stack-scroll-to-top-uses-scroll-view-marker.patch](react-native-screens+4.28.0+006+stack-scroll-to-top-uses-scroll-view-marker.patch) + +- Reason: On iOS, a second tap on a native tab scrolls the tab's content to the top. When the tab's root is a native stack, as with the Inbox tab's split navigator, `RNSScreenStack` handles that tap and picks the scroll view by following each view's first native subview. On the Inbox the first one it reaches is the horizontal tab selector above the list. That selector is already at the top, so nothing scrolls. A `ScrollViewMarker` around the list registers the list's scroll view with the tabs screen, but the stack never reads it. The patch makes the stack scroll the marked scroll view while it shows a single screen, and fall back to the first-subview search otherwise, since the marker can sit on any screen of the stack. The marker only exists on iOS when `RNS_GAMMA_ENABLED=1` is set at `pod install`, which both Podfiles do. +- Upstream PR/issue: [#4985](https://github.com/software-mansion/react-native-screens/pull/4985) +- E/App issue: https://github.com/Expensify/App/issues/104284 +- PR introducing patch: [#104368](https://github.com/Expensify/App/pull/104368) diff --git a/patches/react-native-screens/react-native-screens+4.28.0+002+animate-hiding-the-native-tab-bar.patch b/patches/react-native-screens/react-native-screens+4.28.0+002+animate-hiding-the-native-tab-bar.patch new file mode 100644 index 000000000000..632508daac5b --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+002+animate-hiding-the-native-tab-bar.patch @@ -0,0 +1,14 @@ +diff --git a/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm b/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm +--- a/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm ++++ b/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm +@@ -244,7 +244,9 @@ + _tabBarHidden = newComponentProps.tabBarHidden; + #if RNS_IPHONE_OS_VERSION_AVAILABLE(18_0) + if (@available(iOS 18.0, *)) { +- [_controller setTabBarHidden:_tabBarHidden animated:NO]; ++ // Animated so the bar slides out with the screen that hid it instead of blinking away, except before the bar ++ // is on screen, where the first render would animate it out. ++ [_controller setTabBarHidden:_tabBarHidden animated:[[_controller tabBar] window] != nil]; + } else + #endif // RNS_IPHONE_OS_VERSION_AVAILABLE(18_0) + { diff --git a/patches/react-native-screens/react-native-screens+4.28.0+003+android-tab-label-typography.patch b/patches/react-native-screens/react-native-screens+4.28.0+003+android-tab-label-typography.patch new file mode 100644 index 000000000000..a532d6a859e5 --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+003+android-tab-label-typography.patch @@ -0,0 +1,32 @@ +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +index 10cd91b..6f059bd 100644 +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +@@ -3,6 +3,7 @@ package com.swmansion.rnscreens.gamma.tabs.appearance + import android.annotation.SuppressLint + import android.content.Context + import android.content.res.ColorStateList ++import android.graphics.Typeface + import android.graphics.drawable.StateListDrawable + import android.util.TypedValue + import android.view.MenuItem +@@ -161,13 +162,18 @@ internal class TabsAppearanceApplicator( + tabBarAppearance?.tabBarItemTitleLargeLabelFontSize?.takeIf { it > 0 }?.let { PixelUtil.toPixelFromSP(it) } + ?: context.resources.getDimension(R.dimen.design_bottom_navigation_text_size) + ++ // Both labels drop the 0.5sp letter tracking of Material's text appearance, which reads wider than the app's labels. + // Inactive + smallLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, smallFontSize) + smallLabel.typeface = fontFamily ++ smallLabel.letterSpacing = 0f + + // Active ++ // Bold is set here rather than with `setItemTextAppearanceActiveBoldEnabled`, which re-applies Material's text size ++ // to this label alone, so Material lifts the selected item by the size difference between the two labels. + largeLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, largeFontSize) +- largeLabel.typeface = fontFamily ++ largeLabel.setTypeface(fontFamily, Typeface.BOLD) ++ largeLabel.letterSpacing = 0f + } + } + diff --git a/patches/react-native-screens/react-native-screens+4.28.0+004+no-android-tab-icon-tint.patch b/patches/react-native-screens/react-native-screens+4.28.0+004+no-android-tab-icon-tint.patch new file mode 100644 index 000000000000..95dc7a725632 --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+004+no-android-tab-icon-tint.patch @@ -0,0 +1,33 @@ +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +index 9238416..d7d2024 100644 +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +@@ -64,24 +64,10 @@ internal class TabsAppearanceApplicator( + bottomNavigationView.itemTextColor = ColorStateList(states, fontColors) + + // Icon color +- val iconDisabledColor = +- tabBarAppearance?.disabled?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) +- +- val iconFocusedColor = +- tabBarAppearance?.focused?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) +- +- val iconSelectedColor = +- tabBarAppearance?.selected?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSecondaryContainer) +- +- val iconNormalColor = +- tabBarAppearance?.normal?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) +- +- val iconColors = intArrayOf(iconDisabledColor, iconSelectedColor, iconFocusedColor, iconNormalColor) +- bottomNavigationView.itemIconTintList = ColorStateList(states, iconColors) ++ // Every tab icon arrives as a bitmap that already carries its own colors: the recolored glyph and the user's ++ // avatar on the account tab. A tint list paints over all of it with a single color, so no tint is applied and ++ // the bitmap is drawn as supplied, which is how `tinted: false` already behaves on iOS. ++ bottomNavigationView.itemIconTintList = null + + // LabelVisibilityMode + // From docs: can be one of LABEL_VISIBILITY_AUTO, LABEL_VISIBILITY_SELECTED, LABEL_VISIBILITY_LABELED, or LABEL_VISIBILITY_UNLABELED diff --git a/patches/react-native-screens/react-native-screens+4.28.0+005+hidden-android-tab-bar-touches.patch b/patches/react-native-screens/react-native-screens+4.28.0+005+hidden-android-tab-bar-touches.patch new file mode 100644 index 000000000000..9eea6a155402 --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+005+hidden-android-tab-bar-touches.patch @@ -0,0 +1,14 @@ +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +@@ -36,6 +36,10 @@ + isTabBarHidden: Boolean, + ) { + bottomNavigationView.isVisible = !isTabBarHidden ++ // A GONE child keeps the bounds it had while visible, and React Native's TouchTargetHelper does not check ++ // visibility, so the hidden bar would take touches meant for the React views drawn there. The touch target search ++ // skips a child with a non-invertible transform, so the hidden bar is scaled to zero, which leaves its layout as is. ++ bottomNavigationView.scaleY = if (isTabBarHidden) 0f else 1f + bottomNavigationView.setBackgroundColor( + tabBarAppearance?.tabBarBackgroundColor + ?: resolveColorAttr(context, R.attr.colorSurfaceContainer), diff --git a/patches/react-native-screens/react-native-screens+4.28.0+006+stack-scroll-to-top-uses-scroll-view-marker.patch b/patches/react-native-screens/react-native-screens+4.28.0+006+stack-scroll-to-top-uses-scroll-view-marker.patch new file mode 100644 index 000000000000..833db323d6bf --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+006+stack-scroll-to-top-uses-scroll-view-marker.patch @@ -0,0 +1,18 @@ +diff --git a/node_modules/react-native-screens/ios/RNSScreenStack.mm b/node_modules/react-native-screens/ios/RNSScreenStack.mm +--- a/node_modules/react-native-screens/ios/RNSScreenStack.mm ++++ b/node_modules/react-native-screens/ios/RNSScreenStack.mm +@@ -143,8 +143,12 @@ namespace react = facebook::react; + tabScreenController.tabScreenComponentView.shouldUseRepeatedTabSelectionPopToRootSpecialEffect) { + return [[self popToRootViewControllerAnimated:true] count] > 0; + } else if (tabScreenController.tabScreenComponentView.shouldUseRepeatedTabSelectionScrollToTopSpecialEffect) { +- UIScrollView *scrollView = +- [RNSScrollViewFinder findScrollViewInFirstDescendantChainFrom:[[self topViewController] view]]; ++ // A ScrollViewMarker registers its scroll view with the tabs screen, not with this stack, so it can be on any ++ // screen of the stack. It is used only while the stack shows a single screen. ++ UIScrollView *markedScrollView = ++ [[self viewControllers] count] == 1 ? [tabScreenController.tabScreenComponentView cachedContentScrollView] : nil; ++ UIScrollView *scrollView = markedScrollView ++ ?: [RNSScrollViewFinder findScrollViewInFirstDescendantChainFrom:[[self topViewController] view]]; + return [scrollView rnscreens_scrollToTop]; + } + diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+002+active-indicator-color-precedence.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+002+active-indicator-color-precedence.patch new file mode 100644 index 000000000000..ee75c7d7fdea --- /dev/null +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+002+active-indicator-color-precedence.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +index 1183c51..303635c 100644 +--- a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js ++++ b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +@@ -160,7 +160,7 @@ export function NativeBottomTabView({ + ios: PlatformColor('label'), + default: colors.text + }); +- const activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor ?? typeof activeTintColor === 'string' ? Color(activeTintColor)?.alpha(0.1).string() : undefined; ++ const activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor ?? (typeof activeTintColor === 'string' ? Color(activeTintColor)?.alpha(0.1).string() : undefined); + const onTransitionStart = ({ + closing, + route diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch new file mode 100644 index 000000000000..8ed497d4f84d --- /dev/null +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch @@ -0,0 +1,173 @@ +diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +index b087433de3b..f729d3ecbf1 100644 +--- a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js ++++ b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +@@ -4,11 +4,11 @@ import { getLabel, SafeAreaProviderCompat, Screen as ScreenContent } from '@reac + import { CommonActions, NavigationMetaContext, StackActions, useTheme } from '@react-navigation/native'; + import Color from 'color'; + import * as React from 'react'; +-import { Platform, PlatformColor } from 'react-native'; ++import { Platform, PlatformColor, StyleSheet, View } from 'react-native'; + import { Tabs } from 'react-native-screens'; + import { Deferred } from "./Deferred.js"; + import { NativeScreen } from "./NativeScreen/NativeScreen.js"; +-import { jsx as _jsx } from "react/jsx-runtime"; ++import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; + const meta = { + type: 'native-tabs' + }; +@@ -32,9 +32,21 @@ export function NativeBottomTabView({ + fonts + } = useTheme(); + const focusedRouteKey = state.routes[state.index].key; ++ // A route with `tabBarItemHidden` gets no native tab item. While it is focused, it is drawn over the native host ++ // and the tab bar is hidden, and the host keeps the last focused visible tab selected underneath. ++ const isRouteHidden = route => descriptors[route.key]?.options.tabBarItemHidden === true; ++ const isFocusedRouteHidden = isRouteHidden(state.routes[state.index]); ++ // Derived from the history rather than remembered, so a route that became hidden since it was focused is skipped. ++ const isRouteKeyVisible = key => state.routes.some(route => route.key === key && !isRouteHidden(route)); ++ const nativeSelectedRouteKey = isFocusedRouteHidden ? state.history.findLast(entry => isRouteKeyVisible(entry.key))?.key ?? state.routes.find(route => !isRouteHidden(route))?.key ?? focusedRouteKey : focusedRouteKey; ++ // Hidden routes stay mounted once visited, the same as the native tabs. ++ const visitedHiddenRouteKeysRef = React.useRef(new Set()); ++ if (isFocusedRouteHidden) { ++ visitedHiddenRouteKeysRef.current.add(focusedRouteKey); ++ } + const [nativeState, dispatch] = React.useReducer(reducer, { + confirmed: { +- routeKey: focusedRouteKey, ++ routeKey: nativeSelectedRouteKey, + provenance: 0 + } + }); +@@ -88,7 +100,9 @@ export function NativeBottomTabView({ + target: route.key + }); + } +- if (actionOrigin === 'programmatic-js' || focusedRouteKey === route.key) { ++ // While a hidden route is focused, native keeps the tab underneath it selected, so native reporting that tab is ++ // not a request to leave the hidden route. ++ if (actionOrigin === 'programmatic-js' || nativeSelectedRouteKey === route.key) { + dispatch({ + type: 'CONFIRM_STATE', + confirmed +@@ -114,7 +128,9 @@ export function NativeBottomTabView({ + console.error(`Received 'tabSelectionRejected' for route that doesn't exist: ${selectedScreenKey}`); + return; + } +- if (focusedRouteKey === route.key) { ++ // Android re-sends an unchanged request on a re-render and rejects it as repeated, which reports the tab under a ++ // focused hidden route. Following it would close the hidden route. ++ if (nativeSelectedRouteKey === route.key) { + dispatch({ + type: 'CONFIRM_STATE', + confirmed +@@ -189,17 +205,17 @@ export function NativeBottomTabView({ + const tabBarMinimizeBehavior = currentOptions.tabBarMinimizeBehavior === 'auto' ? 'automatic' : currentOptions.tabBarMinimizeBehavior === 'none' ? 'never' : currentOptions.tabBarMinimizeBehavior; + const shouldHideTabBar = currentOptions.tabBarStyle?.display === 'none'; + const bottomAccessory = currentOptions.bottomAccessory; +- return /*#__PURE__*/_jsx(SafeAreaProviderCompat, { +- children: /*#__PURE__*/_jsx(Tabs.Host, { ++ return /*#__PURE__*/_jsxs(SafeAreaProviderCompat, { ++ children: [/*#__PURE__*/_jsx(Tabs.Host, { + navStateRequest: { +- selectedScreenKey: focusedRouteKey, ++ selectedScreenKey: nativeSelectedRouteKey, + baseProvenance: nativeState.confirmed.provenance + }, + rejectStaleNavStateUpdates: true, + onTabSelected: onTabSelected, + onTabSelectionRejected: onTabSelectionRejected, + onTabSelectionPrevented: onTabSelectionPrevented, +- tabBarHidden: shouldHideTabBar, ++ tabBarHidden: shouldHideTabBar || isFocusedRouteHidden, + colorScheme: dark ? 'dark' : 'light', + nativeContainerStyle: { + backgroundColor: colors.background +@@ -213,6 +229,9 @@ export function NativeBottomTabView({ + tabBarTintColor: activeTintColor + }, + children: state.routes.map((route, index) => { ++ if (isRouteHidden(route)) { ++ return null; ++ } + const { + options, + render, +@@ -224,6 +243,7 @@ export function NativeBottomTabView({ + title, + lazy = false, + tabBarLabel, ++ tabBarAccessibilityLabel, + tabBarSelectionEnabled, + tabBarBadgeStyle, + tabBarIcon, +@@ -290,6 +310,7 @@ export function NativeBottomTabView({ + preventNativeSelection: tabBarSelectionEnabled === false, + badgeValue: tabBarBadge?.toString(), + title: tabTitle, ++ tabBarItemAccessibilityLabel: tabBarAccessibilityLabel, + nativeContainerStyle: { + backgroundColor: colors.background + }, +@@ -369,9 +390,40 @@ export function NativeBottomTabView({ + }) + }, route.key); + }) +- }) ++ }, "native-tabs-host"), ...state.routes.map((route, index) => { ++ if (!isRouteHidden(route) || !visitedHiddenRouteKeysRef.current.has(route.key)) { ++ return null; ++ } ++ const { ++ options, ++ render, ++ navigation ++ } = descriptors[route.key]; ++ const isFocused = state.index === index; ++ return /*#__PURE__*/_jsx(View, { ++ style: [StyleSheet.absoluteFill, { ++ backgroundColor: colors.background ++ }, !isFocused && styles.hiddenRoute], ++ pointerEvents: isFocused ? 'auto' : 'none', ++ children: /*#__PURE__*/_jsx(ScreenWithHeader, { ++ isFocused: isFocused, ++ route: route, ++ navigation: navigation, ++ options: options, ++ children: /*#__PURE__*/_jsx(NavigationMetaContext.Provider, { ++ value: meta, ++ children: render() ++ }) ++ }) ++ }, route.key); ++ })] + }); + } ++const styles = StyleSheet.create({ ++ hiddenRoute: { ++ display: 'none' ++ } ++}); + function ScreenWithHeader({ + isFocused, + route, +diff --git a/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts b/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts +index ac25ed1..c05b04b 100644 +--- a/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts ++++ b/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts +@@ -78,6 +78,15 @@ type IconIOSSfSymbol = { + }; + export type NativeBottomTabIcon = IconIOSSfSymbol | IconImage; + export type NativeBottomTabNavigationOptions = NativeHeaderOptions & { ++ /** ++ * Whether the tab gets no item in the native tab bar. While a hidden tab is focused, it is drawn over the ++ * native tabs and the tab bar is hidden. ++ */ ++ tabBarItemHidden?: boolean; ++ /** ++ * Accessibility label for the tab bar item, read by the screen reader. Useful when the tab has no visible label. ++ */ ++ tabBarAccessibilityLabel?: string; + /** + * Title text for the screen. + */ diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+004+stable-native-tab-identity.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+004+stable-native-tab-identity.patch new file mode 100644 index 000000000000..cb8623e23cac --- /dev/null +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+004+stable-native-tab-identity.patch @@ -0,0 +1,88 @@ +diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +index f729d3ecbf1..8f349432b40 100644 +--- a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js ++++ b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +@@ -39,6 +39,10 @@ export function NativeBottomTabView({ + // Derived from the history rather than remembered, so a route that became hidden since it was focused is skipped. + const isRouteKeyVisible = key => state.routes.some(route => route.key === key && !isRouteHidden(route)); + const nativeSelectedRouteKey = isFocusedRouteHidden ? state.history.findLast(entry => isRouteKeyVisible(entry.key))?.key ?? state.routes.find(route => !isRouteHidden(route))?.key ?? focusedRouteKey : focusedRouteKey; ++ // Native tabs are identified by route name, which survives a route getting a new key. Keyed by route key, a re-keyed ++ // route replaces its native tab, and Android never removes the old one, so the bar shows the tab twice. ++ const getRouteForScreenKey = screenKey => state.routes.find(route => route.name === screenKey); ++ const nativeSelectedScreenKey = state.routes.find(route => route.key === nativeSelectedRouteKey)?.name ?? nativeSelectedRouteKey; + // Hidden routes stay mounted once visited, the same as the native tabs. + const visitedHiddenRouteKeysRef = React.useRef(new Set()); + if (isFocusedRouteHidden) { +@@ -46,7 +50,7 @@ export function NativeBottomTabView({ + } + const [nativeState, dispatch] = React.useReducer(reducer, { + confirmed: { +- routeKey: nativeSelectedRouteKey, ++ routeKey: nativeSelectedScreenKey, + provenance: 0 + } + }); +@@ -89,7 +93,7 @@ export function NativeBottomTabView({ + routeKey: selectedScreenKey, + provenance + }; +- const route = state.routes.find(route => route.key === selectedScreenKey); ++ const route = getRouteForScreenKey(selectedScreenKey); + if (!route) { + console.error(`Received 'tabSelected' for route that doesn't exist: ${selectedScreenKey}`); + return; +@@ -123,7 +127,7 @@ export function NativeBottomTabView({ + routeKey: selectedScreenKey, + provenance + }; +- const route = state.routes.find(route => route.key === selectedScreenKey); ++ const route = getRouteForScreenKey(selectedScreenKey); + if (!route) { + console.error(`Received 'tabSelectionRejected' for route that doesn't exist: ${selectedScreenKey}`); + return; +@@ -154,7 +158,7 @@ export function NativeBottomTabView({ + }); + navigation.emit({ + type: 'tabPress', +- target: preventedScreenKey ++ target: getRouteForScreenKey(preventedScreenKey)?.key ?? preventedScreenKey + }); + }; + const currentOptions = descriptors[state.routes[state.index].key]?.options; +@@ -208,7 +212,7 @@ export function NativeBottomTabView({ + return /*#__PURE__*/_jsxs(SafeAreaProviderCompat, { + children: [/*#__PURE__*/_jsx(Tabs.Host, { + navStateRequest: { +- selectedScreenKey: nativeSelectedRouteKey, ++ selectedScreenKey: nativeSelectedScreenKey, + baseProvenance: nativeState.confirmed.provenance + }, + rejectStaleNavStateUpdates: true, +@@ -306,7 +310,7 @@ export function NativeBottomTabView({ + closing: true, + route + }), +- screenKey: route.key, ++ screenKey: route.name, + preventNativeSelection: tabBarSelectionEnabled === false, + badgeValue: tabBarBadge?.toString(), + title: tabTitle, +@@ -374,6 +378,7 @@ export function NativeBottomTabView({ + }, + overrideScrollViewContentInsetAdjustmentBehavior + }, ++ // Keyed by route key, so a re-keyed route still remounts its content inside the same native tab. + children: /*#__PURE__*/_jsx(Deferred, { + lazy: lazy, + visible: isFocused || isPreloaded, +@@ -387,8 +392,8 @@ export function NativeBottomTabView({ + children: render() + }) + }) +- }) +- }, route.key); ++ }, route.key) ++ }, route.name); + }) + }, "native-tabs-host"), ...state.routes.map((route, index) => { + if (!isRouteHidden(route) || !visitedHiddenRouteKeysRef.current.has(route.key)) { diff --git a/patches/react-navigation/@react-navigation+native+7.4.1+001+initial.patch b/patches/react-navigation/@react-navigation+native+7.4.1+001+initial.patch index b16466d5d91c..c323f024fed0 100644 --- a/patches/react-navigation/@react-navigation+native+7.4.1+001+initial.patch +++ b/patches/react-navigation/@react-navigation+native+7.4.1+001+initial.patch @@ -84,7 +84,7 @@ index 0000000..b996822 +} +//# sourceMappingURL=findFocusedRouteKey.js.map diff --git a/node_modules/@react-navigation/native/lib/module/useLinking.js b/node_modules/@react-navigation/native/lib/module/useLinking.js -index af0cd61..fb06350 100644 +index af0cd61..2805780 100644 --- a/node_modules/@react-navigation/native/lib/module/useLinking.js +++ b/node_modules/@react-navigation/native/lib/module/useLinking.js @@ -4,6 +4,7 @@ import { CommonActions, findFocusedRoute, getActionFromState as getActionFromSta @@ -139,7 +139,19 @@ index af0cd61..fb06350 100644 export function useLinking(ref, { enabled = true, config, -@@ -411,6 +449,9 @@ export function useLinking(ref, { +@@ -266,7 +304,10 @@ export function useLinking(ref, { + if (record?.path === path && record?.state) { + const currentState = navigation.getRootState(); + const [currentFocused, recordFocused] = findMatchingState(currentState, record.state); +- if (previousIndex - index === 1 && currentFocused && recordFocused && isPoppingLastEntry(currentFocused, recordFocused)) { ++ // Custom string history entries (e.g. modal back-guards) aren't routes, so no router can pop them with GO_BACK. ++ // Reset to the record's state instead, which drops the custom entry. ++ const isPoppingCustomHistoryEntry = typeof currentFocused?.history?.at(-1) === 'string'; ++ if (previousIndex - index === 1 && currentFocused && recordFocused && !isPoppingCustomHistoryEntry && isPoppingLastEntry(currentFocused, recordFocused)) { + const pending = pendingPopStateDeltaRef.current; + + // If we detect that the state change is popping the last entry +@@ -411,6 +452,9 @@ export function useLinking(ref, { const historyDelta = getHistoryLength(focusedState) - getHistoryLength(previousFocusedState) - ( // Subtract the delta already applied by the browser to sync only the remaining changes pendingPopStateDelta ?? 0); @@ -149,7 +161,7 @@ index af0cd61..fb06350 100644 if (historyDelta > 0) { // If history length is increased, we should pushState // Note that path might not actually change here, for example, drawer open should pushState -@@ -422,7 +463,8 @@ export function useLinking(ref, { +@@ -422,7 +466,8 @@ export function useLinking(ref, { // If history length is decreased, i.e. entries were removed, we want to go back const nextIndex = history.backIndex({ @@ -159,7 +171,7 @@ index af0cd61..fb06350 100644 }); const currentIndex = history.index; try { -@@ -435,7 +477,18 @@ export function useLinking(ref, { +@@ -435,7 +480,18 @@ export function useLinking(ref, { // We couldn't find an existing entry to go back to, so we'll go back by the delta // This won't be correct if multiple routes were pushed in one go before // Usually this shouldn't happen and this is a fallback for that @@ -179,7 +191,7 @@ index af0cd61..fb06350 100644 } // Store the updated state as well as fix the path if incorrect -@@ -448,10 +501,26 @@ export function useLinking(ref, { +@@ -448,10 +504,26 @@ export function useLinking(ref, { } } else { // If history length is unchanged, we want to replaceState diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index 542721926812..7d55986533fa 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -42,6 +42,7 @@ - PR Introducing Patch: [#24165](https://github.com/Expensify/App/pull/24165) - PR Updating Patch: [#32087](https://github.com/Expensify/App/pull/32087) [#42465](https://github.com/Expensify/App/pull/42465) [#64155](https://github.com/Expensify/App/pull/64155) [#103346](https://github.com/Expensify/App/pull/103346) [#101715](https://github.com/Expensify/App/issues/101715) - Note: Re-ported onto 7.4.1. `createMemoryHistory` and `useLinking` were refactored upstream (`getPathWithoutHash`, `getHistoryLength`, `pendingPopStateDelta`, `isPoppingLastEntry`), so the hunks were re-anchored instead of applied verbatim: `backIndex` keeps upstream's hash handling and adds the focused-route-key match, and `useLinking` keeps upstream's `pendingPopStateDelta` guard and `getHistoryLength` delta while re-adding `historyDeltaByKeys`/`getStaleHistoryDiff`. Two parts of the old patch became redundant and were dropped: the `findFocusedRouteKey` helper file now matches upstream's own module, and the extra `history.replace` right after `history.go(nextIndex - currentIndex)` duplicated the replace that upstream already runs at the end of the same `try` block. +- Note: The `popstate` listener skips upstream's `isPoppingLastEntry` → `GO_BACK` shortcut when the last root history entry is a custom string entry (e.g. a `useSyncModalWithHistory` back-guard). No router can pop such an entry with `GO_BACK`, so it falls back to `resetRoot(record.state)` (the pre-7.3 behavior), which drops the entry and lets the modal close on browser Back. ### [@react-navigation+stack+7.11.2+001+edge-drag-gesture.patch](@react-navigation+stack+7.11.2+001+edge-drag-gesture.patch) @@ -62,6 +63,30 @@ - Note: Not fully covered by the public `detachPreviousScreen` option (this also forces `activityState`). v8 replaces `detachInactiveScreens`/`detachPreviousScreen`/`freezeOnBlur` with a single `inactiveBehavior` option — re-evaluate this patch then. - Note: Re-ported onto 7.11.2. `STATE_TRANSITIONING_OR_BELOW_TOP` is now a module-level constant upstream, so the patch no longer declares it. 7.11.2 (https://github.com/react-navigation/react-navigation/pull/13255) removed the `overflow: active ? undefined : 'hidden'` line from `CardA11yWrapper`, so the `zIndex` hunk was re-anchored on the line above it. The added lines are unchanged. With that `overflow: hidden` gone from `CardA11yWrapper`, the Safari case the `zIndex` part was added for may no longer happen. That has not been verified, so the `zIndex` part stays. +### [@react-navigation+bottom-tabs+7.19.2+002+active-indicator-color-precedence.patch](@react-navigation+bottom-tabs+7.19.2+002+active-indicator-color-precedence.patch) + +- Reason: `tabBarActiveIndicatorColor` was never applied on the native Android tab bar: a missing pair of parentheses made `??` bind to the `typeof` check, so the indicator always got `tabBarActiveTintColor` at 10% alpha. The patch restores the intended precedence, so the active indicator pill takes the color from the design. **This patch can be removed on the upgrade to React Navigation v8**, whose rewritten native tab view no longer has the bug. +- Upstream PR/issue: not reported, because the v8 branch already fixes it and the fix only matters until the App moves to v8. Still present on the `7.x` branch as of 7.20.0. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) +- PR Updating Patch: N/A + +### [@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch](@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch) + +- Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited. Nothing native changes. The host's selection is derived from the tab history, skipping routes that are hidden by then, so a beta change that hides the selected route cannot leave the host pointing at a removed tab. A route that moves between hidden and visible is remounted, and a hidden route ignores preloading and emits no transition events. Native reporting the tab that sits under a focused hidden route, as Android does when it rejects an unchanged request as repeated on a re-render, confirms the state instead of moving JS to that tab, so the hidden route stays open. The patch also forwards a `tabBarAccessibilityLabel` option to RNScreens' `tabBarItemAccessibilityLabel`. The iOS tab labels are drawn into the icon images, so without it VoiceOver announces the tabs with no name. **This part can be removed on the upgrade to React Navigation v8**, which forwards the same option. The `tabBarItemHidden` part has no upstream counterpart and stays. +- Upstream PR/issue: not reported, because hiding an item from a native tab bar has no native counterpart and this works around it in JS for the App's tab set; the accessibility label part is already in v8. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) +- PR Updating Patch: N/A + +### [@react-navigation+bottom-tabs+7.19.2+004+stable-native-tab-identity.patch](@react-navigation+bottom-tabs+7.19.2+004+stable-native-tab-identity.patch) + +- Reason: Identifies each native tab by its route name instead of its route key. The App gives the focused tab route a new key on purpose when it pre-inserts a screen under the RHP (`markFocusedTabRouteForRemount` in `GetStateForActionHandlers.ts`, #90985), so the split navigator remounts without a push-transition flash. Keyed by route key, that new key replaced the whole `Tabs.Screen`. Android never removes the old one: `TabsHostViewManager` does not override `getChildAt`/`getChildCount`, so Fabric's `removeViewAt` finds no child at that index ("view already removed from parent!") and skips `TabsHost.unmountReactSubviewAt`, while the insert goes through. The bar then shows the tab twice, and the stale copy opens a blank page. With this patch the native tab (`key`, `screenKey`, `navStateRequest.selectedScreenKey` and the selection events) uses the route name, which stays the same, and only the tab's content is keyed by the route key, so a re-keyed route still remounts its content inside the same native tab. **Remove this patch** once react-native-screens removes Android tabs correctly and React Navigation's native tab view no longer recreates a tab when its route key changes. +- Upstream PR/issue: not reported yet. The Android removal bug belongs to react-native-screens (`TabsHostViewManager`). +- E/App issue: [#104230](https://github.com/Expensify/App/issues/104230), [#104233](https://github.com/Expensify/App/issues/104233) +- PR Introducing Patch: [#104238](https://github.com/Expensify/App/pull/104238) +- PR Updating Patch: N/A + ### Removed patches - `@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch` was dropped: upstream raised the `createMemoryHistory.go()` fallback timeout from 100ms to 1000ms in 7.3.18 (https://github.com/react-navigation/react-navigation/pull/13217), with the same Firefox rationale as our patch. Original context: [#94571](https://github.com/Expensify/App/issues/94571) / [#95980](https://github.com/Expensify/App/pull/95980), upstream issue https://github.com/react-navigation/react-navigation/issues/11145. diff --git a/patches/sentry-browser-utils/@sentry+browser-utils+10.75.0+001+data-sentry-label.patch b/patches/sentry-browser-utils/@sentry+browser-utils+10.75.0+001+data-sentry-label.patch new file mode 100644 index 000000000000..c5ca7c9b3015 --- /dev/null +++ b/patches/sentry-browser-utils/@sentry+browser-utils+10.75.0+001+data-sentry-label.patch @@ -0,0 +1,114 @@ +diff --git a/node_modules/@sentry/browser-utils/build/cjs/htmlTreeAsString.js b/node_modules/@sentry/browser-utils/build/cjs/htmlTreeAsString.js +index e230278..73016d3 100644 +--- a/node_modules/@sentry/browser-utils/build/cjs/htmlTreeAsString.js ++++ b/node_modules/@sentry/browser-utils/build/cjs/htmlTreeAsString.js +@@ -54,7 +54,41 @@ function htmlTreeAsString(elem, options = {}) { + len += nextStr.length; + currentElem = _safeRead(currentElem, "parentNode"); + } +- return out.reverse().join(separator); ++ const cssSelector = out.reverse().join(separator); ++ let labelElem = null; ++ let dataLabel = null; ++ let pressableElem = null; ++ let current = elem; ++ for (let i = 0; i < 15 && current; i++) { ++ if (typeof HTMLElement !== "undefined" && current instanceof HTMLElement) { ++ const dataset = _safeRead(current, "dataset"); ++ if (dataset) { ++ if (_safeRead(current, "getAttribute", "aria-disabled") === "true") { ++ current = _safeRead(current, "parentNode"); ++ continue; ++ } ++ if (!dataLabel && dataset["sentryLabel"]) { ++ dataLabel = dataset["sentryLabel"]; ++ labelElem = current; ++ } ++ if (!pressableElem && dataset["tag"] === "pressable") { ++ pressableElem = current; ++ } ++ if (dataLabel && pressableElem) break; ++ } ++ } ++ current = _safeRead(current, "parentNode"); ++ } ++ if (!dataLabel) { ++ return cssSelector; ++ } ++ if (pressableElem && labelElem === pressableElem) { ++ return `[data-sentry-label="${dataLabel}"]`; ++ } ++ if (cssSelector.includes('[data-sentry-label="')) { ++ return cssSelector; ++ } ++ return `[data-sentry-label="${dataLabel}"] ${cssSelector}`; + } catch { + return ""; + } +@@ -69,6 +103,9 @@ function _htmlElementAsString(el, keyAttrs) { + if (el instanceof HTMLElement) { + const dataset = _safeRead(el, "dataset"); + if (dataset) { ++ if (dataset["sentryLabel"]) { ++ return `[data-sentry-label="${dataset["sentryLabel"]}"]`; ++ } + if (dataset["sentryComponent"]) { + return dataset["sentryComponent"]; + } +diff --git a/node_modules/@sentry/browser-utils/build/esm/htmlTreeAsString.js b/node_modules/@sentry/browser-utils/build/esm/htmlTreeAsString.js +index 6f8e53a..50f9f83 100644 +--- a/node_modules/@sentry/browser-utils/build/esm/htmlTreeAsString.js ++++ b/node_modules/@sentry/browser-utils/build/esm/htmlTreeAsString.js +@@ -52,7 +52,41 @@ function htmlTreeAsString(elem, options = {}) { + len += nextStr.length; + currentElem = _safeRead(currentElem, "parentNode"); + } +- return out.reverse().join(separator); ++ const cssSelector = out.reverse().join(separator); ++ let labelElem = null; ++ let dataLabel = null; ++ let pressableElem = null; ++ let current = elem; ++ for (let i = 0; i < 15 && current; i++) { ++ if (typeof HTMLElement !== "undefined" && current instanceof HTMLElement) { ++ const dataset = _safeRead(current, "dataset"); ++ if (dataset) { ++ if (_safeRead(current, "getAttribute", "aria-disabled") === "true") { ++ current = _safeRead(current, "parentNode"); ++ continue; ++ } ++ if (!dataLabel && dataset["sentryLabel"]) { ++ dataLabel = dataset["sentryLabel"]; ++ labelElem = current; ++ } ++ if (!pressableElem && dataset["tag"] === "pressable") { ++ pressableElem = current; ++ } ++ if (dataLabel && pressableElem) break; ++ } ++ } ++ current = _safeRead(current, "parentNode"); ++ } ++ if (!dataLabel) { ++ return cssSelector; ++ } ++ if (pressableElem && labelElem === pressableElem) { ++ return `[data-sentry-label="${dataLabel}"]`; ++ } ++ if (cssSelector.includes('[data-sentry-label="')) { ++ return cssSelector; ++ } ++ return `[data-sentry-label="${dataLabel}"] ${cssSelector}`; + } catch { + return ""; + } +@@ -67,6 +101,9 @@ function _htmlElementAsString(el, keyAttrs) { + if (el instanceof HTMLElement) { + const dataset = _safeRead(el, "dataset"); + if (dataset) { ++ if (dataset["sentryLabel"]) { ++ return `[data-sentry-label="${dataset["sentryLabel"]}"]`; ++ } + if (dataset["sentryComponent"]) { + return dataset["sentryComponent"]; + } diff --git a/patches/sentry-browser-utils/details.md b/patches/sentry-browser-utils/details.md new file mode 100644 index 000000000000..531bcb9621d6 --- /dev/null +++ b/patches/sentry-browser-utils/details.md @@ -0,0 +1,15 @@ +# `@sentry/browser-utils` patches + +### [@sentry+browser-utils+10.75.0+001+data-sentry-label.patch](@sentry+browser-utils+10.75.0+001+data-sentry-label.patch) + +- Reason: Enhances the `htmlTreeAsString` function to support `data-sentry-label` attributes for better element identification in Sentry spans. The patch: + - Always includes `data-sentry-label` in the list of checked attributes for each DOM element + - Searches up to 15 levels up the DOM tree to find a `data-sentry-label` attribute + - Prefixes the CSS selector with the found `data-sentry-label` value (e.g., `[data-sentry-label="MyLabel"] div.css-146c3p1.r-1udh08x.r-1udbk01.r-1iln25a > svg`) + + This allows us to identify UI elements by meaningful labels rather than just CSS selectors, making Sentry spans more actionable. + + `@sentry/browser-utils` ships its own copy of `htmlTreeAsString`, which INP, CLS and LCP spans, click breadcrumbs and Session Replay use. This logic used to be patched into the `@sentry/core` copy, which the browser SDK stopped calling after the upgrade to `@sentry/core` 10.73 (https://github.com/Expensify/App/commit/9689acf98e3), so labels disappeared from web spans from release 9.4.76 on. +- Upstream PR/issue: https://github.com/getsentry/sentry-javascript/pull/18398 +- E/App issue: https://github.com/Expensify/App/issues/76128 +- PR Introducing Patch: https://github.com/Expensify/App/pull/104340 diff --git a/patches/sentry-core/@sentry+core+10.75.0+001+initial.patch b/patches/sentry-core/@sentry+core+10.75.0+001+initial.patch index e7b617fb3000..0f994503d38c 100644 --- a/patches/sentry-core/@sentry+core+10.75.0+001+initial.patch +++ b/patches/sentry-core/@sentry+core+10.75.0+001+initial.patch @@ -1,57 +1,3 @@ -diff --git a/node_modules/@sentry/core/build/cjs/utils/browser.js b/node_modules/@sentry/core/build/cjs/utils/browser.js -index 9880e2d..6aee1b0 100644 ---- a/node_modules/@sentry/core/build/cjs/utils/browser.js -+++ b/node_modules/@sentry/core/build/cjs/utils/browser.js -@@ -29,7 +29,38 @@ function htmlTreeAsString(elem, options = {}) { - len += nextStr.length; - currentElem = currentElem.parentNode; - } -- return out.reverse().join(separator); -+ const cssSelector = out.reverse().join(separator); -+ let labelElem = null; -+ let dataLabel = null; -+ let pressableElem = null; -+ let current = elem; -+ for (let i = 0; i < 15 && current; i++) { -+ if (WINDOW.HTMLElement && current instanceof HTMLElement && current.dataset) { -+ if (current.getAttribute("aria-disabled") === "true") { -+ current = current.parentNode; -+ continue; -+ } -+ if (!dataLabel && current.dataset["sentryLabel"]) { -+ dataLabel = current.dataset["sentryLabel"]; -+ labelElem = current; -+ } -+ if (!pressableElem && current.dataset["tag"] === "pressable") { -+ pressableElem = current; -+ } -+ if (dataLabel && pressableElem) break; -+ } -+ current = current.parentNode; -+ } -+ if (!dataLabel) { -+ return cssSelector; -+ } -+ if (pressableElem && labelElem === pressableElem) { -+ return `[data-sentry-label="${dataLabel}"]`; -+ } -+ if (cssSelector.includes('[data-sentry-label="')) { -+ return cssSelector; -+ } -+ return `[data-sentry-label="${dataLabel}"] ${cssSelector}`; - } catch { - return ""; - } -@@ -42,6 +73,9 @@ function _htmlElementAsString(el, keyAttrs) { - } - if (WINDOW.HTMLElement) { - if (elem instanceof HTMLElement && elem.dataset) { -+ if (elem.dataset["sentryLabel"]) { -+ return `[data-sentry-label="${elem.dataset["sentryLabel"]}"]`; -+ } - if (elem.dataset["sentryComponent"]) { - return elem.dataset["sentryComponent"]; - } diff --git a/node_modules/@sentry/core/build/esm/fetch.js b/node_modules/@sentry/core/build/esm/fetch.js index b8fac8e..14ac969 100644 --- a/node_modules/@sentry/core/build/esm/fetch.js @@ -67,57 +13,3 @@ index b8fac8e..14ac969 100644 const contentLength = handlerData.response?.headers?.get("content-length"); if (contentLength) { const contentLengthNum = parseInt(contentLength); -diff --git a/node_modules/@sentry/core/build/esm/utils/browser.js b/node_modules/@sentry/core/build/esm/utils/browser.js -index ca93f32..502acba 100644 ---- a/node_modules/@sentry/core/build/esm/utils/browser.js -+++ b/node_modules/@sentry/core/build/esm/utils/browser.js -@@ -27,7 +27,38 @@ function htmlTreeAsString(elem, options = {}) { - len += nextStr.length; - currentElem = currentElem.parentNode; - } -- return out.reverse().join(separator); -+ const cssSelector = out.reverse().join(separator); -+ let labelElem = null; -+ let dataLabel = null; -+ let pressableElem = null; -+ let current = elem; -+ for (let i = 0; i < 15 && current; i++) { -+ if (WINDOW.HTMLElement && current instanceof HTMLElement && current.dataset) { -+ if (current.getAttribute("aria-disabled") === "true") { -+ current = current.parentNode; -+ continue; -+ } -+ if (!dataLabel && current.dataset["sentryLabel"]) { -+ dataLabel = current.dataset["sentryLabel"]; -+ labelElem = current; -+ } -+ if (!pressableElem && current.dataset["tag"] === "pressable") { -+ pressableElem = current; -+ } -+ if (dataLabel && pressableElem) break; -+ } -+ current = current.parentNode; -+ } -+ if (!dataLabel) { -+ return cssSelector; -+ } -+ if (pressableElem && labelElem === pressableElem) { -+ return `[data-sentry-label="${dataLabel}"]`; -+ } -+ if (cssSelector.includes('[data-sentry-label="')) { -+ return cssSelector; -+ } -+ return `[data-sentry-label="${dataLabel}"] ${cssSelector}`; - } catch { - return ""; - } -@@ -40,6 +71,9 @@ function _htmlElementAsString(el, keyAttrs) { - } - if (WINDOW.HTMLElement) { - if (elem instanceof HTMLElement && elem.dataset) { -+ if (elem.dataset["sentryLabel"]) { -+ return `[data-sentry-label="${elem.dataset["sentryLabel"]}"]`; -+ } - if (elem.dataset["sentryComponent"]) { - return elem.dataset["sentryComponent"]; - } diff --git a/patches/sentry-core/details.md b/patches/sentry-core/details.md index c08ddd227f0d..73cc3cc78a33 100644 --- a/patches/sentry-core/details.md +++ b/patches/sentry-core/details.md @@ -2,17 +2,7 @@ ### [@sentry+core+10.75.0+001+initial.patch](@sentry+core+10.75.0+001+initial.patch) -- Reason: Enhances the `htmlTreeAsString` function to support `data-sentry-label` attributes for better element identification in Sentry spans. The patch: - - Always includes `data-sentry-label` in the list of checked attributes for each DOM element - - Searches up to 15 levels up the DOM tree to find a `data-sentry-label` attribute - - Prefixes the CSS selector with the found `data-sentry-label` value (e.g., `[data-sentry-label="MyLabel"] div.css-146c3p1.r-1udh08x.r-1udbk01.r-1iln25a > svg`) - - This allows us to identify UI elements by meaningful labels rather than just CSS selectors, making Sentry spans more actionable. -- Upstream PR/issue: https://github.com/getsentry/sentry-javascript/pull/18398 -- E/App issue: https://github.com/Expensify/App/issues/76128 -- PR Introducing Patch: https://github.com/Expensify/App/pull/76547 - -The patch also adds `request-id` from fetch response headers to spans, making it easier to correlate Sentry data with backend logs. -- Upstream PR/issue: N/A +- Reason: Adds `request-id` from fetch response headers to spans, making it easier to correlate Sentry data with backend logs. +- Upstream PR/issue: N/A - E/App issue: https://github.com/Expensify/App/issues/75588 - PR Introducing Patch: https://github.com/Expensify/App/pull/79883 diff --git a/patches/victory-native/details.md b/patches/victory-native/details.md index 218061dd9c1d..ad919a91a15c 100644 --- a/patches/victory-native/details.md +++ b/patches/victory-native/details.md @@ -1,6 +1,6 @@ # `victory-native` patches -### [victory-native+41.21.0+001+horizontal-bars.patch](victory-native+41.21.0+001+horizontal-bars.patch) +### [victory-native+42.0.1+001+horizontal-bars.patch](victory-native+42.0.1+001+horizontal-bars.patch) - Reason: @@ -12,30 +12,18 @@ - E/App issue: https://github.com/Expensify/App/issues/91883 - PR introducing patch: https://github.com/Expensify/App/pull/91659 -### [victory-native+41.21.0+002+fix-piesliceangularinset-bug.patch](victory-native+41.21.0+002+fix-piesliceangularinset-bug.patch) -- Reason: - - ``` - Fix bug https://github.com/FormidableLabs/victory-native-xl/issues/652 - ``` - -- Upstream PR/issue: https://github.com/FormidableLabs/victory-native-xl/pull/666 -- E/App issue: https://github.com/Expensify/App/issues/92114 -- PR introducing patch: https://github.com/Expensify/App/pull/92130 - -### [victory-native+41.21.0+003+canvas-props.patch](victory-native+41.21.0+003+canvas-props.patch) +### [victory-native+42.0.1+002+tickCount-4121.patch](victory-native+42.0.1+002+tickCount-4121.patch) - Reason: ``` - Forwards a `canvasProps` prop from CartesianChart/PolarChart to the underlying Skia , so the - expanded (full-screen) chart can opt into Skia's `__destroyWebGLContextAfterRender` static renderer on - web. That renders the chart into a plain 2D canvas bitmap and releases the WebGL context, which keeps - the chart visible through the modal close animation without the WebGL white flash and without holding - a live GPU context per expanded chart. + After updating to v42 the tickCount broke. + This was caused by this commit https://github.com/FormidableLabs/victory-native-xl/pull/664/changes/fcd8affe655878d0bf95ab87c087eab428d4f62d + which fixed some bug but caused us another bug. This small patch reverts it. This is a temporary solution not a real fix. + The fix should address both bugs. ``` - Upstream PR/issue: Not yet. -- E/App issue: https://github.com/Expensify/App/issues/92969 -- PR introducing patch: https://github.com/Expensify/App/pull/97698 +- E/App issue: https://github.com/Expensify/App/issues/91883 +- PR introducing patch: https://github.com/Expensify/App/pull/100186 diff --git a/patches/victory-native/victory-native+41.21.0+001+horizontal-bars.patch b/patches/victory-native/victory-native+41.21.0+001+horizontal-bars.patch deleted file mode 100644 index 064636ba22be..000000000000 --- a/patches/victory-native/victory-native+41.21.0+001+horizontal-bars.patch +++ /dev/null @@ -1,111 +0,0 @@ -diff --git a/node_modules/victory-native/dist/cartesian/components/BarGroup.d.ts b/node_modules/victory-native/dist/cartesian/components/BarGroup.d.ts -index b486f76..45788f0 100644 ---- a/node_modules/victory-native/dist/cartesian/components/BarGroup.d.ts -+++ b/node_modules/victory-native/dist/cartesian/components/BarGroup.d.ts -@@ -16,6 +16,7 @@ type BarGroupProps = { - groupWidth: number; - gapWidth: number; - }) => void; -+ isHorizontal?: boolean; - }; - export declare function BarGroup({ betweenGroupPadding, withinGroupPadding, chartBounds, roundedCorners, children, onBarSizeChange, barWidth: customBarWidth, barCount, }: BarGroupProps): React.DetailedReactHTMLElement, HTMLInputElement>[] | null; - export declare namespace BarGroup { -diff --git a/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts b/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts -index 63b69bc..75641b1 100644 ---- a/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts -+++ b/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts -@@ -1,6 +1,6 @@ - import type { ChartBounds, PointsArray } from "../../types"; - import { type RoundedCorners } from "../../utils/createRoundedRectPath"; --export declare const useBarGroupPaths: (points: PointsArray[], chartBounds: ChartBounds, betweenGroupPadding?: number, withinGroupPadding?: number, roundedCorners?: RoundedCorners, customBarWidth?: number, barCount?: number) => { -+export declare const useBarGroupPaths: (points: PointsArray[], chartBounds: ChartBounds, betweenGroupPadding?: number, withinGroupPadding?: number, roundedCorners?: RoundedCorners, customBarWidth?: number, barCount?: number, isHorizontal?: boolean) => { - barWidth: number; - groupWidth: number; - gapWidth: number; -diff --git a/node_modules/victory-native/src/cartesian/components/BarGroup.tsx b/node_modules/victory-native/src/cartesian/components/BarGroup.tsx -index f46fad7..ad75c04 100644 ---- a/node_modules/victory-native/src/cartesian/components/BarGroup.tsx -+++ b/node_modules/victory-native/src/cartesian/components/BarGroup.tsx -@@ -20,6 +20,7 @@ type BarGroupProps = { - groupWidth: number; - gapWidth: number; - }) => void; -+ isHorizontal?: boolean; - }; - - export function BarGroup({ -@@ -31,6 +32,7 @@ export function BarGroup({ - onBarSizeChange, - barWidth: customBarWidth, - barCount, -+ isHorizontal, - }: BarGroupProps) { - // Collect the bar props - const bars = [] as BarGroupBarProps[]; -@@ -49,6 +51,7 @@ export function BarGroup({ - roundedCorners, - customBarWidth, - barCount, -+ isHorizontal, - ); - - // Handle bar size change -diff --git a/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts b/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts -index 4bfb6d4..9805bfe 100644 ---- a/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts -+++ b/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts -@@ -15,17 +15,19 @@ export const useBarGroupPaths = ( - roundedCorners?: RoundedCorners, - customBarWidth?: number, - barCount?: number, -+ isHorizontal?: boolean, - ) => { - const numGroups = points[0]?.length || 0; - -- const { yScale } = useCartesianChartContext(); -+ const { xScale, yScale } = useCartesianChartContext(); - - const groupWidth = React.useMemo(() => { -+ const boundSize = isHorizontal ? chartBounds.top - chartBounds.bottom : chartBounds.right - chartBounds.left; - return ( -- ((1 - betweenGroupPadding) * (chartBounds.right - chartBounds.left)) / -+ ((1 - betweenGroupPadding) * boundSize) / - Math.max(1, numGroups) - ); -- }, [betweenGroupPadding, chartBounds.left, chartBounds.right, numGroups]); -+ }, [betweenGroupPadding, chartBounds, numGroups, isHorizontal]); - - const barWidth = React.useMemo(() => { - if (customBarWidth) return customBarWidth; -@@ -46,19 +48,24 @@ export const useBarGroupPaths = ( - const offset = -groupWidth / 2 + i * (barWidth + gapWidth); - pointSet.forEach(({ x, y, yValue }) => { - if (typeof y !== "number") return; -- const barHeight = yScale(0) - y; -+ const barHeight = isHorizontal ? xScale(0) - x : yScale(0) - y; - if (roundedCorners) { - const nonUniformRoundedRect = createRoundedRectPath( -- x + offset, -- y, -- barWidth, -- barHeight, -- roundedCorners, -+ isHorizontal ? x : x + offset, -+ isHorizontal ? y + offset : y, -+ isHorizontal ? barHeight : barWidth, -+ isHorizontal ? barWidth : barHeight, -+ isHorizontal ? {topLeft: roundedCorners.bottomLeft, topRight: roundedCorners.topLeft, bottomLeft: roundedCorners.bottomRight, bottomRight: roundedCorners.topRight} : roundedCorners, - Number(yValue), - ); - p.addRRect(nonUniformRoundedRect); - } else { -- p.addRect(Skia.XYWHRect(x + offset, y, barWidth, barHeight)); -+ p.addRect(Skia.XYWHRect( -+ isHorizontal ? x : x + offset, -+ isHorizontal ? y + offset : y, -+ isHorizontal ? barHeight : barWidth, -+ isHorizontal ? barWidth : barHeight, -+ )); - } - }); - return p; diff --git a/patches/victory-native/victory-native+41.21.0+002+fix-piesliceangularinset-bug.patch b/patches/victory-native/victory-native+41.21.0+002+fix-piesliceangularinset-bug.patch deleted file mode 100644 index 827614abdf50..000000000000 --- a/patches/victory-native/victory-native+41.21.0+002+fix-piesliceangularinset-bug.patch +++ /dev/null @@ -1,63 +0,0 @@ -diff --git a/node_modules/victory-native/dist/pie/hooks/useSliceAngularInsetPath.d.ts b/node_modules/victory-native/dist/pie/hooks/useSliceAngularInsetPath.d.ts -index 18425b7..5508130 100644 ---- a/node_modules/victory-native/dist/pie/hooks/useSliceAngularInsetPath.d.ts -+++ b/node_modules/victory-native/dist/pie/hooks/useSliceAngularInsetPath.d.ts -@@ -4,5 +4,5 @@ type SliceAngularInsetPathArgs = { - slice: PieSliceData; - angularInset: PieSliceAngularInsetData; - }; --export declare const useSliceAngularInsetPath: ({ angularInset, slice, }: SliceAngularInsetPathArgs) => readonly [import("@shopify/react-native-skia").SkPath, import("@shopify/react-native-skia").SkPaint]; -+export declare const useSliceAngularInsetPath: ({ angularInset, slice, }: SliceAngularInsetPathArgs) => readonly [import("@shopify/react-native-skia").SkPath]; - export {}; -diff --git a/node_modules/victory-native/src/pie/PieSliceAngularInset.tsx b/node_modules/victory-native/src/pie/PieSliceAngularInset.tsx -index bede090..0523900 100644 ---- a/node_modules/victory-native/src/pie/PieSliceAngularInset.tsx -+++ b/node_modules/victory-native/src/pie/PieSliceAngularInset.tsx -@@ -21,7 +21,7 @@ type PieSliceAngularInsetProps = { - export const PieSliceAngularInset = (props: PieSliceAngularInsetProps) => { - const { angularInset, children, animate, ...rest } = props; - const { slice } = usePieSliceContext(); -- const [path, insetPaint] = useSliceAngularInsetPath({ slice, angularInset }); -+ const [path] = useSliceAngularInsetPath({ slice, angularInset }); - - // If the path is empty, don't render anything - if (path.toSVGString() === "M0 0L0 0M0 0L0 0") { -@@ -34,7 +34,7 @@ export const PieSliceAngularInset = (props: PieSliceAngularInsetProps) => { - - const Component = animate ? AnimatedPath : Path; - return ( -- -+ - {children} - - ); -diff --git a/node_modules/victory-native/src/pie/hooks/useSliceAngularInsetPath.ts b/node_modules/victory-native/src/pie/hooks/useSliceAngularInsetPath.ts -index 022dc49..2da6c64 100644 ---- a/node_modules/victory-native/src/pie/hooks/useSliceAngularInsetPath.ts -+++ b/node_modules/victory-native/src/pie/hooks/useSliceAngularInsetPath.ts -@@ -15,7 +15,7 @@ export const useSliceAngularInsetPath = ({ - angularInset, - slice, - }: SliceAngularInsetPathArgs) => { -- const [path, paint] = useMemo(() => { -+ const [path] = useMemo(() => { - const { radius, center, innerRadius } = slice; - - const path = Skia.Path.Make(); -@@ -72,14 +72,8 @@ export const useSliceAngularInsetPath = ({ - path.moveTo(center.x, center.y); - path.lineTo(endPoint.x, endPoint.y); - } -- -- // Create Paint for inset -- const insetPaint = Skia.Paint(); -- insetPaint.setColor(Skia.Color(angularInset.angularStrokeColor)); -- insetPaint.setStyle(PaintStyle.Stroke); -- insetPaint.setStrokeWidth(angularInset.angularStrokeWidth); -- return [path, insetPaint] as const; -+ return [path] as const; - }, [slice, angularInset]); - -- return [path, paint] as const; -+ return [path] as const; - }; diff --git a/patches/victory-native/victory-native+41.21.0+003+canvas-props.patch b/patches/victory-native/victory-native+41.21.0+003+canvas-props.patch deleted file mode 100644 index 7692cce6596d..000000000000 --- a/patches/victory-native/victory-native+41.21.0+003+canvas-props.patch +++ /dev/null @@ -1,248 +0,0 @@ -diff --git a/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts b/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts -index 6802428..b30fdac 100644 ---- a/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts -+++ b/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts -@@ -8,6 +8,7 @@ import type { ChartPressState, ChartPressStateInit } from "./hooks/useChartPress - import { type ChartTransformState } from "./hooks/useChartTransformState"; - import { type PanTransformGestureConfig, type PinchTransformGestureConfig } from "./utils/transformGestures"; - import { type ChartLayoutModeProps } from "../shared/ChartLayoutModeProps"; -+import { type ChartCanvasProps } from "../shared/ChartWrapper"; - export type CartesianActionsHandle = T extends ChartPressState ? S extends ChartPressStateInit ? { - handleTouch: (v: T, x: number, y: number) => void; - } : never : never; -@@ -67,6 +68,7 @@ type CartesianChartProps, XK extends key - x: InputFields[XK]; - y: Record; - }> | undefined>>; -+ canvasProps?: ChartCanvasProps; - } & ChartLayoutModeProps; - export declare function CartesianChart, XK extends keyof InputFields, YK extends keyof NumericalFields>({ transformState, children, ref, ...rest }: CartesianChartProps): React.JSX.Element; - export {}; -diff --git a/node_modules/victory-native/dist/cartesian/CartesianChart.js b/node_modules/victory-native/dist/cartesian/CartesianChart.js -index 839afbd..3b0837a 100644 ---- a/node_modules/victory-native/dist/cartesian/CartesianChart.js -+++ b/node_modules/victory-native/dist/cartesian/CartesianChart.js -@@ -73,7 +73,7 @@ function CartesianChart(_a) { - ); - } - exports.CartesianChart = CartesianChart; --function CartesianChartContent({ data, xKey, yKeys, padding, domainPadding, children, renderOutside = () => null, axisOptions, domain, chartPressState, chartPressConfig, gestureHandlerConfig, onChartBoundsChange, onScaleChange, gestureLongPressDelay = 100, xAxis, yAxis, frame, transformState, transformConfig, customGestures, actionsRef, viewport, ref, explicitSize, headless, }) { -+function CartesianChartContent({ data, xKey, yKeys, padding, domainPadding, children, renderOutside = () => null, axisOptions, domain, chartPressState, chartPressConfig, gestureHandlerConfig, onChartBoundsChange, onScaleChange, gestureLongPressDelay = 100, xAxis, yAxis, frame, transformState, transformConfig, customGestures, actionsRef, viewport, ref, explicitSize, headless, canvasProps, }) { - var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l; - const { size, hasMeasuredLayoutSize, onLayout, isHeadless } = (0, useChartCanvasSize_1.useChartCanvasSize)({ explicitSize, headless }); - const chartBoundsRef = React.useRef(undefined); -@@ -498,5 +498,5 @@ function CartesianChartContent({ data, xKey, yKeys, padding, domainPadding, chil - height: primaryYScale.range()[1] - Math.min(primaryYScale.range()[0], 0), - }}/>); - } -- return (); -+ return (); - } -diff --git a/node_modules/victory-native/dist/polar/PolarChart.d.ts b/node_modules/victory-native/dist/polar/PolarChart.d.ts -index 6cf2971..5b862da 100644 ---- a/node_modules/victory-native/dist/polar/PolarChart.d.ts -+++ b/node_modules/victory-native/dist/polar/PolarChart.d.ts -@@ -4,6 +4,7 @@ import type { ColorFields, InputFields, NumericalFields, StringKeyOf } from "../ - import { type ChartTransformState } from "../cartesian/hooks/useChartTransformState"; - import { type ChartExplicitSize } from "../shared/ChartExplicitSize"; - import { type ChartLayoutModeProps } from "../shared/ChartLayoutModeProps"; -+import { type ChartCanvasProps } from "../shared/ChartWrapper"; - type PolarChartBaseProps = { - onLayout: ({ nativeEvent: { layout } }: LayoutChangeEvent) => void; - hasMeasuredLayoutSize: boolean; -@@ -16,6 +17,7 @@ type PolarChartBaseProps = { - transformState?: ChartTransformState; - isHeadless: boolean; - explicitSize?: ChartExplicitSize; -+ canvasProps?: ChartCanvasProps; - }; - type PolarChartProps, LabelKey extends StringKeyOf>, ValueKey extends StringKeyOf>, ColorKey extends StringKeyOf>> = { - data: RawData[]; -diff --git a/node_modules/victory-native/dist/polar/PolarChart.js b/node_modules/victory-native/dist/polar/PolarChart.js -index d6416d0..c990be2 100644 ---- a/node_modules/victory-native/dist/polar/PolarChart.js -+++ b/node_modules/victory-native/dist/polar/PolarChart.js -@@ -34,7 +34,7 @@ const GestureHandler_1 = require("../shared/GestureHandler"); - const ChartWrapper_1 = require("../shared/ChartWrapper"); - const useChartCanvasSize_1 = require("../shared/useChartCanvasSize"); - const PolarChartBase = (props) => { -- const { containerStyle, canvasStyle, children, onLayout, hasMeasuredLayoutSize, canvasSize, transformState, isHeadless, explicitSize, } = props; -+ const { containerStyle, canvasStyle, children, onLayout, hasMeasuredLayoutSize, canvasSize, transformState, isHeadless, explicitSize, canvasProps, } = props; - const { width, height } = canvasSize; - const Bridge = (0, its_fine_1.useContextBridge)(); - let composed = react_native_gesture_handler_1.Gesture.Race(); -@@ -44,7 +44,7 @@ const PolarChartBase = (props) => { - const chartContent = ( - {hasMeasuredLayoutSize && children} - ); -- return ( {content}} gestureOverlay={isHeadless ? undefined : ()}/>); -+ return ( {content}} gestureOverlay={isHeadless ? undefined : ()}/>); - }; - const PolarChart = (props) => { - const { data, labelKey, colorKey, valueKey } = props; -diff --git a/node_modules/victory-native/dist/shared/ChartWrapper.d.ts b/node_modules/victory-native/dist/shared/ChartWrapper.d.ts -index 0688ab2..a878191 100644 ---- a/node_modules/victory-native/dist/shared/ChartWrapper.d.ts -+++ b/node_modules/victory-native/dist/shared/ChartWrapper.d.ts -@@ -1,8 +1,9 @@ - import * as React from "react"; - import { type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native"; --import { type CanvasRef } from "@shopify/react-native-skia"; -+import { type CanvasProps, type CanvasRef } from "@shopify/react-native-skia"; - import { type ChartExplicitSize } from "./ChartExplicitSize"; - import { type ChartCanvasSize } from "./chartCanvasSizeUtils"; -+export type ChartCanvasProps = Omit; - type ChartWrapperProps = { - isHeadless: boolean; - explicitSize?: ChartExplicitSize; -@@ -15,6 +16,7 @@ type ChartWrapperProps = { - containerStyle?: StyleProp; - canvasStyle?: StyleProp; - wrapCanvasContent?: (content: React.ReactNode) => React.ReactNode; -+ canvasProps?: ChartCanvasProps; - }; --export declare function ChartWrapper({ isHeadless, explicitSize, onLayout, hasMeasuredLayoutSize, canvasSize, canvasRef, chartContent, gestureOverlay, containerStyle, canvasStyle, wrapCanvasContent, }: ChartWrapperProps): React.JSX.Element; -+export declare function ChartWrapper({ isHeadless, explicitSize, onLayout, hasMeasuredLayoutSize, canvasSize, canvasRef, chartContent, gestureOverlay, containerStyle, canvasStyle, wrapCanvasContent, canvasProps, }: ChartWrapperProps): React.JSX.Element; - export {}; -diff --git a/node_modules/victory-native/dist/shared/ChartWrapper.js b/node_modules/victory-native/dist/shared/ChartWrapper.js -index 5aca3cc..8d9f9ee 100644 ---- a/node_modules/victory-native/dist/shared/ChartWrapper.js -+++ b/node_modules/victory-native/dist/shared/ChartWrapper.js -@@ -28,7 +28,7 @@ const React = __importStar(require("react")); - const react_native_1 = require("react-native"); - const react_native_skia_1 = require("@shopify/react-native-skia"); - const react_native_gesture_handler_1 = require("react-native-gesture-handler"); --function ChartWrapper({ isHeadless, explicitSize, onLayout, hasMeasuredLayoutSize, canvasSize, canvasRef, chartContent, gestureOverlay, containerStyle, canvasStyle, wrapCanvasContent, }) { -+function ChartWrapper({ isHeadless, explicitSize, onLayout, hasMeasuredLayoutSize, canvasSize, canvasRef, chartContent, gestureOverlay, containerStyle, canvasStyle, wrapCanvasContent, canvasProps, }) { - if (isHeadless) { - return {chartContent}; - } -@@ -42,7 +42,7 @@ function ChartWrapper({ isHeadless, explicitSize, onLayout, hasMeasuredLayoutSiz - : null, - containerStyle, - ]} onLayout={explicitSize ? undefined : onLayout}> -- - >; -+ canvasProps?: ChartCanvasProps; - } & ChartLayoutModeProps; - - export function CartesianChart< -@@ -188,6 +189,7 @@ function CartesianChartContent< - ref, - explicitSize, - headless, -+ canvasProps, - }: CartesianChartProps) { - const { size, hasMeasuredLayoutSize, onLayout, isHeadless } = - useChartCanvasSize({ explicitSize, headless }); -@@ -773,6 +775,7 @@ function CartesianChartContent< - canvasRef={canvasRef} - chartContent={chartContent} - gestureOverlay={gestureOverlay} -+ canvasProps={canvasProps} - /> - ); - } -diff --git a/node_modules/victory-native/src/polar/PolarChart.tsx b/node_modules/victory-native/src/polar/PolarChart.tsx -index 4229d7b..7665633 100644 ---- a/node_modules/victory-native/src/polar/PolarChart.tsx -+++ b/node_modules/victory-native/src/polar/PolarChart.tsx -@@ -22,7 +22,7 @@ import { - import { GestureHandler } from "../shared/GestureHandler"; - import { type ChartExplicitSize } from "../shared/ChartExplicitSize"; - import { type ChartLayoutModeProps } from "../shared/ChartLayoutModeProps"; --import { ChartWrapper } from "../shared/ChartWrapper"; -+import { ChartWrapper, type ChartCanvasProps } from "../shared/ChartWrapper"; - import { useChartCanvasSize } from "../shared/useChartCanvasSize"; - - type PolarChartBaseProps = { -@@ -34,6 +34,7 @@ type PolarChartBaseProps = { - transformState?: ChartTransformState; - isHeadless: boolean; - explicitSize?: ChartExplicitSize; -+ canvasProps?: ChartCanvasProps; - }; - - const PolarChartBase = ( -@@ -49,6 +50,7 @@ const PolarChartBase = ( - transformState, - isHeadless, - explicitSize, -+ canvasProps, - } = props; - const { width, height } = canvasSize; - const Bridge: ContextBridge = useContextBridge(); -@@ -77,6 +79,7 @@ const PolarChartBase = ( - canvasSize={canvasSize} - containerStyle={containerStyle} - canvasStyle={canvasStyle} -+ canvasProps={canvasProps} - chartContent={chartContent} - wrapCanvasContent={ - isHeadless ? undefined : (content) => {content} -diff --git a/node_modules/victory-native/src/shared/ChartWrapper.tsx b/node_modules/victory-native/src/shared/ChartWrapper.tsx -index ab149e9..b907381 100644 ---- a/node_modules/victory-native/src/shared/ChartWrapper.tsx -+++ b/node_modules/victory-native/src/shared/ChartWrapper.tsx -@@ -6,11 +6,16 @@ import { - type StyleProp, - type ViewStyle, - } from "react-native"; --import { Canvas, Group, type CanvasRef } from "@shopify/react-native-skia"; -+import { Canvas, Group, type CanvasProps, type CanvasRef } from "@shopify/react-native-skia"; - import { GestureHandlerRootView } from "react-native-gesture-handler"; - import { type ChartExplicitSize } from "./ChartExplicitSize"; - import { type ChartCanvasSize } from "./chartCanvasSizeUtils"; - -+export type ChartCanvasProps = Omit< -+ CanvasProps, -+ "children" | "ref" | "style" | "onLayout" -+>; -+ - type ChartWrapperProps = { - isHeadless: boolean; - explicitSize?: ChartExplicitSize; -@@ -23,6 +28,7 @@ type ChartWrapperProps = { - containerStyle?: StyleProp; - canvasStyle?: StyleProp; - wrapCanvasContent?: (content: React.ReactNode) => React.ReactNode; -+ canvasProps?: ChartCanvasProps; - }; - - export function ChartWrapper({ -@@ -37,6 +43,7 @@ export function ChartWrapper({ - containerStyle, - canvasStyle, - wrapCanvasContent, -+ canvasProps, - }: ChartWrapperProps) { - if (isHeadless) { - return {chartContent}; -@@ -58,6 +65,7 @@ export function ChartWrapper({ - onLayout={explicitSize ? undefined : onLayout} - > - void; ++ isHorizontal?: boolean; + }; + export declare function BarGroup({ betweenGroupPadding, withinGroupPadding, chartBounds, roundedCorners, children, onBarSizeChange, barWidth: customBarWidth, barCount, }: BarGroupProps): React.JSX.Element | null; + export declare namespace BarGroup { +diff --git a/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts b/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts +index 63b69bc..75641b1 100644 +--- a/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts ++++ b/node_modules/victory-native/dist/cartesian/hooks/useBarGroupPaths.d.ts +@@ -1,6 +1,6 @@ + import type { ChartBounds, PointsArray } from "../../types"; + import { type RoundedCorners } from "../../utils/createRoundedRectPath"; +-export declare const useBarGroupPaths: (points: PointsArray[], chartBounds: ChartBounds, betweenGroupPadding?: number, withinGroupPadding?: number, roundedCorners?: RoundedCorners, customBarWidth?: number, barCount?: number) => { ++export declare const useBarGroupPaths: (points: PointsArray[], chartBounds: ChartBounds, betweenGroupPadding?: number, withinGroupPadding?: number, roundedCorners?: RoundedCorners, customBarWidth?: number, barCount?: number, isHorizontal?: boolean) => { + barWidth: number; + groupWidth: number; + gapWidth: number; +diff --git a/node_modules/victory-native/src/cartesian/components/BarGroup.tsx b/node_modules/victory-native/src/cartesian/components/BarGroup.tsx +index f188ddc..526c5a7 100644 +--- a/node_modules/victory-native/src/cartesian/components/BarGroup.tsx ++++ b/node_modules/victory-native/src/cartesian/components/BarGroup.tsx +@@ -20,6 +20,7 @@ type BarGroupProps = { + groupWidth: number; + gapWidth: number; + }) => void; ++ isHorizontal?: boolean; + }; + + export function BarGroup({ +@@ -31,6 +32,7 @@ export function BarGroup({ + onBarSizeChange, + barWidth: customBarWidth, + barCount, ++ isHorizontal, + }: BarGroupProps) { + // Collect the bar props + const bars = [] as BarGroupBarProps[]; +@@ -49,6 +51,7 @@ export function BarGroup({ + roundedCorners, + customBarWidth, + barCount, ++ isHorizontal, + ); + + // Handle bar size change +diff --git a/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts b/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts +index fafab1a..07da62f 100644 +--- a/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts ++++ b/node_modules/victory-native/src/cartesian/hooks/useBarGroupPaths.ts +@@ -17,10 +17,11 @@ export const useBarGroupPaths = ( + roundedCorners?: RoundedCorners, + customBarWidth?: number, + barCount?: number, ++ isHorizontal?: boolean, + ) => { + const numGroups = points[0]?.length || 0; + +- const { yScale } = useCartesianChartContext(); ++ const { xScale, yScale } = useCartesianChartContext(); + + const { barWidth, groupWidth, gapWidth } = getBarGroupDimensions({ + chartBounds, +@@ -30,9 +31,11 @@ export const useBarGroupPaths = ( + barsPerGroup: points.length, + customBarWidth, + barCount, ++ isHorizontal, + }); + + const paths = React.useMemo(() => { ++ const baselineX = xScale(0); + const baselineY = yScale(0); + + return points.map((pointSet, i) => { +@@ -40,11 +43,13 @@ export const useBarGroupPaths = ( + pointSet.forEach((point) => { + const rect = getVerticalBarGroupRect({ + point, ++ baselineX, + baselineY, + barWidth, + groupWidth, + gapWidth, + barIndex: i, ++ isHorizontal, + }); + if (!rect) return; + +@@ -54,7 +59,7 @@ export const useBarGroupPaths = ( + rect.y, + rect.width, + rect.height, +- roundedCorners, ++ isHorizontal ? {topLeft: roundedCorners.bottomLeft, topRight: roundedCorners.topLeft, bottomLeft: roundedCorners.bottomRight, bottomRight: roundedCorners.topRight} : roundedCorners, + Number(point.yValue), + ); + builder.addRRect(nonUniformRoundedRect); +@@ -66,7 +71,7 @@ export const useBarGroupPaths = ( + }); + return builder.build(); + }); +- }, [barWidth, gapWidth, groupWidth, points, roundedCorners, yScale]); ++ }, [barWidth, gapWidth, groupWidth, points, roundedCorners, xScale, yScale, isHorizontal]); + + return { barWidth, groupWidth, gapWidth, paths }; + }; +diff --git a/node_modules/victory-native/src/cartesian/utils/getBarGroupDimensions.ts b/node_modules/victory-native/src/cartesian/utils/getBarGroupDimensions.ts +index 8ff9f1f..1e6f160 100644 +--- a/node_modules/victory-native/src/cartesian/utils/getBarGroupDimensions.ts ++++ b/node_modules/victory-native/src/cartesian/utils/getBarGroupDimensions.ts +@@ -2,13 +2,14 @@ import type { ChartBounds } from "../../types"; + import { getBarGroupDimensionsForAxis } from "./getBarGroupDimensionsForAxis"; + + export type GetBarGroupDimensionsArgs = { +- chartBounds: Pick; ++ chartBounds: ChartBounds; + betweenGroupPadding: number; + withinGroupPadding: number; + groupCount: number; + barsPerGroup: number; + customBarWidth?: number; + barCount?: number; ++ isHorizontal?: boolean, + }; + + export const getBarGroupDimensions = ({ +@@ -19,10 +20,11 @@ export const getBarGroupDimensions = ({ + barsPerGroup, + customBarWidth, + barCount, ++ isHorizontal, + }: GetBarGroupDimensionsArgs) => { + return getBarGroupDimensionsForAxis({ +- axisStart: chartBounds.left, +- axisEnd: chartBounds.right, ++ axisStart: isHorizontal ? chartBounds.bottom : chartBounds.left, ++ axisEnd: isHorizontal ? chartBounds.top : chartBounds.right, + betweenGroupPadding, + withinGroupPadding, + groupCount, +diff --git a/node_modules/victory-native/src/cartesian/utils/getVerticalBarGroupRect.ts b/node_modules/victory-native/src/cartesian/utils/getVerticalBarGroupRect.ts +index 37b9467..52f363d 100644 +--- a/node_modules/victory-native/src/cartesian/utils/getVerticalBarGroupRect.ts ++++ b/node_modules/victory-native/src/cartesian/utils/getVerticalBarGroupRect.ts +@@ -6,18 +6,22 @@ type Point = PointsArray[number]; + + export const getVerticalBarGroupRect = ({ + point, ++ baselineX, + baselineY, + barWidth, + groupWidth, + gapWidth, + barIndex, ++ isHorizontal, + }: { + point: Point; ++ baselineX: number; + baselineY: number; + barWidth: number; + groupWidth: number; + gapWidth: number; + barIndex: number; ++ isHorizontal?: boolean; + }): VerticalBarRect | null => { + const { x, y } = point; + +@@ -25,8 +29,19 @@ export const getVerticalBarGroupRect = ({ + return null; + } + ++ const offset = getBarGroupOffset({ groupWidth, barWidth, gapWidth, barIndex }); ++ ++ if (isHorizontal) { ++ return { ++ x: x, ++ y: y + offset, ++ width: baselineX - x, ++ height: barWidth, ++ } ++ } ++ + return { +- x: x + getBarGroupOffset({ groupWidth, barWidth, gapWidth, barIndex }), ++ x: x + offset, + y, + width: barWidth, + height: baselineY - y, diff --git a/patches/victory-native/victory-native+42.0.1+002+tickCount-4121.patch b/patches/victory-native/victory-native+42.0.1+002+tickCount-4121.patch new file mode 100644 index 000000000000..2df5629281c1 --- /dev/null +++ b/patches/victory-native/victory-native+42.0.1+002+tickCount-4121.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/victory-native/src/utils/tickHelpers.ts b/node_modules/victory-native/src/utils/tickHelpers.ts +index 100fb89..ea9fdca 100644 +--- a/node_modules/victory-native/src/utils/tickHelpers.ts ++++ b/node_modules/victory-native/src/utils/tickHelpers.ts +@@ -17,7 +17,7 @@ export const downsampleTicks = (tickValues: number[], tickCount: number) => { + } + + if (tickCount <= 0) { +- return []; ++ return tickValues; + } + + if (!Array.isArray(tickValues) || tickValues.length <= tickCount) { diff --git a/rock.config.mjs b/rock.config.mjs index b6f00dbe3970..42b54180ee2d 100644 --- a/rock.config.mjs +++ b/rock.config.mjs @@ -22,8 +22,7 @@ export default { bucket: 'ad-hoc-expensify-cash', region: 'us-east-1', acl: 'public-read', - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + // Credentials come from the AWS SDK default chain so AWS_SESSION_TOKEN from OIDC sessions is used publicAccess: isPublicAccess, }), bundler: useMetro ? pluginMetro() : pluginRepack(), diff --git a/server/libs/bundleWithRnStubs.ts b/server/libs/bundleWithRnStubs.ts new file mode 100644 index 000000000000..448daf25f504 --- /dev/null +++ b/server/libs/bundleWithRnStubs.ts @@ -0,0 +1,33 @@ +import assertBuildSuccess from '@server/libs/assertBuildSuccess'; +import createRnStubPlugin from '@server/plugins/rnStubPlugin'; +import {basename, dirname, join, resolve} from 'node:path'; + +type BundleWithRnStubsOptions = { + packageRoot: string; + entrypoint: string; + outFile: string; +}; + +/** Every output is written beside outFile, because the bundle loads CanvasKit's wasm from beside itself. */ +async function bundleWithRnStubs({packageRoot, entrypoint, outFile}: BundleWithRnStubsOptions): Promise { + const buildResult = await Bun.build({ + entrypoints: [entrypoint], + target: 'bun', + packages: 'bundle', + conditions: ['react-native'], + tsconfig: join(packageRoot, 'tsconfig.json'), + plugins: [createRnStubPlugin(resolve(packageRoot, '../stubs'))], + }); + + assertBuildSuccess(buildResult, `Failed to bundle ${entrypoint}`); + + if (buildResult.outputs.length === 0) { + throw new Error(`Bundled output of ${entrypoint} is missing`); + } + + for (const output of buildResult.outputs) { + await Bun.write(output.kind === 'entry-point' ? outFile : join(dirname(outFile), basename(output.path)), output); + } +} + +export default bundleWithRnStubs; diff --git a/server/victory-chart-renderer/scripts/dev.ts b/server/victory-chart-renderer/scripts/dev.ts index 28e91ab32ffd..f71b01ec13a8 100644 --- a/server/victory-chart-renderer/scripts/dev.ts +++ b/server/victory-chart-renderer/scripts/dev.ts @@ -2,52 +2,25 @@ * Development runner for the victory-chart-renderer CLI. * * victory-native is hoisted to the App repo root, so a bare `bun run src/cli.tsx` would - * resolve real react-native native sources that Bun cannot bundle. This script bundles - * src/cli.tsx with rnStubPlugin (redirecting RN imports to server/stubs), writes the - * bundle to .dev/cli.js, then executes it under the current Bun binary. + * resolve real react-native native sources that Bun cannot bundle. * * process.argv entries after this script are forwarded unchanged to the bundled CLI. * The child process cwd is the App repository root (two levels above this package). * * For a standalone executable instead of a Bun-run bundle, use build.ts. */ -import assertBuildSuccess from '@server/libs/assertBuildSuccess'; -import createRnStubPlugin from '@server/plugins/rnStubPlugin'; +import bundleWithRnStubs from '@server/libs/bundleWithRnStubs'; import {spawnSync} from 'node:child_process'; import {mkdirSync} from 'node:fs'; -import {basename, join, resolve} from 'node:path'; +import {join, resolve} from 'node:path'; const packageRoot = resolve(import.meta.dir, '..'); const repoRoot = resolve(packageRoot, '../..'); -const stubRoot = resolve(packageRoot, '../stubs'); -const tsconfigPath = join(packageRoot, 'tsconfig.json'); const outFile = resolve(packageRoot, '.dev/cli.js'); mkdirSync(join(packageRoot, '.dev'), {recursive: true}); -const buildResult = await Bun.build({ - entrypoints: [resolve(packageRoot, 'src/bootstrap.tsx')], - target: 'bun', - packages: 'bundle', - conditions: ['react-native'], - tsconfig: tsconfigPath, - plugins: [createRnStubPlugin(stubRoot)], -}); - -assertBuildSuccess(buildResult, 'Failed to bundle victory-chart-renderer CLI'); - -if (buildResult.outputs.length === 0) { - throw new Error('Bundled CLI output is missing'); -} - -const devDir = join(packageRoot, '.dev'); - -for (const output of buildResult.outputs) { - const outputName = basename(output.path); - const destName = output.kind === 'entry-point' ? 'cli.js' : outputName; - - await Bun.write(join(devDir, destName), output); -} +await bundleWithRnStubs({packageRoot, entrypoint: resolve(packageRoot, 'src/bootstrap.tsx'), outFile}); const runResult = spawnSync(process.execPath, [outFile, ...process.argv.slice(2)], { cwd: repoRoot, diff --git a/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend-truncated-labels.png b/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend-truncated-labels.png index a934e07abadb..3d1eb1a17626 100644 Binary files a/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend-truncated-labels.png and b/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend-truncated-labels.png differ diff --git a/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend.png b/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend.png index 7fbdc5cd98bf..088854b04d90 100644 Binary files a/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend.png and b/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend.png differ diff --git a/server/victory-chart-renderer/tests/plotBoundsDrift.test.ts b/server/victory-chart-renderer/tests/plotBoundsDrift.test.ts new file mode 100644 index 000000000000..7a889732edd3 --- /dev/null +++ b/server/victory-chart-renderer/tests/plotBoundsDrift.test.ts @@ -0,0 +1,70 @@ +import {afterAll, beforeAll, describe, expect, test} from 'bun:test'; + +import type {ChartBounds} from 'victory-native'; + +import bundleWithRnStubs from '@server/libs/bundleWithRnStubs'; +import {spawnSync} from 'node:child_process'; +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import {packageRoot} from './testUtils'; + +type PlotBounds = Pick; + +type ProbeOutput = { + predicted: PlotBounds; + reported: PlotBounds; +}; + +const runDir = mkdtempSync(join(tmpdir(), 'vcr-plot-bounds-')); +const bundlePath = join(runDir, 'plotBoundsProbe.js'); + +function hasPlotBounds(value: unknown): value is PlotBounds { + return typeof value === 'object' && value !== null && 'left' in value && typeof value.left === 'number' && 'right' in value && typeof value.right === 'number'; +} + +function parseProbeOutput(raw: string): ProbeOutput { + const parsed: unknown = JSON.parse(raw); + + if (typeof parsed !== 'object' || parsed === null || !('predicted' in parsed) || !('reported' in parsed) || !hasPlotBounds(parsed.predicted) || !hasPlotBounds(parsed.reported)) { + throw new Error(`Plot bounds probe printed unexpected output:\n${raw}`); + } + + return {predicted: parsed.predicted, reported: parsed.reported}; +} + +let probeOutput: ProbeOutput; + +describe('cartesian plot bounds', () => { + beforeAll(async () => { + await bundleWithRnStubs({ + packageRoot, + entrypoint: fileURLToPath(import.meta.resolve('./probes/plotBoundsProbe.tsx')), + outFile: bundlePath, + }); + + const runResult = spawnSync(process.execPath, [bundlePath], { + cwd: resolve(packageRoot, '../..'), + encoding: 'utf8', + }); + + if (runResult.status !== 0) { + throw new Error(`Plot bounds probe failed:\n${runResult.stderr}\n${runResult.stdout}`); + } + + probeOutput = parseProbeOutput(runResult.stdout); + }, 120_000); + + afterAll(() => { + rmSync(runDir, {recursive: true, force: true}); + }); + + test('should predict the plot bounds victory-native reports', () => { + // Given a real chart laid out the way the vertical bar chart is, whose padding and axes the line chart shares, drawn headless by the probe + // When its reported plot bounds are compared with the ones predicted from its width + // Then they match, so a victory-native upgrade that moves the plot fails here instead of misplacing labels + expect(probeOutput.predicted).toEqual(probeOutput.reported); + }); +}); diff --git a/server/victory-chart-renderer/tests/probes/plotBoundsProbe.tsx b/server/victory-chart-renderer/tests/probes/plotBoundsProbe.tsx new file mode 100644 index 000000000000..f4fd7087213d --- /dev/null +++ b/server/victory-chart-renderer/tests/probes/plotBoundsProbe.tsx @@ -0,0 +1,9 @@ +/* + * Runs as a bundled child process rather than inside the test process, because victory-native resolves + * react-native, which only loads through the stubs rnStubPlugin substitutes at bundle time. + */ +import initSkiaForCli from '../../src/initSkiaForCli'; + +// Skia must be initialized before the chart module and its transitive imports are evaluated. +await initSkiaForCli(); +await import('./plotBoundsProbeMain'); diff --git a/server/victory-chart-renderer/tests/probes/plotBoundsProbeMain.tsx b/server/victory-chart-renderer/tests/probes/plotBoundsProbeMain.tsx new file mode 100644 index 000000000000..7fc9af2e003c --- /dev/null +++ b/server/victory-chart-renderer/tests/probes/plotBoundsProbeMain.tsx @@ -0,0 +1,63 @@ +import {VERTICAL_BAR_DOMAIN_PADDING} from '@components/Charts/barChartConstants'; +import {getCartesianPlotBounds} from '@components/Charts/utils'; +import VictoryTheme, {CARTESIAN_Y_AXIS, CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING} from '@components/Charts/VictoryTheme'; + +import type {ChartBounds} from 'victory-native'; + +import {drawOffscreen, makeOffscreenSurface} from '@shopify/react-native-skia/lib/module/headless'; +import {CartesianChart} from 'victory-native'; + +const CHART_WIDTH = 400; + +const Y_AXIS_LABEL_WIDTH = 37; + +const CHART_PADDING_RIGHT = Y_AXIS_LABEL_WIDTH + GLYPH_PADDING; + +const DATA = [ + {x: 0, y: 120}, + {x: 1, y: 80}, + {x: 2, y: 45}, +]; + +const captured: {bounds: ChartBounds | null} = {bounds: null}; + +const chartElement = ( + + {({chartBounds}) => { + // The bounds reach onChartBoundsChange from an effect, which the offscreen draw never flushes, so they + // are read here instead. Both come from the same value the chart computes for its own layout. + captured.bounds = chartBounds; + return null; + }} + +); + +using surface = makeOffscreenSurface(CHART_WIDTH, CHART_CONTENT_MIN_HEIGHT); +using image = await drawOffscreen(surface, chartElement); + +if (!image) { + throw new Error('Skia failed to draw the probe chart'); +} + +if (!captured.bounds) { + throw new Error('CartesianChart never reported its bounds'); +} + +const {left, right} = getCartesianPlotBounds(CHART_WIDTH, CHART_PADDING_RIGHT); + +console.log(JSON.stringify({predicted: {left, right}, reported: {left: captured.bounds.left, right: captured.bounds.right}})); diff --git a/src/App.tsx b/src/App.tsx index 8a1ed222ff7b..5bf33e75d410 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import '../wdyr'; import {ActionSheetAwareScrollViewProvider} from './components/ActionSheetAwareScrollView'; import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; import {AvatarTooltipsProvider} from './components/Avatar/tooltips/AvatarTooltipContext'; +import {dismissPinnedChartTooltip} from './components/Charts/utils/pinnedChartTooltip'; import ColorSchemeWrapper from './components/ColorSchemeWrapper'; import ComposeProviders from './components/ComposeProviders'; import {CurrencyListContextProvider} from './components/CurrencyListContextProvider'; @@ -97,6 +98,7 @@ function App() { >; }; -// Merge supports many more ATS providers than the one below. Add them here as they are rolled out. +// Merge supports many more ATS providers than the ones below. Add them here as they are rolled out. const MERGE_ATS_PROVIDERS = { + ashby: { + displayName: 'Ashby', + iconUrl: 'https://merge-api-public.s3.amazonaws.com/media/Ashby_Square_Logo.png', + approverFields: [CONST.MERGE.ATS_APPROVER_FIELD.RECRUITER, CONST.MERGE.ATS_APPROVER_FIELD.HIRING_MANAGER, CONST.MERGE.ATS_APPROVER_FIELD.RECRUITING_COORDINATOR], + }, greenhouse: { displayName: 'Greenhouse', iconUrl: 'https://merge-api-public.s3.amazonaws.com/media/Greenhouse_Square_Logo.jpg', + approverFields: [CONST.MERGE.ATS_APPROVER_FIELD.RECRUITER, CONST.MERGE.ATS_APPROVER_FIELD.RECRUITING_COORDINATOR], }, } as const satisfies Record; diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f5609e5d163d..2600eb78d58e 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -126,7 +126,9 @@ type UnofferedOnboardingAccountingConnection = | typeof CONST.POLICY.CONNECTIONS.NAME.DUALENTRY | typeof CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE // Covered by the Microsoft Dynamics option, and still behind the BUSINESS_CENTRAL beta. - | typeof CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL; + | typeof CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL + // Still behind the ZOHO_BOOKS beta. + | typeof CONST.POLICY.CONNECTIONS.NAME.ZOHO_BOOKS; type UnhandledAccountingConnection = Exclude, OnboardingAccountingOption | UnofferedOnboardingAccountingConnection>; @@ -351,6 +353,9 @@ const CONST = { POPOVER_DROPDOWN_WIDTH: 334, POPOVER_DROPDOWN_MIN_HEIGHT: 0, POPOVER_DROPDOWN_MAX_HEIGHT: 416, + + /** Smallest height a dropdown popover shrinks to before it stops being usable */ + POPOVER_DROPDOWN_MIN_USABLE_HEIGHT: 200, POPOVER_REPORT_SUBMIT_TO_CONTENT_HEIGHT: 416, POPOVER_MENU_MAX_HEIGHT: 496, POPOVER_MENU_MAX_HEIGHT_MOBILE: 432, @@ -621,13 +626,23 @@ const CONST = { UNSUPPORTED: 'unsupported', MULTIPLE_RULES: 'multipleRules', UNINTELLIGIBLE: 'unintelligible', + AGENT_RULE: 'agentRule', + IMPOSSIBLE: 'impossible', FAILED: 'failed', }, + IMPOSSIBLE_REASON: { + NO_TRIGGER: 'noTrigger', + NO_CAPABILITY: 'noCapability', + MISSING_DATA: 'missingData', + RETROACTIVE: 'retroactive', + HISTORY_LIMIT: 'historyLimit', + }, RULE_TYPE: { REQUIRE_FIELDS: 'requireFields', FLAG_FOR_REVIEW: 'flagForReview', RESTRICT_CARD_SPEND: 'restrictCardSpend', EXPENSE_DEFAULTS: 'expenseDefaults', + CATEGORY_TAX_RATE: 'categoryTaxRate', }, }, @@ -970,6 +985,226 @@ const CONST = { ALLOWED_FILE_TYPES: ['pdf', 'jpg', 'jpeg', 'png'], FILE_LIMIT: 1, }, + DYNAMIC_FORM: { + /** A select with more options than this opens a searchable list, so it sits on its own page */ + LARGE_SELECT_MIN_OPTIONS: 10, + + /** Most questions sharing a page when pages are built from a flat list of fields */ + MAX_FIELDS_PER_PAGE: 4, + + /** Pages from which the flow shows a step indicator on its own */ + STEP_INDICATOR_MIN_PAGES: 3, + + /** Route segment of the confirmation page, which no page may take */ + CONFIRMATION_PAGE_KEY: 'confirm', + }, + GLOBAL_REIMBURSEMENT_PROVIDER: { + WISE: 'WISE', + CORPAY: 'CORPAY', + }, + WISE: { + /** Server-derived state of the Wise onboarding */ + ONBOARDING_STATUS: { + IN_REVIEW: 'IN_REVIEW', + ACTION_REQUIRED: 'ACTION_REQUIRED', + APPROVED: 'APPROVED', + NOT_APPROVED: 'NOT_APPROVED', + }, + /** Wise's state for one KYC requirement */ + KYC_REQUIREMENT_STATE: { + NOT_PROVIDED: 'NOT_PROVIDED', + IN_REVIEW: 'IN_REVIEW', + VERIFIED: 'VERIFIED', + }, + /** Who asked for the requirement: us before Submit, Wise after */ + KYC_REQUIREMENT_REQUESTED_BY: { + EXPENSIFY: 'EXPENSIFY', + WISE: 'WISE', + }, + /** Wise's KYC requirement types we collect */ + KYC_REQUIREMENT: { + ID_DOCUMENT: 'ID_DOCUMENT', + }, + /** Wise's companyType values, the legal form */ + COMPANY_TYPE: { + LIMITED: 'LIMITED', + PARTNERSHIP: 'PARTNERSHIP', + SOLE_TRADER: 'SOLE_TRADER', + LIMITED_BY_GUARANTEE: 'LIMITED_BY_GUARANTEE', + LIMITED_LIABILITY_COMPANY: 'LIMITED_LIABILITY_COMPANY', + FOR_PROFIT_CORPORATION: 'FOR_PROFIT_CORPORATION', + NON_PROFIT_CORPORATION: 'NON_PROFIT_CORPORATION', + LIMITED_PARTNERSHIP: 'LIMITED_PARTNERSHIP', + LIMITED_LIABILITY_PARTNERSHIP: 'LIMITED_LIABILITY_PARTNERSHIP', + GENERAL_PARTNERSHIP: 'GENERAL_PARTNERSHIP', + SOLE_PROPRIETORSHIP: 'SOLE_PROPRIETORSHIP', + PRIVATE_LIMITED_COMPANY: 'PRIVATE_LIMITED_COMPANY', + PUBLIC_LIMITED_COMPANY: 'PUBLIC_LIMITED_COMPANY', + TRUST: 'TRUST', + OTHER: 'OTHER', + }, + /** Wise's companyRole values */ + COMPANY_ROLE: { + OWNER: 'OWNER', + DIRECTOR: 'DIRECTOR', + OTHER: 'OTHER', + }, + /** Wise's industryCategories values, keys only */ + INDUSTRY_CATEGORIES: [ + 'GROWING_OF_CROPS_AGRICULTURE', + 'GROWING_OF_TOBACCO_SPICES_MEDICINAL_PLANTS', + 'LIVESTOCK_FARMING_ANIMAL_PRODUCTION', + 'FORESTRY_LOGGING', + 'COMMERCIAL_FISHING_AQUACULTURE_SERVICES', + 'EXTRACTION_OF_FOSSIL_FUELS', + 'EXTRACTION_OF_METALS_GOLD_SILVER', + 'QUARRYING_OF_CONSTRUCTION_INDUSTRIAL_MINERALS', + 'EXTRACTION_OF_PRECIOUS_STONES_SPECIALISED_MINERALS', + 'MANUFACTURE_OF_FOOD_PRODUCTS_PREPARED_MEALS', + 'MANUFACTURE_OF_ALCOHOLIC_BEVERAGES_MALT', + 'MANUFACTURE_OF_SOFT_DRINKS_BOTTLED_WATERS', + 'MANUFACTURE_OF_TOBACCO_PRODUCTS', + 'MANUFACTURE_OF_TEXTILES_APPAREL_FOOTWEAR', + 'MANUFACTURE_OF_FUR_LEATHER_GOODS', + 'MANUFACTURE_OF_WOOD_WOOD_BASED_PRODUCTS', + 'MANUFACTURE_OF_PAPER_PAPER_PRODUCTS', + 'PRINTING_MEDIA_REPRODUCTION_SERVICES', + 'MANUFACTURE_OF_CHEMICALS_PETROLEUM_REFINED_PRODUCTS', + 'MANUFACTURE_OF_COATINGS_INKS_PIGMENTS', + 'MANUFACTURE_OF_PLASTIC_RUBBER_PRODUCTS', + 'HOUSEHOLD_BEAUTY_AGRICULTURAL_CHEMICALS', + 'MANUFACTURE_OF_PHARMACEUTICALS_MEDICINES', + 'MANUFACTURE_PROCESSING_OF_GLASS_PRODUCTS', + 'MANUFACTURE_OF_CERAMICS_CLAY_PRODUCTS', + 'MANUFACTURE_OF_CEMENT_CONCRETE_STONE_PRODUCTS', + 'MANUFACTURE_OF_BASIC_IRON_STEEL_NON_FERROUS_METALS', + 'PRODUCTION_OF_PRECIOUS_METALS', + 'PROCESSING_OF_NUCLEAR_FUEL', + 'CASTING_OF_METALS_FOUNDRY_SERVICES', + 'MANUFACTURE_OF_STRUCTURAL_METAL_PRODUCTS', + 'MANUFACTURE_OF_METAL_TANKS_BOILERS_STEAM_GENERATORS', + 'MANUFACTURE_OF_WEAPONS_AMMUNITION_MILITARY_VEHICLES', + 'METAL_TREATMENT_PRECISION_MACHINING', + 'MANUFACTURE_OF_TOOLS_HARDWARE_METAL_PACKAGING', + 'MANUFACTURE_OF_COMPUTERS_ELECTRONICS_MEDICAL_OPTICAL_INSTRUMENTS', + 'MANUFACTURE_OF_JEWELLERY_WATCHES', + 'MANUFACTURE_OF_ELECTRICAL_EQUIPMENT_DOMESTIC_APPLIANCES', + 'GENERAL_PURPOSE_MACHINERY_MECHANICAL_POWER', + 'SPECIAL_PURPOSE_INDUSTRIAL_PROCESS_MACHINERY', + 'MANUFACTURE_OF_MOTOR_VEHICLES_TRAILERS_LIGHT_TRANSPORT', + 'MANUFACTURE_OF_MARINE_RAIL_AEROSPACE_EQUIPMENT', + 'MANUFACTURE_OF_FURNITURE_MATTRESSES_CLEANING_TOOLS', + 'MANUFACTURE_OF_MUSICAL_SPORTS_LEISURE_MISCELLANEOUS_GOODS', + 'MANUFACTURE_OF_MEDICAL_DENTAL_INSTRUMENTS_AND_SUPPLIES', + 'REPAIR_INSTALLATION_OF_INDUSTRIAL_EQUIPMENT', + 'ELECTRIC_POWER_GENERATION_TRANSMISSION_SUPPLY', + 'MANUFACTURE_DISTRIBUTION_TRADE_OF_GAS', + 'STEAM_AIR_CONDITIONING_SUPPLY', + 'WATER_SUPPLY_SEWERAGE_WASTE_MANAGEMENT', + 'DEVELOPMENT_CONSTRUCTION_OF_BUILDINGS', + 'CIVIL_ENGINEERING_INFRASTRUCTURE_CONSTRUCTION', + 'ELECTRICAL_PLUMBING_SPECIALISED_BUILDING_INSTALLATION', + 'BUILDING_COMPLETION_FINISHING_TRADES', + 'ROOFING_SCAFFOLDING_ROOFING_OTHER_SPECIALISED_CONSTRUCTION_ACTIVITIES', + 'AUTOMOTIVE_SALES_PARTS_SERVICES', + 'WHOLESALE_OF_PHARMACEUTICALS_MEDICAL_SUPPLIES', + 'WHOLESALE_OF_WATCHES_AND_JEWELLERY', + 'WHOLESALE_OF_INDUSTRIAL_FUELS_CHEMICALS', + 'WHOLESALE_OF_METALS_METAL_ORES', + 'COMMISSION_AGENTS_GENERAL_TRADE_INTERMEDIARIES', + 'WHOLESALE_OF_FRESH_PERISHABLE_FOODS', + 'WHOLESALE_OF_CROPS_SEEDS_FLOWERS', + 'WHOLESALE_OF_LIVESTOCK_ANIMAL_BY_PRODUCTS', + 'WHOLESALE_OF_TEXTILES_CLOTHING', + 'WHOLESALE_OF_JUICES_TEA_COFFEE_SPICES', + 'WHOLESALE_OF_ALCOHOL', + 'GENERAL_FOOD_BEVERAGE_TOBACCO_WHOLESALE', + 'WHOLESALE_OF_ELECTRONICS_HOUSEHOLD_GOODS', + 'WHOLESALE_OF_HEAVY_INDUSTRIAL_AGRICULTURAL_MACHINERY', + 'WHOLESALE_OF_BUILDING_PLUMBING_TRADE_SUPPLIES', + 'GENERALIST_MISCELLANEOUS_INDUSTRIAL_WHOLESALE', + 'GROCERY_SUPERMARKETS_SPECIALIST_FOOD_RETAIL', + 'ALCOHOL_TOBACCO_RETAIL', + 'RETAIL_SALE_OF_WATCHES_AND_JEWELLERY', + 'RETAIL_OF_ART_ANTIQUES_SPECIALISED_GOODS', + 'PHARMACIES_HEALTH_BEAUTY_RETAIL', + 'AUTOMOTIVE_FUEL_RETAIL', + 'RETAIL_OF_ELECTRONICS_DIGITAL_EQUIPMENT', + 'RETAIL_OF_HOME_GARDEN_DIY', + 'BOOKS_SPORTS_RECREATIONAL_GOODS_RETAIL', + 'RETAIL_OF_CLOTHING_FOOTWEAR', + 'E_COMMERCE_DIRECT_TO_CONSUMER_RETAIL', + 'PUBLIC_MASS_TRANSIT_SCHEDULED', + 'PRIVATE_AVIATION_CHARTER_SERVICES', + 'TAXIS_PRIVATE_HIRE_VEHICLES', + 'FREIGHT_TRANSPORT_LOGISTICS_LAND_WATER_AIR_PIPELINE', + 'SPACE_TRANSPORT_SATELLITE_LAUNCH_SERVICES', + 'WAREHOUSING_CARGO_HANDLING_TRANSPORT_SUPPORT', + 'POSTAL_COURIER_SERVICES', + 'HOTELS_SHORT_STAY_ACCOMMODATION', + 'RESTAURANTS_CATERING_BEVERAGE_SERVICES', + 'MEDIA_CONTENT_PUBLISHING', + 'SOFTWARE_DEVELOPMENT_PUBLISHING', + 'FILM_RADIO_TV_MUSIC_PRODUCTION', + 'TELECOMMUNICATIONS_NETWORKS_SERVICES', + 'IT_CONSULTANCY_TECHNICAL_SUPPORT_SERVICES', + 'DATA_HOSTING_WEB_PORTALS', + 'NEWS_AGENCIES_SPECIALISED_INFORMATION_SERVICES', + 'BANKING_CREDIT_FINANCIAL_INTERMEDIATION', + 'HOLDING_COMPANIES', + 'INVESTMENT_FUNDS_BROKERAGE_ASSET_MANAGEMENT', + 'INSURANCE_REINSURANCE', + 'PENSION_FUNDING_RETIREMENT_SERVICES', + 'REAL_ESTATE_OWNERSHIP_LETTING', + 'REAL_ESTATE_AGENCIES', + 'PROPERTY_FACILITIES_MANAGEMENT_SERVICES', + 'LEGAL_ACCOUNTING_MANAGEMENT_CONSULTANCY', + 'ARCHITECTURE_ENGINEERING_TECHNICAL_CONSULTING', + 'BUSINESS_STRATEGY_MANAGEMENT', + 'MARKET_RESEARCH_PR_MEDIA_REPRESENTATION', + 'ADVERTISING_DESIGN_PHOTOGRAPHY', + 'SCIENTIFIC_RESEARCH_EXPERIMENTAL_DEVELOPMENT', + 'TRANSLATION_INTERPRETATION_SERVICES', + 'VETERINARY_SERVICES', + 'VEHICLE_TRANSPORT_EQUIPMENT_LEASING', + 'LEASING_OF_INTELLECTUAL_PROPERTY_IP', + 'INDUSTRIAL_CONSTRUCTION_OFFICE_EQUIPMENT_LEASING', + 'CONSUMER_LEISURE_HOUSEHOLD_GOODS_RENTAL', + 'EMPLOYMENT_RECRUITMENT_HR_PROVISION', + 'SECURITY_INVESTIGATION_SURVEILLANCE_SERVICES', + 'BUSINESS_PROCESS_ADMINISTRATIVE_SUPPORT', + 'CALL_CENTRES_DEBT_RECOVERY_SERVICES', + 'CONFERENCES_EXHIBITIONS_TRADE_SHOWS', + 'TRAVEL_AGENCIES_TOUR_OPERATORS_TOURIST_SERVICES', + 'FACILITIES_MANAGEMENT_CLEANING', + 'PUBLIC_ADMINISTRATION_REGULATION_DEFENCE', + 'JUSTICE_PUBLIC_SAFETY_SOCIAL_SECURITY', + 'SCHOOLS_COLLEGES_HIGHER_EDUCATION', + 'SPECIALISED_TRAINING_EDUCATIONAL_SUPPORT', + 'CLINICAL_HEALTHCARE_HOSPITALS_MEDICAL_PRACTICES', + 'RESIDENTIAL_CARE_NURSING_FACILITIES', + 'SOCIAL_WORK_CHILDCARE_COMMUNITY_SERVICES', + 'GAMBLING_AND_BETTING_ACTIVITIES', + 'ARTISTIC_CREATION_VENUES', + 'PERFORMING_ARTS', + 'HERITAGE_MUSEUMS_NATURE_PRESERVATION', + 'THEME_PARKS_LEISURE_ATTRACTIONS', + 'PROFESSIONAL_SPORTS_TEAMS_ATHLETES', + 'SPORTS_FACILITIES_FITNESS_CENTRES', + 'BUSINESS_PROFESSIONAL_LABOUR_ASSOCIATIONS', + 'RELIGIOUS_ORGANISATIONS_SPIRITUAL_ACTIVITIES', + 'POLITICAL_ORGANISATIONS_ADVOCACY', + 'REPAIR_OF_COMPUTERS_ELECTRONICS_PERSONAL_GOODS', + 'FUNERAL_CARE_RELATED_ACTIVITIES', + 'WELLBEING_PERSONAL_SERVICES', + 'HAIRDRESSING_BEAUTY_TREATMENT', + 'LAUNDRY_DRY_CLEANING_SERVICES', + 'REPAIR_OF_WATCHES_CLOCKS_JEWELLERY', + 'DOMESTIC_EMPLOYERS_HOUSEHOLD_STAFF', + 'RESIDENTS_PROPERTY_MANAGEMENT', + 'DIPLOMATIC_BODIES', + ], + }, NON_USD_BANK_ACCOUNT: { PURPOSE_OF_TRANSACTION_ID: 'Intercompany_Payment', CURRENT_USER_KEY: 'currentUser', @@ -1174,7 +1409,10 @@ const CONST = { BULK_EDIT: 'bulkEdit', BULK_SUBMIT_APPROVE_PAY: 'bulkSubmitApprovePay', VENDOR_MATCHING: 'vendorMatching', + INDIRECT_TAG_UPLOADS: 'indirectTagUploads', BUSINESS_CENTRAL: 'businessCentral', + ZOHO_BOOKS: 'zohoBooks', + EXACT_ONLINE: 'exactOnline2026', COMMUTER_EXCLUSIONS: 'commuterExclusions', COMMUTER_EXCLUSIONS_ARRANGEMENTS: 'commuterExclusionsArrangements', MULTIPLE_APPROVERS: 'multipleApprovers', @@ -1189,11 +1427,13 @@ const CONST = { REPORT_MERGE: 'reportMerge', INSIGHTS_PAGE: 'insightsPage', INSIGHTS_COMPARE: 'insightsCompare', + NL_FILTERS: 'nlFilters', PAYMENT_HISTORY: 'paymentHistory', UNIFIED_CONNECTIONS: 'unifiedConnections', SPEND_FOOTER_SELECTORS: 'spendFooterSelectors', SUPPORT_TICKET: 'supportTicket', ANCHORED_FIELD_DROPDOWNS: 'anchoredFieldDropdowns', + COMPANY_AGENTS: 'companyAgents', RESIZABLE_TABLE_COLUMNS: 'resizableTableColumns', DESCRIBE_RULE: 'describeRule', }, @@ -1690,6 +1930,7 @@ const CONST = { HOLD: 'hold', DOWNLOAD_PDF: 'downloadPDF', DOWNLOAD_RECEIPTS: 'downloadReceipts', + SHARE: 'share', PRINT: 'print', CHANGE_WORKSPACE: 'changeWorkspace', CHANGE_APPROVER: 'changeApprover', @@ -1853,6 +2094,7 @@ const CONST = { MARKED_REIMBURSED: 'MARKEDREIMBURSED', // OldDot Action MARK_REIMBURSED_FROM_INTEGRATION: 'ACTIONMARKEDREIMBURSEDFROMINTEGRATION', // OldDot Action MERGED_WITH_CASH_TRANSACTION: 'MERGEDWITHCASHTRANSACTION', + MERGED_EXPENSE_DELETED: 'MERGEDEXPENSEDELETED', MODIFIED_EXPENSE: 'MODIFIEDEXPENSE', CONCIERGE_AUTO_MATCH_VENDOR: 'CONCIERGEAUTOMATCHVENDOR', CONCIERGE_AUTO_SELECT_DISTANCE_RATE: 'CONCIERGEAUTOSELECTDISTANCERATE', @@ -1918,6 +2160,10 @@ const CONST = { ADD_AGENT_RULE: 'POLICYCHANGELOG_ADD_AGENT_RULE', UPDATE_AGENT_RULE: 'POLICYCHANGELOG_UPDATE_AGENT_RULE', DELETE_AGENT_RULE: 'POLICYCHANGELOG_DELETE_AGENT_RULE', + ADD_OFFICE_LOCATION: 'POLICYCHANGELOG_ADD_OFFICE_LOCATION', + UPDATE_OFFICE_LOCATION: 'POLICYCHANGELOG_UPDATE_OFFICE_LOCATION', + DELETE_OFFICE_LOCATION: 'POLICYCHANGELOG_DELETE_OFFICE_LOCATION', + UPDATE_EMPLOYEE_OFFICE_LOCATION: 'POLICYCHANGELOG_UPDATE_EMPLOYEE_OFFICE_LOCATION', ADD_RULE: 'POLICYCHANGELOG_ADD_RULE', UPDATE_RULE: 'POLICYCHANGELOG_UPDATE_RULE', REMOVE_RULE: 'POLICYCHANGELOG_REMOVE_RULE', @@ -2224,6 +2470,7 @@ const CONST = { WAITING_TO_APPROVE: 'waitingToApprove', WAITING_TO_PAY: 'waitingToPay', WAITING_FOR_PAYMENT: 'waitingForPayment', + WAITING_FOR_CANCELLED_REIMBURSEMENT: 'waitingForCancelledReimbursement', WAITING_TO_EXPORT: 'waitingToExport', SUBMITTING_TO_SELF: 'submittingToSelf', REJECTED_REPORT: 'rejectedReport', @@ -2366,6 +2613,10 @@ const CONST = { SHOW_HOVER_PREVIEW_DELAY: 270, SHOW_HOVER_PREVIEW_ANIMATION_DURATION: 250, ACTIVITY_INDICATOR_TIMEOUT: 10000, + /** A load that started but has sent no progress event for this long is stalled (see useImageLoadStall) */ + IMAGE_LOAD_STALL_TIMEOUT: 15000, + /** Budget for a load with no progress events at all (web , no Content-Length): a ceiling, not a failure claim */ + IMAGE_LOAD_CEILING_TIMEOUT: 10000, GET_INITIAL_URL_TIMEOUT: 10000, MIN_SMOOTH_SCROLL_EVENT_THROTTLE: 16, }, @@ -2378,6 +2629,7 @@ const CONST = { BREADCRUMB_CATEGORY_MFA: 'mfa', BREADCRUMB_CATEGORY_3DS_NAVIGATION: '3ds.navigation', BREADCRUMB_CATEGORY_3DS_AUTHORIZE: '3ds.authorize', + BREADCRUMB_CATEGORY_SAML_AUTH_SESSION: 'saml.auth_session', BREADCRUMB_CATEGORY_BOOTSPLASH_FLOW: 'bootsplash.flow', BREADCRUMB_CATEGORY_MODULE_INIT: 'module.init', BREADCRUMB_CATEGORY_SCRIPT_LOAD: 'script.load', @@ -2407,6 +2659,7 @@ const CONST = { BUILD_TYPE: 'build_type', /** Written by Sentry's `thirdPartyErrorFilterIntegration` on events whose every frame is foreign to our bundle, read by the noise filters. */ THIRD_PARTY_CODE: 'third_party_code', + TEST_CRASH: 'test_crash', }, EXPENSE_ERROR_TYPE: { REPORT_CREATION_FAILED: 'report_creation_failed', @@ -2550,6 +2803,9 @@ const CONST = { // report list, so durations that include the openApp wait can be excluded from render measurements. ATTRIBUTE_SKELETON_SHOWN: 'skeleton_shown', ATTRIBUTE_IS_PRELOADED: 'is_preloaded', + // Leaving a tab is paid inside the navigate-to-inbox-tab span, so the same tap costs more from some tabs. + ATTRIBUTE_SOURCE_TAB: 'source_tab', + ATTRIBUTE_MS_SINCE_PRELOAD: 'ms_since_preload', // The tap also kicked off an OpenReport request, so exclude these from render-only comparisons. ATTRIBUTE_WAITED_ON_OPEN_REPORT: 'waited_on_open_report', ATTRIBUTE_WAS_LIST_EMPTY: 'was_list_empty', @@ -3522,6 +3778,7 @@ const CONST = { }, ATS_APPROVER_FIELD: { RECRUITER: 'recruiter', + HIRING_MANAGER: 'hiringManager', RECRUITING_COORDINATOR: 'coordinator', }, ATS_FILTER_TYPE: { @@ -3636,6 +3893,7 @@ const CONST = { }, AUTH_TYPE: { TBA: 'tba', + OAUTH2: 'oauth2', }, STEP_KEYS: { install: 'installBundle', @@ -4154,6 +4412,15 @@ const CONST = { NON_REIMBURSABLE_ACCOUNT: 'nonReimbursableAccount', DEFAULT_VENDOR_ID: 'defaultVendorID', PAYMENT_METHOD_CODE: 'paymentMethodCode', + AUTO_SYNC: 'autoSync', + ACCOUNTING_METHOD: 'accountingMethod', + EXPORT_TO_NEXT_OPEN_PERIOD: 'exportToNextOpenPeriod', + SYNC_REIMBURSED_REPORTS: 'syncReimbursedReports', + REIMBURSEMENT_BANK_ACCOUNT_ID: 'reimbursementBankAccountID', + SYNC_EXPENSIFY_CARD_SETTLEMENTS: 'syncExpensifyCardSettlements', + SETTLEMENTS_BANK_ACCOUNT_ID: 'settlementsBankAccountID', + POSTING_MODE: 'postingMode', + AUTO_CREATE_ENTITIES: 'autoCreateEntities', }, BUSINESS_CENTRAL_EXPORT_DATE: { @@ -4598,6 +4865,10 @@ const CONST = { // Pixel distance used to park the growl fully offscreen before it slides in. It only needs to // exceed the growl's height + margins; the exact value isn't tied to a measured dimension. OFFSCREEN_OFFSET: 255, + POSITION: { + TOP: 'top', + BOTTOM_RIGHT: 'bottomRight', + }, }, LOCALES, @@ -4683,8 +4954,10 @@ const CONST = { POLICY_FEATURE: { OVERVIEW: 'overview', MEMBERS: 'members', + AGENTS: 'agents', ROOMS: 'rooms', ASSIGN_ELEVATED_ROLES: 'assignElevatedRoles', + ALL_REPORTS: 'allReports', WORKFLOWS: 'workflows', WORKFLOWS_APPROVALS: 'workflowsApprovals', WORKFLOWS_PAYMENTS: 'workflowsPayments', @@ -4781,6 +5054,7 @@ const CONST = { ID_FAKE: '_FAKE_', SECONDARY_ACTIONS: { IMPORT_SPREADSHEET: 'importSpreadsheet', + APPEND_CUSTOM_TAG_LIST: 'appendCustomTagList', DOWNLOAD_CSV: 'downloadCSV', SETTINGS: 'settings', EXPORT: 'export', @@ -4791,11 +5065,9 @@ const CONST = { REMOVE: 'remove', MAKE_MEMBER: 'makeMember', MAKE_ADMIN: 'makeAdmin', - MAKE_AUDITOR: 'makeAuditor', - MAKE_GUEST: 'makeGuest', - MAKE_CARD_ADMIN: 'makeCardAdmin', - MAKE_PEOPLE_ADMIN: 'makePeopleAdmin', - MAKE_PAYMENTS_ADMIN: 'makePaymentsAdmin', + CHANGE_ROLE: 'changeRole', + CHANGE_OFFICE_LOCATION: 'changeOfficeLocation', + CHANGE_WORK_ARRANGEMENT: 'changeWorkArrangement', }, BULK_ACTION_TYPES: { DELETE: 'delete', @@ -4924,6 +5196,7 @@ const CONST = { }, EXPENSE_REPORT_RULES: { PREVENT_SELF_APPROVAL: 'preventSelfApproval', + PREVENT_PAYOUT_NON_REIMBURSABLE_REPORTS: 'preventPayoutNonReimbursableReports', MAX_EXPENSE_AGE: 'maxExpenseAge', }, PROHIBITED_EXPENSES: { @@ -4946,6 +5219,13 @@ const CONST = { FIXED_DISTANCE: 'fixedDistance', DISABLED: 'disabled', }, + OFFICE_LOCATION_FIELD: { + NAME: 'name', + ADDRESS: 'address', + IS_DEFAULT: 'isDefault', + }, + // The officeID that assigns a member to the company address, which isn't keyed in the workspace's officeLocations + COMPANY_ADDRESS_OFFICE_ID: '1', WORK_ARRANGEMENT: { OFFICE_BASED: 'officeBased', NO_REGULAR_WORKPLACE: 'noRegularWorkplace', @@ -4983,6 +5263,7 @@ const CONST = { CAMPFIRE: 'campfire', ZOHO_BOOKS: 'zohoBooks', BUSINESS_CENTRAL: 'businessCentral', + EXACT_ONLINE: 'exactOnline', GUSTO: 'gusto', ZENEFITS: 'zenefits', MERGE_HR: 'merge_hris', @@ -5021,6 +5302,7 @@ const CONST = { campfire: 'Campfire', zohoBooks: 'Zoho Books', businessCentral: 'Dynamics 365 Business Central', + exactOnline: 'Exact Online', gusto: 'Gusto', billCom: 'Bill.com', zenefits: 'TriNet', @@ -5046,6 +5328,7 @@ const CONST = { this.NAME.RILLET, this.NAME.DUALENTRY, this.NAME.CAMPFIRE, + this.NAME.ZOHO_BOOKS, this.NAME.BUSINESS_CENTRAL, ] as const; }, @@ -5062,6 +5345,7 @@ const CONST = { [this.NAME.RILLET]: 'rillet', [this.NAME.DUALENTRY]: 'dualentry', [this.NAME.CAMPFIRE]: 'campfire', + [this.NAME.ZOHO_BOOKS]: 'zohoBooks', [this.NAME.BUSINESS_CENTRAL]: 'businessCentral', } as const; }, @@ -6034,7 +6318,6 @@ const CONST = { TIME_FORMAT: /^\d{2}:\d{2} [AP]M$/, DATE_TIME_FORMAT: /^\d{2}-\d{2} \d{2}:\d{2} [AP]M$/, ILLEGAL_FILENAME_CHARACTERS: /\/|<|>|\*|"|:|#|\?|\\|\|/g, - ENCODE_PERCENT_CHARACTER: /%(25)+/g, INVISIBLE_CHARACTERS_GROUPS: /[\p{C}\p{Z}]/gu, OTHER_INVISIBLE_CHARACTERS: /[\u3164\u115f\u1160\uffa0\u2800]/g, SHORT_MENTION_HTML: /(.*?)<\/mention-short>/g, @@ -6137,6 +6420,8 @@ const CONST = { FORM_CHARACTER_LIMIT: 50, STANDARD_LENGTH_LIMIT: 100, STANDARD_LIST_ITEM_LIMIT: 12, + // The inline category/tag pickers in the expense table are small popovers, so they show the search input sooner than the full-page pickers. + INLINE_EDIT_PICKER_SEARCH_INPUT_THRESHOLD: 8, SEARCH_BAR_THRESHOLD: 3, // Number of approval workflow cards rendered before the "Load more" affordance on Workspace → Workflows. WORKFLOW_APPROVALS_INITIAL_BATCH: 5, @@ -6773,6 +7058,9 @@ const CONST = { // that should be removed from the copied contents in the getHTMLOfSelection() method SELECTION_SCRAPER_HIDDEN_ELEMENT: 'selection-scrapper-hidden-element', INNER_BOX_SHADOW_ELEMENT: 'inner-box-shadow-element', + COPYABLE_TEXT_ELEMENT: 'copyable-text', + COPYABLE_ROW_ELEMENT: 'copyable-row', + COPYABLE_ROW_CONTINUATION_ELEMENT: 'copyable-row-continuation', MODERATION: { MODERATOR_DECISION_PENDING: 'pending', MODERATOR_DECISION_PENDING_HIDE: 'pendingHide', @@ -7127,8 +7415,11 @@ const CONST = { SINGLE_MARKER_ZOOM: 15, DEFAULT_COORDINATE: [-122.4021, 37.7911] as [number, number], STYLE_URL: 'mapbox://styles/expensify/cllcoiqds00cs01r80kp34tmq', - ANIMATION_DURATION_ON_CENTER_ME: 1000, + ANIMATION_DURATION_DEFAULT: 1000, + ANIMATION_DURATION_LONG: 1500, GPS_ROUTE_ANIMATION_DURATION_MS: 1000, + CAMERA_ANIMATION_MODE: 'flyTo', + FLY_TO_SPEED: 4, }, ONYX_UPDATE_TYPES: { HTTPS: 'https', @@ -7443,6 +7734,12 @@ const CONST = { REPORT_FIELD_TITLE_FIELD_ID: 'text_title', + /** How many report fields are shown side by side in the report view on a wide layout */ + REPORT_FIELDS_PER_ROW: 3, + + /** Below this many options, a report field list is short enough to scan without a search input */ + REPORT_FIELD_LIST_SEARCH_THRESHOLD: 8, + MOBILE_PAGINATION_SIZE: 15, WEB_PAGINATION_SIZE: 30, @@ -7879,6 +8176,11 @@ const CONST = { search: true, reportView: true, }, + HELD: { + column: this.TABLE_COLUMNS.HELD, + search: true, + reportView: true, + }, MCC: {column: this.TABLE_COLUMNS.MCC, search: true, reportView: true}, TAX_CODE: { column: this.TABLE_COLUMNS.TAX_CODE, @@ -8174,6 +8476,7 @@ const CONST = { ORIGINAL_AMOUNT: 'originalamount', REIMBURSABLE: 'reimbursable', BILLABLE: 'billable', + HELD: 'held', TAX_RATE: 'taxrate', TOTAL_AMOUNT: 'amount', ATTENDEES: 'attendees', @@ -8308,6 +8611,7 @@ const CONST = { IN: 'in', SUBMITTED: 'submitted', APPROVED: 'approved', + ANY_APPROVAL: 'anyApproval', PAID: 'paid', EXPORTED: 'exported', POSTED: 'posted', @@ -8322,6 +8626,8 @@ const CONST = { ASSIGNEE: 'assignee', REIMBURSABLE: 'reimbursable', BILLABLE: 'billable', + DECLINED: 'declined', + HELD: 'held', POLICY_ID: 'policyID', ACTION: 'action', HAS: 'has', @@ -8401,6 +8707,7 @@ const CONST = { IN: 'in', SUBMITTED: 'submitted', APPROVED: 'approved', + ANY_APPROVAL: 'any-approval', PAID: 'paid', EXPORTED: 'exported', POSTED: 'posted', @@ -8412,6 +8719,8 @@ const CONST = { ASSIGNEE: 'assignee', REIMBURSABLE: 'reimbursable', BILLABLE: 'billable', + DECLINED: 'declined', + HELD: 'held', ACTION: 'action', HAS: 'has', PURCHASE_AMOUNT: 'purchase-amount', @@ -8452,6 +8761,7 @@ const CONST = { [this.TABLE_COLUMNS.ORIGINAL_AMOUNT]: 'purchase-amount', [this.TABLE_COLUMNS.REIMBURSABLE]: 'reimbursable', [this.TABLE_COLUMNS.BILLABLE]: 'billable', + [this.TABLE_COLUMNS.HELD]: 'held', [this.TABLE_COLUMNS.TAX_RATE]: 'tax-rate', [this.TABLE_COLUMNS.TOTAL_AMOUNT]: 'amount', [this.TABLE_COLUMNS.TOTAL]: 'total', @@ -9299,14 +9609,6 @@ const CONST = { // (ONYXKEYS.NVP_SUBMIT_MIGRATION_MODAL_SHOWN), but the API expects the unprefixed name. SUBMIT_MIGRATION_MODAL_SHOWN_NVP_NAME: 'submitMigrationModalShown', - AI_FEATURES_PROMO_MODAL: 'aiFeaturesPromoModal', - - AI_FEATURES_PROMO_LEARN_MORE_URLS: { - SPEND_ANALYSIS: 'https://help.expensify.com/articles/new-expensify/concierge-ai/How-Concierge-Analyzes-Spend', - EXPENSE_ASSISTANT: 'https://help.expensify.com/articles/new-expensify/concierge-ai/Expense-Assistant', - BUILD_AGENTS: 'https://help.expensify.com/articles/new-expensify/ai-agents/Create-Agent-Rules', - }, - BASE_LIST_ITEM_TEST_ID: 'base-list-item-', SELECTION_BUTTON_TEST_ID: 'selection-button-', PRODUCT_TRAINING_TOOLTIP_NAMES: { @@ -9333,6 +9635,8 @@ const CONST = { INBOX_TAB_STALE_UNREAD_MONTHS: 3, CHANGE_POLICY_TRAINING_MODAL: 'changePolicyModal', AGENTS_RULES_BANNER: 'agentsRulesBanner', + WISE_GLOBAL_REIMBURSEMENTS_WALLET_BANNER: 'wiseGlobalReimbursementsWalletBanner', + WISE_GLOBAL_REIMBURSEMENTS_WORKSPACE_BANNER: 'wiseGlobalReimbursementsWorkspaceBanner', SMART_BANNER_HEIGHT: 152, NAVIGATION_TESTS: { @@ -9432,6 +9736,7 @@ const CONST = { TYPE_TRANSFER_TO: 'transfer_to', TYPE_TRANSFER_FAILED: 'transfer_failed', TYPE_TRANSFER_OLD: 'transfer_old', + TYPE_INVOICING: 'invoicing2018', }, PAYMENT_HISTORY: { STATE: { @@ -9542,11 +9847,14 @@ const CONST = { /** Narrowest drag width. Low enough to hide most content, but wide enough to keep the column's edge reachable. */ MIN_WIDTH: 48, - /** Width of the invisible drag strip centred on a column's edge. */ - HANDLE_HIT_WIDTH: 12, - - /** Width of the line shown while a column's edge is dragged. */ + /** Width of the line shown while a column's edge is dragged, and of the grip shown while its heading is hovered. */ INDICATOR_WIDTH: 2, + + /** Share of the heading's height the grip spans, centred vertically. */ + GRIP_HEIGHT_RATIO: 0.8, + + /** How long, in ms, the grip takes to stretch into the line once its edge is pressed. */ + INDICATOR_MORPH_DURATION: 150, }, }, @@ -9571,6 +9879,14 @@ const CONST = { CTA: 'AgentsRulesBanner-CTA', DISMISS: 'AgentsRulesBanner-Dismiss', }, + WISE_GLOBAL_REIMBURSEMENTS_WALLET_BANNER: { + CTA: 'WiseGlobalReimbursementsWalletBanner-CTA', + DISMISS: 'WiseGlobalReimbursementsWalletBanner-Dismiss', + }, + WISE_GLOBAL_REIMBURSEMENTS_WORKSPACE_BANNER: { + CTA: 'WiseGlobalReimbursementsWorkspaceBanner-CTA', + DISMISS: 'WiseGlobalReimbursementsWorkspaceBanner-Dismiss', + }, PRODUCT_MARKETING_WINDOW: { CTA: 'ProductMarketingWindow-CTA', DISMISS: 'ProductMarketingWindow-Dismiss', @@ -9629,6 +9945,9 @@ const CONST = { SEND_BUTTON: 'AttachmentModal-SendButton', IMAGE_ZOOM: 'AttachmentModal-ImageZoom', }, + IMAGE_LOAD: { + RETRY_BUTTON: 'ImageLoadTimeoutNotice-RetryButton', + }, MODAL: { DISMISS_DIALOG: 'Modal-DismissDialog', }, @@ -9786,6 +10105,7 @@ const CONST = { MODERATION_BUTTON: 'Report-ModerationButton', MONEY_REQUEST_REPORT_ACTIONS_LIST_SELECT_ALL: 'MoneyRequestReportActionsList-SelectAll', MONEY_REQUEST_REPORT_TRANSACTION_ITEM: 'MoneyRequestReportTransactionItem', + REPORT_FIELD_DELETE_BUTTON: 'Report-ReportFieldDeleteButton', REPORT_ACTION_AVATAR: 'Report-ReportActionAvatar', PARTICIPANTS_ROW: 'Report-ParticipantsRow', ROOM_MEMBERS_ROW: 'Report-RoomMembersRow', @@ -9839,6 +10159,11 @@ const CONST = { EXPORT_FILE: 'MoreMenu-ExportFile', DOWNLOAD_PDF: 'MoreMenu-DownloadPDF', DOWNLOAD_RECEIPTS: 'MoreMenu-DownloadReceipts', + SHARE: 'MoreMenu-Share', + SHARE_REPORT: 'MoreMenu-ShareReport', + SHARE_CODE: 'MoreMenu-ShareCode', + REFER_FRIEND: 'MoreMenu-ReferFriend', + COPY_URL: 'MoreMenu-CopyURL', PRINT: 'MoreMenu-Print', CLOSE_PDF_MODAL: 'MoreMenu-ClosePDFModal', SUBMIT: 'MoreMenu-Submit', @@ -9887,6 +10212,7 @@ const CONST = { HOURS_FIELD: 'RequestConfirmationList-HoursField', TIME_RATE_FIELD: 'RequestConfirmationList-TimeRateField', CATEGORY_FIELD: 'RequestConfirmationList-CategoryField', + ADD_CATEGORY_BUTTON: 'RequestConfirmationList-AddCategoryButton', DATE_FIELD: 'RequestConfirmationList-DateField', TAG_FIELD: 'RequestConfirmationList-TagField', TAX_RATE_FIELD: 'RequestConfirmationList-TaxRateField', @@ -9965,6 +10291,9 @@ const CONST = { GETTING_STARTED_ROW: 'HomePage-GettingStartedRow', GETTING_STARTED_FOOTER_HELP: 'HomePage-GettingStartedFooterHelp', }, + CATEGORY_PICKER_MODAL: { + BACK_BUTTON: 'CategoryPickerModal-BackButton', + }, CALENDAR_PICKER: { YEAR_PICKER: 'CalendarPicker-YearPicker', MONTH_PICKER: 'CalendarPicker-MonthPicker', @@ -10106,6 +10435,7 @@ const CONST = { INITIAL: { PROFILE: 'WorkspaceInitial-Profile', MEMBERS: 'WorkspaceInitial-Members', + AGENTS: 'WorkspaceInitial-Agents', ROOMS: 'WorkspaceInitial-Rooms', REPORTS: 'WorkspaceInitial-Reports', ACCOUNTING: 'WorkspaceInitial-Accounting', @@ -10135,6 +10465,10 @@ const CONST = { DESCRIPTION: 'WorkspaceOverview-Description', CURRENCY: 'WorkspaceOverview-Currency', ADDRESS: 'WorkspaceOverview-Address', + OFFICE_LOCATION: 'WorkspaceOverview-OfficeLocation', + COMPANY_ADDRESS_OFFICE_LOCATION: 'WorkspaceOverview-CompanyAddressOfficeLocation', + ADD_OFFICE_LOCATION: 'WorkspaceOverview-AddOfficeLocation', + DELETE_OFFICE_LOCATION: 'WorkspaceOverview-DeleteOfficeLocation', PLAN_TYPE: 'WorkspaceOverview-PlanType', SHARE: 'WorkspaceOverview-Share', CUSTOM_RULES: 'WorkspaceOverview-CustomRules', @@ -10195,6 +10529,7 @@ const CONST = { }, ACCOUNTING: { SETUP_BUTTON: 'WorkspaceAccounting-SetupButton', + EXACT_ONLINE_WAITLIST_BUTTON: 'WorkspaceAccounting-ExactOnlineWaitlistButton', CARD_SECTION_ADD_BUTTON: 'WorkspaceAccounting-CardSectionAddButton', THREE_DOT_MENU: 'WorkspaceAccounting-ThreeDotMenu', }, @@ -10238,6 +10573,7 @@ const CONST = { SPEND_RULE_SECTION_ITEM: 'WorkspaceRules-SpendRuleSectionItem', SPEND_RULE_SAVE: 'WorkspaceRules-SpendRuleSave', SPEND_RULE_DESCRIBE: 'WorkspaceRules-SpendRuleDescribe', + DESCRIBE_RULE_TRY_SUGGESTION: 'WorkspaceRules-DescribeRuleTrySuggestion', SPEND_RULE_DELETE: 'WorkspaceRules-SpendRuleDelete', SPEND_RULE_RESTRICTION_TYPE: 'WorkspaceRules-SpendRuleRestrictionType', AGENT_RULE_ITEM: 'WorkspaceRules-AgentRuleItem', @@ -10350,6 +10686,9 @@ const CONST = { UPLOAD_FILE: { REMOVE_BUTTON: 'UploadFile-RemoveButton', }, + LIST_FIELD: { + REMOVE_BUTTON: 'ListField-RemoveButton', + }, BANK_ACCOUNT: { DATA_SECURE_LINK: 'BankAccount-DataSecureLink', }, @@ -10394,6 +10733,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', @@ -10443,6 +10783,7 @@ const CONST = { AUTHENTICATE_PAYMENT: 'SettingsSubscription-AuthenticatePayment', VIEW_PAYMENT_HISTORY: 'SettingsSubscription-ViewPaymentHistory', PAYMENT_HISTORY_ROW: 'SettingsSubscription-PaymentHistoryRow', + VIEW_BILLING_RECEIPT: 'SettingsSubscription-ViewBillingReceipt', REQUEST_REFUND: 'SettingsSubscription-RequestRefund', CANCEL_SUBSCRIPTION: 'SettingsSubscription-CancelSubscription', }, @@ -10509,10 +10850,6 @@ const CONST = { CLOSE_BUTTON: 'FeatureTraining-CloseButton', BACK_BUTTON: 'FeatureTraining-BackButton', }, - AI_FEATURES_PROMO_MODAL: { - CONFIRM_BUTTON: 'AIFeaturesPromoModal-ConfirmButton', - HELP_BUTTON: 'AIFeaturesPromoModal-HelpButton', - }, }, /** @@ -10529,6 +10866,11 @@ const CONST = { }, AGENTS: { + OWNER_TYPE: { + PERSON: 'person', + WORKSPACE: 'workspace', + DOMAIN: 'domain', + }, BULK_ACTION_TYPES: { DELETE: 'delete', }, diff --git a/src/CONST/runtimeConfigured.ts b/src/CONST/runtimeConfigured.ts index b392e0db9024..2643a0cb5f1f 100644 --- a/src/CONST/runtimeConfigured.ts +++ b/src/CONST/runtimeConfigured.ts @@ -73,9 +73,11 @@ const CONST_RUNTIME: ConstRuntime = { SCREENS.VALIDATE_LOGIN, SCREENS.MIGRATED_USER_WELCOME_MODAL.DYNAMIC_ROOT, SCREENS.SUBMIT_PLAN_WELCOME_MODAL.DYNAMIC_ROOT, - SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT, SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP, + SCREENS.WORKSPACE.MEMBERS_CHANGE_OFFICE_LOCATION, + SCREENS.WORKSPACE.MEMBERS_CHANGE_WORK_ARRANGEMENT, + SCREENS.WORKSPACE.MEMBERS_ROLE, SCREENS.PRE_MOUNT_BUFFER, ...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION), ], diff --git a/src/CONST/runtimeDefaults.ts b/src/CONST/runtimeDefaults.ts index 3385d9db55b5..61707304937e 100644 --- a/src/CONST/runtimeDefaults.ts +++ b/src/CONST/runtimeDefaults.ts @@ -96,9 +96,9 @@ const CONST_RUNTIME_DEFAULTS: ConstRuntime = { 'ValidateLogin', 'Dynamic_MigratedUserWelcomeModal_Root', 'Dynamic_SubmitPlanWelcomeModal_Root', - 'Dynamic_AIFeaturesPromoModal_Root', 'Money_Request_Step_Scan', 'Members_Move_To_Group', + 'Workspace_Members_Role', 'PreMountBuffer', 'Multifactor_Authentication_Validate_Code', 'Multifactor_Authentication_Outcome_Success', diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index 704fa8b8ed02..4bc68f46ac72 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -141,6 +141,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { conciergeReportID, introSelected, guidedSetupAndTourStatus?.isSelfTourViewed, + guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, reportNameValuePairsRef.current, conciergePersonalDetailsRef.current, @@ -188,6 +189,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { conciergeReportID, introSelected, guidedSetupAndTourStatus?.isSelfTourViewed, + guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, reportNameValuePairsRef.current, conciergePersonalDetailsRef.current, diff --git a/src/DelegateAccessHandler.tsx b/src/DelegateAccessHandler.tsx index 22eb3734f3f2..cdb315f0f023 100644 --- a/src/DelegateAccessHandler.tsx +++ b/src/DelegateAccessHandler.tsx @@ -3,14 +3,15 @@ import {useEffect, useRef} from 'react'; import CONST from './CONST'; import useIsLoadingAppRecovery from './hooks/useIsLoadingAppRecovery'; import useOnyx from './hooks/useOnyx'; -import {disconnect} from './libs/actions/Delegate'; +import usePrevious from './hooks/usePrevious'; +import {disconnect, resumeSelfRemovalAsDelegate} from './libs/actions/Delegate'; import Log from './libs/Log'; import ONYXKEYS from './ONYXKEYS'; -import {accountIDSelector, emailSelector} from './selectors/Session'; +import {accountIDSelector, emailSelector, isDelegateSessionSelector} from './selectors/Session'; /** * Component that does not render anything but isolates delegate-access–related Onyx subscriptions - * (account, stashedCredentials, stashedSession, hasLoadedApp, isLoadingApp) + * (account, stashedCredentials, stashedSession, pendingSelfCopilotRemoval, hasLoadedApp, isLoadingApp) * from the root Expensify component so that changes to these keys do not * re-render the entire navigation tree. */ @@ -24,19 +25,37 @@ function DelegateAccessHandler() { const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [sessionAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); + const [isDelegateSession] = useOnyx(ONYXKEYS.SESSION, {selector: isDelegateSessionSelector}); + const [pendingSelfCopilotRemoval] = useOnyx(ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL); useIsLoadingAppRecovery(); // Disconnect delegate when the delegate is no longer in the delegates list + const delegate = account?.delegatedAccess?.delegate; + const isDelegateInDelegatesList = account?.delegatedAccess?.delegates?.some((d) => d.email === delegate); + const wasDelegateInDelegatesList = usePrevious(isDelegateInDelegatesList); useEffect(() => { - if (!account?.delegatedAccess?.delegate) { + if (!delegate) { return; } - if (account?.delegatedAccess?.delegates?.some((d) => d.email === account?.delegatedAccess?.delegate)) { + if (isDelegateInDelegatesList) { + return; + } + // If the delegate was never on the list then do nothing. + // This is the case for company-owned agents where managers have implicit delegate access without being in the delegates list. + if (!wasDelegateInDelegatesList) { return; } disconnect({stashedCredentials, stashedSession}); - }, [account?.delegatedAccess?.delegates, account?.delegatedAccess?.delegate, stashedCredentials, stashedSession]); + }, [delegate, isDelegateInDelegatesList, wasDelegateInDelegatesList, stashedCredentials, stashedSession]); + + // Finish a self-removal from a delegator's copilots that a kill interrupted + useEffect(() => { + if (!pendingSelfCopilotRemoval || !sessionAccountID) { + return; + } + resumeSelfRemovalAsDelegate({delegatorEmail: pendingSelfCopilotRemoval, isDelegateSession: !!isDelegateSession}); + }, [pendingSelfCopilotRemoval, isDelegateSession, sessionAccountID]); // Log delegate mismatch after the app has loaded useEffect(() => { @@ -45,7 +64,7 @@ function DelegateAccessHandler() { } const delegators = account?.delegatedAccess?.delegators ?? []; const hasDelegatorMatch = !!sessionEmail && delegators.some((delegator) => delegator.email === sessionEmail); - const shouldLogMismatch = hasDelegatorMatch && !!account?.primaryLogin && !account?.delegatedAccess?.delegate; + const shouldLogMismatch = hasDelegatorMatch && !!account?.primaryLogin && !delegate; if (!shouldLogMismatch) { return; } @@ -55,7 +74,7 @@ function DelegateAccessHandler() { delegatorsCount: delegators.length, hasPrimaryLogin: !!account?.primaryLogin, }); - }, [account?.delegatedAccess?.delegate, account?.delegatedAccess?.delegators, account?.primaryLogin, hasLoadedApp, isLoadingApp, sessionAccountID, sessionEmail]); + }, [delegate, account?.delegatedAccess?.delegators, account?.primaryLogin, hasLoadedApp, isLoadingApp, sessionAccountID, sessionEmail]); return null; } diff --git a/src/Expensify.tsx b/src/Expensify.tsx index ef75ca168830..f0c6470b93f5 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -38,6 +38,7 @@ import PushNotification from './libs/Notification/PushNotification'; import {endSpan, getSpan, startSpan} from './libs/telemetry/activeSpans'; import {startBootsplashMonitor} from './libs/telemetry/bootsplashTelemetry'; import {scheduleInitialDatabaseSizeMeasurement} from './libs/telemetry/databaseSizeTracker'; +import reportFullyDrawn from './libs/telemetry/reportFullyDrawn'; import {cleanupTelemetryTrackers, initializeTelemetryTrackers} from './libs/telemetry/TelemetrySynchronizer'; import Visibility from './libs/Visibility'; import ONYXKEYS from './ONYXKEYS'; @@ -209,6 +210,7 @@ function Expensify() { setSplashScreenState(CONST.BOOT_SPLASH_STATE.HIDDEN); endSpan(CONST.TELEMETRY.SPAN_OD_ND_TRANSITION); endSpan(CONST.TELEMETRY.SPAN_APP_STARTUP); + reportFullyDrawn(); endSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.ROOT); endSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.SPLASH_HIDER); scheduleInitialDatabaseSizeMeasurement(); diff --git a/src/NAVIGATORS.ts b/src/NAVIGATORS.ts index c23768d32e51..815a4752439e 100644 --- a/src/NAVIGATORS.ts +++ b/src/NAVIGATORS.ts @@ -9,7 +9,6 @@ export default { FEATURE_TRAINING_MODAL_NAVIGATOR: 'FeatureTrainingModalNavigator', MIGRATED_USER_MODAL_NAVIGATOR: 'MigratedUserModalNavigator', SUBMIT_PLAN_MODAL_NAVIGATOR: 'SubmitPlanModalNavigator', - AI_FEATURES_PROMO_MODAL_NAVIGATOR: 'AIFeaturesPromoModalNavigator', TEST_DRIVE_DEMO_NAVIGATOR: 'TestDriveDemoNavigator', REPORTS_SPLIT_NAVIGATOR: 'ReportsSplitNavigator', SETTINGS_SPLIT_NAVIGATOR: 'SettingsSplitNavigator', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 67aed9f0ba9c..f593064e3fe4 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -176,6 +176,9 @@ const ONYXKEYS = { SESSION: 'session', STASHED_SESSION: 'stashedSession', + /** Delegator the acting copilot is removing themselves from, kept until the removal is queued so a kill can't lose it */ + PENDING_SELF_COPILOT_REMOVAL: 'pendingSelfCopilotRemoval', + BETAS: 'betas', BETA_CONFIGURATION: 'betaConfiguration', @@ -193,6 +196,9 @@ const ONYXKEYS = { /** NVP keys */ + /** UTC signup timestamp for the account's Exact Online waitlist membership */ + NVP_EXACT_ONLINE_WAITLIST: 'nvp_expensify_exactOnlineWaitlist', + /** This NVP contains list of at most 5 recent attendees */ NVP_RECENT_ATTENDEES: 'nvp_expensify_recentAttendees', @@ -556,6 +562,11 @@ const ONYXKEYS = { /** Selected domain member account IDs for the move-to-group operation */ RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE: 'domainMembersSelectedForMove', + /** Account IDs of the workspace members selected for a bulk office location or work arrangement change */ + RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT: 'workspaceMembersSelectedForBulkEdit', + /** The workspace members selected for the bulk role change, with the workspace they were selected on */ + RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE: 'workspaceMembersSelectedForRoleChange', + // The theme setting set by the user in preferences. // This can be either "light", "dark", "system", "light-contrast", "dark-contrast" or "system-contrast" PREFERRED_THEME: 'nvp_preferredTheme', @@ -810,6 +821,9 @@ const ONYXKEYS = { /** Corpay fields to be used in the bank account creation setup */ CORPAY_FIELDS: 'corpayFields', + /** A fresh link to Wise's embedded KYC page */ + RAM_ONLY_WISE_KYC_REVIEW_EMBEDDED_LINK: 'wiseKYCReviewEmbeddedLink', + /** The user's session that will be preserved when using imported state */ PRESERVED_USER_SESSION: 'preservedUserSession', @@ -828,6 +842,9 @@ const ONYXKEYS = { /** Signals the UI to show the Enable Global Reimbursements modal when a pay attempt fails because the workspace USD VBBA is not set up on Corpay */ RAM_ONLY_CORPAY_PAY_MODAL: 'corpayPayModal', + /** Answers to sensitive DynamicForm fields, such as SSNs, carried between the pages of one visit to the form instead of saved to its draft */ + RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS: 'dynamicFormSensitiveAnswers', + /** Stores the information about the state of side panel */ NVP_SIDE_PANEL: 'nvp_sidePanel', @@ -837,6 +854,9 @@ const ONYXKEYS = { /** Stores the user's app review prompt state and response */ NVP_APP_REVIEW: 'nvp_appReview', + /** Device-local record of when this client last prompted for an app review. Never written by the server. */ + APP_REVIEW_LAST_PROMPT: 'appReviewLastPrompt', + /** Stores the onboarding RHP variant for A/B/C testing */ NVP_ONBOARDING_RHP_VARIANT: 'nvp_onboardingRHPVariant', @@ -957,6 +977,7 @@ const ONYXKEYS = { POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_', POLICY_TAGS: 'policyTags_', POLICY_VENDORS: 'policyVendors_', + POLICY_MEMBER_BANK_ACCOUNTS: 'policyMemberBankAccounts_', POLICY_RECENTLY_USED_TAGS: 'nvp_recentlyUsedTags_', POLICY_RECENTLY_USED_DESTINATIONS: 'nvp_recentlyUsedDestinations_', // Whether the policy's connection data was attempted to be fetched in @@ -970,6 +991,7 @@ const ONYXKEYS = { RULE: 'rules_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', + WORKSPACE_INVITE_WORK_ARRANGEMENT_DRAFT: 'workspaceInviteWorkArrangementDraft_', WORKSPACE_INVITE_ROLE_DRAFT: 'workspaceInviteRoleDraft_', WORKSPACE_INVITE_APPROVER_DRAFT: 'workspaceInviteApproverDraft_', REPORT: 'report_', @@ -1048,6 +1070,7 @@ const ONYXKEYS = { // Shared NVPs /** Collection of agent prompts keyed by agent accountID, representing agents owned by the current user */ + /** Deprecated. Use agent_ instead */ SHARED_NVP_AGENT_PROMPT: 'sharedNVP_agentPrompt_', /** Collection of objects where each object represents the owner of the workspace that is past due billing AND the user is a member of. */ @@ -1216,6 +1239,10 @@ const ONYXKEYS = { COLLECT_DEPOSIT_ACCOUNT_FORM_DRAFT: 'collectDepositAccountFormDraft', INTERNATIONAL_BANK_ACCOUNT_FORM: 'internationalBankAccountForm', INTERNATIONAL_BANK_ACCOUNT_FORM_DRAFT: 'internationalBankAccountFormDraft', + DYNAMIC_FORM_LIST_ITEM_FORM: 'dynamicFormListItemForm', + DYNAMIC_FORM_LIST_ITEM_FORM_DRAFT: 'dynamicFormListItemFormDraft', + WISE_BUSINESS_ONBOARDING_FORM: 'wiseBusinessOnboardingForm', + WISE_BUSINESS_ONBOARDING_FORM_DRAFT: 'wiseBusinessOnboardingFormDraft', NEW_ROOM_FORM: 'newRoomForm', NEW_ROOM_FORM_DRAFT: 'newRoomFormDraft', NEW_TASK_FORM: 'newTaskForm', @@ -1248,6 +1275,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', @@ -1416,6 +1445,8 @@ const ONYXKEYS = { NEW_RULE_PROMPT_FORM_DRAFT: 'newRulePromptFormDraft', EDIT_AGENT_RULE_FORM: 'editAgentRuleForm', EDIT_AGENT_RULE_FORM_DRAFT: 'editAgentRuleFormDraft', + WORKSPACE_OFFICE_LOCATION_FORM: 'workspaceOfficeLocationForm', + WORKSPACE_OFFICE_LOCATION_FORM_DRAFT: 'workspaceOfficeLocationFormDraft', RILLET_CREDENTIALS_FORM: 'rilletCredentialsForm', RILLET_CREDENTIALS_FORM_DRAFT: 'rilletCredentialsFormDraft', DUALENTRY_CREDENTIALS_FORM: 'dualEntryCredentialsForm', @@ -1490,6 +1521,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; @@ -1555,6 +1587,8 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.MERGE_ACCOUNT_DETAILS_FORM]: FormTypes.MergeAccountDetailsForm; [ONYXKEYS.FORMS.COLLECT_DEPOSIT_ACCOUNT_FORM]: FormTypes.CollectDepositAccountForm; [ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM]: FormTypes.InternationalBankAccountForm; + [ONYXKEYS.FORMS.DYNAMIC_FORM_LIST_ITEM_FORM]: FormTypes.DynamicFormListItemForm; + [ONYXKEYS.FORMS.WISE_BUSINESS_ONBOARDING_FORM]: FormTypes.WiseBusinessOnboardingForm; [ONYXKEYS.FORMS.WORKSPACE_PER_DIEM_FORM]: FormTypes.WorkspacePerDiemForm; [ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS]: FormTypes.EnableGlobalReimbursementsForm; [ONYXKEYS.FORMS.SEARCH_EDIT_MULTIPLE_DESCRIPTION_FORM]: FormTypes.SearchEditMultipleDescriptionForm; @@ -1581,6 +1615,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM]: FormTypes.AddAgentRuleForm; [ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM]: FormTypes.NewRulePromptForm; [ONYXKEYS.FORMS.EDIT_AGENT_RULE_FORM]: FormTypes.EditAgentRuleForm; + [ONYXKEYS.FORMS.WORKSPACE_OFFICE_LOCATION_FORM]: FormTypes.WorkspaceOfficeLocationForm; [ONYXKEYS.FORMS.RILLET_CREDENTIALS_FORM]: FormTypes.RilletCredentialsForm; [ONYXKEYS.FORMS.DUALENTRY_CREDENTIALS_FORM]: FormTypes.DualEntryCredentialsForm; [ONYXKEYS.FORMS.CAMPFIRE_CREDENTIALS_FORM]: FormTypes.CampfireCredentialsForm; @@ -1592,7 +1627,7 @@ type OnyxFormDraftValuesMapping = { }; type OnyxCollectionValuesMapping = { - [ONYXKEYS.COLLECTION.AGENT]: OnyxTypes.AgentPrompt; + [ONYXKEYS.COLLECTION.AGENT]: OnyxTypes.Agent; [ONYXKEYS.COLLECTION.ATTACHMENT]: OnyxTypes.Attachment; [ONYXKEYS.COLLECTION.DOMAIN]: OnyxTypes.Domain; [ONYXKEYS.COLLECTION.DOWNLOAD]: OnyxTypes.Download; @@ -1604,12 +1639,14 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT]: OnyxTypes.PolicyCategories; [ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTagLists; [ONYXKEYS.COLLECTION.POLICY_VENDORS]: OnyxTypes.PolicyVendors; + [ONYXKEYS.COLLECTION.POLICY_MEMBER_BANK_ACCOUNTS]: OnyxTypes.PolicyMemberBankAccounts; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_DESTINATIONS]: OnyxTypes.RecentlyUsedCategories; [ONYXKEYS.COLLECTION.POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED]: boolean; [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyEmployeeList; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: OnyxTypes.InvitedEmailsToAccountIDs; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT]: string; + [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_WORK_ARRANGEMENT_DRAFT]: boolean; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_ROLE_DRAFT]: string; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_APPROVER_DRAFT]: string; [ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report; @@ -1658,7 +1695,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_MERGE_ATS_INITIAL_SYNC_MODAL_SHOWN]: boolean; [ONYXKEYS.COLLECTION.SNAPSHOT]: OnyxTypes.SearchResults; [ONYXKEYS.COLLECTION.INSIGHTS]: OnyxTypes.InsightsDashboard; - [ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT]: OnyxTypes.AgentPrompt; + [ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT]: OnyxTypes.Agent; [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END]: OnyxTypes.BillingGraceEndPeriod; [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER]: OnyxTypes.CardFeeds; [ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS]: OnyxTypes.ExpensifyCardSettings; @@ -1750,6 +1787,7 @@ type OnyxValuesMapping = { [ONYXKEYS.SESSION]: OnyxTypes.Session; [ONYXKEYS.USER_METADATA]: OnyxTypes.UserMetadata; [ONYXKEYS.STASHED_SESSION]: OnyxTypes.Session; + [ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL]: string; [ONYXKEYS.BETAS]: OnyxTypes.Beta[]; [ONYXKEYS.AGENT_RULE_SUGGESTIONS]: OnyxTypes.SuggestedAgentRule[]; [ONYXKEYS.AGENT_TEMPLATES]: OnyxTypes.SuggestedAgent[]; @@ -1765,6 +1803,7 @@ type OnyxValuesMapping = { // The value of this nvp is a string representation of the date when the block expires, or an empty string if the user is not blocked [ONYXKEYS.NVP_BLOCKED_FROM_CHAT]: string; [ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: string; + [ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST]: string; [ONYXKEYS.NVP_RECENT_ATTENDEES]: Attendee[]; [ONYXKEYS.NVP_TRY_FOCUS_MODE]: boolean; [ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION]: boolean; @@ -1861,6 +1900,8 @@ type OnyxValuesMapping = { [ONYXKEYS.VIEWING_PUBLIC_ROOM_REPORT_ID]: string; [ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record; [ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE]: string[]; + [ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT]: OnyxTypes.WorkspaceMembersSelectedForBulkEdit; + [ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE]: OnyxTypes.WorkspaceMembersSelectedForRoleChange; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION_SOURCE]: string; [ONYXKEYS.PREFERRED_THEME]: ValueOf; @@ -1953,6 +1994,7 @@ type OnyxValuesMapping = { [ONYXKEYS.SHARE_TEMP_FILE]: OnyxTypes.ShareTempFile; [ONYXKEYS.VALIDATED_FILE_OBJECT]: OnyxTypes.FileObject | undefined; [ONYXKEYS.CORPAY_FIELDS]: OnyxTypes.CorpayFields; + [ONYXKEYS.RAM_ONLY_WISE_KYC_REVIEW_EMBEDDED_LINK]: OnyxTypes.WiseKYCReviewEmbeddedLink; [ONYXKEYS.PRESERVED_USER_SESSION]: OnyxTypes.Session; [ONYXKEYS.PRESERVED_ACCOUNT]: OnyxTypes.Account; [ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING]: OnyxTypes.DismissedProductTraining; @@ -1962,12 +2004,14 @@ type OnyxValuesMapping = { [ONYXKEYS.LAST_FULL_RECONNECT_TIME]: string; [ONYXKEYS.TRAVEL_PROVISIONING]: OnyxTypes.TravelProvisioning; [ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL]: OnyxTypes.CorpayPayModal; + [ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS]: OnyxTypes.DynamicFormSensitiveAnswers; [ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE]: boolean | undefined; [ONYXKEYS.SHOULD_BILL_WHEN_DOWNGRADING]: boolean | undefined; [ONYXKEYS.BILLING_RECEIPT_DETAILS]: OnyxTypes.BillingReceiptDetails; [ONYXKEYS.NVP_SIDE_PANEL]: OnyxTypes.SidePanel; [ONYXKEYS.NVP_SEARCH_SIDEBAR]: OnyxTypes.SearchSidebar; [ONYXKEYS.NVP_APP_REVIEW]: OnyxTypes.AppReview; + [ONYXKEYS.APP_REVIEW_LAST_PROMPT]: OnyxTypes.AppReviewLastPrompt; [ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT]: OnyxTypes.OnboardingRHPVariant; [ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION]: boolean; [ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE]: OnyxTypes.VacationDelegate; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a1f970ae15ac..7100763e297f 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -16,7 +16,7 @@ import type {SearchKey} from './libs/SearchKeyUtils'; import type {Screen} from './SCREENS'; import type {ExpenseRuleFormFieldID} from './types/form/ExpenseRuleForm'; import type {CardFeedWithDomainID, CompanyCardFeedWithDomainID} from './types/onyx'; -import type {ConnectionName, PolicyReportFieldType, SageIntacctMappingName} from './types/onyx/Policy'; +import type {BusinessCentralBankAccountSettingName, ConnectionName, PolicyReportFieldType, SageIntacctMappingName} from './types/onyx/Policy'; import type {CustomFieldType} from './types/onyx/PolicyEmployee'; import Log from './libs/Log'; @@ -227,6 +227,11 @@ const DYNAMIC_ROUTES = { }), queryParams: ['bankCountry', 'bankCurrency'], }, + WISE_GLOBAL_REIMBURSEMENTS_INTRO: { + path: 'global-reimbursements/:bankAccountID', + entryScreens: [SCREENS.SETTINGS.WALLET.ROOT, SCREENS.WORKSPACE.WORKFLOWS], + getRoute: (bankAccountID: number | undefined) => `global-reimbursements/${bankAccountID}` as const, + }, BANK_ACCOUNT_VERIFY_ACCOUNT: { path: 'verify-bank-account', entryScreens: [SCREENS.REIMBURSEMENT_ACCOUNT], @@ -274,10 +279,6 @@ const DYNAMIC_ROUTES = { path: 'submit-plan-welcome', entryScreens: [SCREENS.HOME, SCREENS.INBOX, SCREENS.REPORT, SCREENS.SEARCH.ROOT, SCREENS.WORKSPACES_LIST, SCREENS.WORKSPACE.PROFILE, SCREENS.SETTINGS.ROOT], }, - AI_FEATURES_PROMO: { - path: 'ai-features-promo', - entryScreens: ['*'], - }, EXPENSE_LIMIT_TYPE_SELECTOR: { path: 'expense-limit-type', entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_AMOUNTS_OVER], @@ -333,6 +334,16 @@ const DYNAMIC_ROUTES = { ], getRoute: (policyID: string, fieldID: string) => `edit/policyField/${policyID}/${encodeURIComponent(fieldID)}` as const, }, + COLLECT_DEPOSIT_ACCOUNT: { + path: 'collect-deposit-account/:subPage?/:action?', + entryScreens: [SCREENS.HOME, SCREENS.SETTINGS.WALLET.ROOT, SCREENS.SETTINGS.BANK_ACCOUNT_PURPOSE], + getRoute: (subPage?: string, action?: 'edit') => { + if (!subPage) { + return 'collect-deposit-account' as const; + } + return `collect-deposit-account/${subPage}${action ? `/${action}` : ''}` as const; + }, + }, MISSING_PERSONAL_DETAILS: { path: 'missing-personal-details/:cardID/:subPage?/:action?', entryScreens: [ @@ -707,8 +718,8 @@ const DYNAMIC_ROUTES = { path: 'avatar-crop', entryScreens: [ SCREENS.SETTINGS.PROFILE.AVATAR, - SCREENS.SETTINGS.AGENTS.EDIT_AVATAR, - SCREENS.SETTINGS.AGENTS.ADD_AVATAR, + SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_AVATAR, + SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD_AVATAR, SCREENS.NEW_CHAT.NEW_CHAT_CONFIRM, SCREENS.WORKSPACE.PROFILE, SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT, @@ -1016,6 +1027,8 @@ const DYNAMIC_ROUTES = { SCREENS.SETTINGS.PROFILE.ADDRESS, SCREENS.SETTINGS.PROFILE.PRIVATE_PERSONAL_DETAILS, SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_OVERVIEW_ADDRESS, + SCREENS.WORKSPACE.OFFICE_LOCATION_ADD, + SCREENS.WORKSPACE.OFFICE_LOCATION_EDIT, SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS, SCREENS.DOMAIN_CARD.DOMAIN_CARD_UPDATE_ADDRESS, SCREENS.TRAVEL.ENABLE, @@ -1032,6 +1045,8 @@ const DYNAMIC_ROUTES = { SCREENS.SETTINGS.PROFILE.ADDRESS, SCREENS.SETTINGS.PROFILE.PRIVATE_PERSONAL_DETAILS, SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_OVERVIEW_ADDRESS, + SCREENS.WORKSPACE.OFFICE_LOCATION_ADD, + SCREENS.WORKSPACE.OFFICE_LOCATION_EDIT, SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS, SCREENS.DOMAIN_CARD.DOMAIN_CARD_UPDATE_ADDRESS, SCREENS.TRAVEL.ENABLE, @@ -1065,10 +1080,6 @@ const DYNAMIC_ROUTES = { path: 'category-edit', entryScreens: [SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS], }, - SETTINGS_CATEGORIES_SETTINGS: { - path: 'manage-settings', - entryScreens: [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_ROOT], - }, SETTINGS_CATEGORY_CREATE: { path: 'category-create', entryScreens: [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_ROOT], @@ -1257,17 +1268,9 @@ const DYNAMIC_ROUTES = { path: 'workspace-tags-imported', entryScreens: [SCREENS.WORKSPACE.TAGS], }, - WORKSPACE_CATEGORIES_SETTINGS: { - path: 'categories-settings', - entryScreens: [SCREENS.WORKSPACE.CATEGORIES], - }, - WORKSPACE_TAGS_SETTINGS: { - path: 'tags-settings', - entryScreens: [SCREENS.WORKSPACE.TAGS], - }, WORKSPACE_EDIT_TAGS: { path: 'workspace-edit-tags/:orderWeight', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_TAGS_SETTINGS, SCREENS.WORKSPACE.DYNAMIC_TAG_LIST_VIEW], + entryScreens: [SCREENS.WORKSPACE.TAGS, SCREENS.WORKSPACE.DYNAMIC_TAG_LIST_VIEW], getRoute: (orderWeight: number) => `workspace-edit-tags/${orderWeight}`, }, WORKSPACE_CATEGORY_CREATE: { @@ -1301,11 +1304,6 @@ const DYNAMIC_ROUTES = { path: 'workspace-tag-edit', entryScreens: [SCREENS.WORKSPACE.DYNAMIC_TAG_SETTINGS], }, - SPEND_CATEGORY_SELECTOR: { - path: 'spend-category-selector/:groupID', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORIES_SETTINGS, SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORIES_SETTINGS], - getRoute: (groupID: string) => `spend-category-selector/${groupID}` as const, - }, DEFAULT_CATEGORY_SELECTOR: { path: 'default-category-selector/:customUnitID', entryScreens: [SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS, SCREENS.WORKSPACE.PER_DIEM_SETTINGS], @@ -1319,6 +1317,14 @@ const DYNAMIC_ROUTES = { path: 'invite-message', entryScreens: [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_INVITE, SCREENS.WORKSPACE.DYNAMIC_WORKFLOWS_APPROVALS_EXPENSES_FROM], }, + WORKSPACE_INVITE_WORK_ARRANGEMENT: { + path: 'work-arrangement', + entryScreens: [ + SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_INVITE_MESSAGE, + SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD_INVITE_NEW_MEMBER, + SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW, + ], + }, WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM: { path: 'expenses-from', entryScreens: [SCREENS.WORKSPACE.WORKFLOWS, SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_NEW, SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_EDIT], @@ -1528,10 +1534,6 @@ const DYNAMIC_ROUTES = { entryScreens: [SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_ROOT], getRoute: (orderWeight: number) => `tag-list/${orderWeight}`, }, - SETTINGS_TAGS_SETTINGS: { - path: 'settings-tags-settings', - entryScreens: [SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_ROOT], - }, SETTINGS_TAG_SETTINGS: { path: 'tag-settings/:orderWeight/:tagName', entryScreens: [SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_ROOT, SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_LIST_VIEW], @@ -1540,7 +1542,7 @@ const DYNAMIC_ROUTES = { }, SETTINGS_TAGS_EDIT: { path: 'settings-tags-edit/:orderWeight', - entryScreens: [SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_ROOT, SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAGS_SETTINGS, SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_LIST_VIEW], + entryScreens: [SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_ROOT, SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_LIST_VIEW], getRoute: (orderWeight: number) => `settings-tags-edit/${orderWeight}`, }, SETTINGS_TAG_EDIT: { @@ -1635,6 +1637,8 @@ const DYNAMIC_ROUTES = { SCREENS.SEARCH.ROOT, SCREENS.REPORT_DETAILS.DYNAMIC_ROOT, ], + getRoute: (shouldShowQRCodeOnly?: boolean) => getUrlWithParams('share-code', {shouldShowQRCodeOnly: shouldShowQRCodeOnly ? 'true' : undefined}), + queryParams: ['shouldShowQRCodeOnly'], }, REFERRAL_DETAILS: { path: 'referral/:contentType', @@ -1646,6 +1650,10 @@ const DYNAMIC_ROUTES = { entryScreens: [SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, SCREENS.SEARCH.ROOT], getRoute: (connectionName: ConnectionName) => `details/export/${connectionName as string}` as const, }, + REPORT_DETAILS_SHARE_REPORT: { + path: 'share-report', + entryScreens: [SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, SCREENS.SEARCH.ROOT], + }, REPORT_CHANGE_WORKSPACE: { path: 'change-workspace', entryScreens: [SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, SCREENS.SEARCH.ROOT], @@ -2107,7 +2115,9 @@ const DYNAMIC_ROUTES = { }, MONEY_REQUEST_STEP_CATEGORY_CREATE: { path: 'add-category', - entryScreens: [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_CATEGORY], + entryScreens: [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_CATEGORY, SCREENS.MONEY_REQUEST.STEP_CONFIRMATION, SCREENS.MONEY_REQUEST.CREATE, SCREENS.SHARE.SUBMIT_DETAILS], + getRoute: (params: {action: IOUAction; iouType: IOUType; transactionID: string; reportID: string | undefined}) => getUrlWithParams('add-category', params), + queryParams: ['action', 'iouType', 'transactionID', 'reportID'], }, MONEY_REQUEST_ATTENDEE: { path: 'attendees', @@ -2151,6 +2161,55 @@ const DYNAMIC_ROUTES = { }, queryParams: ['action', 'iouType', 'transactionID', 'reportID', 'upgradeBackTo', 'shouldSubmitExpense', 'upgradePath', 'shouldReturnToConfirmation'], }, + AGENT_NEW: { + path: 'new-agent', + getRoute: ({policyID}: {policyID?: string} = {}) => { + const params = new URLSearchParams(); + if (policyID) { + params.set('policyID', policyID); + } + const query = params.toString(); + return `new-agent${query ? `?${query}` : ''}` as const; + }, + entryScreens: [SCREENS.SETTINGS.AGENTS.ROOT, SCREENS.WORKSPACE.AGENTS], + }, + AGENT_ADD: { + path: 'custom', + getRoute: ({policyID}: {policyID?: string} = {}) => { + const params = new URLSearchParams(); + if (policyID) { + params.set('policyID', policyID); + } + const query = params.toString(); + return `custom${query ? `?${query}` : ''}` as const; + }, + entryScreens: [SCREENS.SETTINGS.AGENTS.DYNAMIC_NEW], + }, + AGENT_ADD_AVATAR: { + path: 'custom-avatar', + getRoute: () => `custom-avatar` as const, + entryScreens: [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD], + }, + AGENT_EDIT: { + path: ':accountID/edit', + getRoute: (accountID: number) => `${accountID}/edit` as const, + entryScreens: [SCREENS.SETTINGS.AGENTS.ROOT, SCREENS.WORKSPACE.AGENTS], + }, + AGENT_EDIT_NAME: { + path: 'name', + getRoute: () => `name` as const, + entryScreens: [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT], + }, + AGENT_EDIT_PROMPT: { + path: 'prompt', + getRoute: () => `prompt` as const, + entryScreens: [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT, SCREENS.DYNAMIC_PROFILE], + }, + AGENT_EDIT_AVATAR: { + path: 'avatar', + getRoute: () => `avatar` as const, + entryScreens: [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT], + }, } as const satisfies DynamicRoutes; const ROUTES = { @@ -2183,6 +2242,7 @@ const ROUTES = { }, SEARCH_COLUMNS: 'search/columns', SEARCH_ADVANCED_FILTERS: 'search/filters', + SEARCH_NL_FILTER: 'search/describe', SEARCH_ADVANCED_FILTERS_CONTENT: { route: 'search/filters/:filterKey', getRoute: (filterKey: SearchFilterKey | UserFriendlyKey, applyDirectly?: boolean) => { @@ -2524,14 +2584,6 @@ const ROUTES = { return getUrlWithBackToParam(`settings/wallet/add-bank-account/${subPage}${action ? `/${action}` : ''}`, backTo); }, }, - SETTINGS_COLLECT_DEPOSIT_ACCOUNT: { - route: 'settings/wallet/collect-deposit-account/:subPage?/:action?', - getRoute: (subPage?: string, action?: 'edit') => { - const subPagePart = subPage ? `/${subPage}` : ''; - const actionPart = action ? `/${action}` : ''; - return `settings/wallet/collect-deposit-account${subPagePart}${actionPart}` as const; - }, - }, SETTINGS_ADD_US_BANK_ACCOUNT: { route: 'settings/wallet/add-us-bank-account/:subPage?/:action?', getRoute: (subPage?: string, action?: 'edit') => { @@ -2671,45 +2723,6 @@ const ROUTES = { SETTINGS_WALLET_TRAVEL_CVV: 'settings/wallet/travel-cvv', SETTINGS_WALLET_TRAVEL_CVV_VERIFY_ACCOUNT: `settings/wallet/travel-cvv/${VERIFY_ACCOUNT}`, SETTINGS_AGENTS: 'settings/agents', - SETTINGS_AGENTS_NEW: { - route: 'settings/agents/new', - getRoute: ({policyID}: {policyID?: string} = {}) => { - const params = new URLSearchParams(); - if (policyID) { - params.set('policyID', policyID); - } - const query = params.toString(); - return `settings/agents/new${query ? `?${query}` : ''}` as const; - }, - }, - SETTINGS_AGENTS_ADD: { - route: 'settings/agents/new/custom', - getRoute: ({policyID}: {policyID?: string} = {}) => { - const params = new URLSearchParams(); - if (policyID) { - params.set('policyID', policyID); - } - const query = params.toString(); - return `settings/agents/new/custom${query ? `?${query}` : ''}` as const; - }, - }, - SETTINGS_AGENTS_ADD_AVATAR: 'settings/agents/new/custom/avatar', - SETTINGS_AGENTS_EDIT: { - route: 'settings/agents/:accountID/edit', - getRoute: (accountID: number) => `settings/agents/${accountID}/edit` as const, - }, - SETTINGS_AGENTS_EDIT_NAME: { - route: 'settings/agents/:accountID/edit/name', - getRoute: (accountID: number) => `settings/agents/${accountID}/edit/name` as const, - }, - SETTINGS_AGENTS_EDIT_PROMPT: { - route: 'settings/agents/:accountID/edit/prompt', - getRoute: (accountID: number) => `settings/agents/${accountID}/edit/prompt` as const, - }, - SETTINGS_AGENTS_EDIT_AVATAR: { - route: 'settings/agents/:accountID/edit/avatar', - getRoute: (accountID: number) => `settings/agents/${accountID}/edit/avatar` as const, - }, AGENT_REPORT: { route: 'settings/agents/r/:reportID', getRoute: (reportID: string) => `settings/agents/r/${reportID}` as const, @@ -2768,8 +2781,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_TROUBLESHOOT_SERVER: 'settings/troubleshoot/server', SETTINGS_HELP: 'settings/help', @@ -2906,13 +2920,13 @@ const ROUTES = { }, }, MONEY_REQUEST_STEP_REUSE_ROUTE: { - route: ':action/:iouType/reuse-route/:transactionID/:reportID', - getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined) => { + route: ':action/:iouType/reuse-route/:transactionID/:reportID/:backToReport?', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, backToReport?: string) => { if (!transactionID || !reportID) { Log.warn('Invalid transactionID or reportID is used to build the MONEY_REQUEST_STEP_REUSE_ROUTE route'); } - return `${action as string}/${iouType as string}/reuse-route/${transactionID}/${reportID}` as const; + return `${action as string}/${iouType as string}/reuse-route/${transactionID}/${reportID}${backToReport ? `/${backToReport}` : ''}` as const; }, }, MONEY_REQUEST_RECEIPT_PREVIEW: { @@ -3372,6 +3386,14 @@ const ROUTES = { route: 'workspaces/:policyID/overview/share', getRoute: (policyID: string) => `workspaces/${policyID}/overview/share` as const, }, + WORKSPACE_OVERVIEW_OFFICE_LOCATION_ADD: { + route: 'workspaces/:policyID/overview/office-locations/new', + getRoute: (policyID: string) => `workspaces/${policyID}/overview/office-locations/new` as const, + }, + WORKSPACE_OVERVIEW_OFFICE_LOCATION_EDIT: { + route: 'workspaces/:policyID/overview/office-locations/:officeID', + getRoute: (policyID: string, officeID: string) => `workspaces/${policyID}/overview/office-locations/${officeID}` as const, + }, WORKSPACE_AVATAR: { route: 'workspaces/:policyID/avatar', getRoute: (policyID: string, fallbackLetter?: UpperCaseCharacters) => `workspaces/${policyID}/avatar${fallbackLetter ? `?letter=${fallbackLetter}` : ''}` as const, @@ -3380,6 +3402,10 @@ const ROUTES = { route: 'workspaces/:policyID/document', getRoute: (policyID: string) => `workspaces/${policyID}/document` as const, }, + BILLING_RECEIPT: { + route: 'settings/subscription/payment-history/:purchaseID/receipt', + getRoute: (purchaseID: number) => `settings/subscription/payment-history/${purchaseID}/receipt` as const, + }, WORKSPACE_JOIN_USER: { route: 'workspaces/:policyID/join', getRoute: (policyID: string, inviterEmail: string) => `workspaces/${policyID}/join?email=${inviterEmail}` as const, @@ -3522,6 +3548,15 @@ const ROUTES = { return `workspaces/${policyID}/members` as const; }, }, + WORKSPACE_AGENTS: { + route: 'workspaces/:policyID/agents', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_AGENTS route'); + } + return `workspaces/${policyID}/agents` as const; + }, + }, WORKSPACE_ROOMS: { route: 'workspaces/:policyID/rooms', getRoute: (policyID: string | undefined) => { @@ -3539,6 +3574,14 @@ const ROUTES = { route: 'workspaces/:policyID/members/import', getRoute: (policyID: string) => `workspaces/${policyID}/members/import` as const, }, + WORKSPACE_MEMBERS_CHANGE_OFFICE_LOCATION: { + route: 'workspaces/:policyID/members/change-office-location', + getRoute: (policyID: string) => `workspaces/${policyID}/members/change-office-location` as const, + }, + WORKSPACE_MEMBERS_CHANGE_WORK_ARRANGEMENT: { + route: 'workspaces/:policyID/members/change-work-arrangement', + getRoute: (policyID: string) => `workspaces/${policyID}/members/change-work-arrangement` as const, + }, WORKSPACE_MEMBERS_IMPORTED: { route: 'workspaces/:policyID/members/imported', getRoute: (policyID: string) => `workspaces/${policyID}/members/imported` as const, @@ -3839,6 +3882,10 @@ const ROUTES = { route: 'workspaces/:policyID/taxes/settings/tax-name', getRoute: (policyID: string) => `workspaces/${policyID}/taxes/settings/tax-name` as const, }, + WORKSPACE_MEMBERS_ROLE: { + route: 'workspaces/:policyID/change-member-roles', + getRoute: (policyID: string) => `workspaces/${policyID}/change-member-roles` as const, + }, WORKSPACE_MEMBER_DETAILS: { route: 'workspaces/:policyID/members/:accountID', getRoute: (policyID: string, accountID: number) => `workspaces/${policyID}/members/${accountID}` as const, @@ -3851,6 +3898,10 @@ const ROUTES = { route: 'workspaces/:policyID/members/:accountID/work-arrangement', getRoute: (policyID: string, accountID: number) => `workspaces/${policyID}/members/${accountID}/work-arrangement` as const, }, + WORKSPACE_MEMBER_OFFICE_LOCATION: { + route: 'workspaces/:policyID/members/:accountID/office-location', + getRoute: (policyID: string, accountID: number) => `workspaces/${policyID}/members/${accountID}/office-location` as const, + }, WORKSPACE_CUSTOM_FIELDS: { route: 'workspaces/:policyID/members/:accountID/:customFieldType', getRoute: (policyID: string, accountID: number, customFieldType: CustomFieldType) => `/workspaces/${policyID}/members/${accountID}/${customFieldType}` as const, @@ -4484,7 +4535,7 @@ const ROUTES = { }, RULES_AGENT_NEW: { route: 'workspaces/:policyID/rules/agent-rules/new', - getRoute: (policyID: string) => `workspaces/${policyID}/rules/agent-rules/new` as const, + getRoute: (policyID: string, generationID?: string) => getUrlWithParams(`workspaces/${policyID}/rules/agent-rules/new` as const, {generationID}), }, RULES_AGENT_EDIT: { route: 'workspaces/:policyID/rules/agent-rules/:ruleID', @@ -4610,6 +4661,10 @@ const ROUTES = { route: 'workspaces/:policyID/accounting/xero/setup', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/xero/setup` as const, }, + POLICY_ACCOUNTING_ZOHO_BOOKS_OAUTH: { + route: 'workspaces/:policyID/accounting/zoho-books/oauth', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/zoho-books/oauth` as const, + }, POLICY_ACCOUNTING_XERO_IMPORT: { route: 'workspaces/:policyID/accounting/xero/import', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/xero/import` as const, @@ -5433,6 +5488,23 @@ const ROUTES = { route: 'workspaces/:policyID/accounting/business-central/export/payment-method', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/business-central/export/payment-method` as const, }, + POLICY_ACCOUNTING_BUSINESS_CENTRAL_ADVANCED: { + route: 'workspaces/:policyID/accounting/business-central/advanced', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/business-central/advanced` as const, + }, + POLICY_ACCOUNTING_BUSINESS_CENTRAL_ACCOUNTING_METHOD: { + route: 'workspaces/:policyID/accounting/business-central/advanced/accounting-method', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/business-central/advanced/accounting-method` as const, + }, + POLICY_ACCOUNTING_BUSINESS_CENTRAL_ADVANCED_POSTING_MODE: { + route: 'workspaces/:policyID/accounting/business-central/advanced/posting-mode', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/business-central/advanced/posting-mode` as const, + }, + POLICY_ACCOUNTING_BUSINESS_CENTRAL_BANK_ACCOUNT_SELECTOR: { + route: 'workspaces/:policyID/accounting/business-central/advanced/bank-account/:settingName', + getRoute: (policyID: string, settingName: BusinessCentralBankAccountSettingName) => + `workspaces/${policyID}/accounting/business-central/advanced/bank-account/${settingName}` as const, + }, ADD_EXISTING_EXPENSE: { route: 'search/r/:reportID/add-existing-expense/:backToReport?', getRoute: (reportID: string | undefined, backToReport?: string) => `search/r/${reportID}/add-existing-expense/${backToReport ?? ''}` as const, @@ -5655,6 +5727,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 502f881e4be5..c1da1aad7140 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -22,6 +22,7 @@ const SCREENS = { DYNAMIC_CARD_AUTHENTICATION: 'Dynamic_Card_Authentication', WORKSPACE_AVATAR: 'WorkspaceAvatar', WORKSPACE_DOCUMENT: 'WorkspaceDocument', + BILLING_RECEIPT: 'BillingReceipt', REPORT_AVATAR: 'ReportAvatar', NOT_FOUND: 'not-found', TRANSITION_BETWEEN_APPS: 'TransitionBetweenApps', @@ -51,6 +52,7 @@ const SCREENS = { COLUMNS_RHP: 'Search_Columns_RHP', ADVANCED_FILTERS_RHP: 'Search_Advanced_Filters_RHP', ADVANCED_FILTERS_CONTENT_RHP: 'Search_Advanced_Filters_Content_RHP', + NL_FILTER_RHP: 'Search_NL_Filter_RHP', SAVED_SEARCH_RENAME_RHP: 'Search_Saved_Search_Rename_RHP', TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP', TRANSACTION_HOLD_REASON_SEARCH: 'Search_Transaction_Hold_Reason_Search', @@ -84,7 +86,7 @@ const SCREENS = { APP_DOWNLOAD_LINKS: 'Settings_App_Download_Links', ADD_DEBIT_CARD: 'Settings_Add_Debit_Card', ADD_BANK_ACCOUNT: 'Settings_Add_Bank_Account', - COLLECT_DEPOSIT_ACCOUNT: 'Settings_Collect_Deposit_Account', + DYNAMIC_COLLECT_DEPOSIT_ACCOUNT: 'Dynamic_Collect_Deposit_Account', ADD_US_BANK_ACCOUNT: 'Settings_Add_US_Bank_Account', ADD_US_BANK_ACCOUNT_ENTRY_POINT: 'Settings_Add_US_Bank_Account_Entry_Point', UPDATE_PERSONAL_BANK_ACCOUNT: 'Settings_Update_Personal_Bank_Account', @@ -117,7 +119,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', @@ -141,13 +144,13 @@ const SCREENS = { AGENTS: { ROOT: 'Settings_Agents', - NEW: 'Settings_Agents_New', - ADD: 'Settings_Agents_Add', - ADD_AVATAR: 'Settings_Agents_Add_Avatar', - EDIT: 'Settings_Agents_Edit', - EDIT_NAME: 'Settings_Agents_Edit_Name', - EDIT_PROMPT: 'Settings_Agents_Edit_Prompt', - EDIT_AVATAR: 'Settings_Agents_Edit_Avatar', + DYNAMIC_NEW: 'Dynamic_Settings_Agents_New', + DYNAMIC_ADD: 'Dynamic_Settings_Agents_Add', + DYNAMIC_ADD_AVATAR: 'Dynamic_Settings_Agents_Add_Avatar', + DYNAMIC_EDIT: 'Dynamic_Settings_Agents_Edit', + DYNAMIC_EDIT_NAME: 'Dynamic_Settings_Agents_Edit_Name', + DYNAMIC_EDIT_PROMPT: 'Dynamic_Settings_Agents_Edit_Prompt', + DYNAMIC_EDIT_AVATAR: 'Dynamic_Settings_Agents_Edit_Avatar', }, RULES: { @@ -206,6 +209,7 @@ const SCREENS = { DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN: 'Dynamic_Settings_Wallet_Enable_Global_Reimbursements_Sign', SHARE_BANK_ACCOUNT: 'Settings_Wallet_Share_Bank_Account', EDIT_BANK_ACCOUNT_NICKNAME: 'Settings_Wallet_Edit_Bank_Account_Nickname', + DYNAMIC_WISE_GLOBAL_REIMBURSEMENTS_INTRO: 'Dynamic_Settings_Wallet_Wise_Global_Reimbursements_Intro', TRAVEL_CVV: 'Settings_Wallet_Travel_CVV', TRAVEL_CVV_VERIFY_ACCOUNT: 'Settings_Wallet_Travel_CVV_VerifyAccount', PERSONAL_CARD_DETAILS: 'Settings_Wallet_Personal_Card_Details', @@ -430,7 +434,6 @@ const SCREENS = { SETTINGS_CATEGORIES: { DYNAMIC_SETTINGS_CATEGORY_SETTINGS: 'Dynamic_Settings_Category_Settings', - DYNAMIC_SETTINGS_CATEGORIES_SETTINGS: 'Dynamic_Settings_Categories_Settings', DYNAMIC_SETTINGS_CATEGORY_CREATE: 'Dynamic_Settings_Category_Create', DYNAMIC_SETTINGS_CATEGORY_EDIT: 'Dynamic_Settings_Category_Edit', SETTINGS_CATEGORIES_ROOT: 'Settings_Categories', @@ -454,7 +457,6 @@ const SCREENS = { SETTINGS_TAGS: { SETTINGS_TAGS_ROOT: 'Settings_Tags', - DYNAMIC_SETTINGS_TAGS_SETTINGS: 'Dynamic_Settings_Tags_Settings', DYNAMIC_SETTINGS_TAGS_EDIT: 'Dynamic_Settings_Tags_Edit', DYNAMIC_SETTINGS_TAG_CREATE: 'Dynamic_Settings_Tag_Create', DYNAMIC_SETTINGS_TAG_EDIT: 'Dynamic_Settings_Tag_Edit', @@ -509,6 +511,7 @@ const SCREENS = { DYNAMIC_ROOT: 'Dynamic_Report_Details_Root', DYNAMIC_SHARE_CODE: 'Dynamic_Report_Details_Share_Code', DYNAMIC_EXPORT: 'Dynamic_Report_Details_Export', + DYNAMIC_SHARE_REPORT: 'Dynamic_Report_Details_Share_Report', }, REPORT_CHANGE_WORKSPACE: { @@ -602,6 +605,7 @@ const SCREENS = { QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS: 'Policy_Accounting_Quickbooks_Desktop_Import_Customers_Displayed_As', QUICKBOOKS_DESKTOP_ITEMS: 'Policy_Accounting_Quickbooks_Desktop_Import_Items', XERO_SETUP: 'Policy_Accounting_Xero_Setup', + ZOHO_BOOKS_OAUTH: 'Policy_Accounting_Zoho_Books_OAuth', XERO_IMPORT: 'Policy_Accounting_Xero_Import', CLAIM_OFFER: 'Policy_Accounting_Claim_Offer', XERO_ORGANIZATION: 'Policy_Accounting_Xero_Customers', @@ -774,6 +778,10 @@ const SCREENS = { BUSINESS_CENTRAL_EXPORT_COMPANY_CARD_ACCOUNT: 'Policy_Accounting_Business_Central_Export_Company_Card_Account', BUSINESS_CENTRAL_EXPORT_DEFAULT_VENDOR: 'Policy_Accounting_Business_Central_Export_Default_Vendor', BUSINESS_CENTRAL_EXPORT_PAYMENT_METHOD: 'Policy_Accounting_Business_Central_Export_Payment_Method', + BUSINESS_CENTRAL_ADVANCED: 'Policy_Accounting_Business_Central_Advanced', + BUSINESS_CENTRAL_ACCOUNTING_METHOD: 'Policy_Accounting_Business_Central_Accounting_Method', + BUSINESS_CENTRAL_ADVANCED_POSTING_MODE: 'Policy_Accounting_Business_Central_Advanced_Posting_Mode', + BUSINESS_CENTRAL_BANK_ACCOUNT_SELECTOR: 'Policy_Accounting_Business_Central_Bank_Account_Selector', CARD_RECONCILIATION: 'Policy_Accounting_Card_Reconciliation', CARD_RECONCILIATION_SAGE_INTACCT_AUTO_SYNC: 'Policy_Accounting_Card_Reconciliation_Sage_Intacct_Auto_Sync', DYNAMIC_RECONCILIATION_ACCOUNT_SETTINGS: 'Dynamic_Policy_Accounting_Reconciliation_Account_Settings', @@ -856,9 +864,12 @@ const SCREENS = { DYNAMIC_INVOICE_FIELDS_INITIAL_LIST_VALUE: 'Dynamic_Invoice_Fields_Initial_List_Value', INVOICE_FIELDS_TYPE_SELECTOR: 'Workspace_InvoiceFields_TypeSelector', MEMBERS: 'Workspace_Members', + AGENTS: 'Workspace_Agents', ROOMS: 'Workspace_Rooms', ROOM_CREATE: 'Workspace_Room_Create', MEMBERS_IMPORT: 'Members_Import', + MEMBERS_CHANGE_OFFICE_LOCATION: 'Members_Change_Office_Location', + MEMBERS_CHANGE_WORK_ARRANGEMENT: 'Members_Change_Work_Arrangement', MEMBERS_IMPORTED: 'Members_Imported', MEMBERS_IMPORTED_CONFIRMATION: 'Members_Imported_Confirmation', DYNAMIC_WORKSPACE_INVITE: 'Dynamic_Workspace_Invite', @@ -868,7 +879,6 @@ const SCREENS = { CATEGORIES: 'Workspace_Categories', VENDORS: 'Workspace_Vendors', TAGS: 'Workspace_Tags', - DYNAMIC_TAGS_SETTINGS: 'Dynamic_Tags_Settings', DYNAMIC_TAGS_EDIT: 'Dynamic_Tags_Edit', DYNAMIC_TAGS_IMPORT: 'Dynamic_Tags_Import', TAGS_IMPORT_MULTI_LEVEL_SETTINGS: 'Tags_Import_Multi_Level_Settings', @@ -924,6 +934,8 @@ const SCREENS = { DESCRIPTION: 'Workspace_Overview_Description', SHARE: 'Workspace_Overview_Share', NAME: 'Workspace_Overview_Name', + OFFICE_LOCATION_ADD: 'Workspace_Overview_Office_Location_Add', + OFFICE_LOCATION_EDIT: 'Workspace_Overview_Office_Location_Edit', CLIENT_ID: 'Workspace_Overview_Client_ID', CATEGORY_CREATE: 'Category_Create', DYNAMIC_CATEGORY_CREATE: 'Dynamic_Category_Create', @@ -949,15 +961,16 @@ const SCREENS = { DYNAMIC_CATEGORY_TAX_RULE_NEW: 'Dynamic_Category_Tax_Rule_New', DYNAMIC_CATEGORY_TAX_RULE_EDIT: 'Dynamic_Category_Tax_Rule_Edit', CATEGORIES_SETTINGS: 'Categories_Settings', - DYNAMIC_CATEGORIES_SETTINGS: 'Dynamic_Categories_Settings', DYNAMIC_CATEGORIES_IMPORT: 'Dynamic_Categories_Import', DYNAMIC_CATEGORIES_IMPORTED: 'Dynamic_Categories_Imported', - DYNAMIC_SPEND_CATEGORY_SELECTOR: 'Dynamic_Spend_Category_Selector', DYNAMIC_DEFAULT_CATEGORY_SELECTOR: 'Dynamic_Default_Category_Selector', MORE_FEATURES: 'Workspace_More_Features', MEMBER_DETAILS: 'Workspace_Member_Details', MEMBER_DETAILS_ROLE: 'Workspace_Member_Details_Role', + MEMBERS_ROLE: 'Workspace_Members_Role', MEMBER_WORK_ARRANGEMENT: 'Workspace_Member_Work_Arrangement', + MEMBER_OFFICE_LOCATION: 'Workspace_Member_Office_Location', + INVITE_WORK_ARRANGEMENT: 'Workspace_Invite_Work_Arrangement', MEMBER_CUSTOM_FIELD: 'Workspace_Member_Custom_Field', MEMBER_NEW_CARD: 'Workspace_Member_NewCard', DYNAMIC_OWNER_CHANGE_CHECK: 'Dynamic_Workspace_Owner_Change_Check', @@ -1125,10 +1138,6 @@ const SCREENS = { DYNAMIC_ROOT: 'Dynamic_SubmitPlanWelcomeModal_Root', }, - AI_FEATURES_PROMO_MODAL: { - DYNAMIC_ROOT: 'Dynamic_AIFeaturesPromoModal_Root', - }, - TEST_DRIVE_DEMO: { ROOT: 'TestDrive_Demo_Root', }, @@ -1227,6 +1236,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/AIFeaturesPromoModal.tsx b/src/components/AIFeaturesPromoModal.tsx deleted file mode 100644 index 0a5448814613..000000000000 --- a/src/components/AIFeaturesPromoModal.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {dismissProductTraining} from '@libs/actions/Welcome'; -import Navigation from '@libs/Navigation/Navigation'; - -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; - -import React from 'react'; -import {View} from 'react-native'; - -import Badge from './Badge'; -import CenteredModalLayout from './CenteredModalLayout'; -import FeatureTraining from './FeatureTraining'; -import LottieAnimations from './LottieAnimations'; -import Text from './Text'; - -function AIFeaturesPromoModal() { - const {translate} = useLocalize(); - const styles = useThemeStyles(); - const customAgentPromoTitle = ( - - {translate('aiFeaturesPromoModal.customAgents.title')} - - - ); - - const dismissNVP = (isDismissedUsingCloseButton: boolean) => { - dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, isDismissedUsingCloseButton); - }; - - const confirmAndCloseModal = () => { - Navigation.goBack(); - dismissNVP(false); - }; - - const closeModal = () => { - Navigation.goBack(); - dismissNVP(true); - }; - - const illustrationAspectRatio = LottieAnimations.SpendAnalysis.w / LottieAnimations.SpendAnalysis.h; - const subtitle = translate('aiFeaturesPromoModal.subtitle'); - - return ( - - - - - - - {subtitle} - {translate('aiFeaturesPromoModal.spendAnalysis.title')} - {translate('aiFeaturesPromoModal.spendAnalysis.description')} - - - - {translate('common.next')} - - - - - - - - {subtitle} - {translate('aiFeaturesPromoModal.expenseAssistant.title')} - {translate('aiFeaturesPromoModal.expenseAssistant.description')} - - - - {translate('common.next')} - - - - - - - - {subtitle} - {customAgentPromoTitle} - {translate('aiFeaturesPromoModal.customAgents.description')} - - - - {translate('aiFeaturesPromoModal.confirmText')} - - - - - - ); -} - -export default AIFeaturesPromoModal; diff --git a/src/components/AddToWalletButton/index.native.tsx b/src/components/AddToWalletButton/index.native.tsx index 1603c2242ffa..11119ff04d6e 100644 --- a/src/components/AddToWalletButton/index.native.tsx +++ b/src/components/AddToWalletButton/index.native.tsx @@ -1,8 +1,10 @@ import ActivityIndicator from '@components/ActivityIndicator'; +import {useMultifactorAuthentication} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext'; import useThemeStyles from '@hooks/useThemeStyles'; import {getPaymentMethods} from '@libs/actions/PaymentMethods'; +import {isUkEuExpensifyCard} from '@libs/CardUtils'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; @@ -14,7 +16,7 @@ import ROUTES from '@src/ROUTES'; import type {TokenizationStatus} from '@expensify/react-native-wallet'; import {AddToWalletButton as RNAddToWalletButton} from '@expensify/react-native-wallet'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; import {Alert} from 'react-native'; import type AddToWalletButtonProps from './types'; @@ -26,11 +28,16 @@ const isIOS = getPlatform() === CONST.PLATFORM.IOS; function AddToWalletButton({card, cardHolderName, cardDescription, style}: AddToWalletButtonProps) { const [isLoading, setIsLoading] = useState(false); const styles = useThemeStyles(); + const {executeScenario} = useMultifactorAuthentication(); const {isInWallet, isLoading: isCardLoading, isCardAvailable, isWalletAvailable} = useIsCardInWallet(card); - const handleOnPress = useCallback(() => { + const handleOnPress = () => { + if (isUkEuExpensifyCard(card)) { + executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET, {card, cardHolderName, cardDescription}); + return; + } setIsLoading(true); - handleAddCardToWallet(card, cardHolderName, cardDescription, () => setIsLoading(false)) + handleAddCardToWallet(card, cardHolderName, cardDescription) .then((status: TokenizationStatus) => { if (status === 'success') { Log.info('Card added to wallet'); @@ -47,7 +54,7 @@ function AddToWalletButton({card, cardHolderName, cardDescription, style}: AddTo Log.warn(`Error while adding card to wallet: ${error}`); Alert.alert('Failed to add card to wallet', 'Please try again later.'); }); - }, [card, cardDescription, cardHolderName]); + }; if (!isWalletAvailable || isInWallet == null || isInWallet || !isCardAvailable) { return null; diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx index 4f2c928a9243..a707afe9f413 100644 --- a/src/components/AddressForm.tsx +++ b/src/components/AddressForm.tsx @@ -7,13 +7,14 @@ import type {Country} from '@src/CONST'; import CONST from '@src/CONST'; import type ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/HomeAddressForm'; -import type {Errors} from '@src/types/onyx/OnyxCommon'; + +import type {ReactNode} from 'react'; import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useCallback} from 'react'; +import React, {createContext, useCallback, useContext} from 'react'; import {View} from 'react-native'; -import type {FormOnyxValues} from './Form/types'; +import type {FormInputErrors, FormOnyxValues} from './Form/types'; import type {State} from './StateSelector'; import AddressSearch from './AddressSearch'; @@ -28,7 +29,9 @@ type CountryZipRegex = { samples?: string; }; -type AddressFormProps = { +type AddressFormID = typeof ONYXKEYS.FORMS.HOME_ADDRESS_FORM | typeof ONYXKEYS.FORMS.WORKSPACE_OFFICE_LOCATION_FORM; + +type AddressFormProps = { /** Address city field */ city?: string; @@ -51,7 +54,7 @@ type AddressFormProps = { onAddressChanged?: (value: unknown, key: unknown) => void; /** Callback which is executed when the user submits his address changes */ - onSubmit: (values: FormOnyxValues) => void; + onSubmit: (values: FormOnyxValues) => void; /** Whether or not should the form data should be saved as draft */ shouldSaveDraft?: boolean; @@ -60,7 +63,13 @@ type AddressFormProps = { submitButtonText?: string; /** A unique Onyx key identifying the form */ - formID: typeof ONYXKEYS.FORMS.HOME_ADDRESS_FORM; + formID: TFormID; + + /** The inputs of the form, which place the address inputs with ``. Defaults to the address inputs alone. */ + children?: ReactNode; + + /** Validates the inputs the form adds to the address inputs. Its errors are added to the address errors. */ + validate?: (values: FormOnyxValues) => Record; /** Whether to hide the country selector (e.g. when country cannot be changed) */ shouldHideCountrySelector?: boolean; @@ -86,26 +95,36 @@ type AddressFormProps = { addBottomSafeAreaPadding?: boolean; }; -function AddressForm({ - city = '', - country = '', - formID, - onAddressChanged = () => {}, - onSubmit, - shouldSaveDraft = false, - state = '', - street1 = '', - street2 = '', - submitButtonText = '', - zip = '', - shouldHideCountrySelector = false, - enabledWhenOffline: enabledWhenOfflineProp = true, - shouldRequireZip = false, - shouldValidatePhysicalAddress = false, - addBottomSafeAreaPadding = true, -}: AddressFormProps) { +/** The values the address inputs start from and report changes to, shared by AddressForm with its AddressForm.Fields */ +type AddressFieldsSettings = { + city: string; + country: Country | ''; + state: string; + street1: string; + street2: string; + zip: string; + onAddressChanged: (value: unknown, key: unknown) => void; + shouldSaveDraft: boolean; + shouldHideCountrySelector: boolean; +}; + +const AddressFieldsContext = createContext({ + city: '', + country: '', + state: '', + street1: '', + street2: '', + zip: '', + onAddressChanged: () => {}, + shouldSaveDraft: false, + shouldHideCountrySelector: false, +}); + +/** The address inputs of an AddressForm */ +function AddressFormFields() { const styles = useThemeStyles(); const {translate} = useLocalize(); + const {city, country, state, street1, street2, zip, onAddressChanged, shouldSaveDraft, shouldHideCountrySelector} = useContext(AddressFieldsContext); const zipSampleFormat = (country && (COMMON_CONST.COUNTRY_ZIP_REGEX_DATA[country] as CountryZipRegex)?.samples) ?? ''; @@ -113,101 +132,8 @@ function AddressForm({ const isUSAForm = country === CONST.COUNTRY.US; - /** - * @param translate - translate function - * @param isUSAForm - selected country ISO code is US - * @param values - form input values - * @returns - An object containing the errors for each inputID - */ - - const validator = useCallback( - (rawValues: FormOnyxValues): Errors => { - // When hidden, the country input is unregistered so fall back to the country prop. - const values = shouldHideCountrySelector ? {...rawValues, country: rawValues.country || country} : rawValues; - - const errors: Errors & { - zipPostCode?: string | string[]; - } = {}; - const baseRequiredFields = shouldHideCountrySelector ? (['addressLine1', 'city', 'state'] as const) : (['addressLine1', 'city', 'country', 'state'] as const); - const requiredFields = shouldRequireZip ? ([...baseRequiredFields, 'zipPostCode'] as const) : baseRequiredFields; - - // Check "State" dropdown is a valid state if selected Country is USA - if (values.country === CONST.COUNTRY.US && !values.state) { - errors.state = translate('common.error.fieldRequired'); - } - - // Add "Field required" errors if any required field is empty - for (const fieldKey of requiredFields) { - const fieldValue = values[fieldKey] ?? ''; - if (isRequiredFulfilled(fieldValue)) { - continue; - } - - errors[fieldKey] = translate('common.error.fieldRequired'); - } - - if (values.addressLine1.length > CONST.FORM_CHARACTER_LIMIT) { - errors.addressLine1 = translate('common.error.characterLimitExceedCounter', values.addressLine1.length, CONST.FORM_CHARACTER_LIMIT); - } - - if (values.addressLine2.length > CONST.FORM_CHARACTER_LIMIT) { - errors.addressLine2 = translate('common.error.characterLimitExceedCounter', values.addressLine2.length, CONST.FORM_CHARACTER_LIMIT); - } - - if (values.city.length > CONST.FORM_CHARACTER_LIMIT) { - errors.city = translate('common.error.characterLimitExceedCounter', values.city.length, CONST.FORM_CHARACTER_LIMIT); - } - - if (values.country !== CONST.COUNTRY.US && values.state.length > CONST.STATE_CHARACTER_LIMIT) { - errors.state = translate('common.error.characterLimitExceedCounter', values.state.length, CONST.STATE_CHARACTER_LIMIT); - } - - if (shouldValidatePhysicalAddress) { - const addressLine1Error = getInvalidAddressErrorTranslationPath(values.addressLine1); - if (values.addressLine1 && addressLine1Error) { - errors.addressLine1 = translate(addressLine1Error); - } - - const addressLine2Error = getInvalidAddressErrorTranslationPath(values.addressLine2); - if (values.addressLine2 && addressLine2Error) { - errors.addressLine2 = translate(addressLine2Error); - } - } - - // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object - const countryRegexDetails = (values.country ? COMMON_CONST.COUNTRY_ZIP_REGEX_DATA?.[values.country] : {}) as CountryZipRegex; - - // The postal code system might not exist for a country, so no regex either for them. - const countrySpecificZipRegex = countryRegexDetails?.regex; - const countryZipFormat = countryRegexDetails?.samples ?? ''; - - if (countrySpecificZipRegex) { - if (!countrySpecificZipRegex.test(values.zipPostCode?.trim().toUpperCase())) { - if (isRequiredFulfilled(values.zipPostCode?.trim())) { - errors.zipPostCode = translate('privatePersonalDetails.error.incorrectZipFormat', countryZipFormat); - } else { - errors.zipPostCode = translate('common.error.fieldRequired'); - } - } - } else if (!COMMON_CONST.GENERIC_ZIP_CODE_REGEX.test(values?.zipPostCode?.trim()?.toUpperCase() ?? '')) { - errors.zipPostCode = translate('privatePersonalDetails.error.incorrectZipFormat'); - } - - return errors; - }, - [translate, shouldHideCountrySelector, country, shouldRequireZip, shouldValidatePhysicalAddress], - ); - return ( - + <> + + ); +} + +function AddressForm({ + city = '', + country = '', + formID, + onAddressChanged = () => {}, + onSubmit, + shouldSaveDraft = false, + state = '', + street1 = '', + street2 = '', + submitButtonText = '', + zip = '', + shouldHideCountrySelector = false, + enabledWhenOffline: enabledWhenOfflineProp = true, + shouldRequireZip = false, + shouldValidatePhysicalAddress = false, + addBottomSafeAreaPadding = true, + children, + validate, +}: AddressFormProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const validator = useCallback( + (rawValues: FormOnyxValues): FormInputErrors => { + // Every form rendered here has the home address fields, so they are validated the same way + const addressValues: FormOnyxValues = rawValues; + + // When hidden, the country input is unregistered so fall back to the country prop. + const values = shouldHideCountrySelector ? {...addressValues, country: addressValues.country || country} : addressValues; + + const errors: Record = {}; + const baseRequiredFields = shouldHideCountrySelector ? (['addressLine1', 'city', 'state'] as const) : (['addressLine1', 'city', 'country', 'state'] as const); + const requiredFields = shouldRequireZip ? ([...baseRequiredFields, 'zipPostCode'] as const) : baseRequiredFields; + + // Check "State" dropdown is a valid state if selected Country is USA + if (values.country === CONST.COUNTRY.US && !values.state) { + errors.state = translate('common.error.fieldRequired'); + } + + // Add "Field required" errors if any required field is empty + for (const fieldKey of requiredFields) { + const fieldValue = values[fieldKey] ?? ''; + if (isRequiredFulfilled(fieldValue)) { + continue; + } + + errors[fieldKey] = translate('common.error.fieldRequired'); + } + + if (values.addressLine1.length > CONST.FORM_CHARACTER_LIMIT) { + errors.addressLine1 = translate('common.error.characterLimitExceedCounter', values.addressLine1.length, CONST.FORM_CHARACTER_LIMIT); + } + + if (values.addressLine2.length > CONST.FORM_CHARACTER_LIMIT) { + errors.addressLine2 = translate('common.error.characterLimitExceedCounter', values.addressLine2.length, CONST.FORM_CHARACTER_LIMIT); + } + + if (values.city.length > CONST.FORM_CHARACTER_LIMIT) { + errors.city = translate('common.error.characterLimitExceedCounter', values.city.length, CONST.FORM_CHARACTER_LIMIT); + } + + if (values.country !== CONST.COUNTRY.US && values.state.length > CONST.STATE_CHARACTER_LIMIT) { + errors.state = translate('common.error.characterLimitExceedCounter', values.state.length, CONST.STATE_CHARACTER_LIMIT); + } + + if (shouldValidatePhysicalAddress) { + const addressLine1Error = getInvalidAddressErrorTranslationPath(values.addressLine1); + if (values.addressLine1 && addressLine1Error) { + errors.addressLine1 = translate(addressLine1Error); + } + + const addressLine2Error = getInvalidAddressErrorTranslationPath(values.addressLine2); + if (values.addressLine2 && addressLine2Error) { + errors.addressLine2 = translate(addressLine2Error); + } + } + + // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object + const countryRegexDetails = (values.country ? COMMON_CONST.COUNTRY_ZIP_REGEX_DATA?.[values.country] : {}) as CountryZipRegex; + + // The postal code system might not exist for a country, so no regex either for them. + const countrySpecificZipRegex = countryRegexDetails?.regex; + const countryZipFormat = countryRegexDetails?.samples ?? ''; + + if (countrySpecificZipRegex) { + if (!countrySpecificZipRegex.test(values.zipPostCode?.trim().toUpperCase())) { + if (isRequiredFulfilled(values.zipPostCode?.trim())) { + errors.zipPostCode = translate('privatePersonalDetails.error.incorrectZipFormat', countryZipFormat); + } else { + errors.zipPostCode = translate('common.error.fieldRequired'); + } + } + } else if (!COMMON_CONST.GENERIC_ZIP_CODE_REGEX.test(values?.zipPostCode?.trim()?.toUpperCase() ?? '')) { + errors.zipPostCode = translate('privatePersonalDetails.error.incorrectZipFormat'); + } + + // TypeScript can't tell that the address input IDs are keys of a generic form, though every form in AddressFormID has them + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (validate ? {...errors, ...validate(rawValues)} : errors) as FormInputErrors; + }, + [translate, shouldHideCountrySelector, country, shouldRequireZip, shouldValidatePhysicalAddress, validate], + ); + + return ( + + + {children ?? } + ); } -export default AddressForm; +export default Object.assign(AddressForm, {Fields: AddressFormFields}); diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index bb0e131c1d96..f6a10263e150 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -223,7 +223,6 @@ function AddressSearch({ const countryFallback = Object.keys(CONST.ALL_COUNTRIES).find((country) => country === countryFallbackLongName); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const country = countryPrimary || countryFallback || ''; const values = { diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index 9da049afcd37..926c78acd9ea 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -47,7 +47,10 @@ type AmountTextInputProps = { /** Component to render on the right hand side of the input - only shown if clear button is not rendered */ rightHandSideComponent?: React.ReactNode; -} & Pick; +} & Pick< + BaseTextInputProps, + 'autoFocus' | 'autoGrowExtraSpace' | 'containerStyles' | 'submitBehavior' | 'ref' | 'onFocus' | 'onBlur' | 'disabled' | 'accessibilityLabel' | 'shouldAllowFocusInLandscapeMode' +>; function AmountTextInput({ formattedAmount, diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 4b3e7b1dd6c6..3fa41799e214 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -365,7 +365,6 @@ function AttachmentPicker({ return Promise.resolve(null); } - /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ const fileDataName = ('fileName' in fileData && fileData.fileName) || ('name' in fileData && fileData.name) || ''; const fileDataUri = ('uri' in fileData && fileData.uri) || ''; diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx index c207987a8ad7..f135960ba2ae 100644 --- a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx @@ -21,7 +21,6 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {RefObject} from 'react'; import type {ListRenderItemInfo} from 'react-native'; import type {LegacyComposedGesture, GestureType} from 'react-native-gesture-handler'; @@ -78,8 +77,8 @@ function AttachmentCarouselView({ const [activeAttachmentID, setActiveAttachmentID] = useState(attachmentID ?? source); - const pagerRef = useRef(null); - const scrollRef = useAnimatedRef>>(); + const pagerRef = useRef(undefined); + const scrollRef = useAnimatedRef>(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const modalStyles = styles.centeredModalStyles(shouldUseNarrowLayout, true); @@ -107,7 +106,7 @@ function AttachmentCarouselView({ return; } - const item = entry.item as Attachment; + const item = entry.item; if (entry.index != null) { setPage(entry.index); setActiveAttachmentID(item.attachmentID ?? item.source); @@ -120,21 +119,22 @@ function AttachmentCarouselView({ [isFullScreenRef, onNavigate, setPage, setActiveAttachmentID], ); - /** Increments or decrements the index to get another selected item */ + /** Increments or decrements the index to get another selected item, returning whether the carousel moved */ const cycleThroughAttachments = useCallback( (deltaSlide: number) => { if (isFullScreen) { - return; + return false; } const nextIndex = page + deltaSlide; const nextItem = attachments.at(nextIndex); if (!nextItem || nextIndex < 0 || !scrollRef.current) { - return; + return false; } scrollRef.current.scrollToIndex({index: nextIndex, animated: canUseTouchScreen}); + return true; }, [attachments, canUseTouchScreen, isFullScreen, page, scrollRef], ); @@ -230,7 +230,7 @@ function AttachmentCarouselView({ isPagerScrolling.set(false); scrollTo(scrollRef, newIndex * cellWidth, 0, true); }) - .withRef(pagerRef as RefObject), + .withRef(pagerRef), [attachments.length, canUseTouchScreen, cellWidth, page, isScrollEnabled, scrollRef, isPagerScrolling], ); diff --git a/src/components/Attachments/AttachmentCarousel/CarouselActions.tsx b/src/components/Attachments/AttachmentCarousel/CarouselActions.tsx index 2b4b725725e1..811a72c0f8dd 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselActions.tsx +++ b/src/components/Attachments/AttachmentCarousel/CarouselActions.tsx @@ -3,18 +3,20 @@ import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import CONST from '@src/CONST'; type CarouselActionsProps = { - onCycleThroughAttachments: (deltaSlide: number) => void; + /** Moves the carousel by the given number of attachments, returning whether it moved */ + onCycleThroughAttachments: (deltaSlide: number) => boolean; }; function CarouselActions({onCycleThroughAttachments}: CarouselActionsProps) { useKeyboardShortcut( CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT, (event) => { - if (event?.target instanceof HTMLElement) { - // prevents focus from highlighting around the modal - event.target.blur(); + // Blur only when the carousel moves, so a focused PDF on the first attachment keeps scrolling with the keyboard + if (!onCycleThroughAttachments(-1) || !(event?.target instanceof HTMLElement)) { + return; } - onCycleThroughAttachments(-1); + // prevents focus from highlighting around the modal + event.target.blur(); }, {captureOnInputs: false}, ); @@ -22,11 +24,12 @@ function CarouselActions({onCycleThroughAttachments}: CarouselActionsProps) { useKeyboardShortcut( CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT, (event) => { - if (event?.target instanceof HTMLElement) { - // prevents focus from highlighting around the modal - event.target.blur(); + // Blur only when the carousel moves, so a focused PDF on the last attachment keeps scrolling with the keyboard + if (!onCycleThroughAttachments(1) || !(event?.target instanceof HTMLElement)) { + return; } - onCycleThroughAttachments(1); + // prevents focus from highlighting around the modal + event.target.blur(); }, {captureOnInputs: false}, ); diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx index ffb650ffe561..e7218577eb93 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx +++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx @@ -76,7 +76,6 @@ function CarouselItem({item, onPress, isFocused, isModalHovered, reportID}: Caro style={[styles.attachmentRevealButtonContainer]} onPress={onPress} accessibilityRole={CONST.ROLE.BUTTON} - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing accessibilityLabel={item.file?.name || translate('attachmentView.unknownFilename')} sentryLabel={CONST.SENTRY_LABEL.ATTACHMENT_CAROUSEL.ITEM} > diff --git a/src/components/Attachments/AttachmentCarousel/Pager/types.ts b/src/components/Attachments/AttachmentCarousel/Pager/types.ts index 8d84beb7a1ad..a21d16796e06 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/types.ts +++ b/src/components/Attachments/AttachmentCarousel/Pager/types.ts @@ -2,6 +2,7 @@ import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import type {ForwardedRef} from 'react'; import type {GestureType} from 'react-native-gesture-handler'; +import type {BaseGestureConfig} from 'react-native-gesture-handler/lib/typescript/handlers/gestures/gesture'; import type PagerView from 'react-native-pager-view'; import type {SharedValue} from 'react-native-reanimated'; @@ -28,7 +29,7 @@ type AttachmentCarouselPagerStateContextType = { activePage: number; /** Ref to the active attachment */ - pagerRef?: ForwardedRef; + pagerRef?: ForwardedRef | BaseGestureConfig['ref']; /** Indicates if the pager is currently scrolling */ isPagerScrolling: SharedValue; diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts index 1333bceb87db..bc9927bc8044 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts +++ b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts @@ -42,6 +42,8 @@ function extractAttachments( const attachments: Attachment[] = []; const canUserPerformAction = canUserPerformWriteAction(report, isReportArchived); let currentLink = ''; + // A video without data-name waits for its inner text, which server-rendered ))} diff --git a/src/components/Avatar/connected/ChatThreadAvatar.tsx b/src/components/Avatar/connected/ChatThreadAvatar.tsx index 8782125c547d..805ecf8e6159 100644 --- a/src/components/Avatar/connected/ChatThreadAvatar.tsx +++ b/src/components/Avatar/connected/ChatThreadAvatar.tsx @@ -7,6 +7,7 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useDefaultAvatars from '@hooks/useDefaultAvatars'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useParentReportAction from '@hooks/useParentReportAction'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -28,7 +29,6 @@ import type {ColorValue, StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; import {reportAvatarFieldsSelector, reportAvatarKindSelector} from '@selectors/Report'; -import {getParentReportActionSelector} from '@selectors/ReportAction'; import React from 'react'; import useAccountIcons, {seedFallbackIcons} from './useAccountIcons'; @@ -73,9 +73,7 @@ function ChatThreadAvatar({reportID, size, backdropColor, containerStyle, subscr const [thread] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: reportAvatarFieldsSelector}); const parentReportID = getNonEmptyStringOnyxID(thread?.parentReportID); const [parentKind] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, {selector: reportAvatarKindSelector}); - const [parentAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { - selector: (actions) => getParentReportActionSelector(actions, thread?.parentReportActionID), - }); + const parentAction = useParentReportAction(thread, {shouldIgnoreThreadReplies: true}); // A transaction thread points `chatReportID` at its parent, a comment thread leaves it unset. Only a linked thread shows the copilot, the revealed agent and the expense subscript. const isParentActionLinked = !!parentKind && !!thread?.chatReportID && thread.chatReportID === thread.parentReportID; diff --git a/src/components/Avatar/connected/GroupChatAvatar.tsx b/src/components/Avatar/connected/GroupChatAvatar.tsx index 72be1e0d3f0a..90564ed0f581 100644 --- a/src/components/Avatar/connected/GroupChatAvatar.tsx +++ b/src/components/Avatar/connected/GroupChatAvatar.tsx @@ -44,7 +44,6 @@ function GroupChatAvatar({reportID, size, containerStyle, fallbackDisplayName}: const avatar: AvatarIcon = { // A group chat with no uploaded avatar stores an empty string, which has to fall through to the default avatar - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing source: report?.avatarUrl || getDefaultGroupAvatar(reportID), id: CONST.DEFAULT_MISSING_ID, type: CONST.ICON_TYPE_AVATAR, diff --git a/src/components/Avatar/connected/usePolicyIcons.ts b/src/components/Avatar/connected/usePolicyIcons.ts index a8e06d50c612..435d49078fec 100644 --- a/src/components/Avatar/connected/usePolicyIcons.ts +++ b/src/components/Avatar/connected/usePolicyIcons.ts @@ -23,7 +23,6 @@ function usePolicyIcons(policyID?: string, accountID?: number, fallbackDisplayNa type: CONST.ICON_TYPE_WORKSPACE, name, // A workspace with no uploaded avatar has `avatarURL: ''`, which has to fall through to the default avatar. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing source: policyAvatar?.avatarURL || getDefaultWorkspaceAvatar(name), }; diff --git a/src/components/Avatar/connected/useReportWorkspaceIcon.ts b/src/components/Avatar/connected/useReportWorkspaceIcon.ts index 25298dc0b287..e3ada94c08d0 100644 --- a/src/components/Avatar/connected/useReportWorkspaceIcon.ts +++ b/src/components/Avatar/connected/useReportWorkspaceIcon.ts @@ -28,10 +28,8 @@ function useReportWorkspaceIcon(report: WorkspaceIconReportFields | undefined): const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: policyAvatarFieldsSelector}); // Without a policy row, the chat's carried fields come before the report's, which can hold stale values mid-move. '' (no name) falls through. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const workspaceName = policy?.name || parentChat?.policyName || parentChat?.oldPolicyName || report?.policyName || report?.oldPolicyName || translate('workspace.common.unavailable'); // Carried avatars only apply while the policy row is missing entirely. An avatar can be '' (no uploaded avatar), which must fall through - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const avatarURL = policy ? policy.avatarURL : parentChat?.policyAvatar || report?.policyAvatar; return { @@ -39,7 +37,6 @@ function useReportWorkspaceIcon(report: WorkspaceIconReportFields | undefined): type: CONST.ICON_TYPE_WORKSPACE, name: workspaceName, // '' (no uploaded avatar) falls through to the default - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing source: avatarURL || getDefaultWorkspaceAvatar(workspaceName), }; } diff --git a/src/components/Avatar/layouts/SingleAvatar.tsx b/src/components/Avatar/layouts/SingleAvatar.tsx index 7b63c3755ba1..a6fb313cedd8 100644 --- a/src/components/Avatar/layouts/SingleAvatar.tsx +++ b/src/components/Avatar/layouts/SingleAvatar.tsx @@ -13,14 +13,18 @@ type SingleAvatarProps = BaseAvatarProps & { avatar: AvatarIcon; containerStyles: StyleProp; + + /** Styles for the tooltip wrapper. Put outer spacing here so the tooltip centers on the avatar, not on the avatar plus its margin. */ + style?: StyleProp; }; /** `SingleAvatar` renders one avatar wrapped in a `UserDetailsTooltip`, used when there is a single actor to display. */ -function SingleAvatar({avatar, size, containerStyles, fallbackDisplayName}: SingleAvatarProps) { +function SingleAvatar({avatar, size, containerStyles, fallbackDisplayName, style}: SingleAvatarProps) { return ( diff --git a/src/components/AvatarCropModal/AvatarCropView.tsx b/src/components/AvatarCropModal/AvatarCropView.tsx index 2066cd569d00..28c67a1d1e35 100644 --- a/src/components/AvatarCropModal/AvatarCropView.tsx +++ b/src/components/AvatarCropModal/AvatarCropView.tsx @@ -21,11 +21,11 @@ import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; import type {LayoutChangeEvent} from 'react-native'; -import type {GestureUpdateEvent, PanGestureChangeEventPayload, PanGestureHandlerEventPayload} from 'react-native-gesture-handler'; +import type {PanGestureActiveEvent} from 'react-native-gesture-handler'; import React, {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; -import {Gesture, GestureHandlerRootView} from 'react-native-gesture-handler'; +import {GestureHandlerRootView, usePanGesture} from 'react-native-gesture-handler'; import ImageSize from 'react-native-image-size'; import {interpolate, useSharedValue} from 'react-native-reanimated'; import {scheduleOnUI} from 'react-native-worklets'; @@ -189,11 +189,13 @@ function AvatarCropView({imageUri = '', imageName = '', imageType = '', onClose, * Calculates new x & y image translate value on image panning * and updates image's offset. */ - const panGesture = Gesture.Pan().onChange((event) => { - const newX = translateX.get() + event.changeX; - const newY = translateY.get() + event.changeY; + const panGesture = usePanGesture({ + onUpdate: (event) => { + const newX = translateX.get() + event.changeX; + const newY = translateY.get() + event.changeY; - updateImageOffset(newX, newY); + updateImageOffset(newX, newY); + }, }); // This effect is needed to recalculate the maximum offset values @@ -230,7 +232,7 @@ function AvatarCropView({imageUri = '', imageName = '', imageType = '', onClose, isPressableEnabled.set(false); }, - onChange: (event: GestureUpdateEvent) => { + onUpdate: (event: PanGestureActiveEvent) => { 'worklet'; const newSliderValue = clamp(translateSlider.get() + event.changeX, [0, sliderContainerSize]); diff --git a/src/components/AvatarCropModal/ImageCropView.tsx b/src/components/AvatarCropModal/ImageCropView.tsx index cf7127f89563..f53f9d932d63 100644 --- a/src/components/AvatarCropModal/ImageCropView.tsx +++ b/src/components/AvatarCropModal/ImageCropView.tsx @@ -9,12 +9,12 @@ import ControlSelection from '@libs/ControlSelection'; import type IconAsset from '@src/types/utils/IconAsset'; import type {ComponentRef} from 'react'; -import type {LegacyPanGesture} from 'react-native-gesture-handler'; +import type {PanGesture} from 'react-native-gesture-handler'; import type {SharedValue} from 'react-native-reanimated'; import React from 'react'; import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {GestureDetector} from 'react-native-gesture-handler'; import Animated, {interpolate, useAnimatedStyle} from 'react-native-reanimated'; type ImageCropViewProps = { @@ -39,14 +39,14 @@ type ImageCropViewProps = { /** The scale factor of the image */ scale: SharedValue; - /** Configuration object for pan gesture for handling image panning */ - panGesture?: LegacyPanGesture; + /** Pan gesture for handling image panning */ + panGesture: PanGesture; /** Image crop vector mask */ maskImage?: IconAsset; }; -function ImageCropView({imageUri = '', containerSize = 0, panGesture = Gesture.Pan(), maskImage, ...props}: ImageCropViewProps) { +function ImageCropView({imageUri = '', containerSize = 0, panGesture, maskImage, ...props}: ImageCropViewProps) { const icons = useMemoizedLazyExpensifyIcons(['ImageCropCircleMask']); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); diff --git a/src/components/AvatarCropModal/Slider.tsx b/src/components/AvatarCropModal/Slider.tsx index 9184df078fd4..eb7c2cad7d95 100644 --- a/src/components/AvatarCropModal/Slider.tsx +++ b/src/components/AvatarCropModal/Slider.tsx @@ -6,12 +6,12 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isMobileSafari} from '@libs/Browser'; import ControlSelection from '@libs/ControlSelection'; -import type {GestureUpdateEvent, PanGestureChangeEventPayload, PanGestureHandlerEventPayload} from 'react-native-gesture-handler'; +import type {PanGestureActiveEvent} from 'react-native-gesture-handler'; import type {SharedValue} from 'react-native-reanimated'; import React, {useState} from 'react'; import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {GestureDetector, usePanGesture} from 'react-native-gesture-handler'; import Animated, {useAnimatedStyle} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -19,7 +19,7 @@ type SliderProps = { /** React-native-reanimated lib handler which executes when the user is panning slider */ gestureCallbacks: { onBegin: () => void; - onChange: (event: GestureUpdateEvent) => void; + onUpdate: (event: PanGestureActiveEvent) => void; onFinalize: () => void; }; @@ -43,19 +43,20 @@ function Slider({sliderValue, gestureCallbacks}: SliderProps) { }; }); - const panGesture = Gesture.Pan() - .minDistance(5) - .onBegin(() => { + const panGesture = usePanGesture({ + minDistance: 5, + onBegin: () => { scheduleOnRN(setTooltipIsVisible, false); gestureCallbacks.onBegin(); - }) - .onChange((event) => { - gestureCallbacks.onChange(event); - }) - .onFinalize(() => { + }, + onUpdate: (event) => { + gestureCallbacks.onUpdate(event); + }, + onFinalize: () => { scheduleOnRN(setTooltipIsVisible, true); gestureCallbacks.onFinalize(); - }); + }, + }); // We're preventing text selection with ControlSelection.blockElement to prevent safari // default behaviour of cursor - I-beam cursor on drag. See https://github.com/Expensify/App/issues/13688 diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index d05894808357..80d28f64cc33 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -58,6 +58,7 @@ import DisplayNames from './DisplayNames'; import Icon from './Icon'; import ParentNavigationSubtitle from './ParentNavigationSubtitle'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; +import ReportHeaderTitleSkeleton from './ReportHeaderTitleSkeleton'; import Text from './Text'; type AvatarWithDisplayNameProps = { @@ -88,22 +89,42 @@ type AvatarWithDisplayNameProps = { avatarBorderColor?: ColorValue; customDisplayNameStyle?: TextStyle; + + /** Whether the displayed report title should be directly selectable */ + isDisplayNameSelectable?: boolean; + + /** The style of the parent navigation subtitle text */ parentNavigationSubtitleTextStyles?: StyleProp; parentNavigationStatusContainerStyles?: StyleProp; }; -function getCustomDisplayName( - shouldUseCustomSearchTitleName: boolean, - report: OnyxEntry, - title: string, - displayNamesWithTooltips: DisplayNameWithTooltips, - transactions: TransactionListItemType[], - shouldUseFullTitle: boolean, - customSearchDisplayStyle: TextStyle[], - regularStyle: TextStyle[], - isAnonymous: boolean, - isMoneyRequestOrReport: boolean, -): React.ReactNode { +type CustomDisplayNameOptions = { + shouldUseCustomSearchTitleName: boolean; + report: OnyxEntry; + title: string; + displayNamesWithTooltips: DisplayNameWithTooltips; + transactions: TransactionListItemType[]; + shouldUseFullTitle: boolean; + customSearchDisplayStyle: TextStyle[]; + regularStyle: TextStyle[]; + isAnonymous: boolean; + isMoneyRequestOrReport: boolean; + isSelectable: boolean; +}; + +function getCustomDisplayName({ + shouldUseCustomSearchTitleName, + report, + title, + displayNamesWithTooltips, + transactions, + shouldUseFullTitle, + customSearchDisplayStyle, + regularStyle, + isAnonymous, + isMoneyRequestOrReport, + isSelectable, +}: CustomDisplayNameOptions): React.ReactNode { const reportName = report?.reportName ?? CONST.REPORT.DEFAULT_REPORT_NAME; const isIOUOrInvoice = report?.type === CONST.REPORT.TYPE.IOU || report?.type === CONST.REPORT.TYPE.INVOICE; const hasTransactions = transactions.length > 0; @@ -164,6 +185,7 @@ function getCustomDisplayName( numberOfLines={numberOfLines} textStyles={textStyles} shouldUseFullTitle={shouldUseFullTitleProp} + isSelectable={isSelectable} /> ); } @@ -180,6 +202,7 @@ function AvatarWithDisplayName({ avatarBorderColor: avatarBorderColorProp, shouldDisplayStatus = false, customDisplayNameStyle = {}, + isDisplayNameSelectable = false, parentNavigationSubtitleTextStyles, parentNavigationStatusContainerStyles = {}, }: AvatarWithDisplayNameProps) { @@ -198,10 +221,33 @@ function AvatarWithDisplayName({ const {isSmallScreenWidth} = useResponsiveLayout(); const derivedReportNames = useDerivedReportNamesByReportIDs([report?.reportID, report?.parentReportID]); - const derivedParentReportName = getReportNameFromNames(derivedReportNames, report?.parentReportID); const isReportArchived = useReportIsArchived(report?.reportID); - const title = getReportName(report, getReportNameFromNames(derivedReportNames, report?.reportID)); const isParentReportArchived = useReportIsArchived(report?.parentReportID); + const icons = useMemoizedLazyExpensifyIcons(['Pencil']); + + const parentReportAction = report?.parentReportActionID ? parentReportActions?.[report.parentReportActionID] : undefined; + const parentReportActionActorAccountID = parentReportAction?.actorAccountID; + const actorAccountID = useRef(null); + useEffect(() => { + if (!report?.parentReportActionID) { + return; + } + actorAccountID.current = parentReportActionActorAccountID ?? CONST.DEFAULT_NUMBER_ID; + }, [parentReportActionActorAccountID, report?.parentReportActionID]); + + const derivedReportName = getReportNameFromNames(derivedReportNames, report?.reportID); + + // Placeholder name with no derived name means the name is still loading. Search rows may never get a derived name, so skip them. + if (!shouldUseCustomSearchTitleName && derivedReportName === undefined && report?.reportName === CONST.REPORT.DEFAULT_REPORT_NAME) { + return ( + + + + ); + } + + const derivedParentReportName = getReportNameFromNames(derivedReportNames, report?.parentReportID); + const title = getReportName(report, derivedReportName); const subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, rules, true, isReportArchived); const parentNavigationSubtitleData = getParentNavigationSubtitle(report, policy, conciergeReportID, translate, formatPhoneNumber, derivedParentReportName, isParentReportArchived); const isMoneyRequestOrReport = isMoneyRequestReport(report) || isMoneyRequest(report) || isTrackExpenseReport(report) || isInvoiceReport(report); @@ -211,22 +257,10 @@ function AvatarWithDisplayName({ const statusText = shouldDisplayStatus ? getReportStatusTranslation({stateNum: report?.stateNum, statusNum: report?.statusNum, translate}) : undefined; const statusTooltipText = shouldDisplayStatus ? getReportStatusTooltipTranslation({stateNum: report?.stateNum, statusNum: report?.statusNum, translate}) : undefined; const reportStatusColorStyle = shouldDisplayStatus ? getReportStatusColorStyle(theme, report?.stateNum, report?.statusNum) : {}; - const icons = useMemoizedLazyExpensifyIcons(['Pencil']); const shouldShowReportTitleEditButton = shouldEnableDetailPageNavigation && !isSmallScreenWidth && canEditReportTitle(report, policy, currentUserAccountID, rules); - - const parentReportAction = report?.parentReportActionID ? parentReportActions?.[report.parentReportActionID] : undefined; const humanAgentAccountID = getHumanAgentAccountIDFromReportAction(parentReportAction); const humanAgentName = getHumanAgentFirstName(parentReportAction, personalDetails); - const parentReportActionActorAccountID = parentReportAction?.actorAccountID; - const actorAccountID = useRef(null); - useEffect(() => { - if (!report?.parentReportActionID) { - return; - } - actorAccountID.current = parentReportActionActorAccountID ?? CONST.DEFAULT_NUMBER_ID; - }, [parentReportActionActorAccountID, report?.parentReportActionID]); - const goToDetailsPage = () => { navigateToDetailsPage(report, isInSidePanel); }; @@ -272,18 +306,19 @@ function AvatarWithDisplayName({ }; const shouldUseFullTitle = isMoneyRequestOrReport || isAnonymous; - const displayNameContent = getCustomDisplayName( + const displayNameContent = getCustomDisplayName({ shouldUseCustomSearchTitleName, report, title, displayNamesWithTooltips, transactions, shouldUseFullTitle, - [styles.headerText, styles.pre, customDisplayNameStyle], - [isAnonymous ? styles.headerAnonymousFooter : styles.headerText, styles.pre], + customSearchDisplayStyle: [styles.headerText, styles.pre, customDisplayNameStyle], + regularStyle: [isAnonymous ? styles.headerAnonymousFooter : styles.headerText, styles.pre], isAnonymous, isMoneyRequestOrReport, - ); + isSelectable: isDisplayNameSelectable, + }); const multipleAvatars = ( {}, i const {toLocaleDigit} = useLocalize(); const styles = useThemeStyles(); - const [timer, setTimer] = useState(null); + const timerRef = useRef(null); const {isExtraSmallScreenHeight, isInLandscapeMode} = useResponsiveLayout(); const numberPressedRef = useRef(numberPressed); @@ -47,6 +47,18 @@ function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, i numberPressedRef.current = numberPressed; }, [numberPressed]); + // Releasing the button never fires when the pad unmounts mid long press (e.g. on rotation), so stop the interval here + useEffect( + () => () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + ControlSelection.unblock(); + }, + [], + ); + /** * Handle long press key on number pad. * Only handles the '<' key and starts the continuous input timer. @@ -58,11 +70,9 @@ function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, i longPressHandlerStateChanged(true); - const newTimer = setInterval(() => { + timerRef.current = setInterval(() => { numberPressedRef.current?.(key); }, 100); - - setTimer(newTimer); }; let numberPadButtonSize: ValueOf; @@ -99,11 +109,14 @@ function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, i onPress={() => numberPressed(column)} onPressIn={ControlSelection.block} onPressOut={() => { - if (timer) { - clearInterval(timer); + ControlSelection.unblock(); + + if (!timerRef.current) { + return; } - ControlSelection.unblock(); + clearInterval(timerRef.current); + timerRef.current = null; longPressHandlerStateChanged(false); }} onMouseDown={(e) => { diff --git a/src/components/BulkActionBar/BulkActionBarButton.tsx b/src/components/BulkActionBar/BulkActionBarButton.tsx index 544aff053090..6e12a5638216 100644 --- a/src/components/BulkActionBar/BulkActionBarButton.tsx +++ b/src/components/BulkActionBar/BulkActionBarButton.tsx @@ -75,6 +75,8 @@ function BulkActionBarButton({option, onSubItemSelected}: BulkAction size={CONST.BUTTON_SIZE.SMALL} isDisabled={option.disabled} accessibilityLabel={option.text} + accessibilityState={hasSubMenu ? {expanded: isMenuVisible} : undefined} + accessibilityHasPopup={hasSubMenu ? CONST.ROLE.MENU : undefined} sentryLabel={option.sentryLabel} > {!!option.icon && ( diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index a63631deb9dd..bafe5f2c7c23 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -7,6 +7,7 @@ import Text from '@components/Text'; import ThemeProvider from '@components/ThemeProvider'; import ThemeStylesProvider from '@components/ThemeStylesContextProvider'; +import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useInvertedThemePreference from '@hooks/useInvertedThemePreference'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -19,6 +20,8 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Accessibility from '@libs/Accessibility'; +import mergeRefs from '@libs/mergeRefs'; +import moveMiniToolbarFocusWithArrowKey from '@libs/moveContextMenuFocusWithArrowKey'; import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; import {areAllModalsHidden, setDisableDismissOnEscape} from '@userActions/Modal'; @@ -39,6 +42,9 @@ import type {BulkActionBarProps} from './types'; import BulkActionBarButton from './BulkActionBarButton'; import BulkActionBarMenuTheme from './BulkActionBarMenuTheme'; import {defaultPopoverAnchorPosition, MORE_MENU_ANCHOR_ALIGNMENT} from './popoverPosition'; +import useBulkActionBarFocus from './useBulkActionBarFocus'; + +const noop = () => {}; /** * The bar's contents. Everything here takes its colors from the theme it is rendered under, which `BulkActionBar` @@ -77,6 +83,17 @@ function BulkActionBarContent({ const icons = useMemoizedLazyExpensifyIcons(['Close', 'DownArrow', 'UpArrow']); const {calculatePopoverPosition} = usePopoverPosition(); + const isFocused = useIsFocused(); + + // Esc clears the selection, but not while something is open over the bar. An RHP the bar is rendered inside + // reports itself visible too, and is told apart by the screen still being focused. The type is not worth reading + // here, because a popover opened and closed over an RHP leaves it stale at `popover`. + const [modal] = useOnyx(ONYXKEYS.MODAL); + const isCoveredByModal = !!modal?.isVisible && !isFocused; + + const barElementRef = useRef | null>(null); + const {suppressStrayFocusRing, isFocusInsideBar} = useBulkActionBarFocus(barElementRef, isCoveredByModal); + const moreAnchorRef = useRef | null>(null); const [isMoreMenuVisible, setIsMoreMenuVisible] = useState(false); const [moreMenuAnchorPosition, setMoreMenuAnchorPosition] = useState(defaultPopoverAnchorPosition); @@ -87,13 +104,6 @@ function BulkActionBarContent({ const inlineOptions = hasMoreMenu ? options.slice(0, inlineActionCount) : options; const moreOptions = hasMoreMenu ? options.slice(inlineActionCount) : []; - // Esc clears the selection, but not while something is open over the bar. An RHP the bar is rendered inside - // reports itself visible too, and is told apart by the screen still being focused. The type is not worth reading - // here, because a popover opened and closed over an RHP leaves it stale at `popover`. - const [modal] = useOnyx(ONYXKEYS.MODAL); - const isFocused = useIsFocused(); - const isCoveredByModal = !!modal?.isVisible && !isFocused; - const canOwnEscape = !modal?.willAlertModalBecomeVisible && !isCoveredByModal; // The Side Panel is not a `BaseModal`, so it is absent from both the modal state and the registry of open modals. @@ -104,6 +114,7 @@ function BulkActionBarContent({ // `willAlertModalBecomeVisible` is a single flag that every modal writes, so closing the topmost of a stack reads // as though nothing is open any more. The registry of open modals is the only answer that survives stacking, and // it has to be read when the key arrives rather than at subscribe time. + // Esc inside a text field is how you leave the field, so leave the selection alone there. useKeyboardShortcut( CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => { @@ -111,11 +122,17 @@ function BulkActionBarContent({ return; } + suppressStrayFocusRing(); onClearSelection(); }, - {isActive: shouldClearSelectionOnEscape}, + {isActive: shouldClearSelectionOnEscape, captureOnInputs: false}, ); + // The lists this bar floats over run a global Enter shortcut off a keyboard cursor that outlives tabbing away from + // the rows. While one of the bar's own buttons is focused, claim Enter without bubbling so pressing it only works + // that button instead of also opening whichever row the cursor was left on. Same guard as SearchPageFooter's. + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, noop, {isActive: isFocusInsideBar, shouldBubble: false, shouldPreventDefault: false}); + // Escape must not dismiss the screen the selection sits on while the bar is the one answering it. The Side Panel // is deliberately left out of the condition, because each write to this flag resubscribes the app's own Escape // handler to the top of the stack, over the Side Panel's, and that one Escape would then both dismiss the screen @@ -152,16 +169,32 @@ function BulkActionBarContent({ }; }, [isMoreMenuVisible, calculatePopoverPosition]); + // The bar appears where nothing was before, without taking focus, so a screen reader only learns about it and about + // the size of the selection it describes from an announcement. + useAccessibilityAnnouncement(countLabel, !isSelectedCountLoading, {shouldAnnounceOnWeb: true, shouldAnnounceOnNative: true, politeness: 'polite'}); + return ( onBarLayout(event.nativeEvent.layout.width)} > {/* Sized for a three-digit count so the bar keeps still as the selection grows, and so swapping the spinner for the count does not resize it either. */} - {isSelectedCountLoading ? : {countLabel}} + {isSelectedCountLoading ? ( + + ) : ( + {countLabel} + )} {!!noticeText && {noticeText}} {inlineOptions.map((option) => ( @@ -178,6 +211,8 @@ function BulkActionBarContent({ size={CONST.BUTTON_SIZE.SMALL} onPress={() => setIsMoreMenuVisible((isVisible) => !isVisible)} accessibilityLabel={translate('common.more')} + accessibilityState={{expanded: isMoreMenuVisible}} + accessibilityHasPopup={CONST.ROLE.MENU} sentryLabel={CONST.SENTRY_LABEL.BULK_ACTION_BAR.MORE} > {translate('common.more')} @@ -212,7 +247,7 @@ function BulkActionBarContent({ )} {}; + +/** Native has no tab order and no focus ring, so the bar's controls are reached through the screen reader instead. */ +const useBulkActionBarFocus: UseBulkActionBarFocus = () => ({suppressStrayFocusRing: noop, isFocusInsideBar: false}); + +export default useBulkActionBarFocus; diff --git a/src/components/BulkActionBar/useBulkActionBarFocus/index.ts b/src/components/BulkActionBar/useBulkActionBarFocus/index.ts new file mode 100644 index 000000000000..2a2bc210d3e7 --- /dev/null +++ b/src/components/BulkActionBar/useBulkActionBarFocus/index.ts @@ -0,0 +1,156 @@ +import isHTMLElement from '@libs/isHTMLElement'; +import {pickLauncher} from '@libs/LauncherStack'; +import {nominateTriggerFallback} from '@libs/NavigationFocusReturn'; +import markProgrammaticFocus from '@libs/programmaticFocus'; +import restoreFocusWithModality from '@libs/restoreFocusWithModality'; + +import {useEffect, useLayoutEffect, useRef, useState} from 'react'; + +import type UseBulkActionBarFocus from './types'; + +/** + * Whether the browser is already drawing a focus ring on this element. Browsers that cannot be asked are assumed to be + * drawing one, so a ring is never taken away from someone who navigated there with the keyboard. + */ +function isShowingFocusRing(element: HTMLElement): boolean { + try { + return element.matches(':focus-visible'); + } catch { + return true; + } +} + +/** Tracks where focus is relative to the bar, and hands it back when the bar goes away holding it. */ +const useBulkActionBarFocus: UseBulkActionBarFocus = (barRef, isCoveredByScreen) => { + // Where focus was before the bar took it. The bar goes away with the selection, so focus has to be handed back. + const lastFocusedOutsideRef = useRef(null); + + const [isFocusInsideBar, setIsFocusInsideBar] = useState(false); + + useEffect(() => { + const remember = (target: EventTarget | null) => { + if (!isHTMLElement(target) || target === document.body) { + return; + } + + const bar = barRef.current; + if (isHTMLElement(bar) && bar.contains(target)) { + return; + } + + // The role screen and the bar's own "More" menu both take focus to controls that leave with them, and the + // menu renders in a popover outside the bar rather than within it. Remembering either would leave the bar + // holding a target that is already gone when it needs one. A popover is identified by the launcher it + // registered as it opened, which it does before it moves focus into itself. + if (isCoveredByScreen || pickLauncher()) { + return; + } + + lastFocusedOutsideRef.current = target; + // An action that opens a screen is navigated back from, and the restore that follows aims at the bar + // button it was opened from, which the cleared selection has taken away by then. This is the survivor to + // aim at instead, and it is offered here rather than on unmount because the capture happens first. + nominateTriggerFallback(target); + }; + + // The bar appears because of a selection, so the control that made it already holds focus and fires no + // focusin for the listener below to see. + if (!lastFocusedOutsideRef.current) { + remember(document.activeElement); + } + + const handleFocusIn = (event: FocusEvent) => { + const target = event.target; + if (!isHTMLElement(target)) { + return; + } + + const bar = barRef.current; + if (isHTMLElement(bar) && bar.contains(target)) { + setIsFocusInsideBar(true); + return; + } + + setIsFocusInsideBar(false); + remember(target); + }; + + // Focus can also leave for nothing at all, such as an element being removed, which fires no matching focusin. + const handleFocusOut = (event: FocusEvent) => { + if (event.relatedTarget) { + return; + } + + setIsFocusInsideBar(false); + }; + + document.addEventListener('focusin', handleFocusIn); + document.addEventListener('focusout', handleFocusOut); + + return () => { + document.removeEventListener('focusin', handleFocusIn); + document.removeEventListener('focusout', handleFocusOut); + }; + }, [barRef, isCoveredByScreen]); + + // Most of the bar's actions clear the selection themselves, so the bar is torn out from under the button that was + // just pressed and focus would fall to the document. Handing it back here covers every one of those actions, + // rather than only the two the bar runs itself. A layout cleanup rather than a passive one, because this has to + // read the focus before the bar leaves the DOM. + useLayoutEffect( + () => () => { + nominateTriggerFallback(null); + + const bar = barRef.current; + const active = document.activeElement; + + // An action run from a screen opened over the table clears the selection from there, so the bar is gone + // before that screen hands its own focus back and there is nothing left for it to hand back to. Focus has + // already fallen to the document by then, which is the same loss as the bar being torn out from under it. + const hasFocusFallenToDocument = !active || active === document.body; + + // An action run from the bar's own "More" menu is pressed with focus inside a popover that renders outside + // the bar, so the bar does not contain the focus it nonetheless has to hand back. The popover that leaves + // with the bar is the one whose launcher the bar does contain. + const launcher = pickLauncher(); + const isFocusInsideBarPopover = isHTMLElement(bar) && !!launcher && bar.contains(launcher); + if (!hasFocusFallenToDocument && !isFocusInsideBarPopover && !(isHTMLElement(bar) && bar.contains(active))) { + return; + } + + const previous = lastFocusedOutsideRef.current; + if (!previous?.isConnected) { + return; + } + + // The screen that cleared the selection may still be unwinding its focus trap, which would otherwise pull + // focus straight back into the container it is closing. + restoreFocusWithModality(previous); + }, + [barRef], + ); + + const suppressStrayFocusRing = () => { + const active = document.activeElement; + if (!isHTMLElement(active)) { + return; + } + + const bar = barRef.current; + if (isHTMLElement(bar) && bar.contains(active)) { + return; + } + + // Esc makes whatever holds focus match `:focus-visible`, so a checkbox clicked with the mouse would light up + // with a focus ring just as the selection it belongs to disappears. + if (isShowingFocusRing(active)) { + return; + } + + markProgrammaticFocus(active); + }; + + return {suppressStrayFocusRing, isFocusInsideBar}; +}; + +export default useBulkActionBarFocus; diff --git a/src/components/BulkActionBar/useBulkActionBarFocus/types.ts b/src/components/BulkActionBar/useBulkActionBarFocus/types.ts new file mode 100644 index 000000000000..376b5b2b8d98 --- /dev/null +++ b/src/components/BulkActionBar/useBulkActionBarFocus/types.ts @@ -0,0 +1,22 @@ +import type {RefObject} from 'react'; + +type BulkActionBarFocus = { + /** + * Keeps Esc from painting a focus ring on a control the pointer put focus on. Focus that is inside the bar is left + * alone, since the bar hands that back itself as it unmounts. + */ + suppressStrayFocusRing: () => void; + + /** Whether one of the bar's own controls currently holds focus. */ + isFocusInsideBar: boolean; +}; + +/** + * `isCoveredByScreen` is true while another screen is open over the bar. Focus landing outside the bar then belongs to + * that screen rather than to the table, and leaves with it, so it is no use as a hand-back target. Popovers are left + * out of this flag, because an RHP the bar is rendered inside reports itself as a visible modal for as long as it is + * open. The bar tells those apart by the launcher they were opened from instead. + */ +type UseBulkActionBarFocus = (barRef: RefObject, isCoveredByScreen: boolean) => BulkActionBarFocus; + +export default UseBulkActionBarFocus; diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 29e62ca71729..8563ba0635f3 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -51,6 +51,7 @@ function Button({ sentryLabel, ref, accessibilityState, + accessibilityHasPopup, }: ButtonProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -140,6 +141,7 @@ function Button({ testID={testID} accessibilityLabel={accessibilityLabel} accessibilityState={accessibilityState} + accessibilityHasPopup={accessibilityHasPopup} sentryLabel={sentryLabel} role={CONST.ROLE.BUTTON} isNested={isNested} diff --git a/src/components/Button/types.ts b/src/components/Button/types.ts index 6fad32beca96..634b4687d876 100644 --- a/src/components/Button/types.ts +++ b/src/components/Button/types.ts @@ -1,3 +1,5 @@ +import type {AccessibilityHasPopup} from '@components/Pressable/PressResponder/PressResponderContext'; + import type {ButtonVariant} from '@styles/utils/types'; import type CONST from '@src/CONST'; @@ -107,6 +109,9 @@ type BaseButtonProps = WithSentryLabel & /** Accessibility state to pass to the pressable */ accessibilityState?: AccessibilityState; + /** What the button opens, for a button that opens a menu or a dialog rather than acting straight away */ + accessibilityHasPopup?: AccessibilityHasPopup; + ref?: ForwardedRef>; }; diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index 842308fd4bd7..eefa08f45f0e 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -2,6 +2,7 @@ import Button from '@components/Button'; import Icon from '@components/Icon'; import InlineIcon from '@components/Icon/InlineIcon'; import PopoverMenu from '@components/PopoverMenu'; +import Switch from '@components/Switch'; import Text from '@components/Text'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -352,29 +353,64 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM headerText={menuHeaderText} shouldUseScrollView={shouldPopoverUseScrollView} containerStyles={containerStyles} - menuItems={options.map((item, index) => ({ - ...item, - onSelected: item.onSelected - ? () => { - item.onSelected?.(); - if (item.shouldUpdateSelectedIndex) { - setSelectedItemIndex(index); + menuItems={options.map((item, index) => { + const isSwitchDisabled = item.disabled ?? item.switchProps?.disabled; + return { + ...item, + ...(item.switchProps + ? { + shouldShowRightComponent: true, + // The row is the toggle and the only focus target, so it owns the switch role and state. + role: CONST.ROLE.SWITCH, + accessibilityState: {checked: item.switchProps.isOn, disabled: isSwitchDisabled}, + // Mirror the disabled state so the row (and Enter) can't flip a disabled toggle. + disabled: isSwitchDisabled, + shouldGreyOutWhenDisabled: false, + shouldRemoveHoverBackground: !!isSwitchDisabled, + // Decorative Switch: isNested lets it animate without double-toggling, focusable={false} keeps the row the only tab stop, and the wrapper is aria-hidden. + rightComponent: ( + + + + ), } - } - : () => { - onOptionSelected?.(item); - if (item.shouldUpdateSelectedIndex === false) { - return; + : {}), + onSelected: item.onSelected + ? () => { + // Enter routes here directly, bypassing the row's disabled press-guard, so block a disabled toggle. + if (item.switchProps?.disabled) { + return; + } + item.onSelected?.(); + if (item.shouldUpdateSelectedIndex) { + setSelectedItemIndex(index); + } } + : () => { + onOptionSelected?.(item); + if (item.shouldUpdateSelectedIndex === false) { + return; + } - setSelectedItemIndex(index); - }, - shouldCallAfterModalHide: true, - subMenuItems: item.subMenuItems?.map((subItem) => ({ - ...subItem, + setSelectedItemIndex(index); + }, shouldCallAfterModalHide: true, - })), - }))} + subMenuItems: item.subMenuItems?.map((subItem) => ({ + ...subItem, + shouldCallAfterModalHide: true, + })), + }; + })} shouldPutHeaderTextAfterBackButton={shouldPutHeaderTextAfterBackButton} /> )} diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 40b5602a06ef..4e51037901f6 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -1,8 +1,10 @@ import type {PopoverMenuItem} from '@components/PopoverMenu'; +import type {SwitchProps} from '@components/Switch'; import type {ButtonVariant} from '@styles/utils/types'; import type CONST from '@src/CONST'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -43,6 +45,8 @@ type DropdownOption = WithSentryLabel & { titleStyle?: StyleProp; shouldCloseModalOnSelect?: boolean; description?: string; + /** Whether to show the description above the title (e.g. a gray label on top of a prominent value) */ + shouldShowDescriptionOnTop?: boolean; descriptionTextStyle?: StyleProp; wrapperStyle?: StyleProp; displayInDefaultIconColor?: boolean; @@ -55,6 +59,22 @@ type DropdownOption = WithSentryLabel & { shouldShowLoadingSpinnerIcon?: boolean; /** Whether to render a divider before this option */ addSeparatorBefore?: boolean; + + /** When set, renders a Switch on the right of the option as an inline toggle row. The menu builds the Switch from this data so no JSX is threaded through the option config. */ + switchProps?: SwitchProps; + + /** Errors to display under the option (e.g. when an inline toggle's save fails) */ + errors?: Errors | null; + + /** Callback to dismiss the option's errors */ + onCloseError?: () => void; + + /** Whether to show the default right chevron icon (e.g. for a row that opens another page) */ + shouldShowRightIcon?: boolean; + + /** Style for the row that holds the title and the Switch (e.g. to vertically center a toggle against wrapped text) */ + innerContainerStyle?: StyleProp; + /** The type of brick road indicator to show */ brickRoadIndicator?: ValueOf; /** Whether selecting this option should bypass the delete confirmation modal */ shouldSkipDeleteModal?: boolean; diff --git a/src/components/CategoryPicker/CategoryPickerModal.tsx b/src/components/CategoryPicker/CategoryPickerModal.tsx index 3bc13c3d973d..46c08c33dd22 100644 --- a/src/components/CategoryPicker/CategoryPickerModal.tsx +++ b/src/components/CategoryPicker/CategoryPickerModal.tsx @@ -1,20 +1,30 @@ +import Icon from '@components/Icon'; import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; import type PopoverWithMeasuredContentProps from '@components/PopoverWithMeasuredContent/types'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import type {ListItem} from '@components/SelectionList/types'; +import Text from '@components/Text'; +import Tooltip from '@components/Tooltip'; import useKeyboardState from '@hooks/useKeyboardState'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {getEnabledCategoriesCount} from '@libs/CategoryUtils'; -import getBottomSheetHeight from '@libs/getBottomSheetHeight'; +import getBottomSheetHeight, {BOTTOM_SHEET_TOP_PADDING} from '@libs/getBottomSheetHeight'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import getSelectionListPopoverContentHeight from '@libs/getSelectionListPopoverContentHeight'; +import isTablet from '@libs/isTablet'; + +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -22,10 +32,19 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ComponentRef} from 'react'; import React, {useRef, useState} from 'react'; -import {View} from 'react-native'; +import {Dimensions, View} from 'react-native'; +import {useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller'; +import Animated, {useAnimatedStyle} from 'react-native-reanimated'; import CategoryPicker from '.'; +/** + * Height of the add-category header the list shows above its options: its 20px row, 4px above it (20px from the edge + * with the 16px top padding of the list, or of the sheet) and 20px below. Shared with the row sizing the container, so + * the two never disagree. + */ +const CATEGORY_PICKER_HEADER_HEIGHT = variables.iconSizeNormal + 24; + const DEFAULT_ANCHOR_ALIGNMENT = { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, @@ -39,6 +58,12 @@ type CategoryPickerModalProps = { selectedCategory?: string; + /** Whether to show GL codes under each category regardless of the workspace setting */ + shouldAlwaysShowGLCode?: boolean; + + /** Number of enabled categories at which the search input appears. The content height estimate uses the same value. */ + searchInputThreshold?: number; + /** Called when the user confirms a category selection */ onSelected?: (item: ListItem) => void; @@ -51,6 +76,12 @@ type CategoryPickerModalProps = { /** Whether the pop-over shrinks to the height its list needs, treating `popoverHeight` as a ceiling. */ shouldFitContentHeight?: boolean; + /** Shows a header with a back button and an add button, for users who can add a category to the workspace */ + onAddCategory?: () => void; + + /** Sentry label for the add button, so each caller's presses are attributed to it */ + addCategorySentryLabel?: string; + /** Whether the pop-over may flip to the other side of the anchor when it overflows. It shifts by a whole pop-over height, so a caller that already picked the side turns this off. */ shouldSwitchPositionIfOverflow?: boolean; } & Omit; @@ -61,6 +92,8 @@ function CategoryPickerModal({ anchorPosition, policyID, selectedCategory, + shouldAlwaysShowGLCode = false, + searchInputThreshold = CONST.STANDARD_LIST_ITEM_LIMIT, onSelected, anchorAlignment = DEFAULT_ANCHOR_ALIGNMENT, shouldMeasureAnchorPositionFromTop = false, @@ -68,11 +101,16 @@ function CategoryPickerModal({ popoverHeight = CONST.POPOVER_DROPDOWN_MAX_HEIGHT, shouldFitContentHeight = false, shouldSwitchPositionIfOverflow = true, + onAddCategory, + addCategorySentryLabel, }: CategoryPickerModalProps) { const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['BackArrow', 'Plus']); const StyleUtils = useStyleUtils(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth -- must match PopoverWithMeasuredContent's dock decision (bottom-docked only when isSmallScreenWidth) - const {isSmallScreenWidth} = useResponsiveLayout(); + const {isSmallScreenWidth, isInLandscapeMode} = useResponsiveLayout(); const {isKeyboardActive, keyboardActiveHeight} = useKeyboardState(); const {windowHeight} = useWindowDimensions(); const viewportOffsetTop = useViewportOffsetTop(); @@ -83,21 +121,42 @@ function CategoryPickerModal({ const [renderedRowCount, setRenderedRowCount] = useState(); const categoriesCount = policyCategories ?? 0; - const isSearchable = categoriesCount >= CONST.STANDARD_LIST_ITEM_LIMIT; - const estimatedContentHeight = getSelectionListPopoverContentHeight({optionCount: Math.max(renderedRowCount ?? categoriesCount, 1), isSearchable}); + const isSearchable = categoriesCount >= searchInputThreshold; + const headerHeight = onAddCategory ? CATEGORY_PICKER_HEADER_HEIGHT : 0; + // The bottom sheet already pads its own top, so under a header it skips the list's top padding to keep the header 20px from the edge. + const shouldSkipListTopPadding = isSmallScreenWidth && !!onAddCategory; + const estimatedContentHeight = + getSelectionListPopoverContentHeight({optionCount: Math.max(renderedRowCount ?? categoriesCount, 1), isSearchable}) + + headerHeight - + (shouldSkipListTopPadding ? styles.pt4.paddingTop : 0); // The sheet pads itself above the home indicator, except while the keyboard already covers it. The offline indicator // is drawn on the page behind the sheet, so no room is kept for it. const bottomSafeAreaPadding = isSmallScreenWidth && !isKeyboardActive ? safeAreaPaddingBottom : 0; - const bottomSheetHeight = getBottomSheetHeight({ - preferredHeight: popoverHeight, - windowHeight, - keyboardHeight: isKeyboardActive ? keyboardActiveHeight : 0, - topSafeAreaInset: safeAreaTop, - minHeight: getSelectionListPopoverContentHeight({optionCount: 1, isSearchable}), - }); + // A phone in landscape is too short for a sheet over the form, so there the sheet fills the screen. + const shouldFillWindow = isInLandscapeMode; + // A list with a search input makes room for the keyboard inside the sheet, frame by frame with it, in both orientations: + // flipping `avoidKeyboard` on rotation would swap the modal's wrapper and remount the sheet, replaying its entry animation + // and dropping the input's focus. On mobile web the keyboard shrinks the window instead, which the sheet's height follows. + const shouldPadForKeyboard = isSmallScreenWidth && isSearchable && !isTablet(); + // In portrait that list opens as tall as a sheet may be and its top edge stays put when the keyboard comes up, so the gap + // above it is sized off the screen, which the keyboard leaves alone. + const shouldKeepTopEdge = shouldPadForKeyboard && !isInLandscapeMode; + const minSheetHeight = getSelectionListPopoverContentHeight({optionCount: 1, isSearchable}); + const keptTopEdgeSheetHeight = windowHeight - Dimensions.get('screen').height * (1 - CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO) - BOTTOM_SHEET_TOP_PADDING; + const bottomSheetHeight = shouldKeepTopEdge + ? Math.max(minSheetHeight, keptTopEdgeSheetHeight) + : getBottomSheetHeight({ + preferredHeight: popoverHeight, + windowHeight, + keyboardHeight: isKeyboardActive && !shouldPadForKeyboard ? keyboardActiveHeight : 0, + topSafeAreaInset: safeAreaTop, + minHeight: minSheetHeight, + shouldFillWindow, + }); // A short list gets a sheet only as tall as its rows, still within the room the window and keyboard leave. - const fittedSheetHeight = shouldFitContentHeight ? Math.min(bottomSheetHeight, estimatedContentHeight + bottomSafeAreaPadding) : bottomSheetHeight; + const fittedSheetHeight = + shouldFitContentHeight && !shouldFillWindow && !shouldKeepTopEdge ? Math.min(bottomSheetHeight, estimatedContentHeight + bottomSafeAreaPadding) : bottomSheetHeight; const popoverContentHeight = shouldFitContentHeight ? Math.min(popoverHeight, estimatedContentHeight) : popoverHeight; const resolvedHeight = isSmallScreenWidth ? fittedSheetHeight : popoverContentHeight; const popoverDimensions = {width: popoverWidth, height: resolvedHeight}; @@ -105,6 +164,10 @@ function CategoryPickerModal({ // the sheet is sized and offset to the visual viewport, which the keyboard does shrink. const outerStyle = isSmallScreenWidth ? {...styles.w100, ...StyleUtils.getOuterModalStyle(windowHeight, viewportOffsetTop)} : undefined; + // The keyboard's offset is negative while it is open. It covers the home indicator, so the sheet pads for whichever is taller. + const {height: keyboardOffset} = useReanimatedKeyboardAnimation(); + const keyboardRoomStyle = useAnimatedStyle(() => ({paddingBottom: Math.max(safeAreaPaddingBottom, -keyboardOffset.get())})); + const handleCategorySelect = (item: ListItem) => { // If clicking the same category that's already selected, treat it as deselection if (item.keyForList === selectedCategory) { @@ -131,20 +194,74 @@ function CategoryPickerModal({ shouldSkipRemeasurement shouldDisplayBelowModals enableEdgeToEdgeBottomSafeAreaPadding - avoidKeyboard={isSmallScreenWidth} + avoidKeyboard={isSmallScreenWidth && !shouldPadForKeyboard} outerStyle={outerStyle} + // The sheet sizes itself to the window and its list scrolls on its own, so the modal must not cap or wrap it in landscape. + shouldLimitHeightIfBottomDockedInLandscapeMode={false} + shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode={false} > - + + {!!onAddCategory && ( + + + {isSmallScreenWidth && ( + + + + + + )} + {translate('common.category')} + + + + + + + + )} - + ); } export default CategoryPickerModal; +export {CATEGORY_PICKER_HEADER_HEIGHT}; diff --git a/src/components/CategoryPicker/index.tsx b/src/components/CategoryPicker/index.tsx index 38ceeef243dd..6c0ea6db13b2 100644 --- a/src/components/CategoryPicker/index.tsx +++ b/src/components/CategoryPicker/index.tsx @@ -42,6 +42,15 @@ type CategoryPickerProps = { /** Whether the search input should auto-focus when the picker mounts. Only opted into by the inline-edit popover wrapper. */ shouldAutoFocusSearchInput?: boolean; + /** + * Whether to show GL codes regardless of the workspace setting. When false, CategoryPicker reads + * `showCategoryGLCodes && glCodes` from the policy in Onyx. + */ + shouldAlwaysShowGLCode?: boolean; + + /** Number of enabled categories at which the search input appears. Defaults to `CONST.STANDARD_LIST_ITEM_LIMIT`. */ + searchInputThreshold?: number; + /** * Reports how many rows the unfiltered list renders, for a pop-over wrapper to size itself from. The category * count does not match: nested names add a row per parent, a selected out-of-policy category adds one, and @@ -71,13 +80,16 @@ function CategoryPicker({ shouldShowNoneOption = false, addBottomSafeAreaPadding = false, shouldAutoFocusSearchInput = false, + shouldAlwaysShowGLCode = false, + searchInputThreshold = CONST.STANDARD_LIST_ITEM_LIMIT, onRenderedRowCountChange, }: CategoryPickerProps) { const styles = useThemeStyles(); const {inputCallbackRef} = useAutoFocusInput(); - const [shouldShowGLCode] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(policyID)}`, { + const [shouldShowGLCodeFromPolicy] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(policyID)}`, { selector: (policy) => !!policy?.showCategoryGLCodes && !!policy?.glCodes, }); + const shouldShowGLCode = shouldAlwaysShowGLCode || !!shouldShowGLCodeFromPolicy; const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${getNonEmptyStringOnyxID(policyID)}`); const [policyRecentlyUsedCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); @@ -102,6 +114,7 @@ function CategoryPicker({ recentlyUsedCategories: validPolicyRecentlyUsedCategories, translate, shouldShowGLCode, + searchInputThreshold, }); const noneOption: OptionTree[] = shouldShowNoneOption @@ -161,7 +174,7 @@ function CategoryPicker({ sections={isLoadingNewOptions ? getEmptyArray() : sectionsWithTitleStyles} onSelectRow={onSubmit} ListItem={SingleSelectListItem} - shouldShowTextInput={categoriesCount >= CONST.STANDARD_LIST_ITEM_LIMIT} + shouldShowTextInput={categoriesCount >= searchInputThreshold} textInputOptions={textInputOptions} shouldShowLoadingPlaceholder={isLoadingNewOptions} isLoadingNewOptions={isLoadingNewOptions} diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 04112b6a66b5..bd489db5b24f 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -2,11 +2,11 @@ import {ChartFontsProvider} from '@components/Charts/hooks'; import React from 'react'; -import type BarChartProps from './types'; +import type {BarChartContentProps} from './types'; import VerticalBarChartContentBody from './VerticalBarChartContent'; -function BarChartContent(props: BarChartProps) { +function BarChartContent(props: BarChartContentProps) { return ( diff --git a/src/components/Charts/BarChart/VerticalBarChartContent.tsx b/src/components/Charts/BarChart/VerticalBarChartContent.tsx index 5de315e73610..78f0b3fa08d6 100644 --- a/src/components/Charts/BarChart/VerticalBarChartContent.tsx +++ b/src/components/Charts/BarChart/VerticalBarChartContent.tsx @@ -1,51 +1,62 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import {BAR_CORNER_RADIUS, BAR_HIT_GAP_RATIO, VERTICAL_BAR_DOMAIN_PADDING} from '@components/Charts/barChartConstants'; +import {BAR_CORNER_RADIUS, BAR_GROUP_INNER_GAP, BAR_HIT_GAP_RATIO, VERTICAL_BAR_DOMAIN_PADDING} from '@components/Charts/barChartConstants'; import ChartGridLines from '@components/Charts/components/ChartGridLines'; import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels'; -import type {HitTestArgs} from '@components/Charts/hooks'; +import type {CartesianCanvasInputs, HitTestArgs} from '@components/Charts/hooks'; import { - useChartFontManager, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useChartLabelMeasurements, useDynamicYDomain, useLabelHitTesting, + useSyncPointPositions, + useReadyCartesianCanvas, } from '@components/Charts/hooks'; -import {getBarLayout, getPrimaryValue, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils'; -import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING} from '@components/Charts/VictoryTheme'; +import type {ChartSeries, WrappedLabelConfig} from '@components/Charts/types'; +import {getBarLayout, getCartesianPlotBounds, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils'; +import VictoryTheme, {CARTESIAN_Y_AXIS, CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING, MAX_BAR_LABEL_LINES} from '@components/Charts/VictoryTheme'; +import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; -import type {LayoutChangeEvent} from 'react-native'; +import type {TupleToUnion} from 'type-fest'; import type {CartesianChartRenderArg, ChartBounds, PointsArray, Scale} from 'victory-native'; -import React, {useState} from 'react'; import {View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; import {Bar, CartesianChart} from 'victory-native'; import type BarChartProps from './types'; - -function VerticalBarChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onBarPress, shouldShowLabels = true}: BarChartProps) { +import type {BarChartContentProps} from './types'; + +type VerticalBarChartCanvasProps = Omit & CartesianCanvasInputs; + +function VerticalBarChartCanvas({ + data, + series, + yAxisUnit, + yAxisUnitPosition = 'left', + onBarPress, + shouldShowLabels = true, + shouldWrapLabels = false, + chartWidth, + fontManager, +}: VerticalBarChartCanvasProps) { const theme = useTheme(); const styles = useThemeStyles(); - const fontManager = useChartFontManager(); - const [chartWidth, setChartWidth] = useState(0); - const [barAreaWidth, setBarAreaWidth] = useState(0); - const [boundsLeft, setBoundsLeft] = useState(0); - const [boundsRight, setBoundsRight] = useState(0); - - const [{key: primarySeriesKey}] = series; - const chartData = data.map((point, index) => ({ + const StyleUtils = useStyleUtils(); + + const seriesKeys = series.map((seriesItem) => seriesItem.key); + const chartData: Array<{x: number} & Record> = data.map((point, index) => ({ x: index, - y: getPrimaryValue(point, primarySeriesKey), + ...Object.fromEntries(seriesKeys.map((key) => [key, point.values[key]])), })); const yAxisDomain = useDynamicYDomain(data); @@ -60,16 +71,22 @@ function VerticalBarChartContentBody({data, series, isLoading, yAxisUnit, yAxisU } }; - const handleLayout = (event: LayoutChangeEvent) => { - setChartWidth(event.nativeEvent.layout.width); - }; - - const barLayout = getBarLayout(barAreaWidth, data.length); - // Empty label data makes the measurement and layout hooks return early instead of laying out labels nobody sees. const labelData = shouldShowLabels ? data : []; const originalLabels = labelData.map(getXAxisLabel); + const {formatValue, formatCompactValue} = useChartLabelFormats({ + data, + unit: yAxisUnit, + unitPosition: yAxisUnitPosition, + }); + + const yAxisLabelWidth = getYAxisLabelWidth(data, formatCompactValue, fontManager, variables.iconSizeExtraSmall, VERTICAL_BAR_DOMAIN_PADDING); + const chartPaddingRight = yAxisLabelWidth + GLYPH_PADDING + VictoryTheme.axis.labelGap; + const plotBounds = getCartesianPlotBounds(chartWidth, chartPaddingRight); + const barLayout = getBarLayout(plotBounds.width, data.length); + const groupedBarWidth = (barLayout.barWidth - BAR_GROUP_INNER_GAP * (series.length - 1)) / series.length; + const measurements = useChartLabelMeasurements(labelData, fontManager, variables.iconSizeExtraSmall); const {labelRotation, labelSkipInterval, truncatedLabelWidths, xAxisLabelHeight, regularLabelMaxWidth, firstLabelMaxWidth, lastLabelMaxWidth, ellipsisWidth} = useChartLabelLayout({ @@ -77,17 +94,31 @@ function VerticalBarChartContentBody({data, series, isLoading, yAxisUnit, yAxisU fontManager, fontSize: variables.iconSizeExtraSmall, measurements, - tickSpacing: shouldShowLabels && barAreaWidth > 0 ? barLayout.barWidth + barLayout.gap : 0, - labelAreaWidth: barAreaWidth, - firstTickLeftSpace: boundsLeft + barLayout.edgeSpace, - lastTickRightSpace: chartWidth > 0 ? chartWidth - boundsRight + barLayout.edgeSpace : 0, + tickSpacing: shouldShowLabels && plotBounds.width > 0 ? barLayout.barWidth + barLayout.gap : 0, + // Wrapped labels are always horizontal, never skipped, and sized from wrapConfig, so the rotation/skip/truncation + // pass is discarded. Pass 0 to short-circuit the hook, matching how the line chart skips it via labelAreaWidth. + labelAreaWidth: shouldWrapLabels ? 0 : plotBounds.width, + firstTickLeftSpace: plotBounds.left + barLayout.edgeSpace, + lastTickRightSpace: chartWidth - plotBounds.right + barLayout.edgeSpace, }); - const {formatValue, formatCompactValue} = useChartLabelFormats({ - data, - unit: yAxisUnit, - unitPosition: yAxisUnitPosition, - }); + // Named bar labels wrap to two centered lines in the full column width, always horizontal and + // never skipped, so they bypass the rotation/skip/single-line treatment the axis labels use. + const labelColumnWidth = Math.min(barLayout.barWidth + barLayout.gap, plotBounds.width); + const wrapConfig: WrappedLabelConfig | undefined = + shouldWrapLabels && measurements.lineHeight > 0 + ? { + columnWidth: labelColumnWidth, + maxLines: MAX_BAR_LABEL_LINES, + lineHeight: measurements.lineHeight, + hasLabel: originalLabels.map((label) => label.length > 0), + canvasWidth: chartWidth, + } + : undefined; + const wrapLabelHeight = wrapConfig ? wrapConfig.maxLines * wrapConfig.lineHeight : 0; + // When wrapping, labelAreaWidth is 0 above so the hook returns EMPTY_LAYOUT (horizontal, skip 1). + // xAxisLabelHeight is 0 there too, so the wrap height has to be substituted explicitly. + const resolvedLabelHeight = shouldWrapLabels ? wrapLabelHeight : (xAxisLabelHeight ?? 0); const barHitHalfWidth = useSharedValue(0); const plotTop = useSharedValue(0); @@ -102,18 +133,13 @@ function VerticalBarChartContentBody({data, series, isLoading, yAxisUnit, yAxisU labelRotation, labelSkipInterval, chartBottom, + wrapConfig, }); const handleChartBoundsChange = (bounds: ChartBounds) => { - const domainWidth = bounds.right - bounds.left; - const {barWidth, gap} = getBarLayout(domainWidth, data.length); - barHitHalfWidth.set(barWidth > 0 ? barWidth / 2 + gap * BAR_HIT_GAP_RATIO : 0); plotTop.set(bounds.top); plotBottom.set(bounds.bottom); yZero.set(0); - setBarAreaWidth(domainWidth); - setBoundsLeft(bounds.left); - setBoundsRight(bounds.right); }; const checkIsOverBar = (args: HitTestArgs) => { @@ -128,47 +154,55 @@ function VerticalBarChartContentBody({data, series, isLoading, yAxisUnit, yAxisU return isWithinX && args.cursorY >= plotTop.get() && args.cursorY <= plotBottom.get(); }; - const {customGestures, setPointPositions, matchedIndex, isTooltipActive, isCursorOverClickable, initialTooltipPosition, onChartMoved} = useChartInteractions({ - handlePress: onBarPress ? handleBarPress : undefined, - checkIsOver: checkIsOverBar, - isCursorOverLabel: shouldShowLabels ? isCursorOverLabel : undefined, - resolveLabelTouchX: shouldShowLabels ? findLabelCursorX : undefined, - chartBottom, - yZero, - }); + const {customGestures, setPointPositions, matchedIndex, isTooltipActive, isCursorOverClickable, initialTooltipPosition, onChartMoved, onTooltipDismiss, onChartTouchStart} = + useChartInteractions({ + handlePress: onBarPress ? handleBarPress : undefined, + checkIsOver: checkIsOverBar, + isCursorOverLabel: shouldShowLabels ? isCursorOverLabel : undefined, + resolveLabelTouchX: shouldShowLabels ? findLabelCursorX : undefined, + chartBottom, + yZero, + }); + + const syncPointPositions = useSyncPointPositions(data, series, setPointPositions); const handleScaleChange = (xScale: Scale, yScale: Scale) => { + barHitHalfWidth.set(barLayout.barWidth > 0 ? barLayout.barWidth / 2 + barLayout.gap * BAR_HIT_GAP_RATIO : 0); yZero.set(yScale(0)); updateTickPositions(xScale, data.length); - setPointPositions( - chartData.map((point) => xScale(point.x)), - chartData.map((point) => yScale(point.y)), - ); + syncPointPositions(xScale, yScale); }; const cursorStyle = useAnimatedStyle(() => ({ cursor: isCursorOverClickable.get() ? 'pointer' : 'auto', })); - const renderBar = (point: PointsArray[number], chartBounds: ChartBounds) => { - const dataIndex = Number(point.xValue); - const dataPoint = data.at(dataIndex); + const renderBar = (point: PointsArray[number], chartBounds: ChartBounds, seriesItem: ChartSeries, seriesIndex: number) => { + const dataPoint = data.at(Number(point.xValue)); + if (!dataPoint || typeof point.y !== 'number') { + return null; + } + + const pointColor = seriesIndex === 0 ? dataPoint.color : undefined; + const color = dataPoint.seriesColors?.[seriesItem.key] ?? pointColor ?? seriesItem.color ?? VictoryTheme.colors.default; + // Moves the bar from the group's center to the middle of its series' slot, counted from the group's left edge, so the series sit side by side + const offset = -barLayout.barWidth / 2 + seriesIndex * (groupedBarWidth + BAR_GROUP_INNER_GAP) + groupedBarWidth / 2; return ( ); }; - const renderOutside = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { - if (!fontManager || xAxisLabelHeight === undefined) { + const renderOutside = (args: CartesianChartRenderArg, string>) => { + if (xAxisLabelHeight === undefined) { return null; } @@ -192,6 +226,7 @@ function VerticalBarChartContentBody({data, series, isLoading, yAxisUnit, yAxisU labelColor={theme.icon} xScale={args.xScale} chartBoundsBottom={chartBoundsBottom} + wrapConfig={wrapConfig} /> )} - - - ); - } - - if (data.length === 0) { - return null; - } + const labelSpace = shouldShowLabels ? VictoryTheme.axis.xAxisLabelGap + resolvedLabelHeight : 0; + const chartHeight = CHART_CONTENT_MIN_HEIGHT + labelSpace; + const chartPadding = {...VictoryTheme.axis.padding, bottom: labelSpace + VictoryTheme.axis.padding.bottom, right: chartPaddingRight}; return ( - {chartWidth > 0 && ( - - {({points, chartBounds, yScale, yTicks}) => ( - <> - - {points.y.map((point) => renderBar(point, chartBounds))} - - )} - - )} + + {({points, chartBounds, yScale, yTicks}) => ( + <> + + {groupedBarWidth > 0 && + series.flatMap((seriesItem, seriesIndex) => (points[seriesItem.key] ?? []).map((point) => renderBar(point, chartBounds, seriesItem, seriesIndex)))} + + )} + ); } +function VerticalBarChartContentBody({isLoading = false, chartWidth, ...canvasProps}: BarChartContentProps) { + const styles = useThemeStyles(); + const readyCanvas = useReadyCartesianCanvas(isLoading, chartWidth); + + if (!isLoading && canvasProps.data.length === 0) { + return null; + } + + if (!readyCanvas) { + return ( + + + + ); + } + + return ( + + ); +} + export default VerticalBarChartContentBody; diff --git a/src/components/Charts/BarChart/index.native.tsx b/src/components/Charts/BarChart/index.native.tsx index 63798b3dc21b..9aea26a2ab8a 100644 --- a/src/components/Charts/BarChart/index.native.tsx +++ b/src/components/Charts/BarChart/index.native.tsx @@ -1,3 +1,5 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; + import React from 'react'; import type BarChartProps from './types'; @@ -5,7 +7,16 @@ import type BarChartProps from './types'; import BarChartContent from './BarChartContent'; function BarChart(props: BarChartProps) { - return ; + return ( + + {(chartWidth) => ( + + )} + + ); } export default BarChart; diff --git a/src/components/Charts/BarChart/index.tsx b/src/components/Charts/BarChart/index.tsx index 50d908c8083e..e3b165f0a3b5 100644 --- a/src/components/Charts/BarChart/index.tsx +++ b/src/components/Charts/BarChart/index.tsx @@ -1,3 +1,4 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; import SkiaWebChart from '@components/Charts/SkiaWebChart'; import React from 'react'; @@ -7,10 +8,14 @@ import type BarChartProps from './types'; const getBarChartContent = () => import('./BarChartContent'); function BarChart(props: BarChartProps) { return ( - + + {(chartWidth) => ( + + )} + ); } diff --git a/src/components/Charts/BarChart/types.ts b/src/components/Charts/BarChart/types.ts index 064e1322dc25..10d728099a91 100644 --- a/src/components/Charts/BarChart/types.ts +++ b/src/components/Charts/BarChart/types.ts @@ -5,6 +5,15 @@ type BarChartProps = CartesianChartProps & { /** Whether each bar's label is drawn below it. Turn off when something outside the chart already names the bars. */ shouldShowLabels?: boolean; + + /** Whether each bar's label wraps to two centered lines beneath its column instead of the single-line axis treatment (rotation/skip). Used for named bars (merchants, categories, spenders). */ + shouldWrapLabels?: boolean; +}; + +type BarChartContentProps = BarChartProps & { + /** Null until the card has been measured. */ + chartWidth: number | null; }; export default BarChartProps; +export type {BarChartContentProps}; diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index fe42ec8f43bb..41574ddd6771 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -5,30 +5,42 @@ import ChartGridLines from '@components/Charts/components/ChartGridLines'; import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels'; -import type {HitTestArgs} from '@components/Charts/hooks'; +import type {CartesianCanvasInputs, HitTestArgs} from '@components/Charts/hooks'; import { ChartFontsProvider, - useChartFontManager, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useChartLabelMeasurements, useDynamicYDomain, useLabelHitTesting, + useSyncPointPositions, + useReadyCartesianCanvas, } from '@components/Charts/hooks'; -import {getDomainPaddingForEdgeSpace, getPrimaryValue, getXAxisLabel, getYAxisLabelWidth, labelOverhang} from '@components/Charts/utils'; -import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, DASH_INTERVALS, GLYPH_PADDING, LABEL_PADDING, LABEL_ROTATIONS, SIN_45} from '@components/Charts/VictoryTheme'; - +import {getAxisLabelStep, getCartesianPlotBounds, getDomainPaddingForEdgeSpace, getLineRuns, getXAxisLabel, getYAxisLabelWidth, labelOverhang} from '@components/Charts/utils'; +import type {LineRun} from '@components/Charts/utils/getLineRuns'; +import VictoryTheme, { + CARTESIAN_Y_AXIS, + CHART_CONTENT_MIN_HEIGHT, + DASH_INTERVALS, + GLYPH_PADDING, + LABEL_PADDING, + LABEL_ROTATIONS, + SIN_45, + X_AXIS_LABEL_MIN_GAP, +} from '@components/Charts/VictoryTheme'; + +import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; -import type {LayoutChangeEvent} from 'react-native'; +import type {TupleToUnion} from 'type-fest'; import type {CartesianChartRenderArg, ChartBounds, Scale} from 'victory-native'; import {DashPathEffect} from '@shopify/react-native-skia'; -import React, {useState} from 'react'; +import React from 'react'; import {View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; import Animated, {useAnimatedStyle, useDerivedValue, useSharedValue} from 'react-native-reanimated'; @@ -38,26 +50,33 @@ import type {CartesianChartProps, ChartDataPoint} from '..'; type LineChartProps = CartesianChartProps & { onPointPress?: (dataPoint: ChartDataPoint, index: number) => void; + + /** Steps the x-axis may label points at, ascending. When set, labels stay horizontal and only every Nth point is labeled. */ + labelSteps?: readonly number[]; +}; + +type LineChartContentProps = LineChartProps & { + /** Null until the card has been measured. */ + chartWidth: number | null; }; -function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onPointPress}: LineChartProps) { +type LineChartCanvasProps = Omit & CartesianCanvasInputs; + +function LineChartCanvas({data, series, yAxisUnit, yAxisUnitPosition = 'left', onPointPress, labelSteps, chartWidth, fontManager}: LineChartCanvasProps) { const theme = useTheme(); const styles = useThemeStyles(); - const fontManager = useChartFontManager(); - const [chartWidth, setChartWidth] = useState(0); - const [plotAreaWidth, setPlotAreaWidth] = useState(0); - const [boundsLeft, setBoundsLeft] = useState(0); - const [boundsRight, setBoundsRight] = useState(0); + const StyleUtils = useStyleUtils(); const yAxisDomain = useDynamicYDomain(data); - const isLastPointInProgress = !!data.at(-1)?.isInProgress; + const seriesKeys = series.map((seriesItem) => seriesItem.key); + const [{key: primarySeriesKey, color: primarySeriesColor = VictoryTheme.colors.default}] = series; + const openPointFlags = data.map((point) => !!point.isInProgress); - // A lone point has no line leading into it, so it keeps the regular line's dot instead of a dashed segment. - const shouldDashLastSegment = isLastPointInProgress && data.length > 1; - const [{key: primarySeriesKey}] = series; - const chartData = data.map((point, index) => ({ + // Periods that haven't ended, the current one and any later ones, are drawn dashed on the primary series, along with the segments leading into them. + const primaryLineRuns = getLineRuns(openPointFlags); + const chartData: Array<{x: number} & Record> = data.map((point, index) => ({ x: index, - y: getPrimaryValue(point, primarySeriesKey), + ...Object.fromEntries(seriesKeys.map((key) => [key, point.values[key]])), })); const handlePointPress = (index: number) => { @@ -70,10 +89,6 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi } }; - const handleLayout = (event: LayoutChangeEvent) => { - setChartWidth(event.nativeEvent.layout.width); - }; - const chartBottom = useSharedValue(0); const plotTop = useSharedValue(0); const plotLeft = useSharedValue(0); @@ -91,14 +106,15 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi const yAxisLabelWidth = getYAxisLabelWidth(data, formatCompactValue, fontManager, variables.iconSizeExtraSmall, VictoryTheme.line.domainPadding); + const chartPaddingRight = yAxisLabelWidth + GLYPH_PADDING + VictoryTheme.axis.labelGap; + const {left: boundsLeft, right: boundsRight, width: plotAreaWidth} = getCartesianPlotBounds(chartWidth, chartPaddingRight); const tickSpacing = plotAreaWidth > 0 && data.length > 0 ? plotAreaWidth / data.length : 0; - const chartPaddingRight = yAxisLabelWidth + GLYPH_PADDING; const domainPadding = (() => { if (!firstLabelWidth || !lastLabelWidth) { return VictoryTheme.line.domainPadding; } - const labelsExceedTickSpacing = tickSpacing > 0 && maxLabelWidth + LABEL_PADDING > tickSpacing; + const labelsExceedTickSpacing = !labelSteps && tickSpacing > 0 && maxLabelWidth + LABEL_PADDING > tickSpacing; let leftOverhang = firstLabelWidth / 2; let rightOverhang = lastLabelWidth / 2; @@ -110,7 +126,7 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi const edgeSpace = { left: Math.max(0, leftOverhang - VictoryTheme.axis.padding.left), - right: Math.max(0, rightOverhang - chartPaddingRight - VictoryTheme.axis.labelGap), + right: Math.max(0, rightOverhang - chartPaddingRight), }; return {...VictoryTheme.line.domainPadding, ...getDomainPaddingForEdgeSpace(edgeSpace, plotAreaWidth)}; })(); @@ -118,17 +134,41 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi const totalDomainPadding = domainPadding.left + domainPadding.right; const paddingScale = plotAreaWidth > 0 ? plotAreaWidth / (plotAreaWidth + totalDomainPadding) : 0; - const {labelRotation, labelSkipInterval, truncatedLabelWidths, xAxisLabelHeight, regularLabelMaxWidth, firstLabelMaxWidth, lastLabelMaxWidth, ellipsisWidth} = useChartLabelLayout({ + const rotatingLabelLayout = useChartLabelLayout({ data, fontManager, fontSize: variables.iconSizeExtraSmall, tickSpacing, - labelAreaWidth: plotAreaWidth, + // Stepped labels never rotate, so a zero width makes this hook return early instead of computing a rotation + labelAreaWidth: labelSteps ? 0 : plotAreaWidth, firstTickLeftSpace: boundsLeft + domainPadding.left * paddingScale, - lastTickRightSpace: chartWidth > 0 ? chartWidth - boundsRight + domainPadding.right * paddingScale : 0, + lastTickRightSpace: chartWidth - boundsRight + domainPadding.right * paddingScale, measurements, }); + const steppedLabelLayout = (() => { + if (!labelSteps || plotAreaWidth <= 0) { + return undefined; + } + const firstPointX = boundsLeft + domainPadding.left * paddingScale; + const lastPointX = boundsRight - domainPadding.right * paddingScale; + const pointXs = data.map((_, index) => (data.length > 1 ? firstPointX + (index * (lastPointX - firstPointX)) / (data.length - 1) : (firstPointX + lastPointX) / 2)); + const step = getAxisLabelStep({labelWidths, pointXs, steps: labelSteps, minGap: X_AXIS_LABEL_MIN_GAP}); + return { + labelRotation: LABEL_ROTATIONS.HORIZONTAL, + labelSkipInterval: step, + truncatedLabelWidths: labelWidths, + xAxisLabelHeight: lineHeight, + regularLabelMaxWidth: Infinity, + firstLabelMaxWidth: Infinity, + lastLabelMaxWidth: Infinity, + ellipsisWidth: measurements.ellipsisWidth, + }; + })(); + + const {labelRotation, labelSkipInterval, truncatedLabelWidths, xAxisLabelHeight, regularLabelMaxWidth, firstLabelMaxWidth, lastLabelMaxWidth, ellipsisWidth} = + steppedLabelLayout ?? rotatingLabelLayout; + const originalLabels = data.map(getXAxisLabel); const {isCursorOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({ @@ -144,9 +184,6 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi plotTop.set(bounds.top); plotLeft.set(bounds.left); plotRight.set(bounds.right); - setPlotAreaWidth(bounds.right - bounds.left); - setBoundsLeft(bounds.left); - setBoundsRight(bounds.right); }; const labelSpace = VictoryTheme.axis.xAxisLabelGap + (xAxisLabelHeight ?? 0); @@ -169,7 +206,18 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi return isInPlotArea(args) && isCursorOverLabel(args, activeIndex); }; - const {customGestures, setPointPositions, matchedIndex, isTooltipActive, isCursorOverClickable, initialTooltipPosition, activePointPosition, onChartMoved} = useChartInteractions({ + const { + customGestures, + setPointPositions, + matchedIndex, + isTooltipActive, + isCursorOverClickable, + initialTooltipPosition, + activePointPosition, + onChartMoved, + onTooltipDismiss, + onChartTouchStart, + } = useChartInteractions({ handlePress: onPointPress ? handlePointPress : undefined, checkIsOver: checkIsOverBand, isCursorOverLabel: checkIsOverLabelInPlotArea, @@ -177,54 +225,68 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi chartBottom, }); - const isActivePointHollow = useDerivedValue(() => isLastPointInProgress && matchedIndex.get() === data.length - 1); + const activePointX = useDerivedValue(() => activePointPosition.get().x); + const isActivePointHollow = useDerivedValue(() => { + const index = matchedIndex.get(); + return index >= 0 && !!openPointFlags.at(index); + }); + + const syncPointPositions = useSyncPointPositions(data, series, setPointPositions); const handleScaleChange = (xScale: Scale, yScale: Scale) => { updateTickPositions(xScale, data.length); const [rangeStart, rangeEnd] = xScale.range(); bandHalfWidth.set(data.length > 1 ? Math.abs(xScale(1) - xScale(0)) / 2 : Math.abs(rangeEnd - rangeStart)); - setPointPositions( - chartData.map((point) => xScale(point.x)), - chartData.map((point) => yScale(point.y)), - ); + syncPointPositions(xScale, yScale); }; const cursorStyle = useAnimatedStyle(() => ({ cursor: isCursorOverClickable.get() ? 'pointer' : 'auto', })); - const renderOutside = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { + const renderOutside = (args: CartesianChartRenderArg, string>) => { const chartBoundsBottom = args.chartBounds.bottom; chartBottom.set(chartBoundsBottom); - const completePoints = shouldDashLastSegment ? args.points.y.slice(0, -1) : args.points.y; + const primaryPoints = args.points[primarySeriesKey] ?? []; return ( <> - - - {shouldDashLastSegment && ( - - - - )} - {xAxisLabelHeight !== undefined && !!fontManager && ( + {primaryLineRuns + .filter((run) => !run.isDashed && run.end > run.start) + .map((run) => ( + + ))} + {/* Drawn last, the primary line sits on top of the compared one */} + {[...series].reverse().map((seriesItem) => { + const color = seriesItem.color ?? VictoryTheme.colors.default; + const seriesPoints = args.points[seriesItem.key] ?? []; + + // Only the primary series is dashed + const lineRuns: LineRun[] = seriesItem.key === primarySeriesKey ? primaryLineRuns : [{start: 0, end: seriesPoints.length - 1, isDashed: false}]; + return ( + + {lineRuns.map((run) => ( + + {run.isDashed && } + + ))} + + ); + })} + {xAxisLabelHeight !== undefined && ( )} - {!!fontManager && ( - - )} + ({ + color: seriesItem.color ?? VictoryTheme.colors.default, + yPositions: (args.points[seriesItem.key] ?? []).map((point) => point.y), + }))} isActive={isTooltipActive} top={args.chartBounds.top} bottom={chartBoundsBottom} dotRadius={VictoryTheme.line.activeDotRadius} - dotColor={VictoryTheme.colors.default} - guidelineColor={VictoryTheme.colors.default} + guidelineColor={primarySeriesColor} guidelineOpacity={VictoryTheme.line.guidelineOpacity} isHollow={isActivePointHollow} hollowColor={theme.cardBG} @@ -268,72 +332,51 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi ); }; - const dynamicChartStyle = {height: CHART_CONTENT_MIN_HEIGHT + labelSpace}; + const chartHeight = CHART_CONTENT_MIN_HEIGHT + labelSpace; const chartPadding = { ...VictoryTheme.axis.padding, bottom: labelSpace + VictoryTheme.axis.padding.bottom, right: chartPaddingRight, }; - if (isLoading || !fontManager) { - return ( - - - - ); - } - - if (data.length === 0) { - return null; - } - return ( - {chartWidth > 0 && ( - - {({yScale, yTicks, chartBounds}) => ( - - )} - - )} + + {({yScale, yTicks, chartBounds}) => ( + + )} + ); } -function LineChartContent(props: LineChartProps) { +function LineChartContentBody({isLoading = false, chartWidth, ...canvasProps}: LineChartContentProps) { + const styles = useThemeStyles(); + const readyCanvas = useReadyCartesianCanvas(isLoading, chartWidth); + + if (!isLoading && canvasProps.data.length === 0) { + return null; + } + + if (!readyCanvas) { + return ( + + + + ); + } + + return ( + + ); +} + +function LineChartContent(props: LineChartContentProps) { return ( diff --git a/src/components/Charts/LineChart/index.native.tsx b/src/components/Charts/LineChart/index.native.tsx index 2c6ac7c2941f..2fd8ea813de1 100644 --- a/src/components/Charts/LineChart/index.native.tsx +++ b/src/components/Charts/LineChart/index.native.tsx @@ -1,3 +1,5 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; + import React from 'react'; import type {LineChartProps} from './LineChartContent'; @@ -5,7 +7,16 @@ import type {LineChartProps} from './LineChartContent'; import LineChartContent from './LineChartContent'; function LineChart(props: LineChartProps) { - return ; + return ( + + {(chartWidth) => ( + + )} + + ); } LineChart.displayName = 'LineChart'; diff --git a/src/components/Charts/LineChart/index.tsx b/src/components/Charts/LineChart/index.tsx index 7de73f7fcf44..01d3da19af53 100644 --- a/src/components/Charts/LineChart/index.tsx +++ b/src/components/Charts/LineChart/index.tsx @@ -1,3 +1,4 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; import SkiaWebChart from '@components/Charts/SkiaWebChart'; import React from 'react'; @@ -7,10 +8,14 @@ import type {LineChartProps} from './LineChartContent'; const getLineChartContent = () => import('./LineChartContent'); function LineChart(props: LineChartProps) { return ( - + + {(chartWidth) => ( + + )} + ); } diff --git a/src/components/Charts/PieChart/PieChartContent.tsx b/src/components/Charts/PieChart/PieChartContent.tsx index 736eac355ca2..e1ba58eaf205 100644 --- a/src/components/Charts/PieChart/PieChartContent.tsx +++ b/src/components/Charts/PieChart/PieChartContent.tsx @@ -1,21 +1,20 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import ChartTooltip from '@components/Charts/components/ChartTooltip'; -import {TOOLTIP_BAR_GAP, useChartLabelFormats, useTooltipData} from '@components/Charts/hooks'; +import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; +import {CHART_GESTURE_TOUCH_ACTION, TOOLTIP_BAR_GAP, useChartLabelFormats, useTouchPinnedTooltip} from '@components/Charts/hooks'; import type {ChartDataPoint, ChartProps, PieSlice, UnitPosition} from '@components/Charts/types'; import {findSliceAtPosition, getPrimaryValue, processDataIntoSlices} from '@components/Charts/utils'; -import VictoryTheme from '@components/Charts/VictoryTheme'; +import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/VictoryTheme'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {LayoutChangeEvent} from 'react-native'; - import React, {useState} from 'react'; import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; -import Animated, {useSharedValue} from 'react-native-reanimated'; +import {GestureDetector, PointerType, useCompetingGestures, useHoverGesture, useTapGesture} from 'react-native-gesture-handler'; +import Animated, {useDerivedValue, useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'; import {Pie, PolarChart} from 'victory-native'; @@ -32,15 +31,25 @@ type PieChartProps = ChartProps & { /** Whether to draw the slice legend below the donut */ shouldShowLegend?: boolean; + + /** Whether the legend sits beside the donut when the card is wide enough (one row per slice) and stacks beneath it when it isn't, instead of the default wrapping row below the donut. */ + shouldUseResponsiveLegend?: boolean; +}; + +type PieChartContentProps = PieChartProps & { + /** Null until the card has been measured. */ + chartWidth: number | null; }; -function PieChartContent({data, series, isLoading, valueUnit, valueUnitPosition, onSlicePress, shouldShowLegend = true}: PieChartProps) { +type PieChartCanvasProps = Omit & { + chartWidth: number; +}; + +function PieChartCanvas({data, series, valueUnit, valueUnitPosition, onSlicePress, shouldShowLegend = true, shouldUseResponsiveLegend = false, chartWidth}: PieChartCanvasProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const theme = useTheme(); const {translate} = useLocalize(); - const [canvasWidth, setCanvasWidth] = useState(0); - const [canvasHeight, setCanvasHeight] = useState(0); - const [activeSliceIndex, setActiveSliceIndex] = useState(-1); const [isHoveringOverPie, setIsHoveringOverPie] = useState(false); // Shared values for hover state @@ -49,32 +58,49 @@ function PieChartContent({data, series, isLoading, valueUnit, valueUnitPosition, const cursorY = useSharedValue(0); const tooltipPosition = useSharedValue({x: 0, y: 0}); - const handleLayout = (event: LayoutChangeEvent) => { - setCanvasWidth(event.nativeEvent.layout.width); - setCanvasHeight(event.nativeEvent.layout.height); - }; + /** Data index of the slice under the cursor or the last touch tap, resolved on the UI thread together with the tooltip position */ + const matchedIndex = useSharedValue(-1); + const isTooltipActive = useDerivedValue(() => matchedIndex.get() >= 0); + + const canvasHeight = CHART_CONTENT_MIN_HEIGHT; - // Calculate pie geometry - const radius = Math.min(canvasWidth, canvasHeight) / 2; + // Side by side when the legend keeps at least its minimum width next to a square donut. Otherwise stacked. + const isLegendSideBySide = shouldUseResponsiveLegend && shouldShowLegend && chartWidth - canvasHeight - VictoryTheme.pie.legendGap >= VictoryTheme.pie.legendMinWidth; + const donutWidth = isLegendSideBySide ? canvasHeight : chartWidth; + + const radius = Math.min(donutWidth, canvasHeight) / 2; const innerRadius = radius * VictoryTheme.pie.innerRadiusRatio; - const pieGeometry = {radius, innerRadius, centerX: canvasWidth / 2, centerY: canvasHeight / 2}; + const pieGeometry = {radius, innerRadius, centerX: donutWidth / 2, centerY: canvasHeight / 2}; // Slices are sorted by absolute value (largest first) for color assignment, // so slice indices don't match the original data array. We map back via // originalIndex so the tooltip can display the original (possibly negative) value. const [{key: primarySeriesKey}] = series; const processedSlices = processDataIntoSlices(data, primarySeriesKey, pieGeometry); - const activeOriginalDataIndex = activeSliceIndex >= 0 ? (processedSlices.at(activeSliceIndex)?.originalIndex ?? -1) : -1; const {formatValue} = useChartLabelFormats({data, unit: valueUnit, unitPosition: valueUnitPosition}); - const tooltipData = useTooltipData(activeOriginalDataIndex, data, series, formatValue); - // Handle hover state updates - const updateActiveSlice = (x: number, y: number) => { + /** Data index of the slice at the given point, or -1 when the point is outside the slices */ + const findDataIndexAt = (x: number, y: number) => { + 'worklet'; + const {centerX, centerY} = pieGeometry; const sliceIndex = findSliceAtPosition(x, y, centerX, centerY, radius, innerRadius, processedSlices); - setActiveSliceIndex(sliceIndex); - setIsHoveringOverPie(sliceIndex >= 0); + return sliceIndex >= 0 ? (processedSlices.at(sliceIndex)?.originalIndex ?? -1) : -1; + }; + + const hoverAt = (x: number, y: number) => { + 'worklet'; + + cursorX.set(x); + cursorY.set(y); + tooltipPosition.set({x, y: y - TOOLTIP_BAR_GAP}); + const dataIndex = findDataIndexAt(x, y); + const wasOverPie = matchedIndex.get() >= 0; + matchedIndex.set(dataIndex); + if (wasOverPie !== dataIndex >= 0) { + scheduleOnRN(setIsHoveringOverPie, dataIndex >= 0); + } }; // Handle slice press callback @@ -99,154 +125,204 @@ function PieChartContent({data, series, isLoading, valueUnit, valueUnitPosition, if (!isHovering.get()) { return; } - const x = cursorX.get() - deltaX; - const y = cursorY.get() - deltaY; - cursorX.set(x); - cursorY.set(y); - tooltipPosition.set({x, y: y - TOOLTIP_BAR_GAP}); - scheduleOnRN(updateActiveSlice, x, y); + hoverAt(cursorX.get() - deltaX, cursorY.get() - deltaY); }; const onChartMoved = (deltaX: number, deltaY: number) => scheduleOnUI(handleChartMoved, deltaX, deltaY); + const {setIsTouchPinned, onTooltipDismiss, onTapFinalize, onChartTouchStart} = useTouchPinnedTooltip(() => { + matchedIndex.set(-1); + setIsHoveringOverPie(false); + }); + // Hover gesture - const hoverGesture = () => - Gesture.Hover() - .onBegin((e) => { - 'worklet'; - - isHovering.set(true); - cursorX.set(e.x); - cursorY.set(e.y); - tooltipPosition.set({x: e.x, y: e.y - TOOLTIP_BAR_GAP}); - scheduleOnRN(updateActiveSlice, e.x, e.y); - }) - .onUpdate((e) => { - 'worklet'; - - cursorX.set(e.x); - cursorY.set(e.y); - tooltipPosition.set({x: e.x, y: e.y - TOOLTIP_BAR_GAP}); - scheduleOnRN(updateActiveSlice, e.x, e.y); - }) - .onEnd(() => { - 'worklet'; - - isHovering.set(false); - scheduleOnRN(setActiveSliceIndex, -1); - scheduleOnRN(setIsHoveringOverPie, false); - }); + const hoverGesture = useHoverGesture({ + // @ts-expect-error -- touchAction is internal in Gesture Handler 3 config types, see CHART_GESTURE_TOUCH_ACTION + touchAction: CHART_GESTURE_TOUCH_ACTION, + onBegin: (e) => { + 'worklet'; + + if (e.pointerType === PointerType.TOUCH) { + return; + } + isHovering.set(true); + scheduleOnRN(setIsTouchPinned, false); + hoverAt(e.x, e.y); + }, + onUpdate: (e) => { + 'worklet'; + + if (e.pointerType === PointerType.TOUCH) { + return; + } + hoverAt(e.x, e.y); + }, + onDeactivate: (e) => { + 'worklet'; + + if (e.pointerType === PointerType.TOUCH) { + return; + } + isHovering.set(false); + matchedIndex.set(-1); + scheduleOnRN(setIsHoveringOverPie, false); + }, + }); // Tap gesture for click/tap navigation - const tapGesture = () => - Gesture.Tap().onEnd((e) => { + const tapGesture = useTapGesture({ + // @ts-expect-error -- touchAction is internal in Gesture Handler 3 config types, see CHART_GESTURE_TOUCH_ACTION + touchAction: CHART_GESTURE_TOUCH_ACTION, + onFinalize: onTapFinalize, + onDeactivate: (e) => { 'worklet'; + // Touch taps show the slice tooltip instead of drilling in, and the next touch anywhere on the screen hides it + if (e.pointerType === PointerType.TOUCH) { + hoverAt(e.x, e.y); + scheduleOnRN(setIsTouchPinned, matchedIndex.get() >= 0); + return; + } + const {centerX, centerY} = pieGeometry; const sliceIndex = findSliceAtPosition(e.x, e.y, centerX, centerY, radius, innerRadius, processedSlices); if (sliceIndex >= 0) { scheduleOnRN(handleSlicePress, sliceIndex); } - }); + }, + }); // Combined gestures - Race allows both hover and tap to work independently - const combinedGesture = onSlicePress ? Gesture.Race(hoverGesture(), tapGesture()) : hoverGesture(); + const combinedGesture = useCompetingGestures(hoverGesture, tapGesture); + + const highlightSlice = (slice: PieSlice) => { + tooltipPosition.set(slice.tooltipPosition); + matchedIndex.set(slice.originalIndex); + }; + const clearSliceHighlight = () => { + matchedIndex.set(-1); + }; - const renderLegendItem = (slice: PieSlice) => { + // One legend entry: a color dot plus the slice name, wired to drive the tooltip on hover. + // The responsive variant gives each slice its own row with the dot aligned to the first line. The default variant is an inline item for the wrapping row below the donut. + const renderLegendEntry = (slice: PieSlice, isResponsiveRow: boolean) => { + const dot = ; return ( { - tooltipPosition.set(slice.tooltipPosition); - setActiveSliceIndex(slice.ordinalIndex); - }} - onMouseLeave={() => { - setActiveSliceIndex(-1); - }} + style={isResponsiveRow ? styles.pieChartLegendRow : [styles.flexRow, styles.alignItemsCenter]} + onMouseEnter={() => highlightSlice(slice)} + onMouseLeave={clearSliceHighlight} > - - {slice.label} + {isResponsiveRow ? {dot} : dot} + {slice.label} ); }; - if (isLoading) { + const donut = ( + + + {processedSlices.length > 0 && ( + + + {({slice}) => ( + + )} + + + )} + + {processedSlices.length > 0 && ( + + {translate('common.total')} + + {formatValue(data.reduce((sum, point) => sum + getPrimaryValue(point, primarySeriesKey), 0))} + + + )} + + {/* Tooltip */} + + + + ); + + if (shouldUseResponsiveLegend) { return ( - - + + {donut} + {shouldShowLegend && ( + {processedSlices.map((slice) => renderLegendEntry(slice, true))} + )} ); } - if (data.length === 0) { + return ( + <> + {donut} + {shouldShowLegend && {processedSlices.map((slice) => renderLegendEntry(slice, false))}} + + ); +} + +function PieChartContent({isLoading = false, chartWidth, ...canvasProps}: PieChartContentProps) { + const styles = useThemeStyles(); + + if (!isLoading && canvasProps.data.length === 0) { return null; } + if (isLoading || chartWidth === null) { + return ( + + + + ); + } + return ( - <> - - - {processedSlices.length > 0 && ( - - - {({slice}) => ( - - )} - - - )} - - {processedSlices.length > 0 && ( - - {translate('common.total')} - - {formatValue(data.reduce((sum, point) => sum + getPrimaryValue(point, primarySeriesKey), 0))} - - - )} - - {/* Tooltip */} - {activeSliceIndex >= 0 && !!tooltipData && ( - - )} - - - {shouldShowLegend && {processedSlices.map((slice) => renderLegendItem(slice))}} - + ); } diff --git a/src/components/Charts/PieChart/index.native.tsx b/src/components/Charts/PieChart/index.native.tsx index 961f5bdfb82c..97d224a27cbf 100644 --- a/src/components/Charts/PieChart/index.native.tsx +++ b/src/components/Charts/PieChart/index.native.tsx @@ -1,3 +1,5 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; + import React from 'react'; import type {PieChartProps} from './PieChartContent'; @@ -5,7 +7,16 @@ import type {PieChartProps} from './PieChartContent'; import PieChartContent from './PieChartContent'; function PieChart(props: PieChartProps) { - return ; + return ( + + {(chartWidth) => ( + + )} + + ); } PieChart.displayName = 'PieChart'; diff --git a/src/components/Charts/PieChart/index.tsx b/src/components/Charts/PieChart/index.tsx index 66999fee714f..1441e2cdc847 100644 --- a/src/components/Charts/PieChart/index.tsx +++ b/src/components/Charts/PieChart/index.tsx @@ -1,3 +1,4 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; import SkiaWebChart from '@components/Charts/SkiaWebChart'; import React from 'react'; @@ -8,10 +9,14 @@ const getPieChartContent = () => import('./PieChartContent'); function PieChart(props: PieChartProps) { return ( - + + {(chartWidth) => ( + + )} + ); } diff --git a/src/components/Charts/VictoryTheme.ts b/src/components/Charts/VictoryTheme.ts index ae1366c61bf7..6f437df40d64 100644 --- a/src/components/Charts/VictoryTheme.ts +++ b/src/components/Charts/VictoryTheme.ts @@ -1,8 +1,11 @@ /** * Centralized styles and layout constants for the chart components. */ +import type {ColorScheme} from '@styles/index'; import colors from '@styles/theme/colors'; +import CONST from '@src/CONST'; + import {CHART_FONT_FAMILY_NAMES} from './utils/chartFontConstants'; /** Shade groups in the palette */ @@ -46,6 +49,16 @@ function getChartColor(index: number): string { return CHART_PALETTE.at(index % CHART_PALETTE.length) ?? colors.black; } +const CHART_COMPARISON_SHADE = 200; +const CHART_COMPARISON_SHADE_DARK = 700; + +/** Returns the color to draw a compared period in, for an item or series drawn in `color` */ +function getComparisonColor(color: string, colorScheme: ColorScheme): string { + const index = CHART_PALETTE.indexOf(color); + const hue = index < 0 ? undefined : CHART_PALETTE_HUES.at(index % CHART_PALETTE_HUES.length); + return (hue && colors[`${hue}${colorScheme === CONST.COLOR_SCHEME.DARK ? CHART_COMPARISON_SHADE_DARK : CHART_COMPARISON_SHADE}`]) ?? color; +} + /** Index of the default single-color chart color (green400). */ const DEFAULT_CHART_COLOR_INDEX = 3; @@ -55,6 +68,7 @@ const VictoryTheme = { /** Default color used for single-color charts (e.g., line chart, single-color bar chart) */ default: getChartColor(DEFAULT_CHART_COLOR_INDEX), getColor: getChartColor, + getComparisonColor, }, fontFamilies: Array.from(CHART_FONT_FAMILY_NAMES), axis: { @@ -88,9 +102,24 @@ const VictoryTheme = { innerRadiusRatio: 0.8, /** Gap between pie slices in degrees */ padAngle: 1, + /** + * Minimum width (px) the donut legend column keeps in the side-by-side layout, wide enough for a + * common category name on two lines. The card switches to the stacked layout below this. + */ + legendMinWidth: 180, + /** Gap (px) between the donut and its legend in the side-by-side layout */ + legendGap: 24, + /** Gap (px) between the donut and the legend below it in the stacked layout */ + legendStackGap: 32, }, } as const; +const CARTESIAN_Y_AXIS = { + tickCount: VictoryTheme.axis.tickCount, + axisSide: 'right', + lineWidth: 0, +} as const; + /** Minimum height for the chart content area (bars, Y-axis, grid lines) */ const CHART_CONTENT_MIN_HEIGHT = 250; @@ -109,8 +138,17 @@ const SIN_45 = Math.sin(Math.PI / 4); /** Minimum gap between adjacent labels (px) */ const LABEL_PADDING = 4; +/** Minimum clear space between adjacent horizontal x-axis labels on stepped axes (px) */ +const X_AXIS_LABEL_MIN_GAP = 24; + const ELLIPSIS = '...'; +/** Single-character ellipsis used when a wrapped label overflows its last allowed line. */ +const WRAPPED_LABEL_ELLIPSIS = '…'; + +/** Maximum number of lines a wrapped bar label uses before it is truncated. */ +const MAX_BAR_LABEL_LINES = 2; + /** Minimum visible characters (excluding ellipsis) for truncation to be worthwhile */ const MIN_TRUNCATED_CHARS = 10; @@ -131,12 +169,16 @@ const DASH_INTERVALS = [4, 8]; const GLYPH_PADDING = 4; export { + CARTESIAN_Y_AXIS, CHART_CONTENT_MIN_HEIGHT, + X_AXIS_LABEL_MIN_GAP, CHART_Y_SCALE_HEIGHT, LABEL_ROTATIONS, SIN_45, LABEL_PADDING, ELLIPSIS, + WRAPPED_LABEL_ELLIPSIS, + MAX_BAR_LABEL_LINES, MIN_TRUNCATED_CHARS, DIAGONAL_ANGLE_RADIAN_THRESHOLD, MAX_X_AXIS_LABEL_WIDTH, diff --git a/src/components/Charts/barChartConstants.ts b/src/components/Charts/barChartConstants.ts index 6ced8b2c85a1..83ad876b136a 100644 --- a/src/components/Charts/barChartConstants.ts +++ b/src/components/Charts/barChartConstants.ts @@ -7,6 +7,9 @@ const BAR_GAP = 16; /** Widest a bar gets (px), so one or two bars don't stretch across the whole plot. */ const BAR_MAX_WIDTH = 320; +/** Gap (px) between the bars of different series at the same point */ +const BAR_GROUP_INNER_GAP = 4; + const BAR_CORNER_RADIUS = 4; /** Share of the gap on each side of a bar that still counts as hovering it, so neighbors never overlap. */ @@ -16,4 +19,4 @@ const BAR_HIT_GAP_RATIO = 0.25; const VERTICAL_BAR_DOMAIN_PADDING = {top: 32, bottom: 0, left: 0, right: 0}; export default BAR_INNER_PADDING; -export {BAR_GAP, BAR_MAX_WIDTH, BAR_CORNER_RADIUS, BAR_HIT_GAP_RATIO, VERTICAL_BAR_DOMAIN_PADDING}; +export {BAR_GAP, BAR_GROUP_INNER_GAP, BAR_MAX_WIDTH, BAR_CORNER_RADIUS, BAR_HIT_GAP_RATIO, VERTICAL_BAR_DOMAIN_PADDING}; diff --git a/src/components/Charts/components/ActivePointIndicator.tsx b/src/components/Charts/components/ActivePointIndicator.tsx index 6e754e6e841a..082cdfd480f8 100644 --- a/src/components/Charts/components/ActivePointIndicator.tsx +++ b/src/components/Charts/components/ActivePointIndicator.tsx @@ -1,12 +1,19 @@ import type {DerivedValue, SharedValue} from 'react-native-reanimated'; +import type {MaybeNumber} from 'victory-native'; import {Circle, Group, Line} from '@shopify/react-native-skia'; import React from 'react'; import {useDerivedValue} from 'react-native-reanimated'; type ActivePointIndicatorProps = { - /** Canvas position of the active data point */ - position: DerivedValue<{x: number; y: number}>; + /** Canvas x of the active data point */ + x: DerivedValue; + + /** Index of the active data point */ + activeIndex: SharedValue; + + /** One dot per series */ + dots: SeriesDotPositions[]; isActive: SharedValue; @@ -17,7 +24,6 @@ type ActivePointIndicatorProps = { bottom: number; dotRadius: number; - dotColor: string; guidelineColor: string; guidelineOpacity: number; isHollow: DerivedValue; @@ -26,36 +32,34 @@ type ActivePointIndicatorProps = { hollowColor: string; }; +type SeriesDotPositions = { + color: string; + + /** Canvas y of the series at every data point, missing where the series has no value */ + yPositions: MaybeNumber[]; +}; + +type SeriesDotProps = SeriesDotPositions & Pick & Partial>; + const GUIDELINE_WIDTH = 2; const HOLLOW_RING_WIDTH = 2; -function ActivePointIndicator({position, isActive, top, bottom, dotRadius, dotColor, guidelineColor, guidelineOpacity, isHollow, hollowColor}: ActivePointIndicatorProps) { - const opacity = useDerivedValue(() => (isActive.get() ? 1 : 0)); - const guidelineStart = useDerivedValue(() => ({x: position.get().x, y: top})); - const guidelineEnd = useDerivedValue(() => ({x: position.get().x, y: bottom})); - const cx = useDerivedValue(() => position.get().x); - const cy = useDerivedValue(() => position.get().y); - const hollowCenterOpacity = useDerivedValue(() => (isHollow.get() ? 1 : 0)); +function SeriesDot({x, activeIndex, yPositions, color, dotRadius, isHollow, hollowColor}: SeriesDotProps) { + const cy = useDerivedValue(() => yPositions.at(activeIndex.get()) ?? 0); + const opacity = useDerivedValue(() => (typeof yPositions.at(activeIndex.get()) === 'number' ? 1 : 0)); + const hollowCenterOpacity = useDerivedValue(() => (isHollow?.get() ? 1 : 0)); return ( - (isActive.get() ? 1 : 0)); + const guidelineStart = useDerivedValue(() => ({x: x.get(), y: top})); + const guidelineEnd = useDerivedValue(() => ({x: x.get(), y: bottom})); + + return ( + + + {/* Drawn last, the first series' dot sits on top of the others, like its line */} + {dots + .map(({color, yPositions}, index) => ( + + )) + .reverse()} + + ); +} + export default ActivePointIndicator; diff --git a/src/components/Charts/components/ChartTooltip.tsx b/src/components/Charts/components/ChartTooltip.tsx index dd13d3e6b78c..834c74aa1be4 100644 --- a/src/components/Charts/components/ChartTooltip.tsx +++ b/src/components/Charts/components/ChartTooltip.tsx @@ -1,6 +1,8 @@ import {TOOLTIP_BAR_GAP} from '@components/Charts/hooks'; +import {pinChartTooltip, unpinChartTooltip} from '@components/Charts/utils/pinnedChartTooltip'; import Text from '@components/Text'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -8,11 +10,12 @@ import PopoverWithMeasuredContentUtils from '@libs/PopoverWithMeasuredContentUti import variables from '@styles/variables'; -import type {ComponentRef} from 'react'; +import type {ReactNode} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import type {DerivedValue, SharedValue} from 'react-native-reanimated'; -import React, {useLayoutEffect, useRef, useState} from 'react'; +import {useIsFocused} from '@react-navigation/native'; +import React, {useEffect, useEffectEvent, useState} from 'react'; import {View} from 'react-native'; import Animated, {useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -24,14 +27,8 @@ type ChartTooltipProps = { /** Label text (e.g., "Airfare", "Amazon") */ label: string; - /** Formatted amount (e.g., "$1,820.00") */ - amount: string; - - /** Optional percentage to display (e.g., "12%") */ - percentage?: string; - - /** Optional translated expense count (e.g., "841 expenses") */ - expenseCount?: string; + /** Shown under the label, like ChartTooltipSummary */ + children: ReactNode; /** The width of the chart container as displayed in the window */ chartWidth: number; @@ -41,21 +38,26 @@ type ChartTooltipProps = { /** Whether the tooltip should be shown, treated as always shown when omitted */ isVisible?: DerivedValue; + /** Whether the rendered content belongs to the current target, so the old content is never drawn at the new position */ + isContentCurrent?: DerivedValue; + /** Updates the hovered point after the chart is moved in the window (e.g. via scroll) by the given offset */ onChartMoved?: (deltaX: number, deltaY: number) => void; -}; -function getAmountContent(amount: string, percentage?: string): string { - if (!amount || !percentage) { - return amount; - } + /** Hides a tooltip pinned by a touch tap, passed only while it is pinned */ + onDismiss?: () => void; - return `${amount} (${percentage})`; -} + /** Identifies the item the content belongs to, so the content is mounted again and laid out on each change of the item */ + contentKey?: number; + + /** Called once the content is laid out, which is the first moment it is on screen on native */ + onContentLayout?: () => void; +}; -function ChartTooltip({label, amount, percentage, expenseCount, chartWidth, initialTooltipPosition, isVisible, onChartMoved}: ChartTooltipProps) { +function ChartTooltip({label, children, chartWidth, initialTooltipPosition, isVisible, isContentCurrent, onChartMoved, onDismiss, contentKey, onContentLayout}: ChartTooltipProps) { const styles = useThemeStyles(); const {windowWidth, windowHeight} = useWindowDimensions(); + const {top: safeAreaTop, bottom: safeAreaBottom} = useSafeAreaInsets(); /** Shared value to store the measured width of the tooltip container */ const tooltipMeasuredWidth = useSharedValue(0); @@ -72,28 +74,15 @@ function ChartTooltip({label, amount, percentage, expenseCount, chartWidth, init /** JS mirror of isVisible, which turns the anchor's position tracking on and off */ const [isShown, setIsShown] = useState(false); - const amountContent = getAmountContent(amount, percentage); - const content = [label, amountContent, expenseCount].join('|'); - - const tooltipWrapperRef = useRef>(null); - - useLayoutEffect(() => { - tooltipWrapperRef.current?.measure((x: number, y: number, width: number, height: number) => { - if (width <= 0) { - return; - } - tooltipMeasuredWidth.set(width); - tooltipMeasuredHeight.set(height); - }); - }, [content, chartWidth, tooltipMeasuredWidth, tooltipMeasuredHeight]); - - const updateMeasuredSize = (event: LayoutChangeEvent) => { + // The size is set before the content is reported as laid out, so the tooltip is never revealed with the previous content's size + const handleContentLayout = (event: LayoutChangeEvent) => { const {width, height} = event.nativeEvent.layout; if (width <= 0) { return; } tooltipMeasuredWidth.set(width); tooltipMeasuredHeight.set(height); + onContentLayout?.(); }; useAnimatedReaction( @@ -109,6 +98,34 @@ function ChartTooltip({label, amount, percentage, expenseCount, chartWidth, init }, ); + const isFocused = useIsFocused(); + + // A pinned tooltip is registered for the screen's touch start to hide it + useEffect(() => { + if (!isShown || !onDismiss) { + return; + } + + if (!isFocused) { + onDismiss(); + return; + } + pinChartTooltip(onDismiss); + return () => unpinChartTooltip(onDismiss); + }, [isShown, isFocused, onDismiss]); + + const dismissOnWindowResize = useEffectEvent(() => { + if (!isShown || !onDismiss) { + return; + } + onDismiss(); + }); + + // A pinned tooltip is hidden when the window is resized (e.g. on rotation) + useEffect(() => { + dismissOnWindowResize(); + }, [windowWidth, windowHeight]); + const handleOriginChange = (x: number, y: number) => { const previousOrigin = origin.get(); const wasOriginMeasured = isOriginMeasured.get(); @@ -131,7 +148,7 @@ function ChartTooltip({label, amount, percentage, expenseCount, chartWidth, init return Math.max(halfWidth, Math.min(chartWidth - halfWidth, x)); }, [initialTooltipPosition, tooltipMeasuredWidth, chartWidth]); - /** Animated window position of the tooltip, placed above the point and kept within the chart horizontally and within the window */ + /** Animated window position of the tooltip, placed above the point and kept within the chart horizontally and within the window's safe area, since the portal also spans the status bar */ const tooltipStyle = useAnimatedStyle(() => { const {y} = initialTooltipPosition.get(); const {x: originX, y: originY} = origin.get(); @@ -139,17 +156,19 @@ function ChartTooltip({label, amount, percentage, expenseCount, chartWidth, init const height = tooltipMeasuredHeight.get(); const left = originX + clampedCenter.get() - width / 2; + const minTop = safeAreaTop; + const maxBottom = windowHeight - safeAreaBottom; const topAbove = originY + y - height; const shiftedLeft = left + PopoverWithMeasuredContentUtils.computeHorizontalShift(left, width, windowWidth); - const shiftedTop = topAbove + PopoverWithMeasuredContentUtils.computeVerticalShift(topAbove, height, windowHeight, 2 * TOOLTIP_BAR_GAP, true); - const isTooltipShown = (isVisible?.get() ?? true) && isOriginMeasured.get() && width > 0; + const shiftedTop = topAbove + PopoverWithMeasuredContentUtils.computeVerticalShift(topAbove - minTop, height, maxBottom - minTop, 2 * TOOLTIP_BAR_GAP, true); + const isTooltipShown = (isVisible?.get() ?? true) && (isContentCurrent?.get() ?? true) && isOriginMeasured.get() && width > 0; return { left: Math.max(0, Math.min(windowWidth - width, shiftedLeft)), - top: Math.max(0, Math.min(windowHeight - height, shiftedTop)), + top: Math.max(minTop, Math.min(maxBottom - height, shiftedTop)), opacity: isTooltipShown ? 1 : 0, }; - }, [initialTooltipPosition, windowWidth, windowHeight]); + }, [initialTooltipPosition, windowWidth, windowHeight, safeAreaTop, safeAreaBottom]); return ( <> @@ -161,13 +180,14 @@ function ChartTooltip({label, amount, percentage, expenseCount, chartWidth, init - + {label} - {!!amountContent && {amountContent}} - {!!expenseCount && {expenseCount}} + {children} diff --git a/src/components/Charts/components/ChartTooltipAnchor/index.native.tsx b/src/components/Charts/components/ChartTooltipAnchor/index.native.tsx index 2fc80243b4ef..518ab926aab2 100644 --- a/src/components/Charts/components/ChartTooltipAnchor/index.native.tsx +++ b/src/components/Charts/components/ChartTooltipAnchor/index.native.tsx @@ -1,9 +1,59 @@ +import ChartTooltipPortal from '@components/Charts/components/ChartTooltipPortal'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import type {ComponentRef} from 'react'; + +import React, {useEffect, useEffectEvent, useRef} from 'react'; +import {DeviceEventEmitter, View} from 'react-native'; + import type ChartTooltipAnchorProps from './types'; -// The chart tooltip is not shown on native yet, the anchor will be implemented together with the mobile tooltip (https://github.com/Expensify/App/issues/102865) -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function ChartTooltipAnchor(props: ChartTooltipAnchorProps) { - return null; +/** Zero-size view pinned to the chart's top-left corner, which reports where the chart sits in the window when the tooltip shows and after each scroll */ +function ChartTooltipAnchor({isShown, onOriginChange}: ChartTooltipAnchorProps) { + const styles = useThemeStyles(); + const anchorRef = useRef>(null); + const hostOriginRef = useRef>(null); + // Measured against the portal host the tooltip is drawn in, since on Android the window coordinates can leave out the status bar + const measureOrigin = () => hostOriginRef.current?.measureInWindow((hostX, hostY) => anchorRef.current?.measureInWindow((x, y) => onOriginChange(x - hostX, y - hostY))); + const measureOriginAfterScroll = useEffectEvent(() => measureOrigin()); + + useEffect(() => { + if (!isShown) { + return; + } + + // Native views can't be observed while they move, so the position is measured again once the scroll ends + const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (isScrolling: boolean) => { + if (isScrolling) { + return; + } + measureOriginAfterScroll(); + }); + return () => scrollingListener.remove(); + }, [isShown]); + + return ( + <> + + {isShown && ( + + + + )} + + ); } export default ChartTooltipAnchor; diff --git a/src/components/Charts/components/ChartTooltipAnchor/types.ts b/src/components/Charts/components/ChartTooltipAnchor/types.ts index 3345684995cc..648963ef2dd5 100644 --- a/src/components/Charts/components/ChartTooltipAnchor/types.ts +++ b/src/components/Charts/components/ChartTooltipAnchor/types.ts @@ -2,7 +2,7 @@ type ChartTooltipAnchorProps = { /** Whether the tooltip is shown, the anchor only tracks its position while it is */ isShown: boolean; - /** Called with the window position of the chart's top-left corner each time it is measured or moves */ + /** Called with the position of the chart's top-left corner in the layer the tooltip is drawn in, each time it is measured or moves */ onOriginChange: (x: number, y: number) => void; }; diff --git a/src/components/Charts/components/ChartTooltipComparison.tsx b/src/components/Charts/components/ChartTooltipComparison.tsx new file mode 100644 index 000000000000..9560fc50c45d --- /dev/null +++ b/src/components/Charts/components/ChartTooltipComparison.tsx @@ -0,0 +1,59 @@ +import formatAmountWithPercentage from '@components/Charts/utils/formatAmountWithPercentage'; +import type {TooltipChange, TooltipRow} from '@components/Charts/utils/formatTooltipComparison'; +import Icon from '@components/Icon'; +import Text from '@components/Text'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import React from 'react'; +import {View} from 'react-native'; + +type ChartTooltipComparisonProps = { + /** One row per compared series */ + rows: TooltipRow[]; + + /** How the primary series changed against the compared one, shown under the rows */ + change?: TooltipChange; +}; + +/** Renders each compared series' amount in the chart tooltip, followed by the change between them */ +function ChartTooltipComparison({rows, change}: ChartTooltipComparisonProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const icons = useMemoizedLazyExpensifyIcons(['ArrowUpLong', 'ArrowDownLong']); + const changeColor = StyleUtils.getSpendChangeColor(!!change?.isIncrease); + + return ( + <> + {rows.map((row) => { + const amountContent = formatAmountWithPercentage(row.amount, row.percentage); + return ( + + + {row.label ? `${row.label}: ${amountContent}` : amountContent} + + ); + })} + {!!change && ( + + + {formatAmountWithPercentage(change.amount, change.percentage)} + + )} + + ); +} + +export default ChartTooltipComparison; diff --git a/src/components/Charts/components/ChartTooltipLayer.tsx b/src/components/Charts/components/ChartTooltipLayer.tsx index 32a46f1cbb12..9713b857332d 100644 --- a/src/components/Charts/components/ChartTooltipLayer.tsx +++ b/src/components/Charts/components/ChartTooltipLayer.tsx @@ -1,14 +1,19 @@ import {useTooltipData} from '@components/Charts/hooks'; import type {ChartDataPoint, ChartSeries} from '@components/Charts/types'; +import formatTooltipComparison from '@components/Charts/utils/formatTooltipComparison'; + +import useLocalize from '@hooks/useLocalize'; import type {DerivedValue, SharedValue} from 'react-native-reanimated'; import type {NonEmptyTuple} from 'type-fest'; import React, {useState} from 'react'; -import {useAnimatedReaction} from 'react-native-reanimated'; +import {useAnimatedReaction, useDerivedValue, useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; import ChartTooltip from './ChartTooltip'; +import ChartTooltipComparison from './ChartTooltipComparison'; +import ChartTooltipSummary from './ChartTooltipSummary'; type ChartTooltipLayerProps = { matchedIndex: SharedValue; @@ -33,15 +38,21 @@ type ChartTooltipLayerProps = { /** Called with how far the chart moved in the window while the tooltip is shown */ onChartMoved?: (deltaX: number, deltaY: number) => void; + + /** Hides a tooltip pinned by a touch tap, passed only while it is pinned */ + onDismiss?: () => void; }; /** * Renders the chart tooltip in an isolated subtree so that hover-driven state changes * (active index, visibility) only re-render this lightweight component, not the chart itself. */ -function ChartTooltipLayer({matchedIndex, isTooltipActive, data, series, formatValue, chartWidth, initialTooltipPosition, onChartMoved}: ChartTooltipLayerProps) { +function ChartTooltipLayer({matchedIndex, isTooltipActive, data, series, formatValue, chartWidth, initialTooltipPosition, onChartMoved, onDismiss}: ChartTooltipLayerProps) { const [activeDataIndex, setActiveDataIndex] = useState(-1); + // Set from the content's layout, since on native a value set during render can reach the UI thread before the new content is mounted + const laidOutIndex = useSharedValue(-1); + useAnimatedReaction( () => matchedIndex.get(), (idx) => { @@ -49,23 +60,42 @@ function ChartTooltipLayer({matchedIndex, isTooltipActive, data, series, formatV }, ); + const {preferredLocale} = useLocalize(); + const isContentCurrent = useDerivedValue(() => matchedIndex.get() === laidOutIndex.get()); const tooltipData = useTooltipData(activeDataIndex, data, series, formatValue); + const activeDataPoint = data.at(activeDataIndex); - if (!tooltipData) { + if (!tooltipData || !activeDataPoint) { return null; } + const comparison = formatTooltipComparison(activeDataPoint, series, formatValue, preferredLocale); + return ( + onDismiss={onDismiss} + contentKey={activeDataIndex} + onContentLayout={() => laidOutIndex.set(activeDataIndex)} + > + {comparison ? ( + + ) : ( + + )} + ); } diff --git a/src/components/Charts/components/ChartTooltipPortal/index.native.tsx b/src/components/Charts/components/ChartTooltipPortal/index.native.tsx index cba811a14e5a..25dbe332d2c9 100644 --- a/src/components/Charts/components/ChartTooltipPortal/index.native.tsx +++ b/src/components/Charts/components/ChartTooltipPortal/index.native.tsx @@ -1,9 +1,11 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps'; -// The chart tooltip is not shown on native yet, the portal will be implemented together with the mobile tooltip (https://github.com/Expensify/App/issues/102865) -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function ChartTooltipPortal(props: ChildrenProps) { - return null; +import {Portal} from '@gorhom/portal'; +import React from 'react'; + +/** Renders the chart tooltip in the root portal host so no clipping ancestor (e.g. a ScrollView) can cut it off */ +function ChartTooltipPortal({children}: ChildrenProps) { + return {children}; } export default ChartTooltipPortal; diff --git a/src/components/Charts/components/ChartTooltipSummary.tsx b/src/components/Charts/components/ChartTooltipSummary.tsx new file mode 100644 index 000000000000..3996b457b5f8 --- /dev/null +++ b/src/components/Charts/components/ChartTooltipSummary.tsx @@ -0,0 +1,32 @@ +import formatAmountWithPercentage from '@components/Charts/utils/formatAmountWithPercentage'; +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +type ChartTooltipSummaryProps = { + /** Formatted amount (e.g., "$1,820.00") */ + amount: string; + + /** Optional percentage to display (e.g., "12%") */ + percentage?: string; + + /** Optional translated expense count (e.g., "841 expenses") */ + expenseCount?: string; +}; + +/** Renders a single amount in the chart tooltip, with its share and expense count */ +function ChartTooltipSummary({amount, percentage, expenseCount}: ChartTooltipSummaryProps) { + const styles = useThemeStyles(); + const amountContent = formatAmountWithPercentage(amount, percentage); + + return ( + <> + {!!amountContent && {amountContent}} + {!!expenseCount && {expenseCount}} + + ); +} + +export default ChartTooltipSummary; diff --git a/src/components/Charts/components/ChartWidthBox.tsx b/src/components/Charts/components/ChartWidthBox.tsx new file mode 100644 index 000000000000..4d71efe2c303 --- /dev/null +++ b/src/components/Charts/components/ChartWidthBox.tsx @@ -0,0 +1,39 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; + +import {useState} from 'react'; +import {View} from 'react-native'; + +type ChartWidthBoxProps = { + /** Receives the card's width, or null until the card has been measured. */ + children: (chartWidth: number | null) => ReactNode; +}; + +/** On web the chart component mounts only once the chart engine has downloaded, so measuring here keeps the width across that mount. */ +function ChartWidthBox({children}: ChartWidthBoxProps) { + const styles = useThemeStyles(); + const [chartWidth, setChartWidth] = useState(null); + + const onLayout = (event: LayoutChangeEvent) => { + const {width} = event.nativeEvent.layout; + + // A hidden card reports a width of 0. Keeping the last width keeps the chart and its hover state mounted until the card shows again. + if (width <= 0) { + return; + } + setChartWidth(width); + }; + + return ( + + {children(chartWidth)} + + ); +} + +export default ChartWidthBox; diff --git a/src/components/Charts/components/ChartXAxisLabels.tsx b/src/components/Charts/components/ChartXAxisLabels.tsx index e3a8a62dab02..7e83841e387c 100644 --- a/src/components/Charts/components/ChartXAxisLabels.tsx +++ b/src/components/Charts/components/ChartXAxisLabels.tsx @@ -1,5 +1,5 @@ import {useChartParagraphs} from '@components/Charts/hooks'; -import type {LabelRotation} from '@components/Charts/types'; +import type {LabelRotation, WrappedLabelConfig} from '@components/Charts/types'; import {getFontLineMetrics, rotatedLabelCenterCorrection, rotatedLabelYOffset, truncateLabel} from '@components/Charts/utils'; import VictoryTheme, {GLYPH_PADDING, MAX_X_AXIS_LABEL_WIDTH} from '@components/Charts/VictoryTheme'; @@ -47,6 +47,9 @@ type ChartXAxisLabelsProps = { /** Y-pixel coordinate of the bottom edge of the chart plot area. */ chartBoundsBottom: number; + + /** When set, labels wrap at word breaks across the full column, centered on the bar, never rotated or skipped. Used for bar-name labels. */ + wrapConfig?: WrappedLabelConfig; }; function ChartXAxisLabels({ @@ -63,6 +66,7 @@ function ChartXAxisLabels({ labelColor, xScale, chartBoundsBottom, + wrapConfig, }: ChartXAxisLabelsProps) { const angleRad = (Math.abs(labelRotation) * Math.PI) / 180; const truncatedLabels = (() => { @@ -78,11 +82,50 @@ function ChartXAxisLabels({ }); })(); - const paragraphs = useChartParagraphs(truncatedLabels, fontManager, fontSize, labelColor, MAX_X_AXIS_LABEL_WIDTH); + // Wrapped bar labels render from the full label text and let Skia wrap or ellipsize. Single-line axis labels render from the pre-truncated text. + const paragraphLabels = wrapConfig ? labels : truncatedLabels; + const paragraphLayoutWidth = wrapConfig ? wrapConfig.columnWidth : MAX_X_AXIS_LABEL_WIDTH; + const paragraphs = useChartParagraphs(paragraphLabels, fontManager, fontSize, labelColor, paragraphLayoutWidth, wrapConfig?.maxLines); // Derive ascent/descent from the first available paragraph's line metrics. const {ascent, descent} = getFontLineMetrics(fontManager, fontSize); + if (wrapConfig) { + const {columnWidth, canvasWidth} = wrapConfig; + const wrapTop = chartBoundsBottom + VictoryTheme.axis.xAxisLabelGap; + const edgeMargin = VictoryTheme.axis.padding.left; + return paragraphLabels.map((label, i) => { + if (label.length === 0) { + return null; + } + const para = paragraphs?.at(i)?.para ?? null; + if (!para) { + return null; + } + const tickX = xScale(i); + + // Center the column on the bar, then nudge the first/last label inward just enough that its widest + // line stays on-canvas. Edge bars sit close to the canvas edge, so a label as wide as its slot would + // otherwise clip its outer glyph (e.g. "No merchant" rendering as "Vo merchant"). + const halfLine = para.getLongestLine() / 2; + let boxX = tickX - columnWidth / 2; + if (tickX - halfLine < edgeMargin) { + boxX += edgeMargin - (tickX - halfLine); + } else if (tickX + halfLine > canvasWidth - edgeMargin) { + boxX -= tickX + halfLine - (canvasWidth - edgeMargin); + } + return ( + + ); + }); + } + const correction = rotatedLabelCenterCorrection(ascent, descent, angleRad); const labelY = chartBoundsBottom + VictoryTheme.axis.xAxisLabelGap + rotatedLabelYOffset(ascent, descent, angleRad); diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index 5fa23151604b..2110c62d6c78 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -1,11 +1,14 @@ export {default as useChartLabelLayout} from './useChartLabelLayout'; export {default as useChartLabelMeasurements} from './useChartLabelMeasurements'; export {default as useChartParagraphs} from './useChartParagraphs'; -export {useChartFontManager} from '@components/Charts/context/ChartFontsContext'; export {default as ChartFontsProvider} from '@components/Charts/context/ChartFontsProvider'; -export {useChartInteractions, TOOLTIP_BAR_GAP} from './useChartInteractions'; +export {useChartInteractions, TOOLTIP_BAR_GAP, CHART_GESTURE_TOUCH_ACTION} from './useChartInteractions'; export type {HitTestArgs, ResolveTargetIndexArgs} from './useChartInteractions'; export {default as useChartLabelFormats} from './useChartLabelFormats'; export {default as useDynamicYDomain} from './useDynamicYDomain'; export {default as useTooltipData} from './useTooltipData'; export {default as useLabelHitTesting} from './useLabelHitTesting'; +export {default as useReadyCartesianCanvas} from './useReadyCartesianCanvas'; +export type {CartesianCanvasInputs} from './useReadyCartesianCanvas'; +export {default as useSyncPointPositions} from './useSyncPointPositions'; +export {default as useTouchPinnedTooltip} from './useTouchPinnedTooltip'; diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index efe5aeb613f5..ad5566750b70 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -1,15 +1,24 @@ import type {SharedValue} from 'react-native-reanimated'; import {useCallback} from 'react'; -import {Gesture} from 'react-native-gesture-handler'; +import {PointerType, useCompetingGestures, useHoverGesture, useTapGesture} from 'react-native-gesture-handler'; import {useDerivedValue, useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'; import useChartInteractionState from './useChartInteractionState'; +import useTouchPinnedTooltip from './useTouchPinnedTooltip'; /** Gap between bar top and tooltip bottom */ const TOOLTIP_BAR_GAP = 8; +/** + * On web, Gesture Handler 3.3 resets a gesture's touch-action every time its hook config changes, which drops the + * GestureDetector's touchAction="pan-y" and blocks page scrolling from the chart, so the chart gestures set it themselves. + * Fixed upstream in 3.4.0 (https://github.com/software-mansion/react-native-gesture-handler/pull/4522), remove this + * together with the touchAction entries in the chart gesture configs after upgrading. + */ +const CHART_GESTURE_TOUCH_ACTION = 'pan-y'; + /** * Arguments passed to the checkIsOver callback for hit-testing */ @@ -100,6 +109,9 @@ type UseChartInteractionsProps = { /** Scale applied to the rendered chart container */ coordinateScale?: number; + + /** Whether a touch tap shows the tooltip for the tapped data point */ + shouldPinTooltipOnTouch?: boolean; }; function normalizeChartCoordinate(coordinate: number, coordinateScale: number): number { @@ -172,6 +184,7 @@ function useChartInteractions({ chartBottom, yZero, coordinateScale = 1, + shouldPinTooltipOnTouch = true, }: UseChartInteractionsProps) { /** Interaction state compatible with Victory Native's internal logic */ const {state: chartInteractionState} = useChartInteractionState(); @@ -303,22 +316,29 @@ function useChartInteractions({ return updateInteractionFlags(hitTestArgs.targetIndex, hitTestArgs.cursorX, hitTestArgs.cursorY, hitTestArgs.chartBottom); }; + /** Resolves the target at the given chart position, then updates the interaction flags and returns whether the cursor is over the target */ + const matchTargetAt = (cursorX: number, cursorY: number) => { + 'worklet'; + + const bottom = chartBottom?.get() ?? cursorY; + const touchX = cursorY >= bottom && resolveLabelTouchX ? resolveLabelTouchX(cursorX, cursorY) : cursorX; + const targetIndex = getResolvedTargetIndex(cursorX, cursorY, touchX); + applyTargetIndex(targetIndex); + return updateInteractionFlags(targetIndex, cursorX, cursorY, bottom); + }; + /** Moves the hover cursor to the given chart position and updates the matched target and the tooltip visibility */ const updateHoverAt = (cursorX: number, cursorY: number) => { 'worklet'; chartInteractionState.cursor.x.set(cursorX); chartInteractionState.cursor.y.set(cursorY); - const bottom = chartBottom?.get() ?? cursorY; const isOverCurrentTarget = updateCurrentInteractionFlags(); // Only update the matched index when the cursor is not over the current target. // This keeps the active index locked while hovering over a bar/point/label, // preventing it from jumping to a different point during continuous movement. if (!isOverCurrentTarget) { - const touchX = cursorY >= bottom && resolveLabelTouchX ? resolveLabelTouchX(cursorX, cursorY) : cursorX; - const targetIndex = getResolvedTargetIndex(cursorX, cursorY, touchX); - applyTargetIndex(targetIndex); - updateInteractionFlags(targetIndex, cursorX, cursorY, bottom); + matchTargetAt(cursorX, cursorY); } }; @@ -337,6 +357,20 @@ function useChartInteractions({ const onChartMoved = (deltaX: number, deltaY: number) => scheduleOnUI(handleChartMoved, deltaX, deltaY); + const hideTooltip = () => { + 'worklet'; + + chartInteractionState.isActive.set(false); + isCursorOverTarget.set(false); + isCursorOverClickable.set(false); + isTooltipActive.set(false); + }; + + const {setIsTouchPinned, dismissTouchTooltip, onTooltipDismiss, onTapFinalize, onChartTouchStart} = useTouchPinnedTooltip(() => { + hideTooltip(); + applyTargetIndex(-1); + }); + /** * Hover gesture to be placed on the full-height outer container (chart + label area). * Clamps the y coordinate to chartBottom before passing to Victory so that hovering @@ -345,48 +379,77 @@ function useChartInteractions({ * customGestures prop, because Victory's internal GestureHandler view only covers * the plot area and would drop events from the label area. */ - const hoverGesture = () => - Gesture.Hover() - .onBegin((e) => { - 'worklet'; - - const cursorX = normalizeChartCoordinate(e.x, coordinateScale); - const cursorY = normalizeChartCoordinate(e.y, coordinateScale); - chartInteractionState.isActive.set(true); - chartInteractionState.cursor.x.set(cursorX); - chartInteractionState.cursor.y.set(cursorY); - const bottom = chartBottom?.get() ?? cursorY; - const touchX = cursorY >= bottom && resolveLabelTouchX ? resolveLabelTouchX(cursorX, cursorY) : cursorX; - const targetIndex = getResolvedTargetIndex(cursorX, cursorY, touchX); - applyTargetIndex(targetIndex); - updateInteractionFlags(targetIndex, cursorX, cursorY, bottom); - }) - .onUpdate((e) => { - 'worklet'; - - updateHoverAt(normalizeChartCoordinate(e.x, coordinateScale), normalizeChartCoordinate(e.y, coordinateScale)); - }) - .onEnd(() => { - 'worklet'; - - chartInteractionState.isActive.set(false); - isCursorOverTarget.set(false); - isCursorOverClickable.set(false); - isTooltipActive.set(false); - }); + const hoverGesture = useHoverGesture({ + // @ts-expect-error -- touchAction is internal in Gesture Handler 3 config types, see CHART_GESTURE_TOUCH_ACTION + touchAction: CHART_GESTURE_TOUCH_ACTION, + onBegin: (e) => { + 'worklet'; + + if (e.pointerType === PointerType.TOUCH) { + return; + } + scheduleOnRN(setIsTouchPinned, false); + const cursorX = normalizeChartCoordinate(e.x, coordinateScale); + const cursorY = normalizeChartCoordinate(e.y, coordinateScale); + chartInteractionState.isActive.set(true); + chartInteractionState.cursor.x.set(cursorX); + chartInteractionState.cursor.y.set(cursorY); + matchTargetAt(cursorX, cursorY); + }, + onUpdate: (e) => { + 'worklet'; + + if (e.pointerType === PointerType.TOUCH) { + return; + } + updateHoverAt(normalizeChartCoordinate(e.x, coordinateScale), normalizeChartCoordinate(e.y, coordinateScale)); + }, + onDeactivate: (e) => { + 'worklet'; + + if (e.pointerType === PointerType.TOUCH) { + return; + } + hideTooltip(); + }, + }); + + const handleTouchTap = (cursorX: number, cursorY: number) => { + 'worklet'; + + chartInteractionState.isActive.set(true); + const isOverTarget = matchTargetAt(cursorX, cursorY); + if (!isOverTarget) { + scheduleOnRN(dismissTouchTooltip); + return; + } + scheduleOnRN(setIsTouchPinned, true); + }; /** - * Tap gesture. Resolves the nearest data point entirely on the UI thread, - * then schedules onPress on the JS thread if the cursor is over the target. + * Tap gesture. Resolves the nearest data point entirely on the UI thread. + * Touch taps show the tooltip; other pointers schedule handlePress on the JS thread if the cursor is over the target. + * Disabled when there is neither handlePress nor touch pinning, so the chart only reacts to hover. */ - const tapGesture = (onPress: (index: number) => void) => - Gesture.Tap().onEnd((e) => { + const tapGesture = useTapGesture({ + // @ts-expect-error -- touchAction is internal in Gesture Handler 3 config types, see CHART_GESTURE_TOUCH_ACTION + touchAction: CHART_GESTURE_TOUCH_ACTION, + enabled: !!handlePress || shouldPinTooltipOnTouch, + onFinalize: onTapFinalize, + onDeactivate: (e) => { 'worklet'; const cursorX = normalizeChartCoordinate(e.x, coordinateScale); const cursorY = normalizeChartCoordinate(e.y, coordinateScale); chartInteractionState.cursor.x.set(cursorX); chartInteractionState.cursor.y.set(cursorY); + if (e.pointerType === PointerType.TOUCH && shouldPinTooltipOnTouch) { + handleTouchTap(cursorX, cursorY); + return; + } + if (!handlePress) { + return; + } const ox = pointOX.get(); const oy = pointOY.get(); const idx = getResolvedTargetIndex(cursorX, cursorY, cursorX); @@ -401,9 +464,10 @@ function useChartInteractions({ const isClickable = (checkIsClickable ?? checkIsOver)(hitTestArgs); updateInteractionFlags(idx, cursorX, cursorY, currentChartBottom); if (isClickable) { - scheduleOnRN(onPress, idx); + scheduleOnRN(handlePress, idx); } - }); + }, + }); /** * Raw tooltip positioning data. @@ -430,14 +494,14 @@ function useChartInteractions({ y: chartInteractionState.y.y.position.get(), })); - const customGestures = handlePress ? Gesture.Race(hoverGesture(), tapGesture(handlePress)) : hoverGesture(); + const customGestures = useCompetingGestures(hoverGesture, tapGesture); return { /** Custom gestures to be passed to CartesianChart */ customGestures, /** * Call this from handleScaleChange with the canvas x/y positions of each data point. - * Derived from the d3 scale: ox[i] = xScale(i), oy[i] = yScale of the point's primary series value. + * Derived from the d3 scale: ox[i] = xScale(i), oy[i] = yScale of the point's topmost plotted value. */ setPointPositions, /** SharedValue for the currently matched data index — read on the UI thread or sync via useAnimatedReaction */ @@ -452,8 +516,12 @@ function useChartInteractions({ activePointPosition, /** Call with how far the chart moved in the window, e.g. on page scroll, to re-check the hover under a still cursor */ onChartMoved, + /** Hides the tooltip pinned by a touch tap, undefined while nothing is pinned */ + onTooltipDismiss, + /** Marks a touch on this chart, so the screen's touch start keeps the tooltip and lets the tap switch it */ + onChartTouchStart, }; } -export {useChartInteractions, findClosestPoint, normalizeChartCoordinate, TOOLTIP_BAR_GAP}; +export {useChartInteractions, findClosestPoint, normalizeChartCoordinate, TOOLTIP_BAR_GAP, CHART_GESTURE_TOUCH_ACTION}; export type {HitTestArgs, ResolveTargetIndexArgs}; diff --git a/src/components/Charts/hooks/useChartParagraphs.ts b/src/components/Charts/hooks/useChartParagraphs.ts index 1f967cb1c2e0..0c43a6fb0b26 100644 --- a/src/components/Charts/hooks/useChartParagraphs.ts +++ b/src/components/Charts/hooks/useChartParagraphs.ts @@ -1,13 +1,24 @@ import type {ParagraphWithWidth} from '@components/Charts/types'; -import {buildChartParagraph} from '@components/Charts/utils'; +import {buildChartParagraph, buildWrappedChartParagraph} from '@components/Charts/utils'; import type {SkTypefaceFontProvider} from '@shopify/react-native-skia'; /** * Builds and lays out Skia paragraphs for the given labels. * Returns an empty array until fontManager is available. + * + * When `maxLines` is set each label wraps at word breaks, centers its lines, and truncates anything + * past `maxLines` with an ellipsis. Wrapped paragraphs report `width === layoutWidth` so the caller + * can center them within the full column, rather than the measured longest-line width. */ -function useChartParagraphs(labels: string[], fontManager: SkTypefaceFontProvider | null, fontSize: number, labelColor: string, layoutWidth: number): ParagraphWithWidth[] { +function useChartParagraphs( + labels: string[], + fontManager: SkTypefaceFontProvider | null, + fontSize: number, + labelColor: string, + layoutWidth: number, + maxLines?: number, +): ParagraphWithWidth[] { if (!fontManager) { return []; } @@ -15,6 +26,11 @@ function useChartParagraphs(labels: string[], fontManager: SkTypefaceFontProvide if (!label) { return {para: null, width: 0}; } + if (maxLines) { + const para = buildWrappedChartParagraph(label, fontManager, fontSize, labelColor, maxLines); + para.layout(layoutWidth); + return {para, width: layoutWidth}; + } const para = buildChartParagraph(label, fontManager, fontSize, labelColor); para.layout(layoutWidth); return {para, width: para.getLongestLine()}; diff --git a/src/components/Charts/hooks/useDynamicYDomain.ts b/src/components/Charts/hooks/useDynamicYDomain.ts index ca76964abd82..41ba6aae3340 100644 --- a/src/components/Charts/hooks/useDynamicYDomain.ts +++ b/src/components/Charts/hooks/useDynamicYDomain.ts @@ -5,7 +5,7 @@ import {useMemo} from 'react'; /** Keep zero inside the Y-axis domain so bars always grow from a visible baseline, also when all values are negative */ function useDynamicYDomain(data: ChartDataPoint[]): [number, number] { return useMemo((): [number, number] => { - const values = data.flatMap((point) => Object.values(point.values)); + const values = data.flatMap((point) => Object.values(point.values).filter((value) => value !== undefined)); return [Math.min(0, ...values), Math.max(0, ...values)]; }, [data]); } diff --git a/src/components/Charts/hooks/useLabelHitTesting.ts b/src/components/Charts/hooks/useLabelHitTesting.ts index 2517f3b66b5b..7911feec0751 100644 --- a/src/components/Charts/hooks/useLabelHitTesting.ts +++ b/src/components/Charts/hooks/useLabelHitTesting.ts @@ -1,4 +1,4 @@ -import type {LabelRotation} from '@components/Charts/types'; +import type {LabelRotation, WrappedLabelConfig} from '@components/Charts/types'; import {getAdditionalOffset, getFontLineMetrics, isCursorOverChartLabel, rotatedLabelYOffset} from '@components/Charts/utils'; import VictoryTheme, {DIAGONAL_ANGLE_RADIAN_THRESHOLD} from '@components/Charts/VictoryTheme'; @@ -46,6 +46,9 @@ type UseLabelHitTestingParams = { labelRotation: LabelRotation; labelSkipInterval: number; chartBottom: SharedValue; + + /** When set, labels are hit-tested as horizontal boxes centered on each tick, spanning the full bar column and the wrapped lines. Used for wrapped bar-name labels. */ + wrapConfig?: WrappedLabelConfig; }; /** @@ -61,13 +64,27 @@ type UseLabelHitTestingParams = { * Labels are right-aligned at the tick: the 45° parallelogram's upper-right corner is * offset by (iconSize/3 * sinA) left and down, placing the box just below the axis line. */ -function useLabelHitTesting({fontManager, fontSize, truncatedLabelWidths, labelRotation, labelSkipInterval, chartBottom}: UseLabelHitTestingParams) { +function useLabelHitTesting({fontManager, fontSize, truncatedLabelWidths, labelRotation, labelSkipInterval, chartBottom, wrapConfig}: UseLabelHitTestingParams) { const tickXPositions = useSharedValue([]); const angleRad = (Math.abs(labelRotation) * Math.PI) / 180; const fontMetrics = fontManager ? getFontLineMetrics(fontManager, fontSize) : null; + // Wrapped bar labels are horizontal boxes centered on each tick spanning the full bar column, so a + // non-empty label's hit area always covers its painted text even when ChartXAxisLabels nudges an edge + // label inward to avoid canvas-edge clipping (the nudge keeps the longest line within the column). + // The height spans the wrapped lines below the axis gap, from wrapConfig so the box matches the + // reserved and painted label area. Empty labels keep a zero width so they stay non-interactive. + const wrapHitGeometry = + wrapConfig && wrapConfig.lineHeight > 0 + ? { + halfWidths: wrapConfig.hasLabel.map((present) => (present ? wrapConfig.columnWidth / 2 : 0)), + topOffset: VictoryTheme.axis.xAxisLabelGap, + boxHeight: wrapConfig.maxLines * wrapConfig.lineHeight, + } + : null; + let labelHitGeometry: LabelHitGeometry | null = null; if (fontMetrics) { const {ascent, descent} = fontMetrics; @@ -89,11 +106,21 @@ function useLabelHitTesting({fontManager, fontSize, truncatedLabelWidths, labelR /** * Hit-tests whether the cursor is over the x-axis label at `activeIndex`. - * Supports 0°, ~45° (parallelogram), and 90° label orientations. + * Supports wrapped horizontal boxes, and 0°, ~45° (parallelogram), and 90° label orientations. */ const isCursorOverLabel = (args: HitTestArgs, activeIndex: number): boolean => { 'worklet'; + if (wrapHitGeometry) { + const halfWidth = wrapHitGeometry.halfWidths.at(activeIndex) ?? 0; + if (halfWidth === 0) { + return false; + } + const top = args.chartBottom + wrapHitGeometry.topOffset; + const bottom = top + wrapHitGeometry.boxHeight; + return args.cursorX >= args.targetX - halfWidth && args.cursorX <= args.targetX + halfWidth && args.cursorY >= top && args.cursorY <= bottom; + } + if (!labelHitGeometry || activeIndex % labelSkipInterval !== 0) { return false; } diff --git a/src/components/Charts/hooks/useReadyCartesianCanvas.ts b/src/components/Charts/hooks/useReadyCartesianCanvas.ts new file mode 100644 index 000000000000..13e7d3288bfa --- /dev/null +++ b/src/components/Charts/hooks/useReadyCartesianCanvas.ts @@ -0,0 +1,21 @@ +import {useChartFontManager} from '@components/Charts/context/ChartFontsContext'; + +import type {SkTypefaceFontProvider} from '@shopify/react-native-skia'; + +type CartesianCanvasInputs = { + chartWidth: number; + fontManager: SkTypefaceFontProvider; +}; + +function useReadyCartesianCanvas(isLoading: boolean, chartWidth: number | null): CartesianCanvasInputs | null { + const fontManager = useChartFontManager(); + + if (isLoading || !fontManager || chartWidth === null) { + return null; + } + + return {chartWidth, fontManager}; +} + +export default useReadyCartesianCanvas; +export type {CartesianCanvasInputs}; diff --git a/src/components/Charts/hooks/useSyncPointPositions.ts b/src/components/Charts/hooks/useSyncPointPositions.ts new file mode 100644 index 000000000000..a21d783c93f6 --- /dev/null +++ b/src/components/Charts/hooks/useSyncPointPositions.ts @@ -0,0 +1,30 @@ +import type {ChartDataPoint, ChartSeries} from '@components/Charts/types'; + +import type {Scale} from 'victory-native'; + +import {useEffect, useRef} from 'react'; + +/** Returns the canvas x and y of each point. The y is the highest series', so the tooltip above it never covers another one. */ +function getHighestPointPositions(data: ChartDataPoint[], series: readonly ChartSeries[], xScale: Scale, yScale: Scale): [number[], number[]] { + return [data.map((point, index) => xScale(index)), data.map((point) => yScale(Math.max(...series.flatMap(({key}) => point.values[key] ?? []))))]; +} + +/** Returns a function that sets the points' canvas positions from the given scales, and sets them again whenever the data changes. */ +function useSyncPointPositions(data: ChartDataPoint[], series: readonly ChartSeries[], setPointPositions: (ox: number[], oy: number[]) => void): (xScale: Scale, yScale: Scale) => void { + const scales = useRef<{xScale: Scale; yScale: Scale} | null>(null); + + // Victory reports the scales only when they change, and new values often keep the same rounded axes + useEffect(() => { + if (!scales.current) { + return; + } + setPointPositions(...getHighestPointPositions(data, series, scales.current.xScale, scales.current.yScale)); + }, [data, series, setPointPositions]); + + return (xScale: Scale, yScale: Scale) => { + scales.current = {xScale, yScale}; + setPointPositions(...getHighestPointPositions(data, series, xScale, yScale)); + }; +} + +export default useSyncPointPositions; diff --git a/src/components/Charts/hooks/useTouchPinnedTooltip.ts b/src/components/Charts/hooks/useTouchPinnedTooltip.ts new file mode 100644 index 000000000000..8718d874f3eb --- /dev/null +++ b/src/components/Charts/hooks/useTouchPinnedTooltip.ts @@ -0,0 +1,45 @@ +import {markChartTouch} from '@components/Charts/utils/pinnedChartTooltip'; + +import {useState} from 'react'; +import {PointerType} from 'react-native-gesture-handler'; +import {scheduleOnRN} from 'react-native-worklets'; + +/** + * Tracks the tooltip pinned by a touch tap, which a touch anywhere on the screen hides. + * @param hideTooltip - hides the chart's tooltip and clears its matched target + */ +function useTouchPinnedTooltip(hideTooltip: () => void) { + /** True while a tooltip opened by a touch tap is shown */ + const [isTouchPinned, setIsTouchPinned] = useState(false); + + const dismissTouchTooltip = () => { + hideTooltip(); + setIsTouchPinned(false); + }; + + const onTooltipDismiss = isTouchPinned ? dismissTouchTooltip : undefined; + + const onTapFinalize = (e: {pointerType: PointerType; canceled: boolean}) => { + 'worklet'; + + if (!e.canceled || e.pointerType !== PointerType.TOUCH || !isTouchPinned) { + return; + } + scheduleOnRN(dismissTouchTooltip); + }; + + return { + /** Sets whether the tooltip is pinned by a touch tap */ + setIsTouchPinned, + /** Hides the tooltip and unpins it */ + dismissTouchTooltip, + /** Hides the pinned tooltip, undefined while nothing is pinned */ + onTooltipDismiss, + /** Pass to the tap gesture's onFinalize */ + onTapFinalize, + /** Marks a touch on this chart, so the screen's touch start keeps the tooltip and lets the tap switch it */ + onChartTouchStart: () => markChartTouch(onTooltipDismiss), + }; +} + +export default useTouchPinnedTooltip; diff --git a/src/components/Charts/types/index.ts b/src/components/Charts/types/index.ts index fda274c41d3d..db65495e9bfa 100644 --- a/src/components/Charts/types/index.ts +++ b/src/components/Charts/types/index.ts @@ -7,6 +7,12 @@ import type {NonEmptyTuple, ValueOf} from 'type-fest'; type ChartSeries = { /** Identifies this series' amount in every point's `values` */ key: string; + + /** Color of the series' line. Left out, the chart's default color. */ + color?: string; + + /** Names the series in the tooltip, like "Average" */ + label?: string; }; type ChartDataPoint = { @@ -17,18 +23,27 @@ type ChartDataPoint = { shortLabel?: string; /** One amount per series (pre-formatted, e.g., dollars not cents), keyed by that series' key */ - values: Record; + values: Partial>; /** The point's signed share of total spend, in percentage points */ percentOfTotal?: number; + /** Each series' name for the dates this point covers in its period, which names its row in the tooltip */ + seriesLabels?: Partial>; + + /** Each series' share of its own period's total spend, in percentage points, set when several series are compared */ + seriesPercentOfTotal?: Partial>; + /** Color of the point's bar or slice. Left out, the chart's default color. */ color?: string; + /** Color of each series' bar at the point, keyed by the series' key. Left out, `color` for the first series and each other series' own color. */ + seriesColors?: Partial>; + /** Number of expenses in this point */ count?: number; - /** Marks a time-based point whose period hasn't ended yet, so its total is still changing. Only a line chart's last point is drawn differently. */ + /** Marks a time-based point whose period hasn't ended yet, the current one or a later one, so its total is still changing. A line chart draws these points dashed. */ isInProgress?: boolean; }; @@ -91,4 +106,22 @@ type LabelRotation = ValueOf; type ParagraphWithWidth = {para: SkParagraph | null; width: number}; -export type {CartesianChartProps, ChartDataPoint, ChartProps, ChartSeries, LabelRotation, ParagraphWithWidth, PieSlice, UnitPosition, UnitWithFallback}; +/** Layout for wrapped (multi-line, centered, horizontal) bar labels. Absent means single-line axis-style labels. */ +type WrappedLabelConfig = { + /** Pixel width of a bar's column; a wrapped label fills it, centered on the bar. */ + columnWidth: number; + + /** Maximum lines a wrapped label uses before it is truncated with an ellipsis. */ + maxLines: number; + + /** Pixel line height reserved per label line, shared so the hit box matches the painted lines. */ + lineHeight: number; + + /** Whether each label has text, so empty labels stay non-interactive in hit testing. */ + hasLabel: boolean[]; + + /** Full canvas width (px), used to keep edge labels from clipping past the canvas. */ + canvasWidth: number; +}; + +export type {CartesianChartProps, ChartDataPoint, ChartProps, ChartSeries, LabelRotation, ParagraphWithWidth, PieSlice, UnitPosition, UnitWithFallback, WrappedLabelConfig}; diff --git a/src/components/Charts/utils/formatAmountWithPercentage.ts b/src/components/Charts/utils/formatAmountWithPercentage.ts new file mode 100644 index 000000000000..c2424ba8bd4c --- /dev/null +++ b/src/components/Charts/utils/formatAmountWithPercentage.ts @@ -0,0 +1,6 @@ +/** Formats an amount followed by its percentage in parentheses */ +function formatAmountWithPercentage(amount: string, percentage?: string): string { + return percentage ? `${amount} (${percentage})` : amount; +} + +export default formatAmountWithPercentage; diff --git a/src/components/Charts/utils/formatTooltipComparison.ts b/src/components/Charts/utils/formatTooltipComparison.ts new file mode 100644 index 000000000000..7f2edaf02e34 --- /dev/null +++ b/src/components/Charts/utils/formatTooltipComparison.ts @@ -0,0 +1,102 @@ +import type {ChartDataPoint, ChartSeries} from '@components/Charts/types'; +import VictoryTheme from '@components/Charts/VictoryTheme'; + +import {formatPercentOfTotal} from '@libs/PercentageUtils'; + +import type Locale from '@src/types/onyx/Locale'; + +import type {NonEmptyTuple} from 'type-fest'; + +/** One compared series' amount at the active point */ +type TooltipRow = { + key: string; + + /** The series' color, shown as a dot before the row */ + color: string; + + /** Names the series, like "Average", or the dates the point covers in its period */ + label?: string; + + amount: string; + + /** The amount's share of its own period's total spend */ + percentage?: string; +}; + +/** How the primary series changed against the one it is compared with */ +type TooltipChange = { + /** The difference, without its sign */ + amount: string; + + /** The difference relative to the compared amount */ + percentage: string; + + isIncrease: boolean; +}; + +type TooltipComparison = { + /** One row per series that has an amount at the point */ + rows: TooltipRow[]; + + change?: TooltipChange; +}; + +/** Formats how much the primary value rose or fell relative to the compared one, as a percentage */ +function formatChangePercentage(primaryValue: number, comparedValue: number, locale: Locale | undefined): string { + if (comparedValue === 0) { + return '—'; + } + + const difference = primaryValue - comparedValue; + return formatPercentOfTotal((Math.abs(difference) / Math.abs(comparedValue)) * 100, difference, locale); +} + +/** Formats how much the primary value rose or fell against the compared one. */ +function formatChange(primaryValue: number, comparedValue: number, formatAmount: (value: number) => string, locale: Locale | undefined): TooltipChange | undefined { + if (primaryValue === comparedValue) { + return undefined; + } + + const difference = primaryValue - comparedValue; + return { + amount: formatAmount(Math.abs(difference)), + percentage: formatChangePercentage(primaryValue, comparedValue, locale), + isIncrease: difference > 0, + }; +} + +/** Formats each series' amount at the point and the primary series' change against the second */ +function formatTooltipComparison( + dataPoint: ChartDataPoint, + series: NonEmptyTuple, + formatAmount: (value: number) => string, + locale: Locale | undefined, +): TooltipComparison | undefined { + const [{key: primarySeriesKey}, comparedSeries] = series; + const primaryValue = dataPoint.values[primarySeriesKey]; + const comparedValue = comparedSeries && dataPoint.values[comparedSeries.key]; + if (!comparedSeries || (primaryValue === undefined && comparedValue === undefined) || (comparedValue === undefined && !dataPoint.seriesLabels)) { + return undefined; + } + + const rows = series.flatMap(({key, color = VictoryTheme.colors.default, label}) => { + const value = dataPoint.values[key]; + if (value === undefined) { + return []; + } + const percentOfTotal = dataPoint.seriesPercentOfTotal?.[key]; + return { + key, + color: dataPoint.seriesColors?.[key] ?? color, + label: dataPoint.seriesLabels?.[key] ?? label, + amount: formatAmount(value), + percentage: percentOfTotal === undefined ? undefined : formatPercentOfTotal(percentOfTotal, value, locale), + }; + }); + + return {rows, change: primaryValue === undefined || comparedValue === undefined ? undefined : formatChange(primaryValue, comparedValue, formatAmount, locale)}; +} + +export default formatTooltipComparison; +export {formatChangePercentage}; +export type {TooltipChange, TooltipRow}; diff --git a/src/components/Charts/utils/getLineRuns.ts b/src/components/Charts/utils/getLineRuns.ts new file mode 100644 index 000000000000..ed7ad386164c --- /dev/null +++ b/src/components/Charts/utils/getLineRuns.ts @@ -0,0 +1,36 @@ +type LineRun = { + /** Index of the run's first point */ + start: number; + + /** Index of the run's last point, inclusive */ + end: number; + + /** Whether the run is drawn dashed because it touches a period that hasn't ended */ + isDashed: boolean; +}; + +/** + * Splits a line into runs of consecutive segments drawn the same way. A segment is dashed when either of its points is + * a period that hasn't ended, so the result is the same whichever order the points are sorted in. + * A lone point has no segment, so it forms a single solid run and keeps the regular line's dot. + */ +function getLineRuns(openPointFlags: boolean[]): LineRun[] { + if (openPointFlags.length < 2) { + return openPointFlags.length ? [{start: 0, end: 0, isDashed: false}] : []; + } + + const runs: LineRun[] = []; + for (let index = 1; index < openPointFlags.length; index++) { + const isDashed = !!openPointFlags.at(index - 1) || !!openPointFlags.at(index); + const lastRun = runs.at(-1); + if (lastRun?.isDashed === isDashed) { + lastRun.end = index; + } else { + runs.push({start: index - 1, end: index, isDashed}); + } + } + return runs; +} + +export default getLineRuns; +export type {LineRun}; diff --git a/src/components/Charts/utils/getPrimaryValue.ts b/src/components/Charts/utils/getPrimaryValue.ts index b12e52fde8ed..fe4367140ba3 100644 --- a/src/components/Charts/utils/getPrimaryValue.ts +++ b/src/components/Charts/utils/getPrimaryValue.ts @@ -2,7 +2,7 @@ import type {ChartDataPoint} from '@components/Charts/types'; /** A point's amount for the chart's primary series */ function getPrimaryValue(point: ChartDataPoint, primarySeriesKey: string): number { - return point.values[primarySeriesKey]; + return point.values[primarySeriesKey] ?? 0; } export default getPrimaryValue; diff --git a/src/components/Charts/utils/index.ts b/src/components/Charts/utils/index.ts index fe3f59fb994e..a4deec5a373a 100644 --- a/src/components/Charts/utils/index.ts +++ b/src/components/Charts/utils/index.ts @@ -1,6 +1,15 @@ import BAR_INNER_PADDING, {BAR_GAP, BAR_MAX_WIDTH} from '@components/Charts/barChartConstants'; import type {ChartDataPoint, LabelRotation, PieSlice} from '@components/Charts/types'; -import VictoryTheme, {CHART_Y_SCALE_HEIGHT, DIAGONAL_ANGLE_RADIAN_THRESHOLD, ELLIPSIS, LABEL_PADDING, LABEL_ROTATIONS, MAX_X_AXIS_LABEL_WIDTH, SIN_45} from '@components/Charts/VictoryTheme'; +import VictoryTheme, { + CHART_Y_SCALE_HEIGHT, + DIAGONAL_ANGLE_RADIAN_THRESHOLD, + ELLIPSIS, + LABEL_PADDING, + LABEL_ROTATIONS, + MAX_X_AXIS_LABEL_WIDTH, + SIN_45, + WRAPPED_LABEL_ELLIPSIS, +} from '@components/Charts/VictoryTheme'; import {isShareWorthDrawing} from '@libs/PercentageUtils'; @@ -8,9 +17,10 @@ import variables from '@styles/variables'; import type {SkParagraph, SkParagraphBuilder, SkTypefaceFontProvider} from '@shopify/react-native-skia'; -import {FontStyle, FontWeight, Skia} from '@shopify/react-native-skia'; +import {FontStyle, FontWeight, Skia, TextAlign} from '@shopify/react-native-skia'; import {scaleLinear} from 'd3-scale'; +import getLineRuns from './getLineRuns'; import getPrimaryValue from './getPrimaryValue'; /** One reusable ParagraphBuilder per fontManager instance. Auto-GC'd when fontManager is released. */ @@ -44,6 +54,27 @@ function buildChartParagraph(text: string, fontManager: SkTypefaceFontProvider, .build(); } +/** + * Builds a Skia paragraph that wraps at word breaks, centers each line, and truncates anything + * past `maxLines` with an ellipsis. Used for bar-name labels drawn under the Home chart bars. + * + * Unlike buildChartParagraph this does not reuse the shared builder cache: the wrap/ellipsis/align + * paragraph style is fixed when the builder is created, so it cannot share the single-line builder. + * The caller is responsible for calling `para.layout(width)` before measuring or rendering. + */ +function buildWrappedChartParagraph(text: string, fontManager: SkTypefaceFontProvider, fontSize: number, color: string, maxLines: number): SkParagraph { + return Skia.ParagraphBuilder.Make({maxLines, ellipsis: WRAPPED_LABEL_ELLIPSIS, textAlign: TextAlign.Center}, fontManager) + .pushStyle({ + fontFamilies: VictoryTheme.fontFamilies, + fontStyle: {weight: FontWeight.Normal}, + fontSize, + color: Skia.Color(color), + }) + .addText(text) + .pop() + .build(); +} + /** * Calculates the additional offset to apply to the label to center it on the tick mark. * @param angleRad - The angle of the label in radians. @@ -455,6 +486,16 @@ function getBarLayout(plotWidth: number, barCount: number): {barWidth: number; g return {barWidth, gap, edgeSpace, xDomain: [-edgeSpaceInXUnits, barCount - 1 + edgeSpaceInXUnits]}; } +/** + * Horizontal plot bounds of a cartesian chart whose y axis is `CARTESIAN_Y_AXIS`, mirroring victory-native's layout, + * so labels can be laid out from the width before the chart mounts. + */ +function getCartesianPlotBounds(chartWidth: number, paddingRight: number): {left: number; right: number; width: number} { + const left = VictoryTheme.axis.padding.left; + const right = Math.max(left, chartWidth - paddingRight); + return {left, right, width: right - left}; +} + /** * Domain padding that leaves `edgeSpace` px between the plot edges and the first and last points. * victory-native fits the padded domain back into the plot width, so the space on screen is smaller than the padding. @@ -467,6 +508,44 @@ function getDomainPaddingForEdgeSpace(edgeSpace: {left: number; right: number}, return {left: (edgeSpace.left * plotWidth) / pointsSpan, right: (edgeSpace.right * plotWidth) / pointsSpan}; } +type AxisLabelStepParams = { + /** Pixel width of each point's label */ + labelWidths: number[]; + + /** Canvas x position of each point */ + pointXs: number[]; + + /** Allowed steps between labeled points, ascending */ + steps: readonly number[]; + + /** Minimum clear space between adjacent labels (px) */ + minGap: number; +}; + +/** + * Picks the smallest step whose labels (points 0, step, 2 * step, ...), centered under their points, don't overlap. + * When no step fits, multiples of the last step are tried, down to labeling only the first point. + */ +function getAxisLabelStep({labelWidths, pointXs, steps, minGap}: AxisLabelStepParams): number { + const getLeft = (i: number) => (pointXs.at(i) ?? 0) - (labelWidths.at(i) ?? 0) / 2; + const fits = (step: number) => { + for (let i = step; i < pointXs.length; i += step) { + const previousRight = getLeft(i - step) + (labelWidths.at(i - step) ?? 0); + if (getLeft(i) - previousRight < minGap) { + return false; + } + } + return true; + }; + + const candidates = [...steps]; + const lastStep = steps.at(-1) ?? 1; + for (let step = lastStep * 2; step < pointXs.length; step += lastStep) { + candidates.push(step); + } + return candidates.find(fits) ?? Math.max(1, pointXs.length); +} + /** Returns the pixel width needed for Y-axis labels given the chart data. */ function getYAxisLabelWidth( data: ChartDataPoint[], @@ -478,7 +557,7 @@ function getYAxisLabelWidth( if (!fontManager) { return 0; } - const values = data.flatMap((point) => Object.values(point.values)); + const values = data.flatMap((point) => Object.values(point.values).filter((value) => value !== undefined)); const rawDataMax = values.length ? Math.max(...values) : 0; const rawDataMin = values.length ? Math.min(...values) : 0; return Math.max( @@ -491,6 +570,7 @@ function getYAxisLabelWidth( export { buildChartParagraph, + buildWrappedChartParagraph, canFontRenderText, getAdditionalOffset, measureTextWidth, @@ -500,6 +580,7 @@ export { normalizeAngle, isAngleInSlice, findSliceAtPosition, + getLineRuns, getPrimaryValue, processDataIntoSlices, getXAxisLabel, @@ -514,8 +595,10 @@ export { isCursorOverChartLabel, getNiceYAxisTicks, getYAxisLabelWidth, + getCartesianPlotBounds, getBarLayout, getDomainPaddingForEdgeSpace, + getAxisLabelStep, }; export type {ChartLabelHitTestParams}; diff --git a/src/components/Charts/utils/pinnedChartTooltip.ts b/src/components/Charts/utils/pinnedChartTooltip.ts new file mode 100644 index 000000000000..01e1eba77527 --- /dev/null +++ b/src/components/Charts/utils/pinnedChartTooltip.ts @@ -0,0 +1,39 @@ +/** Hides the tooltip pinned by a touch tap */ +let dismissPinnedTooltip: (() => void) | undefined; + +/** Tooltip whose chart received the current touch */ +let touchedTooltip: (() => void) | undefined; + +/** Pins the given tooltip and hides the previous one */ +function pinChartTooltip(dismiss: () => void) { + if (dismissPinnedTooltip !== dismiss) { + dismissPinnedTooltip?.(); + } + dismissPinnedTooltip = dismiss; +} + +/** Forgets the given tooltip if it is still the pinned one */ +function unpinChartTooltip(dismiss: () => void) { + if (dismissPinnedTooltip !== dismiss) { + return; + } + dismissPinnedTooltip = undefined; +} + +function markChartTouch(dismiss: (() => void) | undefined) { + touchedTooltip = dismiss; +} + +/** Hides the pinned tooltip, unless the touch landed on its own chart */ +function dismissPinnedChartTooltip() { + const isOwnChartTouch = !!touchedTooltip && touchedTooltip === dismissPinnedTooltip; + touchedTooltip = undefined; + if (isOwnChartTouch) { + return; + } + const dismiss = dismissPinnedTooltip; + dismissPinnedTooltip = undefined; + dismiss?.(); +} + +export {pinChartTooltip, unpinChartTooltip, markChartTouch, dismissPinnedChartTooltip}; diff --git a/src/components/CheckboxWithLabel.tsx b/src/components/CheckboxWithLabel.tsx index 77d0f0c47bc6..4d9ec5ee97ff 100644 --- a/src/components/CheckboxWithLabel.tsx +++ b/src/components/CheckboxWithLabel.tsx @@ -48,6 +48,9 @@ type CheckboxWithLabelProps = RequiredLabelProps & { defaultValue?: boolean; + /** Whether the checkbox can't be toggled */ + disabled?: boolean; + /** The ID used to uniquely identify the input in a Form */ /* eslint-disable-next-line react/no-unused-prop-types */ inputID?: string; @@ -70,6 +73,7 @@ function CheckboxWithLabel({ accessibilityLabel, style, value, + disabled = false, ref, }: CheckboxWithLabelProps) { const styles = useThemeStyles(); @@ -96,6 +100,7 @@ function CheckboxWithLabel({ onPress={toggleCheckbox} style={[styles.checkboxWithLabelCheckboxStyle]} hasError={!!errorText} + disabled={disabled} ref={ref} accessibilityLabel={accessibilityLabel ?? label ?? ''} /> @@ -103,6 +108,7 @@ function CheckboxWithLabel({ tabIndex={-1} accessible={false} onPress={toggleCheckbox} + disabled={disabled} pressDimmingValue={variables.checkboxLabelActiveOpacity} // We want to disable hover dimming hoverDimmingValue={variables.checkboxLabelHoverOpacity} diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 31653968860b..008e6cb17f4e 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -1,4 +1,4 @@ -import type {ComposerProps, ComposerRef} from '@components/Composer/types'; +import type {ComposerProps} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; @@ -97,7 +97,7 @@ function Composer({ // get a ref to the inner textInput element e.g. if we do // this.textInput = el} /> this will not // return a ref to the component, but rather the HTML element by default - ref(textInputRef.current as ComposerRef); + ref(textInputRef.current); }, // eslint-disable-next-line react-hooks/exhaustive-deps [isInLandscapeMode], diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 8e5d2d2cd711..12c7324da68b 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -1,4 +1,4 @@ -import type {ComposerProps, ComposerRef} from '@components/Composer/types'; +import type {ComposerProps, WebComposerRef} from '@components/Composer/types'; import {useSession} from '@components/OnyxListItemProvider'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; @@ -20,13 +20,15 @@ import Parser from '@libs/Parser'; import CONST from '@src/CONST'; import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; -import type {TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native'; +import type {TextInput, TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native'; import {useIsFocused} from '@react-navigation/native'; import lodashDebounce from 'lodash/debounce'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {DeviceEventEmitter, StyleSheet} from 'react-native'; +type WebMarkdownInput = HTMLDivElement & Pick & {value?: string}; + const excludeNoStyles: Array = []; const excludeReportMentionStyle: Array = ['mentionReport']; const imagePreviewAuthRequiredURLs = [CONST.EXPENSIFY_URL, CONST.STAGING_EXPENSIFY_URL, CONST.QA_EXPENSIFY_URL]; @@ -69,7 +71,7 @@ function Composer({ const addAuthTokenToImageURL = useCallback((url: string) => addEncryptedAuthTokenToURL(url, encryptedAuthToken), [encryptedAuthToken]); const markdownStyle = useMarkdownStyle(textContainsOnlyEmojis, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); const StyleUtils = useStyleUtils(); - const textInputRef = useRef(null); + const textInputRef = useRef(null); const [selection, setSelection] = useState< | { start: number; @@ -156,7 +158,7 @@ function Composer({ } if (textInputRef.current !== event.target && !(isContenteditableDivFocused && !event.clipboardData?.files.length)) { - const eventTarget = event.target as HTMLInputElement | HTMLTextAreaElement | null; + const eventTarget = event.target instanceof HTMLElement ? event.target : null; // To make sure the composer does not capture paste events from other inputs, we check where the event originated // If it did originate in another input, we return early to prevent the composer from handling the paste const isTargetInput = eventTarget?.nodeName === CONST.ELEMENT_NAME.INPUT || eventTarget?.nodeName === CONST.ELEMENT_NAME.TEXTAREA || eventTarget?.contentEditable === 'true'; @@ -290,7 +292,7 @@ function Composer({ return; } - const currentText = textInputRef.current.value; + const currentText = textInputRef.current.value ?? ''; textInputRef.current.clear(); // We need to reset the selection to 0,0 manually after clearing the text input on web @@ -301,14 +303,14 @@ function Composer({ end: 0, }, }, - } as TextInputSelectionChangeEvent; + }; onSelectionChange(selectionEvent); setSelection({start: 0, end: 0}); onClear(currentText); }, [onClear, onSelectionChange]); - useImperativeHandle(ref, () => { + useImperativeHandle(ref, (): WebComposerRef => { const textInput = textInputRef.current; if (!textInput) { throw new Error('textInput is not available. This should never happen and indicates a developer error.'); @@ -321,16 +323,17 @@ function Composer({ // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly blur: () => textInput.blur(), focus: () => textInput.focus(), + isFocused: () => textInput.isFocused(), get scrollTop() { return textInput.scrollTop; }, - } as ComposerRef; + }; }, [clear]); const handleKeyPress = useCallback( (e: TextInputKeyPressEvent) => { // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed - if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) { + if (!onKeyPress || (e.nativeEvent instanceof KeyboardEvent && isEnterWhileComposition(e.nativeEvent))) { return; } @@ -366,7 +369,9 @@ function Composer({ autoComplete="off" autoCorrect={!isMobileSafari()} placeholderTextColor={theme.placeholderText} - ref={textInputRef} + ref={(element: AnimatedMarkdownTextInputRef | null) => { + textInputRef.current = element instanceof HTMLDivElement ? element : null; + }} selection={selection} style={[inputStyleMemo]} markdownStyle={markdownStyle} diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index e00420b8025d..2182c4e33cf4 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -2,9 +2,8 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {FileObject} from '@src/types/utils/Attachment'; -import type {MarkdownTextInput} from '@expensify/react-native-live-markdown'; import type {Ref} from 'react'; -import type {StyleProp, TextInputProps, TextInputSelectionChangeEvent, TextStyle} from 'react-native'; +import type {StyleProp, TextInput, TextInputProps, TextInputSelectionChangeEvent, TextStyle} from 'react-native'; type TextSelection = { start: number; @@ -12,14 +11,22 @@ type TextSelection = { positionX?: number; positionY?: number; }; -type CustomSelectionChangeEvent = TextInputSelectionChangeEvent & { - positionX?: number; - positionY?: number; +type CustomSelectionChangeEvent = { + nativeEvent: { + selection: TextInputSelectionChangeEvent['nativeEvent']['selection'] & Pick; + }; +}; + +type WebComposerRef = Pick & { + readonly scrollTop: number; + setSelection?: never; + setNativeProps?: never; + preventFocusOnFirstResponderOnce?: never; }; -type ComposerRef = MarkdownTextInput & HTMLInputElement & HTMLTextAreaElement; +type ComposerRef = TextInput | WebComposerRef; -type ComposerProps = Omit & +type ComposerProps = Omit & ForwardedFSClassProps & { /** Indicate whether input is multiline */ multiline?: boolean; @@ -35,7 +42,7 @@ type ComposerProps = Omit & /** * Callback when the input was cleared using the .clear ref method. - * The text parameter will be the value of the text that was cleared. + * The text parameter is empty if live-markdown has not initialized its value. */ onClear?: (text: string) => void; @@ -77,4 +84,4 @@ type ComposerProps = Omit & ref?: Ref; }; -export type {TextSelection, ComposerProps, CustomSelectionChangeEvent, ComposerRef}; +export type {TextSelection, ComposerProps, CustomSelectionChangeEvent, ComposerRef, WebComposerRef}; diff --git a/src/components/ConfirmationPage.tsx b/src/components/ConfirmationPage.tsx deleted file mode 100644 index 2bec67d4e35e..000000000000 --- a/src/components/ConfirmationPage.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import Accessibility from '@libs/Accessibility'; -import isIllustrationLottieAnimation from '@libs/isIllustrationLottieAnimation'; - -import CONST from '@src/CONST'; -import type IconAsset from '@src/types/utils/IconAsset'; - -import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; - -import React from 'react'; -import {StyleSheet, View} from 'react-native'; - -import type DotLottieAnimation from './LottieAnimations/types'; - -import Button from './Button'; -import FixedFooter from './FixedFooter'; -import ImageSVG from './ImageSVG'; -import Lottie from './Lottie'; -import LottieAnimations from './LottieAnimations'; -import ScrollView from './ScrollView'; -import Text from './Text'; - -type ConfirmationPageProps = { - illustration?: DotLottieAnimation | IconAsset; - heading: string; - description?: React.ReactNode; - descriptionComponent?: React.ReactNode; - - /** The text for the call to action */ - cta?: React.ReactNode; - - /** Call to action component of the confirmation page */ - ctaComponent?: React.ReactNode; - - /** The text for the primary button label */ - buttonText?: string; - - /** A function that is called when the primary button is clicked on */ - onButtonPress?: () => void; - - /** Whether we should show a primary confirmation button */ - shouldShowButton?: boolean; - - /** Whether the primary confirmation button should be disabled */ - isButtonDisabled?: boolean; - - /** Whether the primary confirmation button should show a loading spinner */ - isButtonLoading?: boolean; - - /** The text for the secondary button label */ - secondaryButtonText?: string; - - onSecondaryButtonPress?: () => void; - shouldShowSecondaryButton?: boolean; - - /** Whether the secondary confirmation button should be disabled */ - isSecondaryButtonDisabled?: boolean; - - /** Whether the secondary confirmation button should show a loading spinner */ - isSecondaryButtonLoading?: boolean; - headingStyle?: TextStyle; - - /** Additional style for the animation */ - illustrationStyle?: StyleProp; - - descriptionStyle?: StyleProp; - ctaStyle?: TextStyle; - footerStyle?: ViewStyle; - - /** Component rendered inside the footer, above the buttons (e.g. an inline error message) */ - footerComponent?: React.ReactNode; - - containerStyle?: ViewStyle; - innerContainerStyle?: ViewStyle; -}; - -function ConfirmationPage({ - illustration = LottieAnimations.Fireworks, - heading, - description, - descriptionComponent, - cta, - ctaComponent, - buttonText = '', - onButtonPress = () => {}, - shouldShowButton = false, - isButtonDisabled = false, - isButtonLoading = false, - secondaryButtonText = '', - onSecondaryButtonPress = () => {}, - shouldShowSecondaryButton = false, - isSecondaryButtonDisabled = false, - isSecondaryButtonLoading = false, - headingStyle, - illustrationStyle, - descriptionStyle, - ctaStyle, - footerStyle, - footerComponent, - containerStyle, - innerContainerStyle, -}: ConfirmationPageProps) { - const styles = useThemeStyles(); - const isReduceMotionEnabled = Accessibility.useReducedMotion(); - const illustrations = useMemoizedLazyIllustrations(['Fireworks']); - const isLottie = isIllustrationLottieAnimation(illustration); - const shouldShowStaticFallback = isLottie && isReduceMotionEnabled && illustration === LottieAnimations.Fireworks; - - return ( - - - - - {(() => { - if (shouldShowStaticFallback) { - return ( - - - - ); - } - if (isLottie) { - return ( - - ); - } - return ( - - - - ); - })()} - {heading} - {!!descriptionComponent && descriptionComponent} - {!!description && {description}} - {cta ? {cta} : null} - {!!ctaComponent && ctaComponent} - - - {!!footerComponent && {footerComponent}} - - {(shouldShowSecondaryButton || shouldShowButton) && ( - - {shouldShowSecondaryButton && ( - - )} - {shouldShowButton && ( - - )} - - )} - - ); -} - -export default ConfirmationPage; - -export type {ConfirmationPageProps}; diff --git a/src/components/ConfirmationPage/ConfirmationPage.tsx b/src/components/ConfirmationPage/ConfirmationPage.tsx new file mode 100644 index 000000000000..7fdf1d908df8 --- /dev/null +++ b/src/components/ConfirmationPage/ConfirmationPage.tsx @@ -0,0 +1,18 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import {View} from 'react-native'; + +type ConfirmationPageProps = { + children: React.ReactNode; + style?: StyleProp; +}; + +function ConfirmationPage({children, style}: ConfirmationPageProps) { + const styles = useThemeStyles(); + + return {children}; +} + +export default ConfirmationPage; diff --git a/src/components/ConfirmationPage/composed/ConfirmationPageDefault.tsx b/src/components/ConfirmationPage/composed/ConfirmationPageDefault.tsx new file mode 100644 index 000000000000..45c78e087a28 --- /dev/null +++ b/src/components/ConfirmationPage/composed/ConfirmationPageDefault.tsx @@ -0,0 +1,101 @@ +import ConfirmationPage from '@components/ConfirmationPage/ConfirmationPage'; +import ConfirmationPageContent from '@components/ConfirmationPage/layout/ConfirmationPageContent'; +import ConfirmationPageDescription from '@components/ConfirmationPage/primitives/ConfirmationPageDescription'; +import ConfirmationPageHeading from '@components/ConfirmationPage/primitives/ConfirmationPageHeading'; +import ConfirmationPageIllustration from '@components/ConfirmationPage/primitives/ConfirmationPageIllustration'; +import ConfirmationPagePrimaryButton from '@components/ConfirmationPage/primitives/ConfirmationPagePrimaryButton'; +import FixedFooter from '@components/FixedFooter'; +import LottieAnimations from '@components/LottieAnimations'; +import type DotLottieAnimation from '@components/LottieAnimations/types'; + +import type {Errors} from '@src/types/onyx/OnyxCommon'; +import type IconAsset from '@src/types/utils/IconAsset'; + +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; + +type ConfirmationPageProps = { + /** Illustration shown at the top: a Lottie animation or a static icon. Defaults to the fireworks animation */ + illustration?: DotLottieAnimation | IconAsset; + + /** Heading text displayed below the illustration */ + heading: string; + + /** Description text displayed below the heading */ + description: string; + + /** The text for the button label */ + buttonText?: string; + + /** A function that is called when the button is clicked on */ + onButtonPress?: () => void; + + /** Whether the button should be disabled */ + isButtonDisabled?: boolean; + + /** Whether the button should show a loading spinner */ + isButtonLoading?: boolean; + + /** Errors rendered as a message inside the footer, above the button */ + requestErrors?: Errors | null; + + /** Additional style for the outermost container */ + containerStyle?: StyleProp; + + /** Additional style for the centered container wrapping the illustration, heading and description */ + innerContainerStyle?: StyleProp; + + /** Additional style for the illustration */ + illustrationStyle?: StyleProp; + + /** Additional style for the heading text */ + headingStyle?: StyleProp; + + /** Additional style for the description text */ + descriptionStyle?: StyleProp; + + /** Additional style for the footer containing the button */ + footerStyle?: StyleProp; +}; + +function ConfirmationPageDefault({ + illustration = LottieAnimations.Fireworks, + heading, + description, + buttonText = '', + onButtonPress = () => {}, + isButtonDisabled = false, + isButtonLoading = false, + headingStyle, + illustrationStyle, + descriptionStyle, + footerStyle, + requestErrors, + containerStyle, + innerContainerStyle, +}: ConfirmationPageProps) { + return ( + + + + {heading} + {description} + + + + + + ); +} + +export default ConfirmationPageDefault; diff --git a/src/components/ConfirmationPage/index.tsx b/src/components/ConfirmationPage/index.tsx new file mode 100644 index 000000000000..53995aa90ea4 --- /dev/null +++ b/src/components/ConfirmationPage/index.tsx @@ -0,0 +1,48 @@ +/** + * ConfirmationPage: a confirmation/illustration screen built with composition API. + * + * Instead of a large flat props list (illustration, heading, description, shouldShowButton, …), + * sub-components are composed as children: + * + * @example + * ```tsx + * import ConfirmationPage from '@components/ConfirmationPage'; + * + * + * + * + * All set! + * Your changes have been saved. + * + * + * + * + * + * ``` + * + * For the common illustration/heading/description/button shape without custom composition, use `ConfirmationPageDefault` instead. + */ +import React from 'react'; + +import ConfirmationPageComponent from './ConfirmationPage'; +import ConfirmationPageContent from './layout/ConfirmationPageContent'; +import ConfirmationPageDescription from './primitives/ConfirmationPageDescription'; +import ConfirmationPageHeading from './primitives/ConfirmationPageHeading'; +import ConfirmationPageIllustration from './primitives/ConfirmationPageIllustration'; +import ConfirmationPagePrimaryButton from './primitives/ConfirmationPagePrimaryButton'; +import ConfirmationPageSecondaryButton from './primitives/ConfirmationPageSecondaryButton'; + +function ConfirmationPageBase(props: React.ComponentProps) { + return ; +} + +const ConfirmationPage = Object.assign(ConfirmationPageBase, { + Illustration: ConfirmationPageIllustration, + Heading: ConfirmationPageHeading, + Description: ConfirmationPageDescription, + PrimaryButton: ConfirmationPagePrimaryButton, + SecondaryButton: ConfirmationPageSecondaryButton, + Content: ConfirmationPageContent, +}); + +export default ConfirmationPage; diff --git a/src/components/ConfirmationPage/layout/ConfirmationPageContent.tsx b/src/components/ConfirmationPage/layout/ConfirmationPageContent.tsx new file mode 100644 index 000000000000..2d2e65db1ae3 --- /dev/null +++ b/src/components/ConfirmationPage/layout/ConfirmationPageContent.tsx @@ -0,0 +1,45 @@ +import FormHelpMessage from '@components/FormHelpMessage'; +import ScrollView from '@components/ScrollView'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getLatestErrorMessage} from '@libs/ErrorUtils'; + +import type {Errors} from '@src/types/onyx/OnyxCommon'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import {View} from 'react-native'; + +type ConfirmationPageContentProps = { + /** Errors rendered as a message at the bottom of the content area, above the footer */ + requestErrors?: Errors | null; + + /** Additional style for the centered container wrapping the children */ + style?: StyleProp; + + /** Content to render, typically the illustration, heading and description */ + children?: React.ReactNode; +}; + +function ConfirmationPageContent({requestErrors, style, children}: ConfirmationPageContentProps) { + const styles = useThemeStyles(); + + return ( + + + {children} + + {!!requestErrors && ( + + + + )} + + ); +} + +export default ConfirmationPageContent; diff --git a/src/components/ConfirmationPage/primitives/ConfirmationPageButtonBase.tsx b/src/components/ConfirmationPage/primitives/ConfirmationPageButtonBase.tsx new file mode 100644 index 000000000000..326a81fa434a --- /dev/null +++ b/src/components/ConfirmationPage/primitives/ConfirmationPageButtonBase.tsx @@ -0,0 +1,38 @@ +import Button from '@components/Button'; +import type {ButtonProps} from '@components/Button'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +type ConfirmationPageButtonProps = Pick & { + text: string; + onPress?: () => void; +}; + +type ConfirmationPageButtonBaseProps = ConfirmationPageButtonProps & + Pick & { + children?: React.ReactNode; + }; + +function ConfirmationPageButtonBase({text, onPress = () => {}, isDisabled, isLoading, testID, variant, children}: ConfirmationPageButtonBaseProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default ConfirmationPageButtonBase; +export type {ConfirmationPageButtonProps}; diff --git a/src/components/ConfirmationPage/primitives/ConfirmationPageDescription.tsx b/src/components/ConfirmationPage/primitives/ConfirmationPageDescription.tsx new file mode 100644 index 000000000000..0cd572040457 --- /dev/null +++ b/src/components/ConfirmationPage/primitives/ConfirmationPageDescription.tsx @@ -0,0 +1,18 @@ +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {StyleProp, TextStyle} from 'react-native'; + +type ConfirmationPageDescriptionProps = { + children: React.ReactNode; + style?: StyleProp; +}; + +function ConfirmationPageDescription({children, style}: ConfirmationPageDescriptionProps) { + const styles = useThemeStyles(); + + return {children}; +} + +export default ConfirmationPageDescription; diff --git a/src/components/ConfirmationPage/primitives/ConfirmationPageHeading.tsx b/src/components/ConfirmationPage/primitives/ConfirmationPageHeading.tsx new file mode 100644 index 000000000000..587fabd468d9 --- /dev/null +++ b/src/components/ConfirmationPage/primitives/ConfirmationPageHeading.tsx @@ -0,0 +1,27 @@ +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import type {StyleProp, TextStyle} from 'react-native'; + +type ConfirmationPageHeadingProps = { + children: string; + style?: StyleProp; +}; + +function ConfirmationPageHeading({children, style}: ConfirmationPageHeadingProps) { + const styles = useThemeStyles(); + + return ( + + {children} + + ); +} + +export default ConfirmationPageHeading; diff --git a/src/components/ConfirmationPage/primitives/ConfirmationPageIllustration.tsx b/src/components/ConfirmationPage/primitives/ConfirmationPageIllustration.tsx new file mode 100644 index 000000000000..41ff0bfd5ada --- /dev/null +++ b/src/components/ConfirmationPage/primitives/ConfirmationPageIllustration.tsx @@ -0,0 +1,74 @@ +import ImageSVG from '@components/ImageSVG'; +import Lottie from '@components/Lottie'; +import LottieAnimations from '@components/LottieAnimations'; +import type DotLottieAnimation from '@components/LottieAnimations/types'; + +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import Accessibility from '@libs/Accessibility'; +import isIllustrationLottieAnimation from '@libs/isIllustrationLottieAnimation'; + +import type IconAsset from '@src/types/utils/IconAsset'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import {StyleSheet, View} from 'react-native'; + +type ConfirmationPageIllustrationProps = { + illustration?: DotLottieAnimation | IconAsset; + illustrationStyle?: StyleProp; +}; + +function ConfirmationPageIllustration({illustration = LottieAnimations.Fireworks, illustrationStyle}: ConfirmationPageIllustrationProps) { + const styles = useThemeStyles(); + const isReduceMotionEnabled = Accessibility.useReducedMotion(); + const illustrations = useMemoizedLazyIllustrations(['Fireworks']); + const isLottie = isIllustrationLottieAnimation(illustration); + const flattenedIllustrationStyle = StyleSheet.flatten(illustrationStyle); + const flattenedIllustrationWidth = flattenedIllustrationStyle?.width; + const flattenedIllustrationHeight = flattenedIllustrationStyle?.height; + const webIllustrationWidth = + typeof flattenedIllustrationWidth === 'string' || typeof flattenedIllustrationWidth === 'number' ? flattenedIllustrationWidth : styles.confirmationAnimation.width; + const webIllustrationHeight = + typeof flattenedIllustrationHeight === 'string' || typeof flattenedIllustrationHeight === 'number' ? flattenedIllustrationHeight : styles.confirmationAnimation.height; + + if (isReduceMotionEnabled && illustration === LottieAnimations.Fireworks) { + return ( + + + + ); + } + + if (isLottie) { + return ( + + ); + } + + return ( + + + + ); +} + +export default ConfirmationPageIllustration; diff --git a/src/components/ConfirmationPage/primitives/ConfirmationPagePrimaryButton.tsx b/src/components/ConfirmationPage/primitives/ConfirmationPagePrimaryButton.tsx new file mode 100644 index 000000000000..acac3acf560a --- /dev/null +++ b/src/components/ConfirmationPage/primitives/ConfirmationPagePrimaryButton.tsx @@ -0,0 +1,21 @@ +import Button from '@components/Button'; + +import CONST from '@src/CONST'; + +import type {ConfirmationPageButtonProps} from './ConfirmationPageButtonBase'; + +import ConfirmationPageButtonBase from './ConfirmationPageButtonBase'; + +function ConfirmationPagePrimaryButton(props: ConfirmationPageButtonProps) { + return ( + + + + ); +} + +export default ConfirmationPagePrimaryButton; diff --git a/src/components/ConfirmationPage/primitives/ConfirmationPageSecondaryButton.tsx b/src/components/ConfirmationPage/primitives/ConfirmationPageSecondaryButton.tsx new file mode 100644 index 000000000000..89a207aad0b1 --- /dev/null +++ b/src/components/ConfirmationPage/primitives/ConfirmationPageSecondaryButton.tsx @@ -0,0 +1,14 @@ +import type {ConfirmationPageButtonProps} from './ConfirmationPageButtonBase'; + +import ConfirmationPageButtonBase from './ConfirmationPageButtonBase'; + +function ConfirmationPageSecondaryButton(props: ConfirmationPageButtonProps) { + return ( + + ); +} + +export default ConfirmationPageSecondaryButton; diff --git a/src/components/ConnectToZohoBooksFlow/index.native.tsx b/src/components/ConnectToZohoBooksFlow/index.native.tsx new file mode 100644 index 000000000000..86c08cc52d17 --- /dev/null +++ b/src/components/ConnectToZohoBooksFlow/index.native.tsx @@ -0,0 +1,19 @@ +import Navigation from '@libs/Navigation/Navigation'; + +import ROUTES from '@src/ROUTES'; + +import {useEffect} from 'react'; + +import type {ConnectToZohoBooksFlowProps} from './types'; + +function ConnectToZohoBooksFlow({policyID}: ConnectToZohoBooksFlowProps) { + useEffect(() => { + // On native the OAuth setup loads inside an in-app WebView screen. + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_ZOHO_BOOKS_OAUTH.getRoute(policyID)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +} + +export default ConnectToZohoBooksFlow; diff --git a/src/components/ConnectToZohoBooksFlow/index.tsx b/src/components/ConnectToZohoBooksFlow/index.tsx new file mode 100644 index 000000000000..552cecf90a76 --- /dev/null +++ b/src/components/ConnectToZohoBooksFlow/index.tsx @@ -0,0 +1,24 @@ +import useEnvironment from '@hooks/useEnvironment'; + +import {getZohoBooksSetupLink} from '@libs/actions/connections/ZohoBooks'; + +import {openLink} from '@userActions/Link'; + +import {useEffect} from 'react'; + +import type {ConnectToZohoBooksFlowProps} from './types'; + +function ConnectToZohoBooksFlow({policyID}: ConnectToZohoBooksFlowProps) { + const {environmentURL} = useEnvironment(); + + useEffect(() => { + // On web the OAuth setup opens in a new browser tab. Open it inline here (within the connect click's + // user-gesture window) instead of navigating to a setup screen, otherwise the popup blocker stops the tab. + openLink(getZohoBooksSetupLink(policyID), environmentURL); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +} + +export default ConnectToZohoBooksFlow; diff --git a/src/components/ConnectToZohoBooksFlow/types.ts b/src/components/ConnectToZohoBooksFlow/types.ts new file mode 100644 index 000000000000..aea62fb07161 --- /dev/null +++ b/src/components/ConnectToZohoBooksFlow/types.ts @@ -0,0 +1,6 @@ +type ConnectToZohoBooksFlowProps = { + policyID: string; +}; + +// eslint-disable-next-line import/prefer-default-export +export type {ConnectToZohoBooksFlowProps}; diff --git a/src/components/CopyTextToClipboard.tsx b/src/components/CopyTextToClipboard.tsx index 1517d62093d2..640b737b8be2 100644 --- a/src/components/CopyTextToClipboard.tsx +++ b/src/components/CopyTextToClipboard.tsx @@ -39,7 +39,6 @@ function CopyTextToClipboard({ const icons = useMemoizedLazyExpensifyIcons(['Copy']); const copyToClipboard = useCallback(() => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing doesn't achieve the same result in this case Clipboard.setString(urlToCopy || text || ''); }, [text, urlToCopy]); diff --git a/src/components/CustomStatusBarAndBackground/index.tsx b/src/components/CustomStatusBarAndBackground/index.tsx index 28bf51ee872a..ff8d27d0bf03 100644 --- a/src/components/CustomStatusBarAndBackground/index.tsx +++ b/src/components/CustomStatusBarAndBackground/index.tsx @@ -104,7 +104,6 @@ function CustomStatusBarAndBackground({isNested = false}: CustomStatusBarAndBack currentRoute.params.backgroundColor; // It's possible for backgroundColorFromRoute to be empty string, so we must use "||" to fallback to backgroundColorFallback. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing currentScreenBackgroundColor = backgroundColorFromRoute || pageTheme.backgroundColor; } diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index 5474def5e2c5..762834252a8b 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -30,6 +30,12 @@ const popoverDimensions = { width: CONST.POPOVER_DATE_WIDTH, }; +// A zero height makes the popover measure the calendar's rendered height. +const measuredPopoverDimensions = { + height: 0, + width: CONST.POPOVER_DATE_WIDTH, +}; + function DatePickerModal({ value, defaultValue, @@ -50,6 +56,9 @@ function DatePickerModal({ shouldPositionFromTop = false, forwardedFSClass, shouldEnableMonthYearBackdropInNarrowPane = false, + shouldMeasureContentHeight = false, + windowMargin, + shouldSwitchPositionIfOverflow = true, }: DatePickerProps) { const [selectedDate, setSelectedDate] = useState(value); const anchorRef = useRef>(null); @@ -88,12 +97,13 @@ function DatePickerModal({ isVisible={isVisible} onClose={onClose} anchorPosition={anchorPosition} - popoverDimensions={popoverDimensions} + popoverDimensions={shouldMeasureContentHeight ? measuredPopoverDimensions : popoverDimensions} + windowMargin={windowMargin} shouldCloseWhenBrowserNavigationChanged={shouldCloseWhenBrowserNavigationChanged} innerContainerStyle={isSmallScreenWidth ? styles.w100 : {width: CONST.POPOVER_DATE_WIDTH}} anchorAlignment={anchorAlignment} restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE} - shouldSwitchPositionIfOverflow + shouldSwitchPositionIfOverflow={shouldSwitchPositionIfOverflow} shouldReturnFocus={false} shouldMeasureAnchorPositionFromTop={shouldPositionFromTop} shouldSkipRemeasurement diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index fd76763912b1..bed4bf8fd42f 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -5,6 +5,7 @@ import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -48,12 +49,20 @@ function DatePicker({ forwardedFSClass, shouldDeferShowUntilPositioned = false, shouldDismissKeyboardBeforeShow = false, + wrapperStyle, rightHandSideComponent, onPickerVisibilityChange, shouldHideCalendarIcon = false, + icon, + shouldEnableMonthYearBackdropInNarrowPane = false, + textInputContainerStyles, }: DateInputWithPickerProps) { const icons = useMemoizedLazyExpensifyIcons(['Calendar']); const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + // The clear button takes the calendar icon's place once a date is picked, so it gets the same horizontal padding as the icon's + // container instead of its own margins. Otherwise the X sits in a different spot than the icon it replaces. + const {paddingHorizontal: iconPaddingHorizontal} = StyleUtils.getTextInputIconContainerStyles(true); const {windowHeight, windowWidth} = useWindowDimensions(); const {translate} = useLocalize(); @@ -219,13 +228,15 @@ function DatePicker({ <> showDatePickerModal()} onSubmitEditing={() => showDatePickerModal()} onKeyPress={handleInputKeyPress} - textInputContainerStyles={isModalVisible ? styles.borderColorFocus : {}} + textInputContainerStyles={[textInputContainerStyles, isModalVisible && styles.borderColorFocus]} shouldHideClearButton={shouldHideClearButton} onClearInput={handleClear} + clearButtonStyle={[styles.mh0, {paddingHorizontal: iconPaddingHorizontal}]} forwardedFSClass={forwardedFSClass} autoComplete={autoComplete} disableKeyboard @@ -263,6 +275,7 @@ function DatePicker({ shouldPositionFromTop={!isInverted} forwardedFSClass={forwardedFSClass} shouldCloseWhenBrowserNavigationChanged + shouldEnableMonthYearBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane} /> ); diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts index fe05b8b8e6b4..d8170c4a43af 100644 --- a/src/components/DatePicker/types.ts +++ b/src/components/DatePicker/types.ts @@ -5,6 +5,8 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; +import type {StyleProp, ViewStyle} from 'react-native'; + type DatePickerBaseProps = ForwardedFSClassProps & { /** The selected date as a `yyyy-MM-dd` string, read with DateUtils.parseCalendarDate. `onInputChange` is called with the picked date in the same format. */ value?: string; @@ -64,6 +66,13 @@ type DateInputWithPickerProps = DatePickerBaseProps & */ shouldDismissKeyboardBeforeShow?: boolean; + /** + * Style for the view that wraps the input and anchors the calendar. It carries a vertical margin by default, + * which a caller laying the field out itself can override, for example to line the input up with a plain + * `TextInput` beside it. + */ + wrapperStyle?: StyleProp; + /** * Reports whether the calendar is open. Opening the picker blurs the input, so this is the signal for "the * user is on this field" rather than `onFocus`, and it is what drives the input's focused border. @@ -126,6 +135,13 @@ type DatePickerProps = { * Used by inline editing flows that require background dimming. */ shouldEnableMonthYearBackdropInNarrowPane?: boolean; + + /** + * Whether to measure the calendar's rendered height instead of assuming `POPOVER_DATE_MIN_HEIGHT`, which only covers + * a 5-row month: months that need a sixth week row reach `POPOVER_DATE_MAX_HEIGHT` instead. Needed where the popover + * can open above its anchor, or its top edge clips in those months. + */ + shouldMeasureContentHeight?: boolean; } & Omit; export type {DateInputWithPickerProps, DatePickerProps}; diff --git a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx index 2cd3fc73e91c..12e99fdf4b84 100644 --- a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx +++ b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx @@ -4,6 +4,7 @@ import UserDetailsTooltip from '@components/UserDetailsTooltip'; import useThemeStyles from '@hooks/useThemeStyles'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import type {ComponentRef, RefObject} from 'react'; @@ -34,6 +35,9 @@ type DisplayNamesTooltipItemProps = ForwardedFSClassProps & { /** Refs to all the names which will be used to correct the horizontal position of the tooltip */ childRefs: RefObject>>; + + /** Whether this display name should be directly selectable inside pressable rows */ + isSelectable?: boolean; }; function DisplayNamesTooltipItem({ @@ -46,6 +50,7 @@ function DisplayNamesTooltipItem({ textStyles = [], childRefs = {current: []}, forwardedFSClass, + isSelectable = false, }: DisplayNamesTooltipItemProps) { const styles = useThemeStyles(); const tooltipIndexBridge = useCallback(() => getTooltipShiftX(index), [getTooltipShiftX, index]); @@ -72,6 +77,8 @@ function DisplayNamesTooltipItem({ }} style={[textStyles, styles.pre]} fsClass={forwardedFSClass} + selectable={isSelectable} + dataSet={isSelectable ? COPYABLE_TEXT_DATA_SET : undefined} > {displayName} diff --git a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx index 28ab5c120ff0..274774ed863d 100644 --- a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx +++ b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx @@ -4,6 +4,7 @@ import Tooltip from '@components/Tooltip'; import useThemeStyles from '@hooks/useThemeStyles'; import {formatReportLastMessageText} from '@libs/ReportUtils'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports @@ -28,6 +29,7 @@ function DisplayNamesWithToolTip({ renderAdditionalText, forwardedFSClass, accessibilityLabel, + isSelectable = false, }: DisplayNamesProps) { const styles = useThemeStyles(); const containerRef = useRef(null); @@ -73,6 +75,8 @@ function DisplayNamesWithToolTip({ ref={containerRef} testID="DisplayNamesWithToolTip" fsClass={forwardedFSClass} + selectable={isSelectable} + dataSet={isSelectable ? COPYABLE_TEXT_DATA_SET : undefined} > {shouldUseFullTitle ? formatReportLastMessageText(fullTitle) @@ -89,6 +93,7 @@ function DisplayNamesWithToolTip({ textStyles={textStyles} childRefs={childRefs} forwardedFSClass={forwardedFSClass} + isSelectable={isSelectable} /> {index < displayNamesWithTooltips.length - 1 && , } {shouldAddEllipsis && index === displayNamesWithTooltips.length - 1 && ...} diff --git a/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx index 9d609fc6eece..7dd8e0f7649e 100644 --- a/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx +++ b/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx @@ -3,6 +3,7 @@ import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import type {StyleProp, TextStyle} from 'react-native'; @@ -22,9 +23,20 @@ type DisplayNamesWithoutTooltipProps = ForwardedFSClassProps & { /** Overrides the text read by screen readers. */ accessibilityLabel?: string; + + /** Whether this display name should be directly selectable inside pressable rows */ + isSelectable?: boolean; }; -function DisplayNamesWithoutTooltip({textStyles = [], numberOfLines = 1, fullTitle = '', renderAdditionalText, forwardedFSClass, accessibilityLabel}: DisplayNamesWithoutTooltipProps) { +function DisplayNamesWithoutTooltip({ + textStyles = [], + numberOfLines = 1, + fullTitle = '', + renderAdditionalText, + forwardedFSClass, + accessibilityLabel, + isSelectable = false, +}: DisplayNamesWithoutTooltipProps) { const styles = useThemeStyles(); return ( {fullTitle} {renderAdditionalText?.()} diff --git a/src/components/DisplayNames/index.native.tsx b/src/components/DisplayNames/index.native.tsx index b3c62e101399..b07d4578867e 100644 --- a/src/components/DisplayNames/index.native.tsx +++ b/src/components/DisplayNames/index.native.tsx @@ -13,7 +13,17 @@ import React, {useMemo} from 'react'; import type DisplayNamesProps from './types'; // As we don't have to show tooltips of the Native platform so we simply render the full display names list. -function DisplayNames({accessibilityLabel, fullTitle, textStyles = [], numberOfLines = 1, renderAdditionalText, forwardedFSClass, testID, shouldParseFullTitle = true}: DisplayNamesProps) { +function DisplayNames({ + accessibilityLabel, + fullTitle, + textStyles = [], + numberOfLines = 1, + renderAdditionalText, + forwardedFSClass, + testID, + shouldParseFullTitle = true, + isSelectable = false, +}: DisplayNamesProps) { const {translate} = useLocalize(); const titleContainsTextAndCustomEmoji = useMemo(() => containsCustomEmoji(fullTitle) && !containsOnlyCustomEmoji(fullTitle), [fullTitle]); const title = useMemo(() => { @@ -28,6 +38,7 @@ function DisplayNames({accessibilityLabel, fullTitle, textStyles = [], numberOfL numberOfLines={numberOfLines} testID={`DisplayNames${testID !== undefined ? `-${testID}` : ''}`} fsClass={forwardedFSClass} + selectable={isSelectable} > {titleContainsTextAndCustomEmoji ? ( { @@ -38,6 +39,7 @@ function DisplayNames({ renderAdditionalText={renderAdditionalText} forwardedFSClass={forwardedFSClass} accessibilityLabel={accessibilityLabel} + isSelectable={isSelectable} /> ); } @@ -52,6 +54,7 @@ function DisplayNames({ renderAdditionalText={renderAdditionalText} forwardedFSClass={forwardedFSClass} accessibilityLabel={accessibilityLabel} + isSelectable={isSelectable} /> ); } @@ -66,6 +69,7 @@ function DisplayNames({ renderAdditionalText={renderAdditionalText} forwardedFSClass={forwardedFSClass} accessibilityLabel={accessibilityLabel} + isSelectable={isSelectable} /> ); } diff --git a/src/components/DisplayNames/types.ts b/src/components/DisplayNames/types.ts index 55d4345f5340..89faf6d4b350 100644 --- a/src/components/DisplayNames/types.ts +++ b/src/components/DisplayNames/types.ts @@ -49,6 +49,9 @@ type DisplayNamesProps = ForwardedFSClassProps & { /** If we should add an ellipsis after the participants list */ shouldAddEllipsis?: boolean; + /** Whether this display name should be directly selectable inside pressable rows */ + isSelectable?: boolean; + /** Whether to parse HTML in the title */ shouldParseHtml?: boolean; diff --git a/src/components/DistanceRequest/DistanceRequestFooter.tsx b/src/components/DistanceRequest/DistanceRequestFooter.tsx index 993ecdd2e76b..681ab2ab815f 100644 --- a/src/components/DistanceRequest/DistanceRequestFooter.tsx +++ b/src/components/DistanceRequest/DistanceRequestFooter.tsx @@ -7,6 +7,7 @@ import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import type {MapMarkerType} from '@hooks/useMapMarkers/types'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useTheme from '@hooks/useTheme'; @@ -14,6 +15,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setSelectedRoute} from '@libs/actions/Transaction'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import {getAvailableReusableDistanceRoutes} from '@libs/ReusableDistanceRoutesUtils'; import {getSelectedRouteKey, getWaypointIndex, isCustomUnitRateIDForP2P} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; @@ -24,9 +26,9 @@ import type Transaction from '@src/types/onyx/Transaction'; import type TransactionState from '@src/types/utils/TransactionStateType'; import type {StyleProp, ViewStyle} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import React from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; const MAX_WAYPOINTS = 25; @@ -55,8 +57,16 @@ function DistanceRequestFooter({waypoints, transaction, navigateToWaypointEditPa const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); + const {isOffline} = useNetwork(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus', 'History']); const [reusableDistanceRoutes] = useOnyx(ONYXKEYS.REUSABLE_DISTANCE_ROUTES); + const hasAvailableReusableRouteSelector = useCallback( + (transactions: OnyxCollection) => getAvailableReusableDistanceRoutes(reusableDistanceRoutes, transactions, isOffline).length > 0, + [reusableDistanceRoutes, isOffline], + ); + const [hasAvailableReusableRoute = false] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { + selector: hasAvailableReusableRouteSelector, + }); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); const activePolicy = usePolicy(activePolicyID); @@ -107,7 +117,7 @@ function DistanceRequestFooter({waypoints, transaction, navigateToWaypointEditPa }); } - const shouldShowReuseRoute = !!navigateToReuseRoutePage && !!reusableDistanceRoutes?.length; + const shouldShowReuseRoute = !!navigateToReuseRoutePage && hasAvailableReusableRoute; const isAddStopDisabled = numberOfWaypoints === MAX_WAYPOINTS; return ( diff --git a/src/components/DistanceRequest/DistanceRequestRenderItem.tsx b/src/components/DistanceRequest/DistanceRequestRenderItem.tsx index 215768b1fb1d..8f530313b2e3 100644 --- a/src/components/DistanceRequest/DistanceRequestRenderItem.tsx +++ b/src/components/DistanceRequest/DistanceRequestRenderItem.tsx @@ -49,7 +49,6 @@ function DistanceRequestRenderItem({waypoints, item = '', onSecondaryInteraction } const waypoint = waypoints?.[`waypoint${index}`] ?? {}; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const title = waypoint.name || waypoint.address; const errorText = isWaypointNullIsland(waypoint) ? translate('violations.noRoute') : undefined; diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index 8fd00ab37379..eb4a2bec6e91 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -8,6 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import {isReceiptError, isTranslationKeyError} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; +import getPlatform from '@libs/getPlatform'; import CONST from '@src/CONST'; import type {TranslationKeyError} from '@src/types/onyx/OnyxCommon'; @@ -43,6 +44,8 @@ type DotIndicatorMessageProps = { textStyles?: StyleProp; + isSelectable?: boolean; + // Unused here, but OfflineWithFeedback still passes it to every error row. // eslint-disable-next-line react/no-unused-prop-types dismissError?: () => void; @@ -50,7 +53,7 @@ type DotIndicatorMessageProps = { onRetryReceiptUpload?: () => void; }; -function DotIndicatorMessage({messages = {}, style, type, textStyles, onRetryReceiptUpload}: DotIndicatorMessageProps) { +function DotIndicatorMessage({messages = {}, style, type, textStyles, isSelectable, onRetryReceiptUpload}: DotIndicatorMessageProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -74,7 +77,12 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, onRetryRec const isErrorMessage = type === 'error'; const receiptError = uniqueMessages.find(isReceiptError); - const isTextSelectable = !canUseTouchScreen() || !shouldUseNarrowLayout; + const isWeb = getPlatform() === CONST.PLATFORM.WEB; + const defaultTextSelectable = !canUseTouchScreen() || !shouldUseNarrowLayout; + const isTextSelectable = isWeb ? (isSelectable ?? defaultTextSelectable) : defaultTextSelectable; + const shouldDisableTextSelection = isWeb && isSelectable === false; + const selectionDataSet = isTextSelectable ? undefined : {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}; + const receiptSelectionDataSet = shouldDisableTextSelection ? selectionDataSet : undefined; const renderMessage = (message: string | ReceiptError | ReactElement, index: number) => { if (isReceiptError(message)) { @@ -125,7 +133,7 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, onRetryRec const messageRow = ( @@ -157,14 +165,20 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, onRetryRec ); if (!isStackedLayout) { return ( - + {messageRow} {buttonsRow} ); } return ( - + {messageRow} {buttonsRow} @@ -172,7 +186,10 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, onRetryRec } return ( - + , 'addTitle' | 'addDescription'> & + ForwardedFSClassProps & { + /** Entries added so far */ + value?: DynamicFormListItem[]; + + /** Called with every entry after one is added, edited or removed */ + onInputChange?: (value: DynamicFormListItem[]) => void; + + /** Called with the remaining entries after one is removed, for a list the flow keeps in memory */ + onItemsChange?: (value: DynamicFormListItem[]) => void; + + /** Validation error shown under the list */ + errorText?: string; + + /** Names an entry whose answers give it no name, when the list has no `itemLabel` */ + label: string; + + /** What one entry is, such as "owner", for the remove confirmation and for an entry whose answers give it no name */ + itemLabel?: string; + + /** The fields of one entry, which name and describe its row */ + itemFields: DynamicFormField[]; + + /** The list is marked `sensitive`, so none of its entries' answers is drafted while the modal edits one */ + isSensitive?: boolean; + + /** Hides the add row once the list holds this many entries */ + maxItems?: number; + + /** Opens the flow's editor page for a new entry. Without it, entries are edited in a modal on this page. */ + onAdd?: () => void; + + /** Opens the flow's editor page of an entry */ + onEdit?: (itemID: string) => void; + + /** Draws the entry's fields inside the modal editor */ + renderFields: (fields: DynamicFormField[], values: DynamicFormValues, isSensitive: boolean) => ReactNode; + }; + +/** The entries of a list field as rows. Removing one updates FormProvider's value. Adding and editing open the flow's editor page, or a modal outside the flow. */ +function ListFieldAdapter({ + value, + onInputChange = () => {}, + onItemsChange, + errorText, + label, + itemLabel, + itemFields, + isSensitive = false, + maxItems, + addTitle, + addDescription, + onAdd, + onEdit, + renderFields, + forwardedFSClass, +}: ListFieldAdapterProps) { + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + const [editingID, setEditingID] = useState(); + const items = Array.isArray(value) ? value : []; + const editingItem = items.find((item) => item.id === editingID); + const summarize = (item: DynamicFormListItem) => summarizeListItem(item, itemFields, translate, itemLabel ?? label); + const modalTitle = editingItem ? summarize(editingItem).title : addTitle; + const sensitiveKeys = new Set(itemFields.filter(hasSensitiveAnswer).flatMap(getAnswerKeys)); + const isKeptOutOfDraft = (key: string) => key !== 'id' && (isSensitive || sensitiveKeys.has(key)); + + // Sensitive answers are not drafted, so the modal's inputs start from the stored entry instead. Every answer of a sensitive list is. + const openModal = (item?: DynamicFormListItem) => { + const answers = Object.fromEntries(Object.entries(item ?? {}).filter(([key]) => key !== 'id' && !isKeptOutOfDraft(key))); + startListItemEdit(answers).then(() => setEditingID(item?.id ?? Str.guid())); + }; + + const closeModal = () => { + setEditingID(undefined); + clearListItemEdit(); + }; + + const saveItem = (answers: DynamicFormValues) => { + if (!editingID) { + return; + } + const item: DynamicFormListItem = {...answers, id: editingID}; + onInputChange(editingItem ? items.map((existing) => (existing.id === editingID ? item : existing)) : [...items, item]); + closeModal(); + }; + + // The modal title takes no FullStory class, so only the masked prompt names the entry + const confirmRemoval = (id: string) => { + const item = items.find((candidate) => candidate.id === id); + if (!item) { + return; + } + showConfirmModal({ + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + title: itemLabel ? translate('dynamicForm.removeItemTitle', {item: itemLabel}) : translate('common.remove'), + prompt: ( + + {translate('dynamicForm.removeItemPrompt', {name: summarize(item).title})} + + ), + confirmText: translate('common.remove'), + cancelText: translate('common.cancel'), + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + const remainingItems = items.filter((candidate) => candidate.id !== id); + onInputChange(remainingItems); + onItemsChange?.(remainingItems); + }); + }; + + // ListField takes no FullStory class, so the mask goes on a View around it + return ( + <> + + ({id: item.id, ...summarize(item)}))} + canAddMore={maxItems === undefined || items.length < maxItems} + errorText={errorText} + onAdd={onAdd ?? (() => openModal())} + onEdit={onEdit ?? ((id) => openModal(items.find((item) => item.id === id)))} + onRemove={confirmRemoval} + /> + + {!onAdd && ( + isKeptOutOfDraft(key)))} + isSensitive={isSensitive} + renderFields={renderFields} + onSave={saveItem} + onClose={closeModal} + /> + )} + + ); +} + +export default ListFieldAdapter; diff --git a/src/components/DynamicForm/components/DynamicFormConfirmationPage.tsx b/src/components/DynamicForm/components/DynamicFormConfirmationPage.tsx new file mode 100644 index 000000000000..063feebcbe28 --- /dev/null +++ b/src/components/DynamicForm/components/DynamicFormConfirmationPage.tsx @@ -0,0 +1,31 @@ +import type {DynamicFormSubPageProps} from '@components/DynamicForm/types'; +import getConfirmationItems from '@components/DynamicForm/utils/getConfirmationItems'; +import ConfirmationStep from '@components/SubStepForms/ConfirmationStep'; + +import useLocalize from '@hooks/useLocalize'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +/** The last page of a dynamic form flow: every answer as a row the user can tap to edit, and a Confirm button */ +function DynamicFormConfirmationPage({isEditing, onNext, onMove, pages, allFields, values, confirmationTitle, isSubmitting, submitError, onOpenListItemEditor}: DynamicFormSubPageProps) { + const {translate} = useLocalize(); + + return ( + onNext()} + onMove={onMove} + pageTitle={confirmationTitle} + groups={getConfirmationItems(pages, allFields, values, translate, onMove, onOpenListItemEditor)} + showOnfidoLinks={false} + isLoading={isSubmitting} + error={submitError} + forwardedFSClass={CONST.FULLSTORY.CLASS.MASK} + /> + ); +} + +export default DynamicFormConfirmationPage; diff --git a/src/components/DynamicForm/components/DynamicFormFieldRow.tsx b/src/components/DynamicForm/components/DynamicFormFieldRow.tsx index 2f88ec864c17..44c4a88c9f19 100644 --- a/src/components/DynamicForm/components/DynamicFormFieldRow.tsx +++ b/src/components/DynamicForm/components/DynamicFormFieldRow.tsx @@ -1,8 +1,11 @@ import renderDynamicField from '@components/DynamicForm/renderDynamicField'; +import type {DynamicFieldContext} from '@components/DynamicForm/renderers/types'; import formatDynamicFieldValue from '@components/DynamicForm/utils/formatDynamicFieldValue'; import getLocalizedText, {getFieldLabel} from '@components/DynamicForm/utils/getLocalizedText'; import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import {getListItems} from '@components/DynamicForm/utils/listItems'; +import summarizeListItem, {getListItemFallbackTitle} from '@components/DynamicForm/utils/summarizeListItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -16,30 +19,55 @@ import {View} from 'react-native'; import type {DynamicFormFieldsProps} from './DynamicFormFields'; -type DynamicFormFieldRowProps = Pick & - Required> & { +type DynamicFormFieldRowProps = Pick & + Required> & + Pick & { field: DynamicFormField; /** The field is the page's only question, so a choice is drawn as the page itself instead of as a row */ isLoneField: boolean; + + /** The page title already says what the field asks, so its label is not drawn above the input. Inputs keep their own accessibility name. */ + shouldHideLabel?: boolean; }; /** Typed answers change on every keystroke, so they ask for new requirements when the user leaves the input instead */ const TYPED_FIELD_TYPES = new Set(['text', 'number', 'amount', 'percent']); /** One field of a schema-driven form: its input, or its value when the field is readonly */ -function DynamicFormFieldRow({field, values, currency, maxFileSize, isLoneField, onRefreshRequirements}: DynamicFormFieldRowProps) { +function DynamicFormFieldRow({ + field, + values, + currency, + maxFileSize, + isLoneField, + shouldHideLabel = false, + onRefreshRequirements, + onOpenListItemEditor, + onListItemsChange, + isSensitive = false, + renderFields, +}: DynamicFormFieldRowProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const label = getFieldLabel(field, translate); if (field.readonly) { + // A list's answer is its entries, which formatDynamicFieldValue cannot name, so they are listed by their titles + const readonlyValue = + field.type === 'list' + ? getListItems(values[field.key]) + .map((item) => summarizeListItem(item, field.itemFields, translate, getListItemFallbackTitle(field, translate)).title) + .join(', ') + : formatDynamicFieldValue(field, values, translate); return ( - - + ); @@ -57,10 +85,10 @@ function DynamicFormFieldRow({field, values, currency, maxFileSize, isLoneField, const shouldRefreshOnChange = !!field.refreshOnChange && !isTypedField; const {input, isMenuRow, labelAbove, showsDescription} = renderDynamicField( field, - {values, translate, styles, currency, maxFileSize, isLoneField}, + {values, translate, styles, currency, maxFileSize, isLoneField, onOpenListItemEditor, onListItemsChange, isSensitive, renderFields}, { inputID: field.key, - shouldSaveDraft: !isSensitiveField(field), + shouldSaveDraft: !isSensitive && !isSensitiveField(field), forwardedFSClass: CONST.FULLSTORY.CLASS.MASK, onValueChange: shouldRefreshOnChange ? (value, key) => onRefreshRequirements?.(key, value) : undefined, onBlur: shouldRefreshOnBlur ? refreshRequirements : undefined, @@ -68,9 +96,13 @@ function DynamicFormFieldRow({field, values, currency, maxFileSize, isLoneField, ); const description = showsDescription ? undefined : getLocalizedText(translate, field.descriptionKey, field.description); + // Some inputs, such as CountryPicker, do not pass the FullStory class on, so the whole row is masked return ( - - {!!labelAbove && ( + + {!!labelAbove && !shouldHideLabel && ( void; + + /** Opens the editor page of a list entry, or of a new one without `itemID`. DynamicFormFlow passes it. Without it, list entries are edited in a modal. */ + onOpenListItemEditor?: (listKey: string, itemID?: string) => void; + + /** Called with a list's entries after the user removes one, so the flow can keep a sensitive list in memory up to date */ + onListItemsChange?: (listKey: string, items: DynamicFormListItem[]) => void; + + /** Every answer here is sensitive, as in the editor of an entry of a sensitive list: none is drafted, and each starts from the answer in `values` */ + isSensitive?: boolean; }; /** The inputs of a schema-driven form. Render it inside a FormProvider and validate the fields of the same content with getDynamicFieldErrors. */ -function DynamicFormFields({content, values, currency = CONST.CURRENCY.USD, maxFileSize, onRefreshRequirements}: DynamicFormFieldsProps) { - const visibleContent = getVisibleContent(content, values); +function DynamicFormFields({ + content, + values, + allFields, + isLoneFieldInHeadline = false, + currency = CONST.CURRENCY.USD, + maxFileSize, + onRefreshRequirements, + onOpenListItemEditor, + onListItemsChange, + isSensitive = false, +}: DynamicFormFieldsProps) { + const visibleContent = getVisibleContent(content, values, allFields); const loneField = getLoneField(getContentFields(visibleContent)); + const renderFields = (itemFields: DynamicFormField[], itemValues: DynamicFormValues, isItemSensitive: boolean) => ( + + ); return visibleContent.map((item, index) => isHeading(item) ? ( @@ -49,7 +85,12 @@ function DynamicFormFields({content, values, currency = CONST.CURRENCY.USD, maxF currency={currency} maxFileSize={maxFileSize} isLoneField={item === loneField} + shouldHideLabel={isLoneFieldInHeadline && item === loneField} onRefreshRequirements={onRefreshRequirements} + onOpenListItemEditor={onOpenListItemEditor} + onListItemsChange={onListItemsChange} + isSensitive={isSensitive} + renderFields={renderFields} /> ), ); diff --git a/src/components/DynamicForm/components/DynamicFormFieldsPage.tsx b/src/components/DynamicForm/components/DynamicFormFieldsPage.tsx new file mode 100644 index 000000000000..84db6032af4b --- /dev/null +++ b/src/components/DynamicForm/components/DynamicFormFieldsPage.tsx @@ -0,0 +1,100 @@ +import type {DynamicFormSubPageProps} from '@components/DynamicForm/types'; +import getContentFields from '@components/DynamicForm/utils/getContentFields'; +import getDynamicFieldErrors from '@components/DynamicForm/utils/getDynamicFieldErrors'; +import getLocalizedText from '@components/DynamicForm/utils/getLocalizedText'; +import getPageTitle from '@components/DynamicForm/utils/getPageTitle'; +import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; +import toDynamicFormValues from '@components/DynamicForm/utils/toDynamicFormValues'; +import FormProvider from '@components/Form/FormProvider'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +import DynamicFormFields from './DynamicFormFields'; + +/** One page of a dynamic form flow: its title and description, its fields and a Next button */ +function DynamicFormFieldsPage({ + currentPageName, + isEditing, + isLastPage, + onNext, + formID, + pages, + allFields, + values, + currency, + maxFileSize, + onRefreshRequirements, + onPageSubmit, + onOpenListItemEditor, + onListItemsChange, +}: DynamicFormSubPageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const page = pages.find((candidate) => candidate.key === currentPageName); + if (!page) { + return null; + } + // A sensitive list lives in memory, which the editor page and a removal update, so the form's own copy of it may be stale + const pageKeys = new Set(getContentFields(page.content).map((field) => field.key)); + const memoryListValues = Object.fromEntries( + allFields.filter((field) => field.type === 'list' && isSensitiveField(field) && pageKeys.has(field.key)).map((field) => [field.key, values[field.key]]), + ); + const withInputValues = (inputValues: unknown) => ({...values, ...toDynamicFormValues(inputValues), ...memoryListValues}); + + return ( + getDynamicFieldErrors(getContentFields(page.content), withInputValues(inputValues), translate, allFields)} + onSubmit={(inputValues) => { + const pageValues = toDynamicFormValues(inputValues); + onPageSubmit(page, pageValues); + onNext(pageValues); + }} + style={[styles.mh5, styles.flexGrow1]} + submitButtonStyles={styles.mb0} + keyboardSubmitBehavior={CONST.KEYBOARD_SUBMIT_BEHAVIOR.SUBMIT_ONLY} + // An offline request is saved to disk, so the last page submits only online, as the confirmation page does + enabledWhenOffline={!isLastPage} + > + {({inputValues}) => { + const currentValues = withInputValues(inputValues); + const title = getPageTitle(page, currentValues, translate, allFields); + const hasOwnTitle = !!getLocalizedText(translate, page.titleKey, page.title); + const description = getLocalizedText(translate, page.descriptionKey, page.description); + return ( + <> + {!!title && ( + + {title} + + )} + {!!description && {description}} + + + ); + }} + + ); +} + +export default DynamicFormFieldsPage; diff --git a/src/components/DynamicForm/components/DynamicFormListItemModal.tsx b/src/components/DynamicForm/components/DynamicFormListItemModal.tsx new file mode 100644 index 000000000000..acd3f61b13f4 --- /dev/null +++ b/src/components/DynamicForm/components/DynamicFormListItemModal.tsx @@ -0,0 +1,94 @@ +import type {DynamicFormValues} from '@components/DynamicForm/types'; +import getDynamicFieldErrors from '@components/DynamicForm/utils/getDynamicFieldErrors'; +import toDynamicFormValues from '@components/DynamicForm/utils/toDynamicFormValues'; +import FormProvider from '@components/Form/FormProvider'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import Modal from '@components/Modal'; +import ScreenWrapper from '@components/ScreenWrapper'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {DynamicFormField} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import type {ReactNode} from 'react'; + +import React from 'react'; + +type DynamicFormListItemModalProps = ForwardedFSClassProps & { + /** Whether the modal is open */ + isVisible: boolean; + + /** Header title: the entry's name, or the add title for a new entry */ + title: string; + + /** The fields of one entry */ + itemFields: DynamicFormField[]; + + /** Stored answers that are not drafted, such as sensitive ones, which the inputs start from */ + keptAnswers: DynamicFormValues; + + /** Every answer of the entry is sensitive, as in a list marked `sensitive`: none is drafted */ + isSensitive: boolean; + + /** Draws the entry's fields inside the modal's form */ + renderFields: (fields: DynamicFormField[], values: DynamicFormValues, isSensitive: boolean) => ReactNode; + + /** Called with the entry's answers when the user saves */ + onSave: (answers: DynamicFormValues) => void; + + /** Called when the user closes the modal without saving */ + onClose: () => void; +}; + +/** The editor of one list entry outside DynamicFormFlow, as a modal over the page holding the list */ +function DynamicFormListItemModal({isVisible, title, itemFields, keptAnswers, isSensitive, renderFields, onSave, onClose, forwardedFSClass}: DynamicFormListItemModalProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [, itemDraftMetadata] = useOnyx(ONYXKEYS.FORMS.DYNAMIC_FORM_LIST_ITEM_FORM_DRAFT); + // Kept answers fill the inputs, so clearing one in the form clears it on save, as on the flow's editor page + const withInputValues = (inputValues: unknown): DynamicFormValues => ({...keptAnswers, ...toDynamicFormValues(inputValues)}); + + return ( + + + + {isVisible && !isLoadingOnyxValue(itemDraftMetadata) && ( + getDynamicFieldErrors(itemFields, withInputValues(inputValues), translate)} + onSubmit={(inputValues) => onSave(withInputValues(inputValues))} + style={[styles.mh5, styles.flexGrow1]} + submitButtonStyles={styles.mb0} + enabledWhenOffline + > + {({inputValues}) => renderFields(itemFields, withInputValues(inputValues), isSensitive)} + + )} + + + ); +} + +export default DynamicFormListItemModal; diff --git a/src/components/DynamicForm/components/DynamicFormListItemPage.tsx b/src/components/DynamicForm/components/DynamicFormListItemPage.tsx new file mode 100644 index 000000000000..460a865eee2e --- /dev/null +++ b/src/components/DynamicForm/components/DynamicFormListItemPage.tsx @@ -0,0 +1,105 @@ +import ActivityIndicator from '@components/ActivityIndicator'; +import type {DynamicFormSubPageProps} from '@components/DynamicForm/types'; +import getDynamicFieldErrors from '@components/DynamicForm/utils/getDynamicFieldErrors'; +import getLocalizedText from '@components/DynamicForm/utils/getLocalizedText'; +import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; +import {getListItems, getListItemSensitiveAnswers, parseListItemPageName} from '@components/DynamicForm/utils/listItems'; +import summarizeListItem, {getListItemFallbackTitle} from '@components/DynamicForm/utils/summarizeListItem'; +import toDynamicFormValues from '@components/DynamicForm/utils/toDynamicFormValues'; +import FormProvider from '@components/Form/FormProvider'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearListItemEdit, startListItemEdit} from '@userActions/DynamicForm'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {DynamicFormListField} from '@src/types/onyx'; + +import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; + +import DynamicFormFields from './DynamicFormFields'; + +/** The editor of one list entry, or of a new one: the entry's fields and a Save button */ +function DynamicFormListItemPage({currentPageName, allFields, values, currency, maxFileSize, onRefreshRequirements, onListItemSave}: DynamicFormSubPageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const page = parseListItemPageName(currentPageName); + const listField = allFields.find((field): field is DynamicFormListField => field.type === 'list' && field.key === page?.listKey); + const item = listField ? getListItems(values[listField.key]).find((candidate) => candidate.id === page?.itemID) : undefined; + const [filledPageName, setFilledPageName] = useState(); + // The entries of a sensitive list are never drafted, so the editor starts empty and its inputs take the entry's answers from memory + const isSensitiveList = !!listField && isSensitiveField(listField); + + // The editor form is shared by every entry, so it is filled with this entry's answers before its inputs mount + useEffect(() => { + let isCancelled = false; + startListItemEdit(isSensitiveList ? {} : (item ?? {})).then(() => { + if (isCancelled) { + return; + } + setFilledPageName(currentPageName); + }); + return () => { + isCancelled = true; + }; + }, [currentPageName, item, isSensitiveList]); + + useEffect(() => clearListItemEdit, []); + + if (!page || !listField) { + return null; + } + // The flow's header stays above this page, so a spinner in the page is enough while the editor fills + if (filledPageName !== currentPageName) { + return ( + + + + ); + } + const sensitiveAnswers = item ? getListItemSensitiveAnswers(listField, item.id, values) : {}; + const keptAnswers = isSensitiveList ? {...item, ...sensitiveAnswers} : sensitiveAnswers; + const withInputValues = (inputValues: unknown) => ({...keptAnswers, ...toDynamicFormValues(inputValues)}); + const itemLabel = getLocalizedText(translate, listField.itemLabelKey, listField.itemLabel); + const addTitle = itemLabel ? translate('dynamicForm.addItem', {item: itemLabel}) : translate('common.add'); + const title = item ? summarizeListItem(item, listField.itemFields, translate, getListItemFallbackTitle(listField, translate)).title : addTitle; + + return ( + getDynamicFieldErrors(listField.itemFields, withInputValues(inputValues), translate)} + onSubmit={(inputValues) => onListItemSave(listField.key, item?.id, withInputValues(inputValues))} + style={[styles.mh5, styles.flexGrow1]} + submitButtonStyles={styles.mb0} + keyboardSubmitBehavior={CONST.KEYBOARD_SUBMIT_BEHAVIOR.SUBMIT_ONLY} + enabledWhenOffline + > + {({inputValues}) => ( + <> + + {title} + + + + )} + + ); +} + +export default DynamicFormListItemPage; diff --git a/src/components/DynamicForm/components/DynamicFormShell.tsx b/src/components/DynamicForm/components/DynamicFormShell.tsx new file mode 100644 index 000000000000..176ceac06c93 --- /dev/null +++ b/src/components/DynamicForm/components/DynamicFormShell.tsx @@ -0,0 +1,63 @@ +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; +import ScreenWrapper from '@components/ScreenWrapper'; + +import type {ReactNode} from 'react'; + +import React from 'react'; + +type DynamicFormShellProps = { + /** The current page */ + children: ReactNode; + + /** testID of the screen wrapper */ + testID: string; + + /** Title in the header */ + headerTitle: string; + + /** Called when the user presses the header's back button */ + onBackButtonPress: () => void; + + /** Unique page keys in order, for the step indicator */ + stepNames: string[]; + + /** Index of the highlighted step */ + stepIndex: number; + + /** Shows the step indicator under the header */ + shouldShowStepIndicator: boolean; +}; + +/** Header, and a step indicator when asked for, around one dynamic form page */ +function DynamicFormShell({children, testID, headerTitle, onBackButtonPress, stepNames, stepIndex, shouldShowStepIndicator}: DynamicFormShellProps) { + if (shouldShowStepIndicator) { + return ( + + {children} + + ); + } + return ( + + + {children} + + ); +} + +export default DynamicFormShell; diff --git a/src/components/DynamicForm/index.tsx b/src/components/DynamicForm/index.tsx new file mode 100644 index 000000000000..380be536fda2 --- /dev/null +++ b/src/components/DynamicForm/index.tsx @@ -0,0 +1,349 @@ +import ActivityIndicator from '@components/ActivityIndicator'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useSubPage from '@hooks/useSubPage'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import Navigation from '@libs/Navigation/Navigation'; +import {findLastPageIndex} from '@libs/SubPageUtils'; + +import {clearSensitiveAnswers, forgetSensitiveAnswers, saveDraftAnswers, saveSensitiveAnswers} from '@userActions/DynamicForm'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {DynamicFormListField, DynamicFormListItem, DynamicFormPage} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import {Str} from 'expensify-common'; +import React, {useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; + +import type {DynamicFormFlowProps, DynamicFormSubPageProps, DynamicFormValues} from './types'; + +import DynamicFormConfirmationPage from './components/DynamicFormConfirmationPage'; +import DynamicFormFieldsPage from './components/DynamicFormFieldsPage'; +import DynamicFormListItemPage from './components/DynamicFormListItemPage'; +import DynamicFormShell from './components/DynamicFormShell'; +import getAnswerKeys from './utils/getAnswerKeys'; +import getContentFields from './utils/getContentFields'; +import getDynamicFieldErrors from './utils/getDynamicFieldErrors'; +import {isPageShown} from './utils/getPageTitle'; +import getSubmittedAnswers from './utils/getSubmittedAnswers'; +import hasSensitiveAnswer from './utils/hasSensitiveAnswer'; +import isSensitiveField from './utils/isSensitiveField'; +import isSupportedField from './utils/isSupportedField'; +import {getListItemPageName, getListItems, getListItemSensitiveKey, getRemovedListItemSensitiveKeys, parseListItemPageName} from './utils/listItems'; +import {getAllFields, getPages} from './utils/paginate'; +import toDynamicFormValues from './utils/toDynamicFormValues'; + +/** An answer the user gave, including `false`, which counts as progress when deciding where a visit resumes */ +function hasDraftAnswer(answer: DynamicFormValues[string]): boolean { + return answer !== undefined && answer !== null && answer !== '' && !(Array.isArray(answer) && answer.length === 0); +} + +/** A whole dynamic form: one page per schema page, skipping pages with nothing to ask, then a confirmation page */ +function DynamicFormFlow({ + schema, + formID, + headerTitle, + confirmationTitle, + testID, + buildRoute, + shouldReplaceRoute, + onSubmit, + onPageSubmit, + onBack, + currency, + maxFileSize, + onRefreshRequirements, + isSubmitting, + submitError, + layout = 'auto', + hasConfirmation = true, +}: DynamicFormFlowProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [draft, draftMetadata] = useOnyx(`${formID}Draft`); + const [sensitiveAnswersByForm] = useOnyx(ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS); + const isDraftLoading = isLoadingOnyxValue(draftMetadata); + const draftValues = toDynamicFormValues(draft); + const values: DynamicFormValues = {...draftValues, ...sensitiveAnswersByForm?.[formID]}; + + const pages = getPages(schema); + const allFields = getAllFields(pages); + const listFields = allFields.filter((field): field is DynamicFormListField => field.type === 'list'); + const shownPages = pages.filter((page) => isPageShown(page, values, allFields)); + const hasPageToShow = shownPages.length > 0; + + // useSubPage needs at least one page, so a form without pages gets the confirmation page + const hasConfirmationPage = pages.length === 0 || hasConfirmation; + // Entry editors come after the confirmation page and are skipped by Next and Back, so only an entry's Add, Edit or row opens them. A readonly list has no editors. + const listItemPageNames = listFields + .filter((field) => !field.readonly) + .flatMap((field) => [getListItemPageName(field.key), ...getListItems(values[field.key]).map((item) => getListItemPageName(field.key, item.id))]); + const subPages = [ + ...pages.map((page) => ({pageName: page.key, component: DynamicFormFieldsPage})), + ...(hasConfirmationPage ? [{pageName: CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY, component: DynamicFormConfirmationPage}] : []), + ...listItemPageNames.map((pageName) => ({pageName, component: DynamicFormListItemPage})), + ]; + // While nothing is shown no page is skipped, since useSubPage needs a page that is not skipped + const skippedPages = hasPageToShow ? pages.filter((page) => !shownPages.includes(page)) : []; + const skipPages = [...skippedPages.map((page) => page.key), ...listItemPageNames]; + const firstShownPageIndex = subPages.findIndex((page) => !skipPages.includes(page.pageName)); + const lastShownPageIndex = findLastPageIndex(subPages, skipPages); + + /** Pages the answers hide are never asked, so they cannot be the first gap */ + const getFirstIncompletePageIndex = (answers: DynamicFormValues) => + pages.findIndex((page) => isPageShown(page, answers, allFields) && Object.keys(getDynamicFieldErrors(getContentFields(page.content), answers, translate, allFields)).length > 0); + + // A visit opened without a page in the URL drops the sensitive answers of earlier visits, so it starts from what the draft holds + let startFrom = firstShownPageIndex; + if (isDraftLoading) { + startFrom = -1; + } else if (allFields.some((field) => hasDraftAnswer(draftValues[field.key]))) { + const firstIncompleteIndex = getFirstIncompletePageIndex(draftValues); + startFrom = firstIncompleteIndex === -1 ? lastShownPageIndex : firstIncompleteIndex; + } + + const {CurrentPage, isEditing, currentPageName, pageIndex, nextPage, prevPage, moveTo, resetToPage, isRedirecting} = useSubPage({ + pages: subPages, + skipPages, + startFrom, + buildRoute, + shouldReplaceRoute, + onFinished: submitIfComplete, + }); + + /** Goes back to a page without stacking a second copy of it. Replaced routes leave nothing in the stack to go back to. */ + const returnTo = (pageName: string) => { + if (shouldReplaceRoute) { + resetToPage(pageName); + return; + } + Navigation.goBack(buildRoute(pageName)); + }; + + /** The confirmation page can be opened by URL before every page is answered, so an incomplete form goes back to its first gap instead. Without a confirmation page the last page's answers arrive here, before their draft is saved. */ + function submitIfComplete(lastPageValues?: unknown) { + const answers = {...values, ...toDynamicFormValues(lastPageValues)}; + const firstIncompleteIndex = getFirstIncompletePageIndex(answers); + const incompletePage = firstIncompleteIndex === -1 ? undefined : pages.at(firstIncompleteIndex); + if (incompletePage) { + returnTo(incompletePage.key); + return; + } + // Fields of a page the answers skip are not submitted + const shownFields = getAllFields(pages.filter((page) => isPageShown(page, answers, allFields))); + const submittedAnswers = getSubmittedAnswers(shownFields, answers, allFields); + if (isSubmitting === undefined) { + clearSensitiveAnswers(formID); + } + onSubmit(submittedAnswers); + } + + const listItemPage = parseListItemPageName(currentPageName); + const findListPage = (listKey: string) => pages.find((page) => getContentFields(page.content).some((field) => field.key === listKey)); + const isOnSkippedPage = !listItemPage && !!currentPageName && skipPages.includes(currentPageName); + const isOnUnavailablePage = !isDraftLoading && !isRedirecting && (!subPages.some((page) => page.pageName === currentPageName) || isOnSkippedPage); + + useEffect(() => { + if (!isRedirecting) { + return; + } + clearSensitiveAnswers(formID); + }, [isRedirecting, formID]); + + // A submission that finishes without an error drops the sensitive answers + const wasSubmittingRef = useRef(false); + useEffect(() => { + if (isSubmitting) { + wasSubmittingRef.current = true; + return; + } + if (!wasSubmittingRef.current || submitError) { + return; + } + wasSubmittingRef.current = false; + clearSensitiveAnswers(formID); + }, [isSubmitting, submitError, formID]); + + // A page the answers now skip is replaced by the next shown page, or the previous one, so Back never lands on it. A stale link opens where a new visit would start. + const pageNames = subPages.map((page) => page.pageName); + const nextShownPageName = pageNames.find((pageName, index) => index > pageIndex && !skipPages.includes(pageName)); + const previousShownPageName = pageNames.findLast((pageName, index) => index < pageIndex && !skipPages.includes(pageName)); + const unavailablePageTarget = isOnSkippedPage ? (nextShownPageName ?? previousShownPageName) : subPages.at(startFrom)?.pageName; + + useEffect(() => { + if (!isOnUnavailablePage || !unavailablePageTarget) { + return; + } + Navigation.navigate(buildRoute(unavailablePageTarget), {forceReplace: true}); + }, [isOnUnavailablePage, buildRoute, unavailablePageTarget]); + + /** Typed answers are drafted as FormProvider cleaned them and inputs left untouched are drafted with their defaults, so the draft matches what the page validated. Sensitive answers go to memory only, and only the draft ones reach the screen. Lists are drafted as they change, so the screen gets their drafted entries, which hold no sensitive answers. */ + const handlePageSubmit = (page: DynamicFormPage, pageValues: DynamicFormValues) => { + const currentValues = {...values, ...pageValues}; + const pageFields = getContentFields(page.content).filter(isSupportedField); + const answers = getSubmittedAnswers(pageFields, currentValues, allFields); + const sensitiveKeys = new Set(pageFields.filter((field) => isSensitiveField(field)).flatMap((field) => getAnswerKeys(field))); + const listKeys = new Set(listFields.map((field) => field.key)); + const draftAnswers: DynamicFormValues = {}; + const listAnswers: DynamicFormValues = {}; + const sensitiveAnswers: DynamicFormValues = {}; + for (const [key, answer] of Object.entries(answers)) { + if (sensitiveKeys.has(key)) { + sensitiveAnswers[key] = answer; + } else if (listKeys.has(key)) { + listAnswers[key] = getListItems(currentValues[key]); + } else { + draftAnswers[key] = answer; + } + } + saveDraftAnswers(formID, draftAnswers); + if (Object.keys(sensitiveAnswers).length > 0) { + saveSensitiveAnswers(formID, sensitiveAnswers); + } + const removedSensitiveKeys = listFields.filter((field) => pageFields.includes(field)).flatMap((field) => getRemovedListItemSensitiveKeys(field, currentValues)); + if (removedSensitiveKeys.length > 0) { + forgetSensitiveAnswers(formID, removedSensitiveKeys); + } + onPageSubmit?.(page, {...draftAnswers, ...listAnswers}); + }; + + /** An entry opened from the confirmation page, or from a page edited from it, returns there */ + const closeListItemEditor = (listKey: string) => { + const listPage = findListPage(listKey); + returnTo(isEditing || !listPage ? CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY : listPage.key); + }; + + const openListItemEditor = (listKey: string, itemID?: string) => { + const editorIndex = subPages.findIndex((page) => page.pageName === getListItemPageName(listKey, itemID)); + // moveTo(-1) would open the last sub-page, which is another entry's editor + if (editorIndex === -1) { + return; + } + moveTo(editorIndex, isEditing || currentPageName === CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY); + }; + + /** The entry is drafted with the form, except its sensitive answers, which are kept in memory under the entry. A sensitive list is kept in memory whole. */ + const saveListItem = (listKey: string, itemID: string | undefined, itemValues: DynamicFormValues) => { + const listField = listFields.find((field) => field.key === listKey); + if (!listField) { + return; + } + const items = getListItems(values[listKey]); + // The add row is hidden once the list is full, but a stale link can still open the editor of a new entry + if (!itemID && listField.maxItems !== undefined && items.length >= listField.maxItems) { + closeListItemEditor(listKey); + return; + } + const id = itemID ?? Str.guid(); + const sensitiveKeys = new Set(listField.itemFields.filter(hasSensitiveAnswer).flatMap(getAnswerKeys)); + const item: DynamicFormListItem = {id}; + const sensitiveAnswers: DynamicFormValues = {}; + for (const [key, answer] of Object.entries(getSubmittedAnswers(listField.itemFields, itemValues))) { + if (sensitiveKeys.has(key)) { + sensitiveAnswers[getListItemSensitiveKey(listKey, id, key)] = answer; + } else { + item[key] = answer; + } + } + const savedItems = itemID ? items.map((existing) => (existing.id === id ? item : existing)) : [...items, item]; + const saveListAnswer = isSensitiveField(listField) ? saveSensitiveAnswers : saveDraftAnswers; + saveListAnswer(formID, {[listKey]: savedItems}); + if (Object.keys(sensitiveAnswers).length > 0) { + saveSensitiveAnswers(formID, sensitiveAnswers); + } + // The editor saves outside the list's input, so a list that asks for fresh requirements on change asks here, as a removal does through its input + if (listField.refreshOnChange) { + onRefreshRequirements?.(listKey, savedItems); + } + closeListItemEditor(listKey); + }; + + /** A sensitive list is kept in memory, so removing an entry is saved right away for the next entry added to build on. A drafted list is saved as it changes. */ + const changeListItems = (listKey: string, items: DynamicFormListItem[]) => { + const listField = listFields.find((field) => field.key === listKey); + if (!listField || !isSensitiveField(listField)) { + return; + } + saveSensitiveAnswers(formID, {[listKey]: items}); + const removedSensitiveKeys = getRemovedListItemSensitiveKeys(listField, {...values, [listKey]: items}); + if (removedSensitiveKeys.length > 0) { + forgetSensitiveAnswers(formID, removedSensitiveKeys); + } + }; + + const goBack = () => { + if (listItemPage) { + closeListItemEditor(listItemPage.listKey); + return; + } + if (isEditing) { + returnTo(CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY); + return; + } + if (pageIndex <= firstShownPageIndex) { + clearSensitiveAnswers(formID); + onBack(); + return; + } + prevPage(); + }; + + // An entry editor highlights its list's step, and the confirmation page the last one + const stepPage = shownPages.find((page) => page.key === currentPageName) ?? (listItemPage && findListPage(listItemPage.listKey)); + // The indicator draws no names and keys its steps by them, so the unique page keys stand in for titles an untitled page does not have + const stepNames = [...shownPages.map((page) => page.key), ...(hasConfirmationPage ? [CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY] : [])]; + const stepPageIndex = stepPage ? shownPages.indexOf(stepPage) : -1; + const stepIndex = stepPageIndex === -1 ? stepNames.length - 1 : stepPageIndex; + // The shell swaps its root when the indicator toggles, which remounts the page. So `auto` decides once, from the answers the draft held on arrival, and keeps it while the user types. + const [isAutoStepIndicatorAsked, setIsAutoStepIndicatorAsked] = useState(); + if (!isDraftLoading && isAutoStepIndicatorAsked === undefined) { + setIsAutoStepIndicatorAsked(shownPages.length >= CONST.DYNAMIC_FORM.STEP_INDICATOR_MIN_PAGES); + } + const isStepIndicatorAsked = layout === 'auto' ? !!isAutoStepIndicatorAsked : layout === 'stepper'; + + return ( + 1} + > + {isDraftLoading || isRedirecting || isOnUnavailablePage || (!hasPageToShow && pages.length > 0) ? ( + + + + ) : ( + + )} + + ); +} + +export default DynamicFormFlow; diff --git a/src/components/DynamicForm/renderDynamicField.tsx b/src/components/DynamicForm/renderDynamicField.tsx index 63d45bd53110..6933b55ced95 100644 --- a/src/components/DynamicForm/renderDynamicField.tsx +++ b/src/components/DynamicForm/renderDynamicField.tsx @@ -6,19 +6,22 @@ import type {DynamicFormFieldOfType} from './types'; import renderAddress from './renderers/addressRenderer'; import CHOICE_RENDERERS from './renderers/choiceRenderers'; import renderFile from './renderers/fileRenderer'; +import renderList from './renderers/listRenderer'; import TEXT_RENDERERS from './renderers/textRenderers'; +import getSensitiveDefaultValue from './utils/getSensitiveDefaultValue'; const RENDERERS: DynamicFieldRendererMap = { ...TEXT_RENDERERS, ...CHOICE_RENDERERS, address: renderAddress, file: renderFile, + list: renderList, }; /** The input for one field, and how the renderer lays it out */ function renderDynamicField(field: DynamicFormFieldOfType, context: DynamicFieldContext, inputProps: DynamicFieldInputProps): DynamicFieldInput { const render: DynamicFieldRenderer = RENDERERS[field.type]; - return render(field, context, inputProps); + return render(field, context, {...inputProps, defaultValue: getSensitiveDefaultValue(field, context.values, context.isSensitive)}); } export default renderDynamicField; diff --git a/src/components/DynamicForm/renderers/addressRenderer.tsx b/src/components/DynamicForm/renderers/addressRenderer.tsx index e4bfe3ffb426..2b178ba0ac90 100644 --- a/src/components/DynamicForm/renderers/addressRenderer.tsx +++ b/src/components/DynamicForm/renderers/addressRenderer.tsx @@ -1,4 +1,5 @@ import getAddressInputKeys, {getAddressCountry, isStateAsked} from '@components/DynamicForm/utils/getAddressInputKeys'; +import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; import AddressFormFields from '@pages/ReimbursementAccount/AddressFormFields'; @@ -7,9 +8,14 @@ import React from 'react'; import type {DynamicFieldRenderer} from './types'; /** An address drawn as one input per part, each under its own draft key */ -const renderAddress: DynamicFieldRenderer<'address'> = (field, {values}, {shouldSaveDraft, forwardedFSClass, onValueChange}) => { +const renderAddress: DynamicFieldRenderer<'address'> = (field, {values}, {shouldSaveDraft, forwardedFSClass, onValueChange, defaultValue}) => { const addressKeys = getAddressInputKeys(field.key); const country = getAddressCountry(field.key, values); + // The street arrives as the field's answer. The other parts of a sensitive address are not drafted either, so they start from memory too. + const getSensitivePart = (key: string) => { + const part = values[key]; + return isSensitiveField(field) && typeof part === 'string' ? part : undefined; + }; return { isMenuRow: false, labelAbove: 'heading', @@ -17,7 +23,13 @@ const renderAddress: DynamicFieldRenderer<'address'> = (field, {values}, {should { return Object.fromEntries(choices.map((choice) => [choice.value, choice.label])); } -/** A lone choice field is the page itself: its options are listed inline, and the page title asks the question */ -function renderInlineChoice(label: string, choices: Choice[], inputProps: DynamicFieldInputProps, {canSelectMultiple, isSearchable}: InlineChoiceOptions): DynamicFieldInput { +/** A lone choice field is the page itself: its options are listed inline under its question, which a page without its own title shows as its title instead */ +function renderInlineChoice( + label: string, + choices: Choice[], + inputProps: DynamicFieldInputProps, + {canSelectMultiple, isSearchable}: InlineChoiceOptions, +): DynamicFieldInput { const listProps = {items: choices, isSearchable, searchInputLabel: label}; return { isMenuRow: true, + labelAbove: 'heading', input: ( , isLoneField: boolean): DynamicFieldInput { return { isMenuRow: false, - labelAbove: isLoneField ? undefined : 'prompt', + labelAbove: isLoneField ? 'heading' : 'prompt', input: ( LONG_CHOICE_LIST_THRESHOLD}); + return renderInlineChoice(label, choices, inputProps, {canSelectMultiple: true, isSearchable: choices.length > CONST.DYNAMIC_FORM.LARGE_SELECT_MIN_OPTIONS}); } return { isMenuRow: true, @@ -89,7 +94,7 @@ const CHOICE_RENDERERS: Pick { const label = getFieldLabel(field, translate); const choices = getFieldChoices(field, values, translate); - const isLong = choices.length > LONG_CHOICE_LIST_THRESHOLD; + const isLong = choices.length > CONST.DYNAMIC_FORM.LARGE_SELECT_MIN_OPTIONS; if (field.presentation === 'tabs') { return renderTabs(label, choices, inputProps, isLoneField); } @@ -131,7 +136,7 @@ const CHOICE_RENDERERS: Pick { - if (isLoneField) { + if (field.presentation !== 'checkbox') { return { isMenuRow: true, + labelAbove: isLoneField ? 'heading' : 'prompt', input: ( = (field, {translate, values, onOpenListItemEditor, onListItemsChange, renderFields}, inputProps) => { + const itemLabel = getLocalizedText(translate, field.itemLabelKey, field.itemLabel); + // A sensitive list is not drafted, so inside the flow its entries come from memory, which the editor page and a removal both update + const isMemoryList = !!onOpenListItemEditor && isSensitiveField(field); + return { + isMenuRow: true, + labelAbove: 'heading', + input: ( + onListItemsChange?.(field.key, items) : undefined} + label={getFieldLabel(field, translate)} + itemLabel={itemLabel} + itemFields={field.itemFields} + isSensitive={isSensitiveField(field)} + maxItems={field.maxItems} + addTitle={itemLabel ? translate('dynamicForm.addItem', {item: itemLabel}) : translate('common.add')} + addDescription={getLocalizedText(translate, field.addItemDescriptionKey, field.addItemDescription)} + onAdd={onOpenListItemEditor && (() => onOpenListItemEditor(field.key))} + onEdit={onOpenListItemEditor && ((itemID) => onOpenListItemEditor(field.key, itemID))} + renderFields={renderFields} + /> + ), + }; +}; + +export default renderList; diff --git a/src/components/DynamicForm/renderers/textRenderers.tsx b/src/components/DynamicForm/renderers/textRenderers.tsx index 5033688a9dd3..7a240126b953 100644 --- a/src/components/DynamicForm/renderers/textRenderers.tsx +++ b/src/components/DynamicForm/renderers/textRenderers.tsx @@ -1,7 +1,6 @@ import AmountForm from '@components/AmountForm'; import CurrencyPicker from '@components/CurrencyPicker'; import DatePicker from '@components/DatePicker'; -import type {DynamicFormValues} from '@components/DynamicForm/types'; import getLocalizedText, {getFieldLabel} from '@components/DynamicForm/utils/getLocalizedText'; import InputWrapper from '@components/Form/InputWrapper'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; @@ -15,6 +14,7 @@ import type {DynamicFormNumberField, DynamicFormTextField} from '@src/types/onyx import type {InputModeOptions} from 'react-native'; +import {subYears} from 'date-fns'; import React from 'react'; import {View} from 'react-native'; @@ -41,16 +41,10 @@ function getTextHint(field: DynamicFormTextField | DynamicFormNumberField, trans return translate('dynamicForm.exampleHint', {example: field.example}); } -/** Sensitive answers are not drafted, so a page the user comes back to is refilled from the answers kept in memory */ -function getSensitiveDefaultValue(field: DynamicFormTextField | DynamicFormNumberField, values: DynamicFormValues): string | undefined { - const value = values[field.key]; - return field.sensitive && typeof value === 'string' ? value : undefined; -} - function renderTextInput( field: DynamicFormTextField | DynamicFormNumberField, - {values, translate}: DynamicFieldContext, - inputProps: DynamicFieldInputProps, + {translate}: DynamicFieldContext, + inputProps: DynamicFieldInputProps, inputMode: InputModeOptions | undefined, isMultiline = false, ): DynamicFieldInput { @@ -61,7 +55,6 @@ function renderTextInput( = { text: (field, context, inputProps) => renderTextInput(field, context, inputProps, getTextInputMode(field), field.multiline), number: (field, context, inputProps) => renderTextInput(field, context, inputProps, CONST.INPUT_MODE.NUMERIC), - date: (field, {translate}, inputProps) => ({ - isMenuRow: false, - input: ( - - ), - }), + date: (field, {translate}, inputProps) => { + const isDateOfBirth = field.rule === 'dateOfBirth'; + return { + isMenuRow: false, + input: ( + + ), + }; + }, amount: (field, {values, translate, styles, currency}, inputProps) => { const chosenCurrency = field.currencyKey ? values[field.currencyKey] : undefined; + const amountCurrency = typeof chosenCurrency === 'string' && chosenCurrency !== '' ? chosenCurrency : currency; // AmountForm takes no FullStory class, so the mask goes on a View around it const amountInput = ( @@ -98,7 +98,7 @@ const TEXT_RENDERERS: Pick @@ -117,7 +117,8 @@ const TEXT_RENDERERS: Pick diff --git a/src/components/DynamicForm/renderers/types.ts b/src/components/DynamicForm/renderers/types.ts index 89825a0825b7..d13ec5e7dd04 100644 --- a/src/components/DynamicForm/renderers/types.ts +++ b/src/components/DynamicForm/renderers/types.ts @@ -1,29 +1,55 @@ import type {DynamicFormFieldsProps} from '@components/DynamicForm/components/DynamicFormFields'; -import type {DynamicFormFieldOfType} from '@components/DynamicForm/types'; -import type {InputComponentBaseProps} from '@components/Form/types'; +import type {DynamicFormFieldOfType, DynamicFormValues} from '@components/DynamicForm/types'; +import type {FormValue, InputComponentBaseProps} from '@components/Form/types'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {ThemeStyles} from '@src/styles'; -import type {DynamicFormFieldType} from '@src/types/onyx'; +import type {DynamicFormField, DynamicFormFieldType, DynamicFormListItem} from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; -import type {ReactElement} from 'react'; +import type {ReactElement, ReactNode} from 'react'; -type DynamicFieldContext = Pick & +type DynamicFieldContext = Pick & Required> & { translate: LocalizedTranslate; styles: ThemeStyles; /** The field is the page's only question, so a choice is drawn as the page itself instead of as a row */ isLoneField: boolean; + + /** Draws a list entry's fields in the modal editor used outside DynamicFormFlow */ + renderFields: (fields: DynamicFormField[], values: DynamicFormValues, isSensitive: boolean) => ReactNode; }; +/** The answer each field type's input holds. An address holds its street, and its other parts sit under their own keys. */ +type DynamicFieldAnswerMap = { + text: string; + number: string; + select: string; + radio: string; + multiselect: string[]; + date: string; + address: string; + file: FileObject[]; + amount: string; + country: string; + countryMultiselect: string[]; + boolean: boolean; + currency: string; + percent: string; + list: DynamicFormListItem[]; +}; + /** Props every input gets from the renderer */ -type DynamicFieldInputProps = Required & +type DynamicFieldInputProps = Required & Required> & Pick & { onBlur?: () => void; + + /** A sensitive answer is not drafted, so the input starts from the answer the flow keeps in memory */ + defaultValue?: TAnswer; }; type DynamicFieldInput = { @@ -40,9 +66,13 @@ type DynamicFieldInput = { showsDescription?: boolean; }; -type DynamicFieldRenderer = (field: DynamicFormFieldOfType, context: DynamicFieldContext, inputProps: DynamicFieldInputProps) => DynamicFieldInput; +type DynamicFieldRenderer = ( + field: DynamicFormFieldOfType, + context: DynamicFieldContext, + inputProps: DynamicFieldInputProps, +) => DynamicFieldInput; /** One renderer per field type, so typecheck fails when a type has none */ type DynamicFieldRendererMap = {[TType in DynamicFormFieldType]: DynamicFieldRenderer}; -export type {DynamicFieldContext, DynamicFieldInput, DynamicFieldInputProps, DynamicFieldRenderer, DynamicFieldRendererMap}; +export type {DynamicFieldAnswerMap, DynamicFieldContext, DynamicFieldInput, DynamicFieldInputProps, DynamicFieldRenderer, DynamicFieldRendererMap}; diff --git a/src/components/DynamicForm/types.ts b/src/components/DynamicForm/types.ts index dacf69a06497..596f229297fd 100644 --- a/src/components/DynamicForm/types.ts +++ b/src/components/DynamicForm/types.ts @@ -1,6 +1,11 @@ import type {FormValue} from '@components/Form/types'; -import type {DynamicFormField, DynamicFormFieldType} from '@src/types/onyx'; +import type {SubPageProps, UseSubPageProps} from '@hooks/useSubPage/types'; + +import type {OnyxFormKey} from '@src/ONYXKEYS'; +import type {DynamicFormField, DynamicFormFieldType, DynamicFormPage, DynamicFormSchema} from '@src/types/onyx'; + +import type {DynamicFormFieldsProps} from './components/DynamicFormFields'; /** Current answers keyed by draft key. Address parts sit under `.`. */ type DynamicFormValues = Partial>; @@ -8,4 +13,75 @@ type DynamicFormValues = Partial>; /** The variant of one type. An intersection rather than Extract, since one variant can cover several types, such as select and radio. */ type DynamicFormFieldOfType = DynamicFormField & {type: TType}; -export type {DynamicFormFieldOfType, DynamicFormValues}; +/** `auto` shows a step indicator at three or more pages, `stepper` always, `pages` never */ +type DynamicFormLayout = 'auto' | 'pages' | 'stepper'; + +type DynamicFormFlowProps = Pick & { + /** The pages as the server groups them, or a flat list of fields the App paginates. Fields of a type this App version does not know are left out. */ + schema: DynamicFormSchema; + + /** The form whose draft holds the answers. Sensitive answers stay in memory instead. */ + formID: OnyxFormKey; + + /** Title in the header of every page */ + headerTitle: string; + + /** Title of the confirmation page */ + confirmationTitle: string; + + /** testID of the screen wrapper */ + testID: string; + + /** Route of a page. The flow passes `edit` when the user opens a page from the confirmation page. */ + buildRoute: UseSubPageProps['buildRoute']; + + /** Replaces the route on each page change instead of pushing a new one, for flows on dynamic routes */ + shouldReplaceRoute?: UseSubPageProps['shouldReplaceRoute']; + + /** Receives the answers to every visible field, sensitive ones included. The flow drops sensitive answers once `isSubmitting` turns false without `submitError`, or right away when `isSubmitting` is not passed. */ + onSubmit: (answers: DynamicFormValues) => void; + + /** Receives a page and its draft answers when the user leaves it with Next, for flows that save each page. Sensitive answers are not passed, since a page save would persist them in the request queue. */ + onPageSubmit?: (page: DynamicFormPage, answers: DynamicFormValues) => void; + + /** Leaves the flow from its first page */ + onBack: () => void; + + /** Shows the confirmation page's Confirm button loading while the screen sends the answers */ + isSubmitting?: boolean; + + /** Error from sending the answers, shown on the confirmation page */ + submitError?: string; + + /** When the step indicator shows: `auto` from three shown pages on, `stepper` always, `pages` never */ + layout?: DynamicFormLayout; + + /** Whether a confirmation page follows the last page. On by default. A form with no page always gets one, since there has to be a page to land on. */ + hasConfirmation?: boolean; +}; + +/** Props of every page in the flow: useSubPage's routing props plus the form's data */ +type DynamicFormSubPageProps = SubPageProps & + Pick & { + pages: DynamicFormPage[]; + + /** Every supported field of the form, which is where a `showWhen` finds its controller */ + allFields: DynamicFormField[]; + + /** Draft answers merged with the sensitive answers kept in memory */ + values: DynamicFormValues; + + onPageSubmit: (page: DynamicFormPage, pageValues: DynamicFormValues) => void; + + onOpenListItemEditor: NonNullable; + + onListItemsChange: NonNullable; + + /** The last page submits the form, since no confirmation page follows */ + isLastPage: boolean; + + /** Saves a list entry from its editor page. Without `itemID` it adds a new entry. */ + onListItemSave: (listKey: string, itemID: string | undefined, answers: DynamicFormValues) => void; + }; + +export type {DynamicFormFieldOfType, DynamicFormFlowProps, DynamicFormLayout, DynamicFormSubPageProps, DynamicFormValues}; diff --git a/src/components/DynamicForm/utils/getAnswerKeys.ts b/src/components/DynamicForm/utils/getAnswerKeys.ts new file mode 100644 index 000000000000..ff3ee4717b1e --- /dev/null +++ b/src/components/DynamicForm/utils/getAnswerKeys.ts @@ -0,0 +1,17 @@ +import type {DynamicFormField} from '@src/types/onyx'; + +import getAddressInputKeys from './getAddressInputKeys'; + +/** The draft keys a field writes to: its own, plus its address parts or its chosen currency */ +function getAnswerKeys(field: DynamicFormField): string[] { + switch (field.type) { + case 'address': + return Object.values(getAddressInputKeys(field.key)).filter(Boolean); + case 'amount': + return field.currencyKey ? [field.key, field.currencyKey] : [field.key]; + default: + return [field.key]; + } +} + +export default getAnswerKeys; diff --git a/src/components/DynamicForm/utils/getConfirmationItems.ts b/src/components/DynamicForm/utils/getConfirmationItems.ts new file mode 100644 index 000000000000..6117ad965157 --- /dev/null +++ b/src/components/DynamicForm/utils/getConfirmationItems.ts @@ -0,0 +1,96 @@ +import type {DynamicFormValues} from '@components/DynamicForm/types'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {SummaryGroup, SummaryGroupRow} from '@components/SubStepForms/ConfirmationStep'; + +import {getLetterAvatarURLForName} from '@libs/UserAvatarUtils'; + +import type {DynamicFormField, DynamicFormListField, DynamicFormPage} from '@src/types/onyx'; + +import noop from 'lodash/noop'; + +import formatDynamicFieldValue from './formatDynamicFieldValue'; +import getContentFields from './getContentFields'; +import {getFieldLabel} from './getLocalizedText'; +import {getPageSectionName, isPageShown} from './getPageTitle'; +import getVisibleFields from './getVisibleFields'; +import isSensitiveField from './isSensitiveField'; +import isSupportedField from './isSupportedField'; +import {getListItems} from './listItems'; +import maskSensitiveValue from './maskSensitiveValue'; +import summarizeListItem, {getListItemFallbackTitle} from './summarizeListItem'; + +/** One avatar row per list entry, which opens the entry. A readonly list gets plain rows that open nothing. */ +function getListItemRows( + field: DynamicFormListField, + values: DynamicFormValues, + translate: LocalizedTranslate, + onEditListItem: (listKey: string, itemID: string) => void, +): SummaryGroupRow[] { + return getListItems(values[field.key]).map((item): SummaryGroupRow => { + const {title, description} = summarizeListItem(item, field.itemFields, translate, getListItemFallbackTitle(field, translate)); + if (field.readonly) { + return {id: `${field.key}-${item.id}`, title, description, shouldShowRightIcon: false, onPress: noop, interactive: false}; + } + return { + kind: 'avatar', + id: `${field.key}-${item.id}`, + title, + description, + avatarSource: getLetterAvatarURLForName(title), + onPress: () => onEditListItem(field.key, item.id), + }; + }); +} + +/** One section per shown page holding its visible answers, with each list entry as an avatar row and an empty list as a plain row. Tapping a row opens the page or entry to edit. Readonly rows open nothing. */ +function getConfirmationItems( + pages: DynamicFormPage[], + allFields: DynamicFormField[], + values: DynamicFormValues, + translate: LocalizedTranslate, + onEditPage: (pageIndex: number) => void, + onEditListItem: (listKey: string, itemID: string) => void, +): SummaryGroup[] { + return pages.flatMap((page, pageIndex): SummaryGroup[] => { + if (!isPageShown(page, values, allFields)) { + return []; + } + + const fields = getVisibleFields(getContentFields(page.content).filter(isSupportedField), values, allFields); + + if (fields.length === 0) { + return []; + } + + const handleEdit = () => onEditPage(pageIndex); + + return [ + { + id: page.key, + name: getPageSectionName(page, values, translate, allFields), + rows: fields.flatMap((field): SummaryGroupRow[] => { + const listItemRows = field.type === 'list' ? getListItemRows(field, values, translate, onEditListItem) : []; + if (listItemRows.length > 0) { + return listItemRows; + } + + const answer = formatDynamicFieldValue(field, values, translate); + const isEditable = !field.readonly; + + return [ + { + id: field.key, + description: getFieldLabel(field, translate), + title: isSensitiveField(field) ? maskSensitiveValue(answer) : answer, + shouldShowRightIcon: isEditable, + onPress: isEditable ? handleEdit : noop, + interactive: isEditable, + }, + ]; + }), + }, + ]; + }); +} + +export default getConfirmationItems; diff --git a/src/components/DynamicForm/utils/getContentFields.ts b/src/components/DynamicForm/utils/getContentFields.ts index aa7d39ce4744..4d40f29a1fbf 100644 --- a/src/components/DynamicForm/utils/getContentFields.ts +++ b/src/components/DynamicForm/utils/getContentFields.ts @@ -1,10 +1,10 @@ -import type {DynamicFormContentItem, DynamicFormHeading} from '@src/types/onyx'; +import type {DynamicFormContentItem} from '@src/types/onyx'; import isHeading from './isHeading'; -/** The fields of a page's content, for getDynamicFieldErrors and other field-only checks */ -function getContentFields(content: TItem[]): Array> { - return content.filter((item): item is Exclude => !isHeading(item)); +/** The fields of a page's content, for getDynamicFieldErrors and other field-only checks. Extract rather than Exclude, since every field is structurally a heading too. */ +function getContentFields(content: TItem[]): Array> { + return content.filter((item): item is Extract => !isHeading(item)); } export default getContentFields; diff --git a/src/components/DynamicForm/utils/getDynamicFieldErrors.ts b/src/components/DynamicForm/utils/getDynamicFieldErrors.ts index b1e393910e3b..de0bfcb55b61 100644 --- a/src/components/DynamicForm/utils/getDynamicFieldErrors.ts +++ b/src/components/DynamicForm/utils/getDynamicFieldErrors.ts @@ -19,6 +19,7 @@ import type { DynamicFormChoiceField, DynamicFormField, DynamicFormFieldType, + DynamicFormListField, DynamicFormNumberField, DynamicFormSchemaField, DynamicFormTextField, @@ -26,9 +27,11 @@ import type { import getAddressInputKeys, {getAddressCountry, isStateAsked} from './getAddressInputKeys'; import getFieldOptions from './getFieldOptions'; -import getVisibleFields, {getLoneField} from './getVisibleFields'; +import getVisibleFields from './getVisibleFields'; import isSupportedField from './isSupportedField'; +import {getListItems, getListItemSensitiveAnswers} from './listItems'; import logSchemaProblem from './logSchemaProblem'; +import summarizeListItem, {getListItemFallbackTitle} from './summarizeListItem'; /** Checks a field the user has answered. Unanswered fields only get the required check. */ type FieldValidator = (field: DynamicFormFieldOfType, values: DynamicFormValues, translate: LocalizedTranslate) => string[]; @@ -100,6 +103,27 @@ function getMultiChoiceErrors(field: DynamicFormFieldOfType<'multiselect' | 'cou return !hasNonStringEntry && keys.every((key) => offeredKeys.has(key)) ? [] : [translate('dynamicForm.error.invalidOption')]; } +/** The list shows one error, so the first entry with a problem gives it, prefixed by the entry title so the user knows which one to open. The flow keeps sensitive answers in memory apart from their entry, so one lost with the memory is asked for again. */ +function getListErrors(field: DynamicFormListField, values: DynamicFormValues, translate: LocalizedTranslate): string[] { + const items = getListItems(values[field.key]); + const messages: string[] = []; + if (field.minItems !== undefined && items.length < field.minItems) { + messages.push(translate('dynamicForm.error.tooFewItems', {min: field.minItems})); + } + if (field.maxItems !== undefined && items.length > field.maxItems) { + messages.push(translate('dynamicForm.error.tooManyItems', {max: field.maxItems})); + } + const fallbackTitle = getListItemFallbackTitle(field, translate); + for (const item of items) { + const error = Object.values(getDynamicFieldErrors(field.itemFields, {...item, ...getListItemSensitiveAnswers(field, item.id, values)}, translate)).at(0); + if (error) { + messages.push(translate('dynamicForm.error.itemError', {item: summarizeListItem(item, field.itemFields, translate, fallbackTitle).title, error})); + break; + } + } + return messages; +} + /** For inputs that only accept valid answers: UploadFile checks type and count, AmountForm rejects invalid typing */ const noChecks = () => []; @@ -153,6 +177,7 @@ const VALIDATORS: {[TType in DynamicFormFieldType]: FieldValidator} = { const percent = Number(getStringAnswer(field, values)); return Number.isFinite(percent) && percent >= PERCENT_MIN && percent <= PERCENT_MAX ? [] : [translate('dynamicForm.error.outOfRange', {min: PERCENT_MIN, max: PERCENT_MAX})]; }, + list: getListErrors, }; /** An optional address the user has started filling in is checked like a required one, so it is never submitted in part */ @@ -191,11 +216,13 @@ function validateField(field: DynamicFormFie return validate(field, values, translate); } -/** Errors for the fields the user can see, keyed by draft key, in the shape FormProvider's `validate` returns. */ -function getDynamicFieldErrors(fields: DynamicFormSchemaField[], values: DynamicFormValues, translate: LocalizedTranslate): Record { +/** + * Errors for the fields the user can see, keyed by draft key, in the shape FormProvider's `validate` returns. + * Pass the whole form as `allFields` when a `showWhen` can name a field on another page. + */ +function getDynamicFieldErrors(fields: DynamicFormSchemaField[], values: DynamicFormValues, translate: LocalizedTranslate, allFields?: DynamicFormField[]): Record { const errors: Record = {}; - const visibleFields = getVisibleFields(fields.filter(isSupportedField), values); - const loneField = getLoneField(visibleFields); + const visibleFields = getVisibleFields(fields.filter(isSupportedField), values, allFields); for (const field of visibleFields) { if (field.readonly) { continue; @@ -205,7 +232,7 @@ function getDynamicFieldErrors(fields: DynamicFormSchemaField[], values: Dynamic addErrorMessage(errors, inputID, message); } } - if (!isAnswered(values[field.key], field.type === 'boolean' && field === loneField)) { + if (!isAnswered(values[field.key], field.type === 'boolean' && field.presentation !== 'checkbox')) { if (field.type === 'address' ? isAddressRequired(field, values) : field.required) { addErrorMessage(errors, field.key, translate('common.error.fieldRequired')); } diff --git a/src/components/DynamicForm/utils/getPageTitle.ts b/src/components/DynamicForm/utils/getPageTitle.ts new file mode 100644 index 000000000000..7caeeda107cb --- /dev/null +++ b/src/components/DynamicForm/utils/getPageTitle.ts @@ -0,0 +1,39 @@ +import type {DynamicFormValues} from '@components/DynamicForm/types'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; + +import type {DynamicFormField, DynamicFormPage} from '@src/types/onyx'; + +import getContentFields from './getContentFields'; +import getLocalizedText, {getFieldLabel} from './getLocalizedText'; +import {getLoneField, getVisibleContent, isFieldVisible} from './getVisibleFields'; + +/** The page's own title, else the schema's wording of its lone question. A label never names a section, since the label is already on the question's row. */ +function getPageSectionName(page: DynamicFormPage, values: DynamicFormValues, translate: LocalizedTranslate, allFields?: DynamicFormField[]): string { + const ownTitle = getLocalizedText(translate, page.titleKey, page.title); + if (ownTitle) { + return ownTitle; + } + const loneField = getLoneField(getContentFields(getVisibleContent(page.content, values, allFields))); + return (loneField && getLocalizedText(translate, loneField.questionKey, loneField.question)) ?? ''; +} + +/** The page's section name, else the label of its lone question. An untitled page with several questions has no headline, since no one of them names the page. */ +function getPageTitle(page: DynamicFormPage, values: DynamicFormValues, translate: LocalizedTranslate, allFields?: DynamicFormField[]): string { + const sectionName = getPageSectionName(page, values, translate, allFields); + if (sectionName) { + return sectionName; + } + const loneField = getLoneField(getContentFields(getVisibleContent(page.content, values, allFields))); + return loneField ? getFieldLabel(loneField, translate) : ''; +} + +/** A page is shown when its own `showWhen` matches and it has a field to show. A page that is not shown is skipped, and its fields are not asked or submitted. */ +function isPageShown(page: DynamicFormPage, values: DynamicFormValues, allFields: DynamicFormField[]): boolean { + if (!isFieldVisible(page, values, allFields)) { + return false; + } + return getContentFields(getVisibleContent(page.content, values, allFields)).length > 0; +} + +export default getPageTitle; +export {getPageSectionName, isPageShown}; diff --git a/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts b/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts new file mode 100644 index 000000000000..6e799e8bb578 --- /dev/null +++ b/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts @@ -0,0 +1,62 @@ +import type {DynamicFieldAnswerMap} from '@components/DynamicForm/renderers/types'; +import type {DynamicFormFieldOfType, DynamicFormValues} from '@components/DynamicForm/types'; +import type {FormValue} from '@components/Form/types'; + +import type {DynamicFormFieldType, DynamicFormListItem} from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; + +import isSensitiveField from './isSensitiveField'; +import {isListItem} from './listItems'; + +type AnswerGuards = {[TType in DynamicFormFieldType]: (answer: FormValue) => answer is DynamicFieldAnswerMap[TType]}; + +function isString(answer: FormValue): answer is string { + return typeof answer === 'string'; +} + +function isStringList(answer: FormValue): answer is string[] { + return Array.isArray(answer) && answer.every((item) => typeof item === 'string'); +} + +function isBoolean(answer: FormValue): answer is boolean { + return typeof answer === 'boolean'; +} + +function isFileList(answer: FormValue): answer is FileObject[] { + return Array.isArray(answer) && answer.every((item) => typeof item === 'object'); +} + +function isListItems(answer: FormValue): answer is DynamicFormListItem[] { + return Array.isArray(answer) && answer.every(isListItem); +} + +const ANSWER_GUARDS: AnswerGuards = { + text: isString, + number: isString, + select: isString, + radio: isString, + multiselect: isStringList, + date: isString, + address: isString, + file: isFileList, + amount: isString, + country: isString, + countryMultiselect: isStringList, + boolean: isBoolean, + currency: isString, + percent: isString, + list: isListItems, +}; + +/** Sensitive answers are not drafted, so a page the user comes back to is refilled from the answers kept in memory. `isInSensitiveForm` treats every field as sensitive. */ +function getSensitiveDefaultValue( + field: DynamicFormFieldOfType, + values: DynamicFormValues, + isInSensitiveForm = false, +): DynamicFieldAnswerMap[TType] | undefined { + const answer = values[field.key]; + const isAnswer: AnswerGuards[TType] = ANSWER_GUARDS[field.type]; + return (isInSensitiveForm || isSensitiveField(field)) && answer !== undefined && isAnswer(answer) ? answer : undefined; +} + +export default getSensitiveDefaultValue; diff --git a/src/components/DynamicForm/utils/getSubmittedAnswers.ts b/src/components/DynamicForm/utils/getSubmittedAnswers.ts new file mode 100644 index 000000000000..47463db3d78c --- /dev/null +++ b/src/components/DynamicForm/utils/getSubmittedAnswers.ts @@ -0,0 +1,29 @@ +import type {DynamicFormValues} from '@components/DynamicForm/types'; + +import type {DynamicFormField} from '@src/types/onyx'; + +import getAnswerKeys from './getAnswerKeys'; +import getVisibleFields from './getVisibleFields'; +import {getListItems, getListItemSensitiveAnswers} from './listItems'; + +/** The answers to the fields the user can see, so answers left on fields hidden later are not submitted. List entries get their sensitive answers back from memory. Pass the whole form as `allFields` when `fields` is one page of it. */ +function getSubmittedAnswers(fields: DynamicFormField[], values: DynamicFormValues, allFields = fields): DynamicFormValues { + const answers: DynamicFormValues = {}; + for (const field of getVisibleFields(fields, values, allFields)) { + for (const key of getAnswerKeys(field)) { + if (values[key] !== undefined) { + answers[key] = values[key]; + } + } + if (field.type === 'list' && values[field.key] !== undefined) { + answers[field.key] = getListItems(values[field.key]).map((item) => ({ + id: item.id, + ...getSubmittedAnswers(field.itemFields, {...item, ...getListItemSensitiveAnswers(field, item.id, values)}), + })); + } + } + return answers; +} + +export default getSubmittedAnswers; +export {getAnswerKeys}; diff --git a/src/components/DynamicForm/utils/hasSensitiveAnswer.ts b/src/components/DynamicForm/utils/hasSensitiveAnswer.ts new file mode 100644 index 000000000000..0cd13f5f5f18 --- /dev/null +++ b/src/components/DynamicForm/utils/hasSensitiveAnswer.ts @@ -0,0 +1,10 @@ +import type {DynamicFormField} from '@src/types/onyx'; + +import isSensitiveField from './isSensitiveField'; + +/** The field is sensitive, or is a list whose entries hold a sensitive answer at any depth, so its answer must stay out of the draft */ +function hasSensitiveAnswer(field: DynamicFormField): boolean { + return isSensitiveField(field) || (field.type === 'list' && field.itemFields.some(hasSensitiveAnswer)); +} + +export default hasSensitiveAnswer; diff --git a/src/components/DynamicForm/utils/isSupportedField.ts b/src/components/DynamicForm/utils/isSupportedField.ts index 13f3c55328bc..935a777668c9 100644 --- a/src/components/DynamicForm/utils/isSupportedField.ts +++ b/src/components/DynamicForm/utils/isSupportedField.ts @@ -17,6 +17,7 @@ const SUPPORTED_FIELD_TYPES: Record = { file: true, amount: true, percent: true, + list: true, }; /** A field of a type this App version does not know, or with no key to store its answer under, is left out, so it neither renders nor blocks submission */ diff --git a/src/components/DynamicForm/utils/listItems.ts b/src/components/DynamicForm/utils/listItems.ts new file mode 100644 index 000000000000..f0e77da270b7 --- /dev/null +++ b/src/components/DynamicForm/utils/listItems.ts @@ -0,0 +1,63 @@ +import type {DynamicFormValues} from '@components/DynamicForm/types'; + +import type {DynamicFormListField, DynamicFormListItem} from '@src/types/onyx'; + +import getAnswerKeys from './getAnswerKeys'; +import hasSensitiveAnswer from './hasSensitiveAnswer'; + +const PAGE_NAME_SEPARATOR = '~'; +const NEW_ITEM_ID = 'new'; + +/** Route segment of a list entry's editor page, or of the page that adds one */ +function getListItemPageName(listKey: string, itemID = NEW_ITEM_ID): string { + return `${listKey}${PAGE_NAME_SEPARATOR}${itemID}`; +} + +/** The list key and entry ID an editor page name points at, or undefined for any other page */ +function parseListItemPageName(pageName: string | undefined): {listKey: string; itemID: string | undefined} | undefined { + if (!pageName?.includes(PAGE_NAME_SEPARATOR)) { + return undefined; + } + const [listKey = '', itemID] = pageName.split(PAGE_NAME_SEPARATOR); + return {listKey, itemID: itemID === NEW_ITEM_ID ? undefined : itemID}; +} + +/** Memory key of one sensitive answer of a list entry, since sensitive answers never reach the draft */ +function getListItemSensitiveKey(listKey: string, itemID: string, fieldKey: string): string { + return [listKey, itemID, fieldKey].join(PAGE_NAME_SEPARATOR); +} + +function isListItem(value: unknown): value is DynamicFormListItem { + return typeof value === 'object' && value !== null && 'id' in value && typeof value.id === 'string'; +} + +/** The entries stored under a list field's key, ignoring anything malformed */ +function getListItems(value: unknown): DynamicFormListItem[] { + if (!Array.isArray(value)) { + return []; + } + const items: unknown[] = value; + return items.filter(isListItem); +} + +/** An entry's sensitive answers, keyed by field key, from the answers kept in memory */ +function getListItemSensitiveAnswers(field: DynamicFormListField, itemID: string, values: DynamicFormValues): DynamicFormValues { + return Object.fromEntries( + field.itemFields + .filter(hasSensitiveAnswer) + .flatMap(getAnswerKeys) + .flatMap((answerKey) => { + const answer = values[getListItemSensitiveKey(field.key, itemID, answerKey)]; + return answer === undefined ? [] : [[answerKey, answer]]; + }), + ); +} + +/** Memory keys of sensitive answers whose entry the user has removed from the list */ +function getRemovedListItemSensitiveKeys(field: DynamicFormListField, values: DynamicFormValues): string[] { + const sensitiveKeys = field.itemFields.filter(hasSensitiveAnswer).flatMap(getAnswerKeys); + const keptKeys = new Set(getListItems(values[field.key]).flatMap((item) => sensitiveKeys.map((answerKey) => getListItemSensitiveKey(field.key, item.id, answerKey)))); + return Object.keys(values).filter((key) => key.startsWith(`${field.key}${PAGE_NAME_SEPARATOR}`) && !keptKeys.has(key)); +} + +export {getListItemPageName, getListItemSensitiveAnswers, getListItemSensitiveKey, getListItems, getRemovedListItemSensitiveKeys, isListItem, parseListItemPageName}; diff --git a/src/components/DynamicForm/utils/maskSensitiveValue.ts b/src/components/DynamicForm/utils/maskSensitiveValue.ts new file mode 100644 index 000000000000..d760451921e9 --- /dev/null +++ b/src/components/DynamicForm/utils/maskSensitiveValue.ts @@ -0,0 +1,12 @@ +import {isValidSSNFullNine} from '@libs/ValidationUtils'; + +const VISIBLE_CHARACTERS = 4; + +/** All but the last four characters hidden, as in •••-••-6789, so the user can tell which number they entered. A short value shows at most half of itself. */ +function maskSensitiveValue(value: string): string { + const visibleCharacters = Math.min(VISIBLE_CHARACTERS, Math.floor(value.length / 2)); + const masked = '•'.repeat(value.length - visibleCharacters) + value.slice(value.length - visibleCharacters); + return isValidSSNFullNine(value) ? `${masked.slice(0, 3)}-${masked.slice(3, 5)}-${masked.slice(5)}` : masked; +} + +export default maskSensitiveValue; diff --git a/src/components/DynamicForm/utils/paginate.ts b/src/components/DynamicForm/utils/paginate.ts new file mode 100644 index 000000000000..986f0d565ad3 --- /dev/null +++ b/src/components/DynamicForm/utils/paginate.ts @@ -0,0 +1,90 @@ +import CONST from '@src/CONST'; +import type {DynamicFormField, DynamicFormPage, DynamicFormSchema, DynamicFormSchemaField} from '@src/types/onyx'; + +import getContentFields from './getContentFields'; +import isSupportedField from './isSupportedField'; + +const OWN_PAGE_TYPES = new Set(['address', 'file', 'multiselect', 'countryMultiselect', 'list']); + +function needsOwnPage(field: DynamicFormField): boolean { + if (OWN_PAGE_TYPES.has(field.type)) { + return true; + } + if (field.type !== 'select') { + return false; + } + return !!field.dependsOn || (field.values?.length ?? 0) > CONST.DYNAMIC_FORM.LARGE_SELECT_MIN_OPTIONS; +} + +function toSlug(key: string): string { + return key + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/^-+|-+$/g, ''); +} + +/** + * Groups a flat list of fields into pages. Fields that need room (addresses, files, multi-selects, lists, dependent or large selects) get a page of their own, + * and the rest share a page up to `MAX_FIELDS_PER_PAGE` in a row. A field with `showWhen` joins the page of its controller and does not count toward that limit. + * The pages have no title and no key, `getPages` names them. + */ +function paginate(fields: DynamicFormSchemaField[]): DynamicFormPage[] { + const pages: DynamicFormPage[] = []; + const pageOfKey = new Map(); + let sharedPage: DynamicFormPage | undefined; + let sharedQuestionCount = 0; + + for (const field of fields.filter(isSupportedField)) { + const controllerPage = field.showWhen ? pageOfKey.get(field.showWhen.key) : undefined; + if (controllerPage) { + controllerPage.content.push(field); + pageOfKey.set(field.key, controllerPage); + continue; + } + + if (needsOwnPage(field)) { + const ownPage: DynamicFormPage = {key: '', content: [field]}; + pages.push(ownPage); + pageOfKey.set(field.key, ownPage); + sharedPage = undefined; + continue; + } + + if (!sharedPage || sharedQuestionCount >= CONST.DYNAMIC_FORM.MAX_FIELDS_PER_PAGE) { + sharedPage = {key: '', content: []}; + sharedQuestionCount = 0; + pages.push(sharedPage); + } + sharedPage.content.push(field); + sharedQuestionCount++; + pageOfKey.set(field.key, sharedPage); + } + return pages; +} + +/** Every page's key becomes a URL-safe route segment that is unique and never the confirmation page's. Pages with no usable key get `page-N`. */ +function withRouteKeys(pages: DynamicFormPage[]): DynamicFormPage[] { + const takenKeys = new Set([CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY]); + return pages.map((page, index) => { + const baseKey = toSlug(page.key) || `page-${index + 1}`; + let key = baseKey; + for (let suffix = 2; takenKeys.has(key); suffix++) { + key = `${baseKey}-${suffix}`; + } + takenKeys.add(key); + return {...page, key}; + }); +} + +/** The pages of a schema, as the server grouped them or as `paginate` builds them */ +function getPages(schema: DynamicFormSchema): DynamicFormPage[] { + return withRouteKeys('pages' in schema ? schema.pages : paginate(schema.fields)); +} + +/** Every field of every page, which is where a `showWhen` finds its controller */ +function getAllFields(pages: DynamicFormPage[]): DynamicFormField[] { + return pages.flatMap((page) => getContentFields(page.content).filter(isSupportedField)); +} + +export default paginate; +export {getAllFields, getPages}; diff --git a/src/components/DynamicForm/utils/summarizeListItem.ts b/src/components/DynamicForm/utils/summarizeListItem.ts new file mode 100644 index 000000000000..133bb472f999 --- /dev/null +++ b/src/components/DynamicForm/utils/summarizeListItem.ts @@ -0,0 +1,45 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; + +import type {DynamicFormField, DynamicFormListField, DynamicFormListItem} from '@src/types/onyx'; + +import formatDynamicFieldValue from './formatDynamicFieldValue'; +import getLocalizedText, {getFieldLabel} from './getLocalizedText'; +import getVisibleFields from './getVisibleFields'; +import isSensitiveField from './isSensitiveField'; + +const DESCRIPTION_ANSWER_LIMIT = 2; + +/** Long answers, such as a full address, would crowd the row, so they are left out of its description */ +const TYPES_LEFT_OUT_OF_DESCRIPTION = new Set(['date', 'address', 'country', 'file']); + +/** The noun for one entry of the list, or the list's label, which names an entry whose answers give it no name */ +function getListItemFallbackTitle(field: DynamicFormListField, translate: LocalizedTranslate): string { + return getLocalizedText(translate, field.itemLabelKey, field.itemLabel) ?? getFieldLabel(field, translate); +} + +/** The leading run of text answers names the row, as first and last name do. Up to two short remaining answers describe it. Hidden answers are left out, and an entry with no shown answer is named by `fallbackTitle`. */ +function summarizeListItem(item: DynamicFormListItem, itemFields: DynamicFormField[], translate: LocalizedTranslate, fallbackTitle: string): {title: string; description: string} { + const shownFields = getVisibleFields(itemFields, item).filter((field) => !isSensitiveField(field) && formatDynamicFieldValue(field, item, translate) !== ''); + const firstTextIndex = shownFields.findIndex((field) => field.type === 'text'); + const titleFields: DynamicFormField[] = []; + for (const field of shownFields.slice(Math.max(firstTextIndex, 0))) { + if (field.type !== 'text') { + break; + } + titleFields.push(field); + } + const firstShownField = shownFields.at(0); + if (titleFields.length === 0 && firstShownField) { + titleFields.push(firstShownField); + } + const title = titleFields.map((field) => formatDynamicFieldValue(field, item, translate)).join(' ') || fallbackTitle; + const description = shownFields + .filter((field) => !titleFields.includes(field) && !TYPES_LEFT_OUT_OF_DESCRIPTION.has(field.type)) + .slice(0, DESCRIPTION_ANSWER_LIMIT) + .map((field) => formatDynamicFieldValue(field, item, translate)) + .join(', '); + return {title, description}; +} + +export default summarizeListItem; +export {getListItemFallbackTitle}; diff --git a/src/components/DynamicForm/utils/toDynamicFormValues.ts b/src/components/DynamicForm/utils/toDynamicFormValues.ts index 2d25c264db05..fa24cb81af08 100644 --- a/src/components/DynamicForm/utils/toDynamicFormValues.ts +++ b/src/components/DynamicForm/utils/toDynamicFormValues.ts @@ -1,6 +1,8 @@ import type {DynamicFormValues} from '@components/DynamicForm/types'; import type {FormValue} from '@components/Form/types'; +import {isListItem} from './listItems'; + function isFormValue(value: unknown): value is FormValue { if (typeof value === 'string' || typeof value === 'boolean' || value instanceof Date) { return true; @@ -12,7 +14,8 @@ function isFormValue(value: unknown): value is FormValue { return ( items.every((item) => typeof item === 'string') || items.every((item) => typeof item === 'boolean') || - items.every((item) => typeof item === 'object' && item !== null && ('name' in item || 'uri' in item)) + items.every((item) => typeof item === 'object' && item !== null && ('name' in item || 'uri' in item)) || + items.every(isListItem) ); } diff --git a/src/components/EditableCell/usePopoverEditState.ts b/src/components/EditableCell/usePopoverEditState.ts index 3d5d5e433703..c34de688592d 100644 --- a/src/components/EditableCell/usePopoverEditState.ts +++ b/src/components/EditableCell/usePopoverEditState.ts @@ -1,6 +1,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import CONST from '@src/CONST'; +import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import type {ComponentRef} from 'react'; import type {View} from 'react-native'; @@ -28,7 +29,7 @@ type UsePopoverEditStateOptions = { /** Custom equality function. If not provided, Object.is is used. */ isEqual?: (newValue: unknown, originalValue: unknown) => boolean; - /** Height of the popover content (used for overflow detection). Defaults to CONST.POPOVER_DATE_MAX_HEIGHT */ + /** Preferred popover height. Shrunk when the chosen side has less room. */ popoverHeight?: number; /** Padding between the anchor and the popover */ @@ -49,7 +50,7 @@ function usePopoverEditStateImpl({ value, onSave, isEqual, - popoverHeight = CONST.POPOVER_DROPDOWN_MAX_HEIGHT, + popoverHeight: preferredPopoverHeight = CONST.POPOVER_DROPDOWN_MAX_HEIGHT, padding = CONST.MODAL.POPOVER_MENU_PADDING, anchorEdge = CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, }: UsePopoverEditStateOptions) { @@ -58,15 +59,23 @@ function usePopoverEditStateImpl({ const [isEditing, setIsEditing] = useState(false); const [isPopoverVisible, setIsPopoverVisible] = useState(false); const [popoverPosition, setPopoverPosition] = useState({horizontal: 0, vertical: 0}); - const [isInverted, setIsInverted] = useState(false); + const [shouldOpenAbove, setShouldOpenAbove] = useState(false); + const [popoverHeight, setPopoverHeight] = useState(preferredPopoverHeight); const openPopover = () => { anchorRef.current?.measureInWindow((x, y, width, height) => { - const wouldExceedBottom = y + popoverHeight + padding > windowHeight; - setIsInverted(wouldExceedBottom); + // Reserve `padding` next to the anchor and next to the window edge. + const spaceBelow = windowHeight - (y + height + padding) - padding; + const spaceAbove = y - padding - padding; + + const shouldOpenPopoverAbove = spaceBelow < preferredPopoverHeight && spaceAbove > spaceBelow; + const availableSpace = shouldOpenPopoverAbove ? spaceAbove : spaceBelow; + + setShouldOpenAbove(shouldOpenPopoverAbove); + setPopoverHeight(Math.min(preferredPopoverHeight, Math.max(availableSpace, CONST.POPOVER_DROPDOWN_MIN_USABLE_HEIGHT))); setPopoverPosition({ horizontal: anchorEdge === CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT ? x : x + width, - vertical: y + (wouldExceedBottom ? 0 : height + padding), + vertical: shouldOpenPopoverAbove ? y - padding : y + height + padding, }); setIsPopoverVisible(true); }); @@ -103,12 +112,19 @@ function usePopoverEditStateImpl({ }); }, [canEdit, isEditing]); + const anchorAlignment: AnchorAlignment = { + horizontal: anchorEdge, + vertical: shouldOpenAbove ? CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM : CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, + }; + return { isEditing, anchorRef, isPopoverVisible, popoverPosition, - isInverted, + popoverHeight, + anchorAlignment, + shouldOpenAbove, startEditing, cancelEditing, handleSave, @@ -131,8 +147,10 @@ type UsePopoverEditStateOptionsGeneric = { * Handles: * - Anchor ref for popover positioning * - measureInWindow-based position calculation - * - Overflow detection (inverts when too close to bottom) + * - Side selection (opens above the anchor when the popover can't fit below it) * - Adaptive height calculation (shrinks popover when space is limited) + * + * Callers should pass `shouldSwitchPositionIfOverflow={false}` to the popover, since flipping the chosen side would cover the anchor. * - Auto-open after layout via requestAnimationFrame * - isEditing + isPopoverVisible toggling * - Auto-cancel when canEdit becomes false diff --git a/src/components/EmojiPicker/CategoryShortcutButton.tsx b/src/components/EmojiPicker/CategoryShortcutButton.tsx index ea285eb09a8c..44790c1606cb 100644 --- a/src/components/EmojiPicker/CategoryShortcutButton.tsx +++ b/src/components/EmojiPicker/CategoryShortcutButton.tsx @@ -8,6 +8,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getButtonState from '@libs/getButtonState'; +import Log from '@libs/Log'; import variables from '@styles/variables'; @@ -17,6 +18,19 @@ import type IconAsset from '@src/types/utils/IconAsset'; import React, {useState} from 'react'; +const CATEGORY_TRANSLATION_KEYS: ReadonlyMap = new Map([ + ['frequentlyUsed', 'emojiPicker.headers.frequentlyUsed'], + ['smileysAndEmotion', 'emojiPicker.headers.smileysAndEmotion'], + ['animalsAndNature', 'emojiPicker.headers.animalsAndNature'], + ['foodAndDrink', 'emojiPicker.headers.foodAndDrink'], + ['travelAndPlaces', 'emojiPicker.headers.travelAndPlaces'], + ['activities', 'emojiPicker.headers.activities'], + ['objects', 'emojiPicker.headers.objects'], + ['symbols', 'emojiPicker.headers.symbols'], + ['flags', 'emojiPicker.headers.flags'], +]); +const FALLBACK_CATEGORY_TRANSLATION_KEY: TranslationPaths = 'emojiPicker.headers.frequentlyUsed'; + type CategoryShortcutButtonProps = { /** The emoji code of the category header */ code: string; @@ -37,10 +51,15 @@ function CategoryShortcutButton({code, icon, onPress, isSelected = false}: Categ const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const [isHighlighted, setIsHighlighted] = useState(false); + const translationKey = CATEGORY_TRANSLATION_KEYS.get(code); + if (!translationKey) { + Log.alert(`No emoji category translation mapped for code: ${code}`); + } + const label = translate(translationKey ?? FALLBACK_CATEGORY_TRANSLATION_KEY); return ( (); const emojiPopoverAnchorRef = useRef(null); + const emptyEmojiPopoverAnchor = useRef(null); const emojiAnchorDimension = useRef({ width: 0, height: 0, @@ -78,7 +79,7 @@ function EmojiPicker({viewportOffsetTop, ref}: EmojiPickerProps) { * * Don't directly get the ref from emojiPopoverAnchorRef, instead use getEmojiPopoverAnchor() */ - const getEmojiPopoverAnchor = useCallback(() => emojiPopoverAnchorRef.current ?? (emojiPopoverAnchorRef as EmojiPopoverAnchor), []); + const getEmojiPopoverAnchor = useCallback(() => emojiPopoverAnchorRef.current ?? emptyEmojiPopoverAnchor, []); /** * Show the emoji picker menu. @@ -266,7 +267,7 @@ function EmojiPicker({viewportOffsetTop, ref}: EmojiPickerProps) { vertical: emojiPopoverAnchorPosition.vertical, horizontal: emojiPopoverAnchorPosition.horizontal, }} - anchorRef={getEmojiPopoverAnchor() as RefObject | HTMLDivElement>} + anchorRef={getEmojiPopoverAnchor()} withoutOverlay={isWithoutOverlay} popoverDimensions={{ width: CONST.EMOJI_PICKER_SIZE.WIDTH, diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx index 75a683bdc258..a6e3a9c9f380 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx @@ -17,7 +17,6 @@ import type {EmojiPickerList, EmojiPickerListItem} from '@libs/EmojiUtils'; import {getRemovedSkinToneEmoji} from '@libs/EmojiUtils'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import type {ListRenderItem} from '@shopify/flash-list'; import type {ComponentRef} from 'react'; @@ -161,11 +160,11 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro ref={getHeaderRef(index)} accessible accessibilityRole="header" - accessibilityLabel={translate(`emojiPicker.headers.${code}` as TranslationPaths)} + accessibilityLabel={translate(`emojiPicker.headers.${item.code}`)} style={[styles.emojiHeaderContainer, target === 'StickyHeader' ? styles.mh4 : {width: windowWidth}]} onLayout={() => handleHeaderLayout(index)} > - {translate(`emojiPicker.headers.${code}` as TranslationPaths)} + {translate(`emojiPicker.headers.${item.code}`)} ); } diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx index 813b5e35cdbb..aaf716e5d6df 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx @@ -22,7 +22,6 @@ import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposit import {shouldAutoFocusOnKeyPress} from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import type {ListRenderItem} from '@shopify/flash-list'; import type {ComponentRef} from 'react'; @@ -84,7 +83,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro } pendingHeaderFocusIndexRef.current = null; - (headerRef.current as unknown as HTMLElement).focus?.(); + headerRef.current.focus(); return true; }, []); @@ -343,7 +342,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro target === 'StickyHeader' ? styles.stickyHeaderEmoji : undefined, ]} > - {translate(`emojiPicker.headers.${code}` as TranslationPaths)} + {translate(`emojiPicker.headers.${item.code}`)} ); } diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal.tsx index abf6024e355b..223c1ba3e505 100644 --- a/src/components/FeatureTrainingModal.tsx +++ b/src/components/FeatureTrainingModal.tsx @@ -36,6 +36,9 @@ type FeatureTrainingModalProps = { modalInnerContainerStyle?: ViewStyle; + /** Whether the modal should close when the user navigates back */ + shouldHandleNavigationBack?: boolean; + /** Composed feature training content */ children?: ReactNode; }; @@ -47,6 +50,7 @@ function FeatureTrainingModal({ shouldCloseOnConfirm = true, shouldUseScrollView: shouldUseScrollViewProp = false, width, + shouldHandleNavigationBack, children, }: FeatureTrainingModalProps) { const styles = useThemeStyles(); @@ -96,6 +100,7 @@ function FeatureTrainingModal({ shouldTreatModalAsCovering type={onboardingIsMediumOrLargerScreenWidth ? CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE : CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED} onClose={closeModal} + shouldHandleNavigationBack={shouldHandleNavigationBack} innerContainerStyle={{ boxShadow: 'none', ...(shouldUseScrollView ? styles.pb0 : styles.pb5), @@ -131,3 +136,4 @@ function FeatureTrainingModal({ } export default FeatureTrainingModal; +export type {FeatureTrainingModalProps}; diff --git a/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts b/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts index fe33ad258b96..149c7ce80515 100644 --- a/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts +++ b/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts @@ -1,7 +1,7 @@ import type {FocusTrapProps} from 'focus-trap-react'; import type {ComponentRef, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports -- type-only: the launcher union must cover every anchor shape popovers pass, including RN Text anchors -import type {Text, View} from 'react-native'; +import type {Text, TextInput, View} from 'react-native'; type FocusTrapOptions = Exclude; @@ -18,10 +18,10 @@ type FocusTrapForModalProps = { * "+") leave nothing to infer the launcher from, so both the dismiss-time focus return and the nav-back * restore have no target. Pass the same ref the popover already uses to position itself. * - * Deliberately covers every anchor shape in use (`View`, `Text`, DOM element): the trap narrows it with + * Deliberately covers every anchor shape in use (`View`, `Text`, `TextInput`, DOM element): the trap narrows it with * `instanceof HTMLElement` anyway, so a ref it cannot use is simply ignored rather than rejected. */ - launcherRef?: RefObject | ComponentRef | HTMLElement | null>; + launcherRef?: RefObject | ComponentRef | ComponentRef | HTMLElement | null>; }; export default FocusTrapForModalProps; diff --git a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts index a080dbe0b495..f57fd2550fc5 100644 --- a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts +++ b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts @@ -25,6 +25,7 @@ const WIDE_LAYOUT_INACTIVE_SCREENS: string[] = [ SCREENS.WORKSPACE.WORKFLOWS, SCREENS.WORKSPACE.INVOICES, SCREENS.WORKSPACE.MEMBERS, + SCREENS.WORKSPACE.AGENTS, SCREENS.WORKSPACE.HR, SCREENS.WORKSPACE.RECRUITING, SCREENS.WORKSPACE.CATEGORIES, diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 1ea8c40129dd..09aa12866c1d 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -58,6 +58,7 @@ function getInitialValueByType(valueType?: ValueTypeKey): InitialDefaultValue { return new Date(); case 'stringList': case 'files': + case 'listItems': return []; default: return ''; diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 2d79dc256398..9f8687cdfdc3 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -11,6 +11,7 @@ import type DatePicker from '@components/DatePicker'; import type CurrencyInlineListAdapter from '@components/DynamicForm/adapters/CurrencyInlineListAdapter'; import type FileUploadAdapter from '@components/DynamicForm/adapters/FileUploadAdapter'; import type InlineSelectionListAdapter from '@components/DynamicForm/adapters/InlineSelectionListAdapter'; +import type ListFieldAdapter from '@components/DynamicForm/adapters/ListFieldAdapter'; import type TabsAdapter from '@components/DynamicForm/adapters/TabsAdapter'; import type YesNoAdapter from '@components/DynamicForm/adapters/YesNoAdapter'; import type EmojiPickerButtonDropdown from '@components/EmojiPicker/EmojiPickerButtonDropdown'; @@ -48,6 +49,7 @@ import type {Country} from '@src/CONST'; import type {OnyxFormKey, OnyxValues} from '@src/ONYXKEYS'; import type {Form} from '@src/types/form'; import type {BaseForm} from '@src/types/form/Form'; +import type {DynamicFormListItem} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; @@ -98,9 +100,10 @@ type ValidInputs = | typeof InlineSelectionListAdapter | typeof CurrencyInlineListAdapter | typeof YesNoAdapter - | typeof TabsAdapter; + | typeof TabsAdapter + | typeof ListFieldAdapter; -type ValueTypeKey = 'string' | 'boolean' | 'date' | 'country' | 'stringList' | 'disabledListValues' | 'files'; +type ValueTypeKey = 'string' | 'boolean' | 'date' | 'country' | 'stringList' | 'disabledListValues' | 'files' | 'listItems'; type ValueTypeMap = { string: string; boolean: boolean; @@ -109,6 +112,7 @@ type ValueTypeMap = { stringList: string[]; disabledListValues: boolean[]; files: FileObject[]; + listItems: DynamicFormListItem[]; }; type FormValue = ValueOf; diff --git a/src/components/FormHelpMessage.tsx b/src/components/FormHelpMessage.tsx index 4e737bf5ab04..35428a985e3e 100644 --- a/src/components/FormHelpMessage.tsx +++ b/src/components/FormHelpMessage.tsx @@ -8,7 +8,7 @@ import Parser from '@libs/Parser'; import CONST from '@src/CONST'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {StyleProp, TextStyle, ViewProps, ViewStyle} from 'react-native'; import isEmpty from 'lodash/isEmpty'; import React, {useContext, useMemo} from 'react'; @@ -32,6 +32,13 @@ type FormHelpMessageProps = { /** Container style props */ style?: StyleProp; + /** Message text style props */ + messageStyle?: StyleProp; + + /** Data attributes applied to the message container. */ + dataSet?: ViewProps['dataSet']; + + /** Whether to show dot indicator */ shouldShowRedDotIndicator?: boolean; /** Whether should render error text as HTML or as Text */ @@ -54,6 +61,8 @@ function FormHelpMessage({ children, isError = true, style, + messageStyle, + dataSet, shouldShowRedDotIndicator = true, shouldRenderMessageAsHTML = false, isInfo = false, @@ -116,7 +125,10 @@ function FormHelpMessage({ } return ( - + {isError && shouldShowRedDotIndicator && ( ) : ( void; @@ -49,7 +52,7 @@ type GrowlIconTypes = Record< } >; -function GrowlNotificationContent({bodyText, type, duration, action, nonce, onDismissed}: GrowlNotificationContentProps) { +function GrowlNotificationContent({bodyText, type, duration, action, position, nonce, onDismissed}: GrowlNotificationContentProps) { // Normalized: 0 = fully offscreen for the current anchor, 1 = fully visible. The container // multiplies this against the live `inactiveY`, so the offscreen position stays correct // even when the responsive layout flips after the growl is dismissed. @@ -64,7 +67,8 @@ function GrowlNotificationContent({bodyText, type, duration, action, nonce, onDi const icons = useMemoizedLazyExpensifyIcons(['Exclamation', 'Checkmark']); // Derived live so that resizing the window flips position + slide direction during display. - const useBottomPosition = !!action && !shouldUseNarrowLayout; + // Narrow layouts stay at the top. The bottom-right anchor is a toast the width of the sidebar on wide screens. + const useBottomPosition = !shouldUseNarrowLayout && (!!action || position === CONST.GROWL.POSITION.BOTTOM_RIGHT); const inactiveY = useBottomPosition ? INACTIVE_OFFSET : INACTIVE_POSITION_Y; const types: GrowlIconTypes = { diff --git a/src/components/GrowlNotification/index.tsx b/src/components/GrowlNotification/index.tsx index aea3afdf456a..e0f0cf40163e 100644 --- a/src/components/GrowlNotification/index.tsx +++ b/src/components/GrowlNotification/index.tsx @@ -1,5 +1,5 @@ import {setIsReady} from '@libs/Growl'; -import type {GrowlAction, GrowlRef, GrowlType} from '@libs/Growl'; +import type {GrowlAction, GrowlPosition, GrowlRef, GrowlType} from '@libs/Growl'; import type {ForwardedRef} from 'react'; @@ -12,6 +12,7 @@ type GrowlContent = { type: GrowlType; duration: number; action?: GrowlAction; + position?: GrowlPosition; /** Bumped on every show() call so identical-args re-shows still remount and re-trigger the growl. */ nonce: number; @@ -29,8 +30,8 @@ type GrowlNotificationProps = { function GrowlNotification({ref}: GrowlNotificationProps) { const [content, setContent] = useState(null); - const show = (text: string, growlType: GrowlType, duration: number, action?: GrowlAction) => { - setContent((prev) => ({bodyText: text, type: growlType, duration, action, nonce: (prev?.nonce ?? 0) + 1})); + const show = (text: string, growlType: GrowlType, duration: number, action?: GrowlAction, position?: GrowlPosition) => { + setContent((prev) => ({bodyText: text, type: growlType, duration, action, position, nonce: (prev?.nonce ?? 0) + 1})); }; useImperativeHandle(ref, () => ({show}), [show]); @@ -56,6 +57,7 @@ function GrowlNotification({ref}: GrowlNotificationProps) { type={content.type} duration={content.duration} action={content.action} + position={content.position} nonce={content.nonce} onDismissed={handleDismissed} /> diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer.tsx index c404b6ac6a0c..8d555dcf9778 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer.tsx @@ -15,7 +15,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {StyleProp, TextStyle} from 'react-native'; import type {CustomRendererProps, RenderersProps, TPhrasing, TText} from 'react-native-render-html'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React from 'react'; import {TNodeChildrenRenderer, useRendererProps} from 'react-native-render-html'; @@ -35,7 +35,9 @@ function ConciergeLinkRenderer({tnode, style}: ConciergeLinkRendererProps) { const styles = useThemeStyles(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {onPress: onPressFromProps} = useRendererProps('concierge-link') ?? {}; @@ -45,7 +47,15 @@ function ConciergeLinkRenderer({tnode, style}: ConciergeLinkRendererProps) { */ const navigateToConciergeChat = () => { onPressFromProps?.(); - navigateToConciergeChatAction({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); + navigateToConciergeChatAction({ + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + shouldDismissModal: false, + }); }; // Define link style based on context diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/HTMLTableScroll/index.native.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/HTMLTableScroll/index.native.tsx index 08d056c991f9..226098c15d55 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/HTMLTableScroll/index.native.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/HTMLTableScroll/index.native.tsx @@ -1,8 +1,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; -import {useMemo} from 'react'; import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {GestureDetector, usePanGesture} from 'react-native-gesture-handler'; import Animated, {cancelAnimation, useAnimatedStyle, useSharedValue, withDecay} from 'react-native-reanimated'; import type HTMLTableScrollProps from './types'; @@ -18,22 +17,19 @@ function HTMLTableScroll({viewportWidth, contentWidth, children}: HTMLTableScrol const maxScroll = Math.max(0, contentWidth - viewportWidth); const translateX = useSharedValue(0); - const panGesture = useMemo( - () => - Gesture.Pan() - .activeOffsetX([-10, 10]) - .failOffsetY([-10, 10]) - .onBegin(() => { - cancelAnimation(translateX); - }) - .onChange((event) => { - translateX.set(Math.min(0, Math.max(-maxScroll, translateX.get() + event.changeX))); - }) - .onEnd((event) => { - translateX.set(withDecay({velocity: event.velocityX, clamp: [-maxScroll, 0]})); - }), - [maxScroll, translateX], - ); + const panGesture = usePanGesture({ + activeOffsetX: [-10, 10], + failOffsetY: [-10, 10], + onBegin: () => { + cancelAnimation(translateX); + }, + onUpdate: (event) => { + translateX.set(Math.min(0, Math.max(-maxScroll, translateX.get() + event.changeX))); + }, + onDeactivate: (event) => { + translateX.set(withDecay({velocity: event.velocityX, clamp: [-maxScroll, 0]})); + }, + }); const animatedStyle = useAnimatedStyle(() => ({transform: [{translateX: translateX.get()}]})); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index b0eab6765a9b..fa0fefe5b04d 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -54,6 +54,7 @@ function ImageRenderer({tnode}: CustomRendererProps) { const attachmentSourceAttribute = htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] ?? (new RegExp(CONST.ATTACHMENT_OR_RECEIPT_LOCAL_URL, 'i').test(htmlAttribs.src) ? htmlAttribs.src : null); const isAttachmentOrReceipt = !!attachmentSourceAttribute; + const isUploadedAttachment = !!htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]; const attachmentID = htmlAttribs[CONST.ATTACHMENT_ID_ATTRIBUTE]; // Files created/uploaded/hosted by App should resolve from API ROOT. Other URLs aren't modified @@ -83,6 +84,9 @@ function ImageRenderer({tnode}: CustomRendererProps) { const thumbnailImageComponent = ( ) => void; }; @@ -39,7 +35,7 @@ type VictoryChartCartesianProps = { * Renders the CartesianChart with data, axes, and domain config drawn from context. * Labels and legend overlays are handled internally via `renderOutside`. */ -function VictoryChartCartesian({explicitSize, headless, shouldUseStaticCanvas, onRenderArgs}: VictoryChartCartesianProps) { +function VictoryChartCartesian({explicitSize, headless, onRenderArgs}: VictoryChartCartesianProps) { const {tnode, data, xKey, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, labelItems, legendItems, chartContentStyles, pixelScale} = useVictoryChartContext(); const theme = useTheme(); const timezone = useCurrentTimezone(); @@ -69,7 +65,6 @@ function VictoryChartCartesian({explicitSize, headless, shouldUseStaticCanvas, o domainPadding={domainPadding} padding={padding} {...getChartLayoutModeProps(explicitSize, headless)} - canvasProps={shouldUseStaticCanvas ? getStaticChartCanvasProps() : undefined} renderOutside={(renderArgs) => { const overlayContent = ( diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx index 808d07b7621d..1ad119aacffe 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx @@ -3,6 +3,7 @@ * and tap-to-navigate behaviour for bar chart series. */ import ChartTooltip from '@components/Charts/components/ChartTooltip'; +import ChartTooltipSummary from '@components/Charts/components/ChartTooltipSummary'; import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import {useVictoryChartLayoutScale} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartLayoutContext'; import useVictoryBarInteractions from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions'; @@ -55,13 +56,16 @@ function VictoryChartCartesianInteractive() { {!!activeTooltipData && hasTooltipLabels && chartWidth > 0 && ( + > + + )} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx index fbd751310a73..21904ab9e5ea 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx @@ -9,12 +9,9 @@ import VictoryChartPolar from './VictoryChartPolar'; type VictoryChartContentProps = { explicitSize?: {width: number; height: number}; headless?: boolean; - - /** Render into a static bitmap canvas instead of a live WebGL canvas (web) */ - shouldUseStaticCanvas?: boolean; }; -function VictoryChartContent({explicitSize, headless, shouldUseStaticCanvas}: VictoryChartContentProps) { +function VictoryChartContent({explicitSize, headless}: VictoryChartContentProps) { const {type} = useVictoryChartContext(); switch (type) { case CHART_TYPE.CARTESIAN: @@ -22,7 +19,6 @@ function VictoryChartContent({explicitSize, headless, shouldUseStaticCanvas}: Vi ); case CHART_TYPE.POLAR: @@ -30,7 +26,6 @@ function VictoryChartContent({explicitSize, headless, shouldUseStaticCanvas}: Vi ); default: diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx index a104930fc5a0..10db813a50e9 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx @@ -103,7 +103,7 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr styles.overflowHidden, ]} > - + ))} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx index e17cbf07a196..5f4450d414f1 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx @@ -53,7 +53,6 @@ function ExpandedChartBox({width, height, clippedHeight, providerScale, backgrou diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx index 1e6c3891c7b8..c14a048ec76e 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx @@ -4,7 +4,6 @@ import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRendere import getChartDesignWidth from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartDesignWidth'; import getChartLayoutModeProps from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartLayoutModeProps'; import getHierarchyID from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getHierarchyID'; -import getStaticChartCanvasProps from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps'; import useCurrentTimezone from '@hooks/useCurrentTimezone'; import useTheme from '@hooks/useTheme'; @@ -21,15 +20,12 @@ import VictoryChartLegend from './VictoryChartLegend'; type VictoryChartPolarProps = { explicitSize?: {width: number; height: number}; headless?: boolean; - - /** Render into a static bitmap canvas instead of a live WebGL canvas (web) */ - shouldUseStaticCanvas?: boolean; }; /** * Renders the PolarChart with data drawn from context. */ -function VictoryChartPolar({explicitSize, headless, shouldUseStaticCanvas}: VictoryChartPolarProps) { +function VictoryChartPolar({explicitSize, headless}: VictoryChartPolarProps) { const {tnode, data, labelItems, legendItems, chartContentStyles} = useVictoryChartContext(); const theme = useTheme(); const timezone = useCurrentTimezone(); @@ -67,7 +63,6 @@ function VictoryChartPolar({explicitSize, headless, shouldUseStaticCanvas}: Vict valueKey={VALUE_KEY} colorKey={COLOR_KEY} {...getChartLayoutModeProps(explicitSize, headless)} - canvasProps={shouldUseStaticCanvas ? getStaticChartCanvasProps() : undefined} > {headless ? ( {chartContent} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts index ef4ccb363cab..108142cbb336 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts @@ -226,6 +226,7 @@ function useVictoryBarInteractions() { chartBottom, yZero, coordinateScale, + shouldPinTooltipOnTouch: false, }); const [activeBarIndex, setActiveBarIndex] = useState(-1); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.native.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.native.ts deleted file mode 100644 index a06c1d23f515..000000000000 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.native.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type GetStaticChartCanvasProps from './types'; - -/** Native Skia views don't flash on re-composite, so the chart keeps its regular renderer. */ -const getStaticChartCanvasProps: GetStaticChartCanvasProps = () => undefined; - -export default getStaticChartCanvasProps; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.ts deleted file mode 100644 index 8b898def5617..000000000000 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type GetStaticChartCanvasProps from './types'; - -/** - * Web: renders the chart into a plain 2D canvas bitmap and releases the WebGL context right after - * drawing. A bitmap stays visible through the modal close animation (a live WebGL canvas flashes - * white when re-composited) and doesn't hold a GPU context per expanded chart. - */ -// eslint-disable-next-line @typescript-eslint/naming-convention -- prop name is defined by react-native-skia -const getStaticChartCanvasProps: GetStaticChartCanvasProps = () => ({__destroyWebGLContextAfterRender: true}); - -export default getStaticChartCanvasProps; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/types.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/types.ts deleted file mode 100644 index 4d13cf87d8f2..000000000000 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type {CanvasProps} from '@shopify/react-native-skia'; - -type StaticChartCanvasProps = Pick | undefined; - -type GetStaticChartCanvasProps = () => StaticChartCanvasProps; - -export default GetStaticChartCanvasProps; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx index 7c8713c71e5e..6066d93220f0 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx @@ -25,7 +25,10 @@ function VideoRenderer({tnode, key}: VideoRendererProps) { const sourceURL = tryResolveUrlFromApiRoot(attrHref); // Prefer the stored file name, like ImageRenderer does: deriving it from the URL yields the raw storage // key, which has no extension and so downloads as a file the OS treats as a generic document. - const fileName = htmlAttribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || getFileName(`${sourceURL}`); + // Server-rendered ); diff --git a/src/components/ImageWithLoading.tsx b/src/components/ImageWithLoading.tsx index 6e88b97139cf..5e775d60c587 100644 --- a/src/components/ImageWithLoading.tsx +++ b/src/components/ImageWithLoading.tsx @@ -1,3 +1,4 @@ +import useImageLoadStall from '@hooks/useImageLoadStall'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -11,6 +12,7 @@ import type {ImageObjectPosition, ImageOnLoadEvent, ImageProps} from './Image/ty import AttachmentOfflineIndicator from './AttachmentOfflineIndicator'; import Image from './Image'; +import ImageLoadTimeoutNotice from './ImageLoadTimeoutNotice'; import LoadingIndicator from './LoadingIndicator'; type ImageWithSizeLoadingProps = { @@ -27,6 +29,9 @@ type ImageWithSizeLoadingProps = { /** Low-resolution URI shown as a placeholder while the full image loads */ previewUri?: string; + + /** Whether the Retry notice may replace the spinner on stall; small thumbnails suppress it */ + shouldShowTimeoutNotice?: boolean; } & ImageProps; function ImageWithLoading({ @@ -41,15 +46,28 @@ function ImageWithLoading({ onLayout, style, previewUri, + shouldShowTimeoutNotice = true, ...rest }: ImageWithSizeLoadingProps) { const styles = useThemeStyles(); const isLoadedRef = useRef(null); const [isImageCached, setIsImageCached] = useState(true); const [isLoading, setIsLoading] = useState(false); + const [hasLoadTimedOut, setHasLoadTimedOut] = useState(false); + // On web the image keeps the onError it was first given, so handleError reads this ref, not the stale state. + const hasLoadTimedOutRef = useRef(false); + const updateLoadTimedOut = (value: boolean) => { + hasLoadTimedOutRef.current = value; + setHasLoadTimedOut(value); + }; + const [reloadKey, setReloadKey] = useState(0); const {isOffline} = useNetwork(); const handleError = () => { + // A stalled load that later errors is still a slow load, so keep the timeout notice instead of "not found". + if (hasLoadTimedOutRef.current && shouldShowTimeoutNotice) { + return; + } onError?.(); if (isLoadedRef.current) { isLoadedRef.current = false; @@ -61,10 +79,23 @@ function ImageWithLoading({ setIsLoading(false); }; + /** The transport went silent. Stop the spinner and offer a retry; the image and preview stay mounted in case the load still finishes. */ + const handleLoadTimeout = () => { + updateLoadTimedOut(true); + setIsLoading(false); + }; + + const retryLoad = () => { + updateLoadTimedOut(false); + setIsLoading(true); + setReloadKey((key) => key + 1); + }; + const imageLoadedSuccessfully = (e: ImageOnLoadEvent) => { isLoadedRef.current = true; setIsLoading(false); setIsImageCached(true); + updateLoadTimedOut(false); onLoad?.(e); }; @@ -82,12 +113,17 @@ function ImageWithLoading({ return () => clearTimeout(timeout); }, [isLoading]); + const shouldShowLoadingIndicator = (previewUri ? isLoading : isLoading && !isImageCached) && !isOffline; + + // !hasLoadTimedOut keeps a cached image unwatched and lets Retry re-arm a fresh window + const reportLoadActivity = useImageLoadStall(isLoading && !isOffline && !hasLoadTimedOut, handleLoadTimeout); + return ( - {isLoading && + {(isLoading || hasLoadTimedOut) && !!previewUri && ( // Preview is a placeholder; parent onLoad should fire only when the full image is ready. // eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. @@ -103,6 +139,7 @@ function ImageWithLoading({ {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. */} { @@ -111,6 +148,7 @@ function ImageWithLoading({ } setIsLoading(true); }} + onProgress={reportLoadActivity} onError={handleError} onLoad={(e) => { imageLoadedSuccessfully(e); @@ -126,12 +164,17 @@ function ImageWithLoading({ loadingIconSize={loadingIconSize} loadingIndicatorStyles={loadingIndicatorStyles} /> - {(previewUri ? isLoading : isLoading && !isImageCached) && !isOffline && ( + {shouldShowLoadingIndicator && ( )} + {hasLoadTimedOut && !isOffline && shouldShowTimeoutNotice && } {isLoading && shouldShowOfflineIndicator && !isImageCached && } ); diff --git a/src/components/ImageWithSizeCalculation.tsx b/src/components/ImageWithSizeCalculation.tsx index 6b8d7d97336d..f8da72316cb7 100644 --- a/src/components/ImageWithSizeCalculation.tsx +++ b/src/components/ImageWithSizeCalculation.tsx @@ -47,6 +47,9 @@ type ImageWithSizeCalculationProps = { /** Low-resolution URI shown as a placeholder while the full image loads */ previewUri?: string; + + /** Identity of the picture for the native image view, see BaseImageProps */ + recyclingKey?: string; }; /** @@ -68,6 +71,7 @@ function ImageWithSizeCalculation({ onLoad, resizeMode, previewUri, + recyclingKey, }: ImageWithSizeCalculationProps) { const styles = useThemeStyles(); @@ -79,6 +83,7 @@ function ImageWithSizeCalculation({ }; return ( + // Thumbnails are sized for the image alone, so the Retry notice would be clipped inside them. ); } diff --git a/src/components/ImportOnyxState/BaseImportOnyxState.tsx b/src/components/ImportOnyxState/BaseImportOnyxState.tsx index 8f09f7e6a25c..80c6a27b9460 100644 --- a/src/components/ImportOnyxState/BaseImportOnyxState.tsx +++ b/src/components/ImportOnyxState/BaseImportOnyxState.tsx @@ -1,68 +1,43 @@ import AttachmentPicker from '@components/AttachmentPicker'; -import DecisionModal from '@components/DecisionModal'; import MenuItem from '@components/MenuItem'; import MenuItemSectionRoot from '@components/MenuItem/presets/MenuItemSectionRoot'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import type {FileObject} from '@src/types/utils/Attachment'; import React from 'react'; -function BaseImportOnyxState({ - onFileRead, - isErrorModalVisible, - setIsErrorModalVisible, -}: { - onFileRead: (file: FileObject) => void; - isErrorModalVisible: boolean; - setIsErrorModalVisible: (value: boolean) => void; -}) { +function BaseImportOnyxState({onFileRead}: {onFileRead: (file: FileObject) => void}) { const icons = useMemoizedLazyExpensifyIcons(['Upload']); const {translate} = useLocalize(); - // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); - return ( - <> - - {({openPicker}) => { - return ( - { - openPicker({ - onPicked: (data) => onFileRead(data.at(0) ?? {}), - }); - }} - > - - - - {translate('initialSettingsPage.troubleshoot.importOnyxState')} - - - - ); - }} - - setIsErrorModalVisible(false)} - secondOptionText={translate('common.ok')} - isVisible={isErrorModalVisible} - onClose={() => setIsErrorModalVisible(false)} - /> - + + {({openPicker}) => { + return ( + { + openPicker({ + onPicked: (data) => onFileRead(data.at(0) ?? {}), + }); + }} + > + + + + {translate('initialSettingsPage.troubleshoot.importOnyxState')} + + + + ); + }} + ); } diff --git a/src/components/ImportOnyxState/index.native.tsx b/src/components/ImportOnyxState/index.native.tsx index f9ab8420a62c..3905ee956275 100644 --- a/src/components/ImportOnyxState/index.native.tsx +++ b/src/components/ImportOnyxState/index.native.tsx @@ -1,3 +1,5 @@ +import useDecisionModal from '@hooks/useDecisionModal'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {setIsUsingImportedState, setPreservedAccount, setPreservedUserSession} from '@libs/actions/App'; @@ -12,7 +14,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {FileObject} from '@src/types/utils/Attachment'; -import React, {useState} from 'react'; +import React from 'react'; import ReactNativeBlobUtil from 'react-native-blob-util'; import type ImportOnyxStateProps from './types'; @@ -31,7 +33,8 @@ function readOnyxFile(fileUri: string) { } export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { - const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const {translate} = useLocalize(); + const {showDecisionModal} = useDecisionModal(); const [session] = useOnyx(ONYXKEYS.SESSION); const [account] = useOnyx(ONYXKEYS.ACCOUNT); @@ -62,18 +65,16 @@ export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { }) .catch((error) => { console.error('Error importing state:', error); - setIsErrorModalVisible(true); + showDecisionModal({ + title: translate('initialSettingsPage.troubleshoot.invalidFile'), + prompt: translate('initialSettingsPage.troubleshoot.invalidFileDescription'), + secondOptionText: translate('common.ok'), + }); }) .finally(() => { setIsLoading(false); }); }; - return ( - - ); + return ; } diff --git a/src/components/ImportOnyxState/index.tsx b/src/components/ImportOnyxState/index.tsx index 6902e8c60c9b..f643a63366ec 100644 --- a/src/components/ImportOnyxState/index.tsx +++ b/src/components/ImportOnyxState/index.tsx @@ -1,3 +1,5 @@ +import useDecisionModal from '@hooks/useDecisionModal'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {setIsUsingImportedState, setPreservedAccount, setPreservedUserSession} from '@libs/actions/App'; @@ -11,14 +13,15 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {FileObject} from '@src/types/utils/Attachment'; -import React, {useState} from 'react'; +import React from 'react'; import type ImportOnyxStateProps from './types'; import BaseImportOnyxState from './BaseImportOnyxState'; export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { - const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const {translate} = useLocalize(); + const {showDecisionModal} = useDecisionModal(); const [session] = useOnyx(ONYXKEYS.SESSION); const [account] = useOnyx(ONYXKEYS.ACCOUNT); @@ -58,18 +61,16 @@ export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { }) .catch((error) => { console.error('Error importing state:', error); - setIsErrorModalVisible(true); + showDecisionModal({ + title: translate('initialSettingsPage.troubleshoot.invalidFile'), + prompt: translate('initialSettingsPage.troubleshoot.invalidFileDescription'), + secondOptionText: translate('common.ok'), + }); }) .finally(() => { setIsLoading(false); }); }; - return ( - - ); + return ; } diff --git a/src/components/ImportSpreadsheet.tsx b/src/components/ImportSpreadsheet.tsx index 0eadf12bd75a..3304bf6da096 100644 --- a/src/components/ImportSpreadsheet.tsx +++ b/src/components/ImportSpreadsheet.tsx @@ -33,6 +33,7 @@ import HeaderWithBackButton from './HeaderWithBackButton'; import ImageSVG from './ImageSVG'; import RenderHTML from './RenderHTML'; import ScreenWrapper from './ScreenWrapper'; +import ScrollView from './ScrollView'; import Text from './Text'; type ImportSpreadsheetProps = { @@ -47,6 +48,9 @@ type ImportSpreadsheetProps = { isImportingMultiLevelTags?: boolean; + /** Whether the imported tag lists are appended to the existing (accounting connection) tag lists */ + isAppendingToExistingLists?: boolean; + /** Whether an OFX/QFX bank statement can be picked alongside a spreadsheet */ shouldAllowBankStatements?: boolean; @@ -54,7 +58,15 @@ type ImportSpreadsheetProps = { onStatementPicked?: (file: FileObject) => Promise; }; -function ImportSpreadsheet({backTo, goTo, shouldForceReplaceNavigation = false, isImportingMultiLevelTags, shouldAllowBankStatements, onStatementPicked}: ImportSpreadsheetProps) { +function ImportSpreadsheet({ + backTo, + goTo, + shouldForceReplaceNavigation = false, + isImportingMultiLevelTags, + isAppendingToExistingLists, + shouldAllowBankStatements, + onStatementPicked, +}: ImportSpreadsheetProps) { const [importedSpreadsheet] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET); const icons = useMemoizedLazyExpensifyIcons(['SpreadsheetComputer']); const styles = useThemeStyles(); @@ -188,6 +200,7 @@ function ImportSpreadsheet({backTo, goTo, shouldForceReplaceNavigation = false, file.name, isImportingMultiLevelTags ?? false, importedSpreadsheet?.importTransactionSettings, + isAppendingToExistingLists, ); }) .then(() => { @@ -285,33 +298,35 @@ function ImportSpreadsheet({backTo, goTo, shouldForceReplaceNavigation = false, }} /> - - {!(isDraggingOver ?? isDraggingOver) && desktopView} - - { - const file = e?.dataTransfer?.files[0]; - if (!file || isReadingFile) { - return; - } - readFile(file); - }} - > - - - - {translate('common.dropTitle')} - {translate('common.dropMessage')} + + + {!(isDraggingOver ?? isDraggingOver) && desktopView} + + { + const file = e?.dataTransfer?.files[0]; + if (!file || isReadingFile) { + return; + } + readFile(file); + }} + > + + + + {translate('common.dropTitle')} + {translate('common.dropMessage')} + - - - + + + )} diff --git a/src/components/ImportedFromAccountingSoftware.tsx b/src/components/ImportedFromAccountingSoftware.tsx index 7bc1d7acd1a4..d16e7d3b46ee 100644 --- a/src/components/ImportedFromAccountingSoftware.tsx +++ b/src/components/ImportedFromAccountingSoftware.tsx @@ -57,6 +57,7 @@ function ImportedFromAccountingSoftware({policyID, currentConnectionName, transl 'RilletSquare', 'DualEntrySquare', 'CampfireSquare', + 'ZohoBooksSquare', 'BusinessCentralSquare', 'GustoSquare', ]); diff --git a/src/components/InteractiveStepWrapper.tsx b/src/components/InteractiveStepWrapper.tsx index a97c48504e7e..95fb562daa7d 100644 --- a/src/components/InteractiveStepWrapper.tsx +++ b/src/components/InteractiveStepWrapper.tsx @@ -26,6 +26,9 @@ type InteractiveStepWrapperProps = { startStepIndex?: number; stepNames?: readonly string[]; + + /** Remounts the sub header when `startStepIndex` changes, for flows that move between steps on one screen. Other flows drive the sub header through its own state. */ + shouldRemountSubHeaderOnStepChange?: boolean; shouldEnableMaxHeight?: boolean; shouldShowOfflineIndicator?: boolean; shouldShowOfflineIndicatorInWideScreen?: boolean; @@ -59,6 +62,7 @@ function InteractiveStepWrapper({ headerSubtitle, startStepIndex, stepNames, + shouldRemountSubHeaderOnStepChange = false, shouldEnableMaxHeight, shouldShowOfflineIndicator, shouldShowOfflineIndicatorInWideScreen, @@ -94,6 +98,8 @@ function InteractiveStepWrapper({ {!!stepNames && ( + bankAccount.accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS && + isBankAccountPartiallySetup(bankAccount.accountData?.state) && + !bankAccount.accountData?.policyIDs?.length && + bankAccount.bankCurrency === currency, + ); + if (partialBusinessBankAccount?.accountData?.bankAccountID) { + navigateToBankAccountRoute({bankAccountID: partialBusinessBankAccount.accountData.bankAccountID, backTo: Navigation.getActiveRoute()}); + return; + } + + prepareStandaloneBankAccountSetup(currency); + navigateToBankAccountRoute({backTo: Navigation.getActiveRoute()}); + return; + } + // If user has a locked account we exit early if (policy !== undefined && policy?.achAccount?.state === CONST.BANK_ACCOUNT.STATE.LOCKED) { Log.info('[KYC Wallet] Dropping payment method selection: policy achAccount is locked', false, {policyID: policy.id, achBankAccountID: policy.achAccount?.bankAccountID}); diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 034067553d11..b294f14a20ba 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -1,10 +1,9 @@ +import {renderTabRootScrollComponent} from '@components/Navigation/TabBarBottomContent/TabRootScrollViewMarker'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; -import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePrevious from '@hooks/usePrevious'; -import useReportAttributes from '@hooks/useReportAttributes'; import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -13,15 +12,13 @@ import getPlatform from '@libs/getPlatform'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report} from '@src/types/onyx'; import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; import type {ReactElement} from 'react'; import {useRoute} from '@react-navigation/native'; import {FlashList} from '@shopify/flash-list'; -import React, {memo, useCallback, useContext, useEffect, useMemo, useRef} from 'react'; +import React, {useContext, useEffect, useRef} from 'react'; import {StyleSheet, View} from 'react-native'; import type {LHNOptionsListProps, RenderItemProps} from './types'; @@ -30,115 +27,73 @@ import LHNTooltipContextProvider from './LHNTooltipContextProvider'; import OptionRowLHNData from './OptionRowLHN'; import OptionRowRendererComponent from './OptionRowRendererComponent'; -const keyExtractor = (item: Report) => `report_${item.reportID}`; +const keyExtractor = (reportID: string) => `report_${reportID}`; const platform = getPlatform(); const isWeb = platform === CONST.PLATFORM.WEB; function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optionMode, shouldDisableFocusOptions = false, onFirstItemRendered = () => {}}: LHNOptionsListProps) { const {saveScrollOffset, getScrollOffset, saveScrollIndex, getScrollIndex} = useContext(ScrollOffsetContext); const {isOffline} = useNetwork(); - const flashListRef = useRef>(null); const route = useRoute(); - const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - const reportAttributes = useReportAttributes(); - const [policy] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [personalDetails] = useAllPersonalDetails(); - const styles = useThemeStyles(); + const tabRootScrollProps = useTabRootScrollProps(contentContainerStyles, {hasBottomSafeAreaPadding: true}); + const previousOptionMode = usePrevious(optionMode); + + // Controls the visibility of the educational tooltip based on user scrolling. + // Hides the tooltip when the user is scrolling and displays it once scrolling stops. + const triggerScrollEvent = useScrollEventEmitter(); + + const flashListRef = useRef>(null); + const hasCalledOnLayout = useRef(false); + const estimatedItemSize = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight; + const extraData = [data.length, optionMode, isOffline]; + const savedScrollIndex = getScrollIndex(route); + const initialScrollIndex = isWeb && savedScrollIndex !== undefined && savedScrollIndex >= 0 && savedScrollIndex < data.length ? savedScrollIndex : undefined; // When the first item renders we want to call the onFirstItemRendered callback. // At this point in time we know that the list is actually displaying items. - const hasCalledOnLayout = React.useRef(false); - const onLayoutItem = useCallback(() => { + const onLayoutItem = () => { if (hasCalledOnLayout.current) { return; } hasCalledOnLayout.current = true; onFirstItemRendered(); - }, [onFirstItemRendered]); - - // Controls the visibility of the educational tooltip based on user scrolling. - // Hides the tooltip when the user is scrolling and displays it once scrolling stops. - const triggerScrollEvent = useScrollEventEmitter(); + }; /** * Function which renders a row in the list */ - const renderItem = useCallback( - ({item, index}: RenderItemProps): ReactElement | null => { - if (!item) { - return null; - } - const reportID = item.reportID; - const itemReportAttributes = reportAttributes?.[reportID]; - const itemParentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.parentReportID}`]; - const itemOneTransactionThreadReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${itemReportAttributes?.oneTransactionThreadReportID}`]; - - let invoiceReceiverPolicyID = '-1'; - if (item.invoiceReceiver && 'policyID' in item.invoiceReceiver) { - invoiceReceiverPolicyID = item.invoiceReceiver.policyID; - } - if (itemParentReport?.invoiceReceiver && 'policyID' in itemParentReport.invoiceReceiver) { - invoiceReceiverPolicyID = itemParentReport.invoiceReceiver.policyID; - } - const itemInvoiceReceiverPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiverPolicyID}`]; - const itemPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`]; - - return ( - - ); - }, - [reportAttributes, reports, policy, personalDetails, optionMode, shouldDisableFocusOptions, onSelectRow, onLayoutItem], - ); - - const extraData = useMemo( - () => [reports, reportAttributes, policy, personalDetails, data.length, optionMode, isOffline], - [reports, reportAttributes, policy, personalDetails, data.length, optionMode, isOffline], - ); - - const previousOptionMode = usePrevious(optionMode); - - useEffect(() => { - if (previousOptionMode === null || previousOptionMode === optionMode || !flashListRef.current) { + const renderItem = ({item, index}: RenderItemProps): ReactElement | null => { + if (!item) { + return null; + } + return ( + + ); + }; + + const onScroll: NonNullable['onScroll']> = (e) => { + // If the layout measurement is 0, it means the FlashList is not displayed but the onScroll may be triggered with offset value 0. + // We should ignore this case. + if (e.nativeEvent.layoutMeasurement.height === 0) { return; } + saveScrollOffset(route, e.nativeEvent.contentOffset.y); + if (isWeb) { + saveScrollIndex(route, Math.floor(e.nativeEvent.contentOffset.y / estimatedItemSize)); + } + triggerScrollEvent(); + }; - // If the option mode changes want to scroll to the top of the list because rendered items will have different height. - flashListRef.current.scrollToOffset({offset: 0}); - }, [previousOptionMode, optionMode]); - - const onScroll = useCallback['onScroll']>>( - (e) => { - // If the layout measurement is 0, it means the FlashList is not displayed but the onScroll may be triggered with offset value 0. - // We should ignore this case. - if (e.nativeEvent.layoutMeasurement.height === 0) { - return; - } - saveScrollOffset(route, e.nativeEvent.contentOffset.y); - if (isWeb) { - saveScrollIndex(route, Math.floor(e.nativeEvent.contentOffset.y / estimatedItemSize)); - } - triggerScrollEvent(); - }, - [estimatedItemSize, route, saveScrollIndex, saveScrollOffset, triggerScrollEvent], - ); - - const onLayout = useCallback(() => { + const onLayout = () => { const offset = getScrollOffset(route); if (!(offset && flashListRef.current) || isWeb) { @@ -152,20 +107,27 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio } flashListRef.current.scrollToOffset({offset}); }); - }, [getScrollOffset, route]); + }; - const savedScrollIndex = getScrollIndex(route); - const initialScrollIndex = isWeb && savedScrollIndex !== undefined && savedScrollIndex >= 0 && savedScrollIndex < data.length ? savedScrollIndex : undefined; + useEffect(() => { + if (previousOptionMode === null || previousOptionMode === optionMode || !flashListRef.current) { + return; + } + + // If the option mode changes want to scroll to the top of the list because rendered items will have different height. + flashListRef.current.scrollToOffset({offset: 0}); + }, [previousOptionMode, optionMode]); return ( ); } -export default memo(LHNOptionsList); +export default LHNOptionsList; diff --git a/src/components/LHNOptionsList/LHNTooltipContextProvider.tsx b/src/components/LHNOptionsList/LHNTooltipContextProvider.tsx index 520bcc33f270..c2037058f131 100644 --- a/src/components/LHNOptionsList/LHNTooltipContextProvider.tsx +++ b/src/components/LHNOptionsList/LHNTooltipContextProvider.tsx @@ -4,7 +4,6 @@ import useRootNavigationState from '@hooks/useRootNavigationState'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useIsFocused} from '@react-navigation/native'; @@ -13,27 +12,27 @@ import React from 'react'; import {LHNTooltipContext} from './LHNTooltipContext'; type LHNTooltipContextProviderProps = { - data: Report[]; + /** The ordered IDs of the reports rendered in the LHN */ + data: string[]; children: React.ReactNode; }; function LHNTooltipContextProvider({data, children}: LHNTooltipContextProviderProps) { + const isScreenFocused = useIsFocused(); + const isReportsSplitNavigatorLast = useRootNavigationState((state) => state?.routes?.at(-1)?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const [isFullscreenVisible] = useOnyx(ONYXKEYS.FULLSCREEN_VISIBILITY); const reportAttributes = useReportAttributes(); - const firstReport = data.find((report) => { - const attrs = reportAttributes?.[report.reportID]; + const firstReportIDWithGBRorRBR = data.find((reportID) => { + const attrs = reportAttributes?.[reportID]; if (!isEmptyObject(attrs?.reportErrors)) { return true; } return attrs?.requiresAttention; }); - const firstReportIDWithGBRorRBR = firstReport?.reportID; - - const isScreenFocused = useIsFocused(); - const isReportsSplitNavigatorLast = useRootNavigationState((state) => state?.routes?.at(-1)?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR); const value = { onboardingPurpose: introSelected?.choice, diff --git a/src/components/LHNOptionsList/OptionRowLHN/OptionRow/Avatar.tsx b/src/components/LHNOptionsList/OptionRowLHN/OptionRow/Avatar.tsx index 824a5c91c58e..309f119f3fc6 100644 --- a/src/components/LHNOptionsList/OptionRowLHN/OptionRow/Avatar.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN/OptionRow/Avatar.tsx @@ -36,7 +36,7 @@ function AvatarInner({optionItem, viewMode, avatarBackgroundColor}: AvatarProps) const personalDetails = usePersonalDetails(); const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT; - const singleAvatarContainerStyle = [styles.actionAvatar, styles.mr3]; + const singleAvatarContainerStyle = [styles.actionAvatar]; const avatarSize = isInFocusMode ? CONST.AVATAR_SIZE.SMALL : CONST.AVATAR_SIZE.DEFAULT; const delegateAccountID = getDelegateAccountIDFromReportAction(optionItem?.parentReportAction); @@ -105,6 +105,7 @@ function AvatarInner({optionItem, viewMode, avatarBackgroundColor}: AvatarProps) avatar={primaryIcon} size={avatarSize} containerStyles={singleAvatarContainerStyle} + style={styles.mr3} /> ); } diff --git a/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx index e90e1e05fb5a..b51a68b79550 100644 --- a/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx @@ -7,142 +7,233 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useGetExpensifyCardFromReportAction from '@hooks/useGetExpensifyCardFromReportAction'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; +import {useReportAttributesByID} from '@hooks/useReportAttributes'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getLastVisibleActionIncludingTransactionThread, getOriginalMessage, isActionableTrackExpense, isInviteOrRemovedAction} from '@libs/ReportActionsUtils'; -import {canUserPerformWriteAction as canUserPerformWriteActionUtil, shouldShowMarkAsDone} from '@libs/ReportUtils'; +import { + getIOUReportIDFromReportActionPreview, + getLastVisibleActionIncludingTransactionThread, + getLastVisibleAction, + getOriginalMessage, + getReportActionActorAccountID, + isActionableJoinRequest, + isActionableMentionWhisper, + isActionableTrackExpense, + isInviteOrRemovedAction, + isMoneyRequestAction, +} from '@libs/ReportActionsUtils'; +import {canUserPerformWriteAction as canUserPerformWriteActionUtil, parseMovedTransactionReportIDs, shouldShowMarkAsDone} from '@libs/ReportUtils'; import SidebarUtils from '@libs/SidebarUtils'; import CONST from '@src/CONST'; import {getMovedReportID} from '@src/libs/ModifiedExpenseMessage'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportActions as ReportActionsType} from '@src/types/onyx'; -import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; +import type {PersonalDetailsList, Report, ReportAction, ReportActions as ReportActionsType} from '@src/types/onyx'; +import type {ReportAttributesDerivedValue, VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import type {ViewStyle} from 'react-native'; -import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import React, {useCallback, useMemo} from 'react'; +import {accountIDSelector} from '@selectors/PersonalDetails'; +import {reportNamesByIDsSelector} from '@selectors/ReportAttributes'; +import React from 'react'; import {StyleSheet, View} from 'react-native'; import OptionRowLHN from './OptionRowLHN'; +const EMPTY_PERSONAL_DETAILS: PersonalDetailsList = {}; + +function invoiceReceiverPolicyIDSelector(report: OnyxEntry): string | undefined { + return report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : undefined; +} + +function invoiceReceiverAccountIDSelector(report: OnyxEntry): number | undefined { + return report?.invoiceReceiver && 'accountID' in report.invoiceReceiver ? report.invoiceReceiver.accountID : undefined; +} + +function getActionAccountIDs(action: OnyxEntry): Array { + if (!action) { + return []; + } + const accountIDs = [action.actorAccountID, getReportActionActorAccountID(action, undefined, undefined)]; + if (isActionableJoinRequest(action)) { + accountIDs.push(getOriginalMessage(action)?.accountID); + } + if (isActionableMentionWhisper(action)) { + accountIDs.push(...(getOriginalMessage(action)?.inviteeAccountIDs ?? [])); + } + return accountIDs; +} + +type OptionRowAccountIDsParams = { + report: OnyxEntry; + parentReportAction: OnyxEntry; + parentReportActionActorAccountID: number | undefined; + invoiceChatReceiverAccountID: number | undefined; + lastAction: OnyxEntry; + ownLastAction: OnyxEntry; + reimburserAccountID: number | undefined; + currentUserAccountID: number; +}; + +function getOptionRowAccountIDs({ + report, + parentReportAction, + parentReportActionActorAccountID, + invoiceChatReceiverAccountID, + lastAction, + ownLastAction, + reimburserAccountID, + currentUserAccountID, +}: OptionRowAccountIDsParams): number[] { + const accountIDs = new Set([ + ...Object.keys(report?.participants ?? {}).map(Number), + report?.ownerAccountID, + report?.managerID, + report?.lastActorAccountID, + parentReportAction?.actorAccountID, + parentReportActionActorAccountID, + invoiceReceiverAccountIDSelector(report), + invoiceChatReceiverAccountID, + ...getActionAccountIDs(lastAction), + ...getActionAccountIDs(ownLastAction), + reimburserAccountID, + CONST.ACCOUNT_ID.CONCIERGE, + CONST.ACCOUNT_ID.NOTIFICATIONS, + currentUserAccountID, + ]); + return Array.from(accountIDs).filter((accountID): accountID is number => accountID !== undefined); +} + +function getActionReportIDs(action: OnyxEntry): Array { + if (!action) { + return []; + } + const {fromReportID, displayReportID} = parseMovedTransactionReportIDs(action); + return [fromReportID, displayReportID, getIOUReportIDFromReportActionPreview(action)]; +} + /* * This component gets the data from onyx for the actual * OptionRowLHN component. * OptionRowLHN is auto-memoized by React Compiler, so it will * only re-render when the inputs derived from this wrapper change. */ -function OptionRowLHNData({ - isOptionFocused = false, - fullReport, - reportAttributes, - reportAttributesDerived, - oneTransactionThreadReport, - personalDetails = {}, - policy, - invoiceReceiverPolicy, - viewMode = 'default', - reportID, - ...propsToForward -}: OptionRowLHNDataProps) { +function OptionRowLHNData({isOptionFocused = false, viewMode = 'default', reportID, ...propsToForward}: OptionRowLHNDataProps) { const styles = useThemeStyles(); - const {currentReportID: currentReportIDValue} = useCurrentReportIDState(); - const isReportFocused = isOptionFocused && currentReportIDValue === reportID; const {translate, localeCompare, dateFnsLocale, formatPhoneNumber} = useLocalize(); const {convertToDisplayString, convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); + const {currentReportID: currentReportIDValue} = useCurrentReportIDState(); const {login, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - const oneTransactionThreadReportID = oneTransactionThreadReport?.reportID; - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - - // Per-item report actions subscriptions (scoped by specific report ID) + const [fullReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const reportAttributes = useReportAttributesByID(reportID); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); - const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(fullReport?.parentReportID)}`); - const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(oneTransactionThreadReportID)}`); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); + const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - // Scoped VISIBLE_REPORT_ACTIONS selector - const visibleActionsSelector = useCallback( - (data: VisibleReportActionsDerivedValue | undefined) => { - if (!data) { - return undefined; - } - const result: VisibleReportActionsDerivedValue = {}; - const reportEntry = data[reportID]; - if (reportEntry) { - result[reportID] = reportEntry; - } - if (oneTransactionThreadReportID) { - const txThreadEntry = data[oneTransactionThreadReportID]; - if (txThreadEntry) { - result[oneTransactionThreadReportID] = txThreadEntry; - } + const isInvoice = fullReport?.type === CONST.REPORT.TYPE.INVOICE; + const isIOUReport = fullReport?.type === CONST.REPORT.TYPE.IOU; + const visibleActionsSelector = (data: VisibleReportActionsDerivedValue | undefined) => { + if (!data) { + return undefined; + } + const result: VisibleReportActionsDerivedValue = {}; + const reportEntry = data[reportID]; + if (reportEntry) { + result[reportID] = reportEntry; + } + if (reportAttributes?.oneTransactionThreadReportID) { + const txThreadEntry = data[reportAttributes.oneTransactionThreadReportID]; + if (txThreadEntry) { + result[reportAttributes.oneTransactionThreadReportID] = txThreadEntry; } - return result; - }, - [reportID, oneTransactionThreadReportID], - ); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {selector: visibleActionsSelector}); + } + return result; + }; - // Per-item NVP subscription instead of collection-level subscription in parent - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); + const [oneTransactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportAttributes?.oneTransactionThreadReportID)}`); + const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportAttributes?.oneTransactionThreadReportID)}`); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {selector: visibleActionsSelector}); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(fullReport?.policyID)}`); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fullReport?.policyID}`); + const reimburserAccountID = usePersonalDetailByLogin(policy?.reimburser ?? policy?.achAccount?.reimburser, accountIDSelector); + const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(fullReport?.parentReportID)}`); + const [parentReportInvoiceReceiverPolicyID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(fullReport?.parentReportID)}`, { + selector: invoiceReceiverPolicyIDSelector, + }); + const [invoiceChatReceiverAccountID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(isInvoice ? fullReport?.chatReportID : undefined)}`, { + selector: invoiceReceiverAccountIDSelector, + }); + const [chatReportForIOU] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(isIOUReport ? fullReport?.chatReportID : undefined)}`); + const invoiceReceiverPolicyID = parentReportInvoiceReceiverPolicyID ?? invoiceReceiverPolicyIDSelector(fullReport); const parentReportAction = fullReport?.parentReportActionID ? parentReportActions?.[fullReport.parentReportActionID] : undefined; - + const parentReportActionActorAccountID = getReportActionActorAccountID(parentReportAction, fullReport, fullReport); const isReportArchived = !!reportNameValuePairs?.private_isArchived; const canUserPerformWrite = canUserPerformWriteActionUtil(fullReport, isReportArchived); - - const lastAction = useMemo(() => { - const actionsCollection: OnyxCollection = { - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: reportActions ?? undefined, - }; - if (oneTransactionThreadReportID) { - actionsCollection[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneTransactionThreadReportID}`] = transactionThreadReportActions ?? undefined; - } - return getLastVisibleActionIncludingTransactionThread(reportID, canUserPerformWrite, actionsCollection, visibleReportActionsData, oneTransactionThreadReportID); - }, [reportID, canUserPerformWrite, reportActions, transactionThreadReportActions, visibleReportActionsData, oneTransactionThreadReportID]); - + const actionsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: reportActions ?? undefined, + }; + if (reportAttributes?.oneTransactionThreadReportID) { + actionsCollection[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportAttributes.oneTransactionThreadReportID}`] = transactionThreadReportActions ?? undefined; + } + const lastAction = getLastVisibleActionIncludingTransactionThread( + reportID, + canUserPerformWrite, + actionsCollection, + visibleReportActionsData, + reportAttributes?.oneTransactionThreadReportID, + ); + // getLastMessageTextForReport swaps an IOU CREATE/TRACK last action of a one-transaction report for the report's own last action + const ownLastAction = + reportAttributes?.oneTransactionThreadReportID && isMoneyRequestAction(lastAction) + ? getLastVisibleAction(reportID, canUserPerformWrite, {}, {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: reportActions ?? undefined}, visibleReportActionsData) + : undefined; const whisperTransactionID = isActionableTrackExpense(lastAction) ? getOriginalMessage(lastAction)?.transactionID : undefined; - const [whisperTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(whisperTransactionID)}`); - - const lastMessageTextFromReport = useMemo(() => { - if (whisperTransactionID && !whisperTransaction) { - return ''; - } - return undefined; - }, [whisperTransactionID, whisperTransaction]); + const lastActionReportID = isInviteOrRemovedAction(lastAction) && lastAction?.actionName ? getOriginalMessage(lastAction)?.reportID : undefined; + const reportNameIDs = [reportID, lastActionReportID ? String(lastActionReportID) : undefined, ...getActionReportIDs(lastAction), ...getActionReportIDs(ownLastAction)]; - const lastActionReportID = useMemo(() => { - if (isInviteOrRemovedAction(lastAction)) { - const lastActionOriginalMessage = lastAction?.actionName ? getOriginalMessage(lastAction) : null; - return lastActionOriginalMessage?.reportID; - } - return undefined; - }, [lastAction]); + const [invoiceReceiverPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(invoiceReceiverPolicyID)}`); + const [whisperTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(whisperTransactionID)}`); const [lastActionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(lastActionReportID ? String(lastActionReportID) : undefined)}`); - - const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); - const hasDraftComment = !!draftComment && !draftComment.match(CONST.REGEX.EMPTY_COMMENT); - const [movedFromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.FROM)}`); const [movedToReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.TO)}`); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fullReport?.policyID}`); - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); - const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - + const [reportNames] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { + selector: (attributes: OnyxEntry) => reportNamesByIDsSelector(attributes, reportNameIDs), + }); + const [personalDetails] = usePersonalDetailsByIDs( + getOptionRowAccountIDs({ + report: fullReport, + parentReportAction, + parentReportActionActorAccountID, + invoiceChatReceiverAccountID, + lastAction, + ownLastAction, + reimburserAccountID, + currentUserAccountID, + }), + ); const card = useGetExpensifyCardFromReportAction({reportAction: lastAction, policyID: fullReport?.policyID}); - - const isIOUReport = fullReport?.type === CONST.REPORT.TYPE.IOU; - const [chatReportForIOU] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(isIOUReport ? fullReport?.chatReportID : undefined)}`); const reportPreviewSenderID = useReportPreviewSenderID({ iouReport: isIOUReport ? fullReport : undefined, action: parentReportAction, chatReport: chatReportForIOU, }); + const isReportFocused = isOptionFocused && currentReportIDValue === reportID; + const hasDraftComment = !!draftComment && !draftComment.match(CONST.REGEX.EMPTY_COMMENT); + const lastMessageTextFromReport = whisperTransactionID && !whisperTransaction ? '' : undefined; + // React Compiler auto-memoizes each expression in OptionRowLHN independently, // so there is no need to stabilize the optionItem reference with deepEqual. // When getOptionData returns a fresh object with the same content, the Compiler @@ -153,7 +244,7 @@ function OptionRowLHNData({ reportAttributes, oneTransactionThreadReport, reportNameValuePairs, - personalDetails, + personalDetails: personalDetails ?? EMPTY_PERSONAL_DETAILS, policy, parentReportAction, conciergeReportID, @@ -170,26 +261,24 @@ function OptionRowLHNData({ movedFromReport, movedToReport, currentUserAccountID, - reportAttributesDerived, + reportAttributesDerived: reportNames, visibleReportActionsData, policyTags, currentUserLogin: login ?? '', isTrackIntentUser, formatPhoneNumber, rules, + bankAccountList, }); // For single-sender IOUs, trim to the sender's avatar to match the header. // The header uses reportPreviewSenderID as accountID for its primary avatar, // so we pick the matching icon from getIconsForIOUReport to stay consistent. - const finalOptionItem = useMemo(() => { - if (!optionItem || !isIOUReport || reportPreviewSenderID === undefined || !optionItem.icons || optionItem.icons.length <= 1) { - return optionItem; - } - + let finalOptionItem = optionItem; + if (optionItem && isIOUReport && reportPreviewSenderID !== undefined && optionItem.icons && optionItem.icons.length > 1) { const senderIcon = optionItem.icons.find((icon) => Number(icon.id) === reportPreviewSenderID); - return {...optionItem, icons: [senderIcon ?? optionItem.icons.at(0)].filter((icon): icon is Icon => !!icon)}; - }, [optionItem, isIOUReport, reportPreviewSenderID]); + finalOptionItem = {...optionItem, icons: [senderIcon ?? optionItem.icons.at(0)].filter((icon): icon is Icon => !!icon)}; + } const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT; const placeholderRowStyle = StyleSheet.flatten( @@ -230,11 +319,4 @@ function OptionRowLHNData({ OptionRowLHNData.displayName = 'OptionRowLHNData'; -/** - * This component is rendered in a list. - * On scroll we want to avoid that a item re-renders - * just because the list has to re-render when adding more items. - * Thats also why the React.memo is used on the outer component here, as we just - * use it to prevent re-renders from parent re-renders. - */ -export default React.memo(OptionRowLHNData); +export default OptionRowLHNData; diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts index c95083c09ce1..a5f6f28c79c8 100644 --- a/src/components/LHNOptionsList/types.ts +++ b/src/components/LHNOptionsList/types.ts @@ -1,11 +1,8 @@ import type CONST from '@src/CONST'; import type {OptionData} from '@src/libs/ReportUtils'; -import type {PersonalDetailsList, Policy, Report} from '@src/types/onyx'; -import type {ReportAttributes, ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {ComponentRef, RefObject} from 'react'; import type {LayoutChangeEvent, StyleProp, View, ViewStyle} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; type OptionMode = ValueOf; @@ -17,7 +14,8 @@ type CustomLHNOptionsListProps = { /** Extra styles for the section list container */ contentContainerStyles?: StyleProp; - data: Report[]; + /** The ordered IDs of the reports to render */ + data: string[]; onSelectRow?: (optionItem: OptionData, popoverAnchor: RefObject | null>) => void; /** Toggle between compact and default view of the option */ @@ -35,14 +33,6 @@ type OptionRowLHNDataProps = { /** Whether row should be focused */ isOptionFocused?: boolean; - personalDetails?: PersonalDetailsList; - fullReport: OnyxEntry; - oneTransactionThreadReport: OnyxEntry; - - /** The policy which the user has access to and which the report could be tied to */ - policy?: OnyxEntry; - - invoiceReceiverPolicy?: OnyxEntry; reportID: string; /** Toggle between compact and default view */ @@ -54,9 +44,6 @@ type OptionRowLHNDataProps = { /** Callback to execute when the OptionList lays out */ onLayout?: (event: LayoutChangeEvent) => void; - reportAttributes: OnyxEntry; - reportAttributesDerived?: ReportAttributesDerivedValue['reports']; - /** TestID of the row, indicating order */ testID: number; }; @@ -83,6 +70,6 @@ type OptionRowLHNProps = { shouldShowMarkAsDoneCopy?: boolean; }; -type RenderItemProps = {item: Report; index: number}; +type RenderItemProps = {item: string; index: number}; export type {LHNOptionsListProps, OptionRowLHNDataProps, OptionRowLHNProps, RenderItemProps}; diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx index a4df05548a56..1bd0531e14ed 100644 --- a/src/components/Lightbox/index.tsx +++ b/src/components/Lightbox/index.tsx @@ -4,11 +4,13 @@ import {useAttachmentCarouselPagerActions, useAttachmentCarouselPagerState} from import type {Attachment} from '@components/Attachments/types'; import Image from '@components/Image'; import type {ImageOnLoadEvent} from '@components/Image/types'; +import ImageLoadTimeoutNotice from '@components/ImageLoadTimeoutNotice'; import MultiGestureCanvas, {DEFAULT_ZOOM_RANGE} from '@components/MultiGestureCanvas'; import type {OnScaleChangedCallback, ZoomRange} from '@components/MultiGestureCanvas/types'; import {getCanvasFitScale} from '@components/MultiGestureCanvas/utils'; import useCanvasSize from '@hooks/useCanvasSize'; +import useImageLoadStall from '@hooks/useImageLoadStall'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -20,7 +22,7 @@ import type {Dimensions} from '@src/types/utils/Layout'; import type {StyleProp, ViewStyle} from 'react-native'; -import React, {useState} from 'react'; +import React, {useRef, useState} from 'react'; import {PixelRatio, StyleSheet, View} from 'react-native'; import {useSharedValue} from 'react-native-reanimated'; @@ -28,6 +30,8 @@ import NUMBER_OF_CONCURRENT_LIGHTBOXES from './numberOfConcurrentLightboxes'; const FALLBACK_OFFSET = 2; +const LOADING_CONTEXT = {context: 'Lightbox'}; + const cachedImageDimensions = new Map(); function getImagePriority(isActive: boolean, isLightboxVisible: boolean) { @@ -144,6 +148,15 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange const [isLightboxImageLoaded, setLightboxImageLoaded] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [hasLoadFailed, setHasLoadFailed] = useState(false); + const [hasLoadTimedOut, setHasLoadTimedOut] = useState(false); + // On web the image keeps the onError it was first given, so handleError reads this ref, not the stale state. + const hasLoadTimedOutRef = useRef(false); + const updateLoadTimedOut = (value: boolean) => { + hasLoadTimedOutRef.current = value; + setHasLoadTimedOut(value); + }; + const [reloadKey, setReloadKey] = useState(0); const isFallbackVisible = !hasSiblingCarouselItems ? !isLightboxVisible : !(isActive && isLightboxVisible && isLightboxImageLoaded); const [isFallbackImageLoaded, setFallbackImageLoaded] = useState(false); @@ -197,6 +210,30 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange const isALocalFile = isLocalFile(uri); const shouldShowOfflineIndicator = isOffline && !isLoading && !isALocalFile; + const shouldShowLoadingIndicator = !isImageLoaded && !shouldShowOfflineIndicator && !hasLoadFailed && !hasLoadTimedOut; + + const handleError = () => { + // A stalled load that later errors is still a slow load, so keep the timeout notice instead of "not found". + if (hasLoadTimedOutRef.current) { + return; + } + setHasLoadFailed(true); + onError?.(); + }; + + /** The transport went silent, so stop the spinner and offer a retry while the image stays mounted. */ + const handleLoadTimeout = () => { + updateLoadTimedOut(true); + setIsLoading(false); + }; + + const retryLoad = () => { + updateLoadTimedOut(false); + setIsLoading(true); + setReloadKey((key) => key + 1); + }; + + const reportLoadActivity = useImageLoadStall(!isOffline && shouldShowLoadingIndicator, handleLoadTimeout); return ( {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. */} { updateContentSize(e); setLightboxImageLoaded(true); + updateLoadTimedOut(false); }} waitForSession={() => { // only active lightbox should call this function @@ -240,6 +280,7 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange } setContentSize(cachedImageDimensions.get(uri)); setLightboxImageLoaded(false); + setHasLoadFailed(false); }} onLoadEnd={() => { setIsLoading(false); @@ -268,12 +309,14 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange )} {/* Show activity indicator while the lightbox is still loading the image. */} - {!isImageLoaded && !shouldShowOfflineIndicator && ( + {shouldShowLoadingIndicator && ( )} + {hasLoadTimedOut && !isOffline && } {!isImageLoaded && shouldShowOfflineIndicator && } )} diff --git a/src/components/ListField.tsx b/src/components/ListField.tsx new file mode 100644 index 000000000000..64a0b6aebe6f --- /dev/null +++ b/src/components/ListField.tsx @@ -0,0 +1,137 @@ +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getLetterAvatarURLForName} from '@libs/UserAvatarUtils'; + +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + +import CONST from '@src/CONST'; +import variables from '@src/styles/variables'; + +import React from 'react'; +import {View} from 'react-native'; + +import UserAvatar from './Avatar/UserAvatar'; +import Button from './Button'; +import FormHelpMessage from './FormHelpMessage'; +import Icon from './Icon'; +import MenuItem from './MenuItem'; +import PressableWithFeedback from './Pressable/PressableWithFeedback'; + +type ListFieldRow = { + /** ID of the entry the row shows */ + id: string; + + /** Names the row and its buttons for screen readers, and the letter avatar is built from it */ + title: string; + + /** Second line of the row */ + description?: string; +}; + +type ListFieldProps = { + /** Entries, one row each */ + rows: ListFieldRow[]; + + /** Title of the add row, such as "Add owner" */ + addTitle: string; + + /** Hint shown under the add row */ + addDescription?: string; + + /** Hides the add row once the list is full */ + canAddMore?: boolean; + + /** Validation error shown under the list */ + errorText?: string; + + /** Called when the user taps the add row */ + onAdd: () => void; + + /** Called when the user taps Edit on a row */ + onEdit: (id: string) => void; + + /** Called when the user taps a row's remove button, so the caller can confirm before removing */ + onRemove: (id: string) => void; +}; + +/** A list of named items on a page, each with an avatar, an Edit button and a remove button, followed by an add row */ +function ListField({rows, addTitle, addDescription, canAddMore = true, errorText = '', onAdd, onEdit, onRemove}: ListFieldProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const theme = useTheme(); + const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Close']); + + return ( + <> + {rows.map((row) => ( + // The row itself does nothing, so it stays out of the accessibility tree and screen readers can reach its buttons + + + + + + + {row.title} + {!!row.description && {row.description}} + + + + + onRemove(row.id)} + > + + + + + + + ))} + {canAddMore && ( + + + + + + + {addTitle} + {!!addDescription && {addDescription}} + + + + )} + {!!errorText && ( + + + + )} + + ); +} + +export default ListField; +export type {ListFieldRow}; diff --git a/src/components/LoadingIndicator.tsx b/src/components/LoadingIndicator.tsx index 0acc191135a3..29ae6d6d5f59 100644 --- a/src/components/LoadingIndicator.tsx +++ b/src/components/LoadingIndicator.tsx @@ -1,5 +1,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; +import type {ExtraLoadingContext} from '@libs/AppState/types'; + import CONST from '@src/CONST'; import type {ActivityIndicatorProps as RNActivityIndicatorProps, StyleProp, ViewStyle} from 'react-native'; @@ -14,15 +16,21 @@ type LoadingIndicatorIconSize = RNActivityIndicatorProps['size']; type LoadingIndicatorProps = { style?: StyleProp; iconSize?: LoadingIndicatorIconSize; + + /** Extra context logged if the spinner is shown for longer than expected */ + extraLoadingContext?: ExtraLoadingContext; }; -function LoadingIndicator({style, iconSize}: LoadingIndicatorProps) { +function LoadingIndicator({style, iconSize, extraLoadingContext}: LoadingIndicatorProps) { const styles = useThemeStyles(); return ( - + ); diff --git a/src/components/LottieAnimations/index.tsx b/src/components/LottieAnimations/index.tsx index 5054bf0ca5d4..1ea00c573280 100644 --- a/src/components/LottieAnimations/index.tsx +++ b/src/components/LottieAnimations/index.tsx @@ -96,22 +96,6 @@ const DotLottieAnimations = { w: 204, h: 204, }, - SpendAnalysis: { - file: require('@assets/animations/SpendAnalysis.lottie'), - w: 440, - h: 240, - backgroundColor: colors.pink700, - }, - ExpenseAssistant: { - file: require('@assets/animations/ExpenseAssistant.lottie'), - w: 440, - h: 240, - }, - CustomAgents: { - file: require('@assets/animations/CustomAgents.lottie'), - w: 440, - h: 240, - }, ConciergeThinkingScanGradient: { file: require('@assets/animations/ConciergeThinkingScanGradient.lottie'), w: 550, diff --git a/src/components/MapView/Compass.tsx b/src/components/MapView/Compass.tsx index ed9c9902fa87..d36c15f89870 100644 --- a/src/components/MapView/Compass.tsx +++ b/src/components/MapView/Compass.tsx @@ -25,7 +25,7 @@ function Compass({interactive, shouldDisplayCompass, cameraRef, mapHeading}: Com const resetMapToNorth = () => { cameraRef.current?.setCamera({ heading: 0, - animationDuration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME, + animationDuration: CONST.MAPBOX.ANIMATION_DURATION_DEFAULT, }); }; diff --git a/src/components/MapView/GPSMapViewContent.tsx b/src/components/MapView/GPSMapViewContent.tsx index c36f8fb8ade7..911cec50a4c1 100644 --- a/src/components/MapView/GPSMapViewContent.tsx +++ b/src/components/MapView/GPSMapViewContent.tsx @@ -74,6 +74,8 @@ function GPSMapViewContent({ // and the GPS trip is not active or the foreground location permissions are not granted. const shouldFollowUserLocation = !userInteractedWithMap && (noWaypoints || isTrackingGPS) && foregroundLocationPermissionsGranted; + const cameraPadding = utils.getCameraPadding(mapPadding); + // When the route/waypoints are cleared (e.g. discarding a GPS trip), // resume following the user's current location. const prevWaypointsLength = useRef(waypoints?.length ?? 0); @@ -87,7 +89,8 @@ function GPSMapViewContent({ if (!foregroundLocationPermissionsGranted) { cameraRef.current?.setCamera({ zoomLevel: CONST.MAPBOX.DEFAULT_ZOOM, - animationDuration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME, + animationDuration: CONST.MAPBOX.ANIMATION_DURATION_DEFAULT, + animationMode: CONST.MAPBOX.CAMERA_ANIMATION_MODE, centerCoordinate, }); } @@ -97,32 +100,20 @@ function GPSMapViewContent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [waypoints?.length]); - useFocusEffect(() => { - if (noWaypoints || shouldFollowUserLocation || !!lastLocation || userInteractedWithMap) { - return; - } - - if (singlePointCoordinate) { - cameraRef.current?.setCamera({ - zoomLevel: CONST.MAPBOX.SINGLE_MARKER_ZOOM, - animationDuration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME, - centerCoordinate: singlePointCoordinate, - }); - return; - } - - const {southWest, northEast} = utils.getBounds( - waypoints.map((waypoint) => waypoint.coordinate), - directionCoordinates, - ); - cameraRef.current?.fitBounds(northEast, southWest, mapPadding, CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME); - }); + // Coming back to the screen (e.g. tab change, Save and back) drops the manual pan, so the camera props frame the route again + // or resume following the user's location while tracking + useFocusEffect(() => setUserInteractedWithMap(false)); const centerMap = () => { const waypointCoordinates = waypoints?.map((waypoint) => waypoint.coordinate) ?? []; if (!isTrackingGPS && (waypointCoordinates.length > 1 || directionCoordinates?.length > 1)) { const {southWest, northEast} = utils.getBounds(waypointCoordinates, directionCoordinates); - cameraRef.current?.fitBounds(southWest, northEast, mapPadding, CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME); + cameraRef.current?.setCamera({ + bounds: {ne: northEast, sw: southWest}, + padding: cameraPadding, + animationDuration: CONST.MAPBOX.ANIMATION_DURATION_DEFAULT, + animationMode: CONST.MAPBOX.CAMERA_ANIMATION_MODE, + }); return; } // Reset the user interaction state to allow the map to follow the user's location @@ -132,7 +123,8 @@ function GPSMapViewContent({ if (!foregroundLocationPermissionsGranted) { cameraRef.current?.setCamera({ zoomLevel: CONST.MAPBOX.DEFAULT_ZOOM, - animationDuration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME, + animationDuration: CONST.MAPBOX.ANIMATION_DURATION_DEFAULT, + animationMode: CONST.MAPBOX.CAMERA_ANIMATION_MODE, centerCoordinate, }); } @@ -161,9 +153,6 @@ function GPSMapViewContent({ const shouldFollowFallbackLocation = noWaypoints && !foregroundLocationPermissionsGranted; - const cameraPadding: Mapbox.CameraPadding | undefined = - mapPadding !== undefined ? {paddingLeft: mapPadding, paddingRight: mapPadding, paddingTop: mapPadding, paddingBottom: mapPadding} : undefined; - // defaultSettings with bounds ensures there is immediate snap to GPS trip on map load const defaultSettings: Mapbox.CameraStop | undefined = { bounds: waypointsBounds, @@ -218,6 +207,8 @@ function GPSMapViewContent({ defaultSettings={defaultSettings} centerCoordinate={shouldFollowFallbackLocation ? centerCoordinate : waypointsCenterCoordinate} zoomLevel={waypointsZoomLevel} + animationDuration={CONST.MAPBOX.ANIMATION_DURATION_DEFAULT} + animationMode={CONST.MAPBOX.CAMERA_ANIMATION_MODE} /> {/** Show fallback location if foreground location permissions are not granted */} diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx index 79b60982987d..6157656c1374 100644 --- a/src/components/MapView/MapView.tsx +++ b/src/components/MapView/MapView.tsx @@ -10,6 +10,8 @@ import {clearUserLocation, setUserLocation} from '@libs/actions/UserLocation'; import getCurrentPosition from '@libs/getCurrentPosition'; import type {GeolocationErrorCallback} from '@libs/getCurrentPosition/getCurrentPosition.types'; import {GeolocationErrorCode} from '@libs/getCurrentPosition/getCurrentPosition.types'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import type {CancelHandle} from '@libs/Navigation/TransitionTracker'; import CONST from '@src/CONST'; import useLocalize from '@src/hooks/useLocalize'; @@ -18,10 +20,10 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {MapState} from '@rnmapbox/maps'; -import {useFocusEffect, useNavigation} from '@react-navigation/native'; +import {useFocusEffect} from '@react-navigation/native'; import Mapbox, {MarkerView} from '@rnmapbox/maps'; import {getForegroundPermissionsAsync} from 'expo-location'; -import {memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {useSharedValue} from 'react-native-reanimated'; @@ -35,6 +37,10 @@ import responder from './responder'; import useAccessToken from './useAccessToken'; import utils from './utils'; +// Default is 1000ms which feels a bit too long for the animations when coming back from waypoints +// selection screen. 300ms is much lower and animations still trigger correctly +const MAX_WAIT_FOR_TRANSITION_TIME_FOR_CAMERA_MOVEMENTS_MS = 300; + function MapView({ accessToken, style, @@ -50,22 +56,24 @@ function MapView({ interactive = true, distanceInMeters, unit, - ref, shouldDisplayCurrentLocation = true, shouldDisplayCompass = true, }: MapViewProps) { const [userLocation] = useOnyx(ONYXKEYS.USER_LOCATION); - const navigation = useNavigation(); const {isOffline} = useNetwork(); - const {translate} = useLocalize(); + const {translate, preferredLocale} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Crosshair', 'MapCurrentLocation']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Crosshair', 'Expand', 'MapCurrentLocation']); const cameraRef = useRef(null); + const pendingFramingRef = useRef(null); const [isIdle, setIsIdle] = useState(false); const initialLocation = useMemo(() => initialState && {longitude: initialState.location[0], latitude: initialState.location[1]}, [initialState]); const currentPosition = userLocation ?? initialLocation; + const currentPositionLongitude = currentPosition?.longitude; + const currentPositionLatitude = currentPosition?.latitude; const [userInteractedWithMap, setUserInteractedWithMap] = useState(false); + const [isWaypointsNextCenterTarget, setIsWaypointsNextCenterTarget] = useState(false); const shouldInitializeCurrentPosition = useRef(true); const isAccessTokenReady = useAccessToken({accessToken}); @@ -139,55 +147,77 @@ function MapView({ cameraRef.current.setCamera({ zoomLevel: CONST.MAPBOX.DEFAULT_ZOOM, - animationDuration: 1500, + animationDuration: CONST.MAPBOX.ANIMATION_DURATION_LONG, + animationMode: CONST.MAPBOX.CAMERA_ANIMATION_MODE, centerCoordinate: [currentPosition.longitude, currentPosition.latitude], }); }, [currentPosition, shouldPanMapToCurrentPosition]); - useImperativeHandle( - ref, - () => ({ - flyTo: (location: [number, number], zoomLevel: number = CONST.MAPBOX.DEFAULT_ZOOM, animationDuration?: number) => - cameraRef.current?.setCamera({zoomLevel, centerCoordinate: location, animationDuration}), - fitBounds: (northEast: [number, number], southWest: [number, number], paddingConfig?: number | number[] | undefined, animationDuration?: number | undefined) => - cameraRef.current?.fitBounds(northEast, southWest, paddingConfig, animationDuration), - }), - [], - ); - const allDirectionCoordinates = utils.getCoordinatesFromAllDirections(directionCoordinates, alternateDirection); - // When the page loses focus, we temporarily set the "idled" state to false. - // When the page regains focus, the onIdled method of the map will set the actual "idled" state, - // which in turn triggers the callback. + // Frames the camera on every focus once the map has loaded. useFocusEffect( useCallback(() => { - if (!waypoints || waypoints.length === 0 || !isIdle) { + if (!isIdle) { return; } + // The camera goes back to its default framing below, so the center button starts the cycle again from the current position. + setIsWaypointsNextCenterTarget(false); + + if (!waypoints || waypoints.length === 0) { + if (currentPositionLongitude === undefined || currentPositionLatitude === undefined) { + return; + } + + pendingFramingRef.current = TransitionTracker.runAfterTransitions({ + callback: () => + cameraRef.current?.setCamera({ + zoomLevel: CONST.MAPBOX.DEFAULT_ZOOM, + animationMode: CONST.MAPBOX.CAMERA_ANIMATION_MODE, + animationDuration: CONST.MAPBOX.ANIMATION_DURATION_DEFAULT, + centerCoordinate: [currentPositionLongitude, currentPositionLatitude], + }), + waitForUpcomingTransition: true, + maxWaitForUpcomingTransitionMs: MAX_WAIT_FOR_TRANSITION_TIME_FOR_CAMERA_MOVEMENTS_MS, + }); + return () => pendingFramingRef.current?.cancel(); + } + if (waypoints.length === 1) { - cameraRef.current?.setCamera({ - zoomLevel: CONST.MAPBOX.SINGLE_MARKER_ZOOM, - animationDuration: 1500, - centerCoordinate: waypoints.at(0)?.coordinate, + pendingFramingRef.current = TransitionTracker.runAfterTransitions({ + callback: () => + cameraRef.current?.setCamera({ + zoomLevel: CONST.MAPBOX.SINGLE_MARKER_ZOOM, + animationDuration: CONST.MAPBOX.ANIMATION_DURATION_LONG, + animationMode: CONST.MAPBOX.CAMERA_ANIMATION_MODE, + centerCoordinate: waypoints.at(0)?.coordinate, + }), + waitForUpcomingTransition: true, + maxWaitForUpcomingTransitionMs: MAX_WAIT_FOR_TRANSITION_TIME_FOR_CAMERA_MOVEMENTS_MS, }); } else { const {southWest, northEast} = utils.getBounds( waypoints.map((waypoint) => waypoint.coordinate), allDirectionCoordinates, ); - cameraRef.current?.fitBounds(northEast, southWest, mapPadding, 1000); + + pendingFramingRef.current = TransitionTracker.runAfterTransitions({ + callback: () => + cameraRef.current?.setCamera({ + bounds: {ne: northEast, sw: southWest}, + padding: utils.getCameraPadding(mapPadding), + animationDuration: CONST.MAPBOX.ANIMATION_DURATION_DEFAULT, + animationMode: CONST.MAPBOX.CAMERA_ANIMATION_MODE, + }), + waitForUpcomingTransition: true, + maxWaitForUpcomingTransitionMs: MAX_WAIT_FOR_TRANSITION_TIME_FOR_CAMERA_MOVEMENTS_MS, + }); } - }, [mapPadding, waypoints, isIdle, allDirectionCoordinates]), - ); - useEffect(() => { - const unsubscribe = navigation.addListener('blur', () => { - setIsIdle(false); - }); - return unsubscribe; - }, [navigation]); + return () => pendingFramingRef.current?.cancel(); + }, [mapPadding, waypoints, isIdle, allDirectionCoordinates, currentPositionLongitude, currentPositionLatitude]), + ); useEffect(() => { if (!isOffline) { @@ -212,20 +242,62 @@ function MapView({ mapHeading.set(e.properties.heading ?? 0); }; + // Only a real, displayed user location is something to alternate with. currentPosition falls back to initialState + // (e.g. the first waypoint), which would otherwise make the first press zoom into that waypoint instead of framing the route. + const canCenterOnCurrentPosition = shouldDisplayCurrentLocation && !!userLocation; + const {singleWaypointCoordinate, hasWaypointsTarget} = utils.getCenterTargets( + waypoints, + allDirectionCoordinates, + canCenterOnCurrentPosition ? [userLocation.longitude, userLocation.latitude] : undefined, + ); + + // The waypoints can disappear after the user centered on their location, so the button only offers them while they are still a target. + const shouldShowWaypointsCenterIcon = isWaypointsNextCenterTarget && hasWaypointsTarget; + const centerMap = useCallback(() => { + pendingFramingRef.current?.cancel(); const waypointCoordinates = waypoints?.map((waypoint) => waypoint.coordinate) ?? []; - if (waypointCoordinates.length > 1 || (allDirectionCoordinates ?? []).length > 1) { - const {southWest, northEast} = utils.getBounds(waypoints?.map((waypoint) => waypoint.coordinate) ?? [], allDirectionCoordinates); - cameraRef.current?.fitBounds(southWest, northEast, mapPadding, CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME); + const shouldCenterOnWaypoints = hasWaypointsTarget && (isWaypointsNextCenterTarget || !canCenterOnCurrentPosition); + setIsWaypointsNextCenterTarget(!shouldCenterOnWaypoints && hasWaypointsTarget && canCenterOnCurrentPosition); + + if (shouldCenterOnWaypoints) { + if (singleWaypointCoordinate) { + cameraRef.current?.setCamera({ + heading: 0, + centerCoordinate: singleWaypointCoordinate, + zoomLevel: CONST.MAPBOX.SINGLE_MARKER_ZOOM, + animationDuration: CONST.MAPBOX.ANIMATION_DURATION_DEFAULT, + animationMode: CONST.MAPBOX.CAMERA_ANIMATION_MODE, + }); + return; + } + const {southWest, northEast} = utils.getBounds(waypointCoordinates, allDirectionCoordinates); + cameraRef.current?.setCamera({ + bounds: {ne: northEast, sw: southWest}, + padding: utils.getCameraPadding(mapPadding), + animationDuration: CONST.MAPBOX.ANIMATION_DURATION_DEFAULT, + animationMode: CONST.MAPBOX.CAMERA_ANIMATION_MODE, + }); return; } cameraRef?.current?.setCamera({ heading: 0, - centerCoordinate: [currentPosition?.longitude ?? 0, currentPosition?.latitude ?? 0], - animationDuration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME, + centerCoordinate: [currentPositionLongitude ?? 0, currentPositionLatitude ?? 0], + animationDuration: CONST.MAPBOX.ANIMATION_DURATION_DEFAULT, + animationMode: CONST.MAPBOX.CAMERA_ANIMATION_MODE, zoomLevel: CONST.MAPBOX.SINGLE_MARKER_ZOOM, }); - }, [allDirectionCoordinates, currentPosition?.longitude, currentPosition?.latitude, mapPadding, waypoints]); + }, [ + allDirectionCoordinates, + canCenterOnCurrentPosition, + currentPositionLongitude, + currentPositionLatitude, + hasWaypointsTarget, + isWaypointsNextCenterTarget, + mapPadding, + singleWaypointCoordinate, + waypoints, + ]); const centerCoordinate = useMemo(() => (currentPosition ? [currentPosition.longitude, currentPosition.latitude] : initialState?.location), [currentPosition, initialState?.location]); @@ -240,8 +312,23 @@ function MapView({ return {ne: northEast, sw: southWest}; }, [waypoints, allDirectionCoordinates]); + const hasWaypoints = !!waypoints?.length; + const defaultSettings: Mapbox.CameraStop | undefined = useMemo(() => { if (interactive) { + // Start framed on the waypoints the same way the focus effect frames them, so the map does not fly in from the current location + if (waypoints?.length === 1) { + return { + zoomLevel: CONST.MAPBOX.SINGLE_MARKER_ZOOM, + centerCoordinate: waypoints.at(0)?.coordinate, + }; + } + if (waypoints?.length && waypointsBounds) { + return { + bounds: waypointsBounds, + padding: utils.getCameraPadding(mapPadding), + }; + } if (!centerCoordinate) { return undefined; } @@ -256,19 +343,31 @@ function MapView({ return { bounds: waypointsBounds, }; - }, [interactive, centerCoordinate, waypointsBounds, initialState?.zoom]); + }, [interactive, waypoints, waypointsBounds, mapPadding, centerCoordinate, initialState?.zoom]); - const initCenterCoordinate = useMemo(() => (interactive ? centerCoordinate : undefined), [interactive, centerCoordinate]); + // The controlled centerCoordinate is applied after defaultSettings, so skip it while waypoints are shown or it would pull the camera back to the current location + const initCenterCoordinate = useMemo(() => (interactive && !hasWaypoints ? centerCoordinate : undefined), [interactive, hasWaypoints, centerCoordinate]); const initBounds = useMemo(() => (interactive ? undefined : waypointsBounds), [interactive, waypointsBounds]); + // Localize the map labels to the user's preferred app locale so they match the rest of the app. + const localizeLabels = useMemo<{locale: string} | undefined>(() => { + const language = utils.getNativeMapboxLanguage(preferredLocale); + return language ? {locale: language} : undefined; + }, [preferredLocale]); + return !isOffline && isAccessTokenReady && !!defaultSettings ? ( setUserInteractedWithMap(true)} + onTouchStart={() => { + pendingFramingRef.current?.cancel(); + setUserInteractedWithMap(true); + setIsWaypointsNextCenterTarget(false); + }} pitchEnabled={pitchEnabled} attributionPosition={{...styles.r2, ...styles.b2}} scaleBarEnabled={false} @@ -280,11 +379,15 @@ function MapView({ > {interactive && shouldDisplayCurrentLocation && ( diff --git a/src/components/MapView/MapView.web.tsx b/src/components/MapView/MapView.web.tsx index 6030df312f83..ca941dd6fd95 100644 --- a/src/components/MapView/MapView.web.tsx +++ b/src/components/MapView/MapView.web.tsx @@ -14,7 +14,7 @@ import PendingMapView from './PendingMapView'; const MapViewImpl = lazy(() => import('./MapViewImpl.web')); -function MapView({ref, ...props}: MapViewProps) { +function MapView(props: MapViewProps) { const {isOffline} = useNetwork(); const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -49,10 +49,7 @@ function MapView({ref, ...props}: MapViewProps) { } > {!isOffline ? ( - + ) : ( (null); const initialLocation = useMemo(() => ({longitude: initialState.location[0], latitude: initialState.location[1]}), [initialState]); const currentPosition = userLocation ?? initialLocation; const prevUserPosition = usePrevious(currentPosition); const [userInteractedWithMap, setUserInteractedWithMap] = useState(false); + const [isWaypointsNextCenterTarget, setIsWaypointsNextCenterTarget] = useState(false); const [isHoveringDirection, setIsHoveringDirection] = useState(false); // Mapbox corrupts its camera state when the map container is resized to zero (e.g. while a page // transition collapses it), and every camera update throws afterwards. The map therefore mounts @@ -86,6 +87,7 @@ function MapViewImpl({ const resetBoundariesRef = useRef<() => void>(() => {}); const setRef = useCallback((newRef: MapRef | null) => setMapRef(newRef), []); const shouldInitializeCurrentPosition = useRef(true); + const [lastViewState, setLastViewState] = useState(); // Determines if map can be panned to user's detected // location without bothering the user. It will return @@ -146,6 +148,7 @@ function MapViewImpl({ center: [currentPosition.longitude, currentPosition.latitude], zoom: CONST.MAPBOX.DEFAULT_ZOOM, animate: shouldAnimatePan, + speed: CONST.MAPBOX.FLY_TO_SPEED, }); }, [currentPosition, mapRef, prevUserPosition.longitude, prevUserPosition.latitude, shouldPanMapToCurrentPosition]); @@ -166,10 +169,14 @@ function MapViewImpl({ return; } + // The camera goes back to framing the waypoints below, so the center button starts the cycle again from the current position. + setIsWaypointsNextCenterTarget(false); + if (waypoints.length === 1) { mapRef.flyTo({ center: waypoints.at(0)?.coordinate, zoom: CONST.MAPBOX.SINGLE_MARKER_ZOOM, + speed: CONST.MAPBOX.FLY_TO_SPEED, }); return; } @@ -180,7 +187,7 @@ function MapViewImpl({ waypoints.map((waypoint) => waypoint.coordinate), allDirectionCoordinates, ); - map.fitBounds([northEast, southWest], {padding: mapPadding}); + map.fitBounds([northEast, southWest], {padding: mapPadding, speed: CONST.MAPBOX.FLY_TO_SPEED}); }, [waypoints, mapRef, mapPadding, allDirectionCoordinates]); // The ResizeObserver outlives renders, so it reads the latest reset logic through a ref. @@ -188,6 +195,7 @@ function MapViewImpl({ resetBoundariesRef.current = resetBoundaries; }, [resetBoundaries]); + // eslint-disable-next-line react-hooks/set-state-in-effect -- we need to reset Center button target when camera moves on waypoints change useEffect(resetBoundaries, [resetBoundaries]); useEffect(() => { @@ -212,6 +220,32 @@ function MapViewImpl({ }; }, [mapRef]); + // cspell:ignore styledata + // Keep the map labels in the user's preferred app locale and its disputed borders drawn from the user's + // own worldview, reapplying whenever the map, the locale or the country changes. + useEffect(() => { + if (!mapRef) { + return; + } + + const map = mapRef.getMap(); + const applyLocalization = () => { + map.setLanguage(utils.getMapboxLanguage(preferredLocale)); + map.setWorldview(utils.getMapboxWorldview(countryByIp)); + }; + + if (map.isStyleLoaded()) { + applyLocalization(); + return; + } + + // The style must be loaded before labels and borders can be localized, so defer until it is ready. + map.once('styledata', applyLocalization); + return () => { + map.off('styledata', applyLocalization); + }; + }, [mapRef, preferredLocale, countryByIp]); + // Tracks the wrapper size so the map mounts when it appears and unmounts when it collapses. // Re-runs when the wrapper can come into existence, i.e. once the map is no longer pending. useEffect(() => { @@ -231,29 +265,39 @@ function MapViewImpl({ }; }, [accessToken, isOffline]); - useImperativeHandle( - ref, - () => ({ - flyTo: (location: [number, number], zoomLevel: number = CONST.MAPBOX.DEFAULT_ZOOM, animationDuration?: number) => - mapRef?.flyTo({ - center: location, - zoom: zoomLevel, - duration: animationDuration, - }), - fitBounds: (northEast: [number, number], southWest: [number, number]) => mapRef?.fitBounds([northEast, southWest]), - }), - [mapRef], + // Only a real, displayed user location is something to alternate with. currentPosition falls back to initialState + // (e.g. the first waypoint), which would otherwise make the first press zoom into that waypoint instead of framing the route. + const canCenterOnCurrentPosition = shouldDisplayCurrentLocation && !!userLocation; + const {singleWaypointCoordinate, hasWaypointsTarget} = utils.getCenterTargets( + waypoints, + allDirectionCoordinates, + canCenterOnCurrentPosition ? [userLocation.longitude, userLocation.latitude] : undefined, ); + // The waypoints can disappear after the user centered on their location, so the button only offers them while they are still a target. + const shouldShowWaypointsCenterIcon = isWaypointsNextCenterTarget && hasWaypointsTarget; + const centerMap = useCallback(() => { if (!mapRef) { return; } const waypointCoordinates = waypoints?.map((waypoint) => waypoint.coordinate) ?? []; - if (waypointCoordinates.length > 1 || (allDirectionCoordinates ?? []).length > 1) { - const {northEast, southWest} = utils.getBounds(waypoints?.map((waypoint) => waypoint.coordinate) ?? [], allDirectionCoordinates); + const shouldCenterOnWaypoints = hasWaypointsTarget && (isWaypointsNextCenterTarget || !canCenterOnCurrentPosition); + setIsWaypointsNextCenterTarget(!shouldCenterOnWaypoints && hasWaypointsTarget && canCenterOnCurrentPosition); + + if (shouldCenterOnWaypoints) { + if (singleWaypointCoordinate) { + mapRef.flyTo({ + center: singleWaypointCoordinate, + zoom: CONST.MAPBOX.SINGLE_MARKER_ZOOM, + bearing: 0, + speed: CONST.MAPBOX.FLY_TO_SPEED, + }); + return; + } + const {northEast, southWest} = utils.getBounds(waypointCoordinates, allDirectionCoordinates); const map = mapRef?.getMap(); - map?.fitBounds([southWest, northEast], {padding: mapPadding, animate: true, duration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME}); + map?.fitBounds([southWest, northEast], {padding: mapPadding, speed: CONST.MAPBOX.FLY_TO_SPEED}); return; } @@ -261,10 +305,24 @@ function MapViewImpl({ center: [currentPosition?.longitude ?? 0, currentPosition?.latitude ?? 0], zoom: CONST.MAPBOX.SINGLE_MARKER_ZOOM, bearing: 0, - animate: true, - duration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME, + speed: CONST.MAPBOX.FLY_TO_SPEED, }); - }, [allDirectionCoordinates, currentPosition?.longitude, currentPosition?.latitude, mapRef, waypoints, mapPadding]); + }, [ + allDirectionCoordinates, + canCenterOnCurrentPosition, + currentPosition?.longitude, + currentPosition?.latitude, + hasWaypointsTarget, + isWaypointsNextCenterTarget, + mapRef, + mapPadding, + singleWaypointCoordinate, + waypoints, + ]); + + const saveViewState = (event: ViewStateChangeEvent) => { + setLastViewState(event.viewState); + }; const initialViewState: Partial | undefined = useMemo(() => { if (!interactive) { @@ -284,12 +342,34 @@ function MapViewImpl({ fitBoundsOptions: {padding: mapPadding}, }; } + if (lastViewState && waypoints?.length) { + return lastViewState; + } + // Start framed on the waypoints the same way resetBoundaries frames them, so the map does not fly in from the current location + const firstWaypointCoordinate = waypoints?.length === 1 ? waypoints.at(0)?.coordinate : undefined; + if (firstWaypointCoordinate) { + return { + longitude: firstWaypointCoordinate[0], + latitude: firstWaypointCoordinate[1], + zoom: CONST.MAPBOX.SINGLE_MARKER_ZOOM, + }; + } + if (waypoints?.length) { + const {northEast, southWest} = utils.getBounds( + waypoints.map((waypoint) => waypoint.coordinate), + allDirectionCoordinates, + ); + return { + bounds: [northEast, southWest], + fitBoundsOptions: {padding: mapPadding}, + }; + } return { longitude: currentPosition?.longitude, latitude: currentPosition?.latitude, zoom: initialState.zoom, }; - }, [waypoints, allDirectionCoordinates, interactive, currentPosition?.longitude, currentPosition?.latitude, initialState.zoom, mapPadding]); + }, [waypoints, allDirectionCoordinates, interactive, currentPosition?.longitude, currentPosition?.latitude, initialState.zoom, mapPadding, lastViewState]); // The route layers only need to be interactive when there is an alternate route to pick, so that clicking a route selects it. const interactiveLayerIds = useMemo(() => (interactive && hasAlternateDirection ? ALTERNATE_DIRECTIONS_LAYER_IDS : undefined), [interactive, hasAlternateDirection]); @@ -298,6 +378,7 @@ function MapViewImpl({ setUserInteractedWithMap(true); // Dragging must keep the grabbing cursor even when it starts on top of a route. setIsHoveringDirection(false); + setIsWaypointsNextCenterTarget(false); }, []); const selectClickedDirection = useCallback( @@ -323,6 +404,7 @@ function MapViewImpl({ {hasValidContainerSize && ( diff --git a/src/components/MapView/MapViewTypes.ts b/src/components/MapView/MapViewTypes.ts index d3d5520728b4..d19ec2f0d692 100644 --- a/src/components/MapView/MapViewTypes.ts +++ b/src/components/MapView/MapViewTypes.ts @@ -56,9 +56,6 @@ type MapViewProps = { /** Unit of measurement for distance */ unit?: Unit; - /** Reference to the outerElement */ - ref?: React.ForwardedRef; - shouldDisplayCurrentLocation?: boolean; /** Whether it should display the compass overlay on the map */ @@ -189,14 +186,6 @@ type WayPoint = { markerType: MapMarkerType; }; -/** Represents a handle to interact with a map view. */ -type MapViewHandle = { - flyTo: (location: Coordinate, zoomLevel: number, animationDuration?: number) => void; - - /** Fit the map view to a bounding box */ - fitBounds: (ne: Coordinate, sw: Coordinate, paddingConfig?: number | number[], animationDuration?: number) => void; -}; - type DistanceSymbolMarkerProps = {distanceSymbolCoordinate: Coordinate; children: React.ReactNode; onPress: () => void}; type AlternateDirectionsProps = Required> & Pick; diff --git a/src/components/MapView/utils.ts b/src/components/MapView/utils.ts index 486043333b7a..07da9fe0daae 100644 --- a/src/components/MapView/utils.ts +++ b/src/components/MapView/utils.ts @@ -1,4 +1,59 @@ -import type {AlternateDirection, Coordinate} from './MapViewTypes'; +import {LOCALES} from '@src/CONST/LOCALES'; +import type {Locale} from '@src/CONST/LOCALES'; + +import type {CameraPadding} from '@rnmapbox/maps'; + +import type {AlternateDirection, Coordinate, WayPoint} from './MapViewTypes'; + +/** App locales whose value isn't already the BCP-47 code Mapbox expects for label localization. */ +const LOCALE_TO_MAPBOX_LANGUAGE: Partial> = { + [LOCALES.PT_BR]: 'pt', + [LOCALES.ZH_HANS]: 'zh-Hans', +}; + +/** + * Languages the native Mapbox Maps SDKs can localize labels to, i.e. the Mapbox Streets v8 `name_*` fields. + * Unlike mapbox-gl on web, the native SDKs reject any other language: iOS throws and Android logs a warning on every map load. + */ +const NATIVE_MAPBOX_SUPPORTED_LANGUAGES = new Set(['ar', 'en', 'es', 'fr', 'de', 'it', 'pt', 'ru', 'ja', 'ko', 'vi', 'zh-Hans', 'zh-Hant']); + +/** + * Maps an app locale to the BCP-47 language code Mapbox uses to localize map labels. + * Most app locales are already valid Mapbox codes, so only a couple need remapping. + * On web, unsupported codes fall back to each label's local language on the Mapbox side. + */ +function getMapboxLanguage(locale: Locale | undefined): string | undefined { + if (!locale) { + return undefined; + } + return LOCALE_TO_MAPBOX_LANGUAGE[locale] ?? locale; +} + +/** + * Maps an app locale to the language the native Mapbox SDKs localize labels to, or undefined when they can't. + * Leaving the language unset keeps the style's default labels instead of raising a native error. + */ +function getNativeMapboxLanguage(locale: Locale | undefined): string | undefined { + const language = getMapboxLanguage(locale); + return language && NATIVE_MAPBOX_SUPPORTED_LANGUAGES.has(language) ? language : undefined; +} + +/** A worldview is an ISO 3166-1 alpha-2 country code, so anything that isn't two letters can't be one. */ +const ISO_ALPHA_2_COUNTRY = /^[A-Z]{2}$/; + +/** + * Maps the user's country to the Mapbox worldview used to draw disputed borders. + * Mapbox only defines a worldview for the handful of countries that dispute borders and falls back to the + * style's default for every other country code, so the country is passed straight through rather than + * matched against a list that would go stale as Mapbox adds worldviews. Anything that isn't a country code + * is dropped, because Mapbox raises an error for codes it can't parse. + */ +function getMapboxWorldview(country: string | undefined): string | undefined { + if (!country || !ISO_ALPHA_2_COUNTRY.test(country)) { + return undefined; + } + return country; +} /** A geographic point as a plain longitude/latitude pair. Mapbox's `LngLat` became a class in mapbox-gl 3.x, but these helpers only read `.lng`/`.lat`, so a literal shape is all that's needed. */ type LngLatLiteral = {lng: number; lat: number}; @@ -30,15 +85,15 @@ function getBounds(waypoints: Coordinate[], directionCoordinates: undefined | Co } /** - * Calculates the distance between two points on the Earth's surface given their latitude and longitude coordinates. + * Calculates the distance between two points on the Earth's surface given their [longitude, latitude] coordinates. */ function haversineDistance(coordinate1: Coordinate, coordinate2: Coordinate) { // Radius of the Earth in meters const R = 6371e3; - const lat1 = ((coordinate1.at(0) ?? 0) * Math.PI) / 180; - const lat2 = ((coordinate2.at(0) ?? 0) * Math.PI) / 180; - const deltaLat = (((coordinate2.at(0) ?? 0) - (coordinate1.at(0) ?? 0)) * Math.PI) / 180; - const deltaLon = (((coordinate2.at(1) ?? 0) - (coordinate1.at(1) ?? 0)) * Math.PI) / 180; + const lat1 = ((coordinate1.at(1) ?? 0) * Math.PI) / 180; + const lat2 = ((coordinate2.at(1) ?? 0) * Math.PI) / 180; + const deltaLat = (((coordinate2.at(1) ?? 0) - (coordinate1.at(1) ?? 0)) * Math.PI) / 180; + const deltaLon = (((coordinate2.at(0) ?? 0) - (coordinate1.at(0) ?? 0)) * Math.PI) / 180; // The square of half the chord length between the points const halfChordLengthSq = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2); @@ -140,14 +195,14 @@ function simpleInterpolateCoordinate(start: Coordinate, end: Coordinate, progres function getBoundsCenter(bounds: {southWest: Coordinate; northEast: Coordinate}) { const { - southWest: [south, west], - northEast: [north, east], + southWest: [west, south], + northEast: [east, north], } = bounds; const latitudeCenter = (north + south) / 2; const longitudeCenter = (east + west) / 2; - return {lng: latitudeCenter, lat: longitudeCenter}; + return {lng: longitudeCenter, lat: latitudeCenter}; } /** Coordinate that lies `fraction` of the way along a route, measured by length rather than by index, as the density of the coordinates varies along a route. */ @@ -162,8 +217,7 @@ function findCoordinateAtRouteFraction(lineCoordinates: Coordinate[], fraction: break; } - // map Coordinates are [long, lat], but haversineDistance treats [0] as latitude and [1] as longitude - totalLength += haversineDistance([startPoint[1], startPoint[0]], [endPoint[1], endPoint[0]]); + totalLength += haversineDistance(startPoint, endPoint); } const targetLength = totalLength * fraction; @@ -177,7 +231,7 @@ function findCoordinateAtRouteFraction(lineCoordinates: Coordinate[], fraction: break; } - const segmentLength = haversineDistance([startPoint[1], startPoint[0]], [endPoint[1], endPoint[0]]); + const segmentLength = haversineDistance(startPoint, endPoint); if (walkedLength + segmentLength >= targetLength) { return simpleInterpolateCoordinate(startPoint, endPoint, segmentLength > 0 ? (targetLength - walkedLength) / segmentLength : 0); @@ -237,8 +291,33 @@ function getCoordinatesFromAllDirections(directionCoordinates: Coordinate[] | Co return [...(directionCoordinatesFlattened ?? []), ...(alternateDirectionCoordinatesFlattened ?? [])]; } +/** Applies the same padding to every side of the native camera viewport */ +function getCameraPadding(padding: number | undefined): CameraPadding | undefined { + if (padding === undefined) { + return undefined; + } + return {paddingLeft: padding, paddingRight: padding, paddingTop: padding, paddingBottom: padding}; +} + +/** Resolves what the center button can move the camera to: the waypoints (a fitted route or a lone waypoint) and/or the current position */ +function getCenterTargets(waypoints: WayPoint[] | undefined, directionCoordinates: Coordinate[] | undefined, currentPosition: Coordinate | undefined) { + const waypointCoordinates = waypoints?.map((waypoint) => waypoint.coordinate) ?? []; + const hasRoute = waypointCoordinates.length > 1 || (directionCoordinates ?? []).length > 1; + + // With a single waypoint there is no route to fit yet, so that lone waypoint is what the button alternates with. + const singleWaypointCoordinate = !hasRoute ? waypointCoordinates.at(0) : undefined; + + // A lone waypoint at the current position is the same target, so there is nothing to alternate with. + const isSingleWaypointAtCurrentPosition = !!singleWaypointCoordinate && !!currentPosition && areSameCoordinate(singleWaypointCoordinate, currentPosition); + const hasWaypointsTarget = hasRoute || (!!singleWaypointCoordinate && !isSingleWaypointAtCurrentPosition); + + return {waypointCoordinates, singleWaypointCoordinate, hasWaypointsTarget}; +} + export default { getBounds, + getCameraPadding, + getCenterTargets, areSameCoordinate, areCoordinatesEqual, getSinglePointCoordinate, @@ -249,4 +328,7 @@ export default { isSingleSegmentRoute, convertSegmentedRouteToSingleSegmentRoute, getCoordinatesFromAllDirections, + getMapboxLanguage, + getNativeMapboxLanguage, + getMapboxWorldview, }; diff --git a/src/components/MenuItem/MenuItem.tsx b/src/components/MenuItem/MenuItem.tsx index 3c36e9ba6d81..e18763366a7a 100644 --- a/src/components/MenuItem/MenuItem.tsx +++ b/src/components/MenuItem/MenuItem.tsx @@ -24,6 +24,7 @@ import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; import getContextMenuAccessibilityHint from '@components/utils/getContextMenuAccessibilityHint'; import getContextMenuAccessibilityProps from '@components/utils/getContextMenuAccessibilityProps'; +import useCopyableTextRowPress from '@hooks/useCopyableTextRowPress'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -37,8 +38,10 @@ import {canUseTouchScreen, hasHoverSupport} from '@libs/DeviceCapabilities'; import {containsCustomEmoji, containsOnlyCustomEmoji} from '@libs/EmojiUtils'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import getButtonState from '@libs/getButtonState'; +import getPlatform from '@libs/getPlatform'; import mergeRefs from '@libs/mergeRefs'; import Parser from '@libs/Parser'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import {getAccountIDFromAvatarID} from '@libs/UserAvatarUtils'; @@ -58,7 +61,7 @@ import type WithSentryLabel from '@src/types/utils/SentryLabel'; import type {ImageContentFit} from 'expo-image'; import type {ComponentRef, ReactElement, ReactNode, Ref} from 'react'; -import type {GestureResponderEvent, ImageStyle, Role, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {AccessibilityState, GestureResponderEvent, ImageStyle, Role, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {AnimatedStyle} from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; @@ -194,6 +197,9 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Hint to display at the bottom of the component */ hintText?: string | ReactNode; + /** Any additional styles to pass to hint text. */ + hintTextStyle?: StyleProp; + /** Should the error text red dot indicator be shown */ shouldShowRedDotIndicator?: boolean; @@ -225,7 +231,6 @@ type MenuItemBaseProps = ForwardedFSClassProps & titleAccessibilityRole?: typeof CONST.ROLE.HEADER; titleComponent?: ReactElement; - titleContainerStyle?: StyleProp; /** A right-aligned subtitle for this menu option */ subtitle?: string | number; @@ -387,6 +392,9 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Should enable copy to clipboard action */ copyable?: boolean; + /** Whether the title text should be directly selectable */ + isTitleSelectable?: boolean; + /** Plaid image for the bank */ plaidUrl?: string; @@ -405,6 +413,9 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** The accessibility role to use for this menu item */ role?: Role; + /** Overrides the computed accessibilityState (e.g. `{checked}` for a row that acts as a switch) */ + accessibilityState?: AccessibilityState; + shouldShowBadgeInSeparateRow?: boolean; /** Whether to show the badge below the title */ @@ -484,6 +495,7 @@ function MenuItem({ errorTextStyle, shouldShowRedDotIndicator, hintText, + hintTextStyle, success = false, iconReportID, focused = false, @@ -491,7 +503,6 @@ function MenuItem({ title, accessibilityLabel, titleComponent, - titleContainerStyle, subtitle, subtitleStyle, shouldShowBasicTitle, @@ -557,6 +568,7 @@ function MenuItem({ plaidUrl, copyValue = title, copyable = false, + isTitleSelectable = false, hasSubMenuItems = false, forwardedFSClass, ref, @@ -564,6 +576,7 @@ function MenuItem({ shouldUseNativeHoverEvents = false, sentryLabel, role = CONST.ROLE.BUTTON, + accessibilityState, shouldBeAccessible = true, tabIndex = 0, titleAccessibilityRole, @@ -581,6 +594,15 @@ function MenuItem({ const {singleExecution, waitForNavigate} = useMenuItemGroupActions() ?? {}; const popoverAnchor = useRef>(null); const pressableRef = useRef>(null); + const { + didTouchStartOnCopyableText, + handleCopyableTextRowPressIn, + isPressStartOnCopyableText, + markMouseDownOnCopyableText, + markTouchStartOnCopyableText, + shouldSuppressCopyableTextRowLongPress, + shouldSuppressCopyableTextRowPress, + } = useCopyableTextRowPress({shouldTrackTouchStart: true}); useRemoveNonInteractiveClickHandler(pressableRef, interactive); const deviceHasHoverSupport = hasHoverSupport(); const isCompactMenu = useIsCompactMenu(); @@ -658,6 +680,7 @@ function MenuItem({ {description} @@ -681,6 +704,7 @@ function MenuItem({ }, [helperText, shouldParseHelperText, shouldEscapeText]); const shouldRenderTitleAsHTML = shouldRenderAsHTML && !!title && Parser.isHTML(title); + const shouldEnableTextSelection = isTitleSelectable && getPlatform() === CONST.PLATFORM.WEB; const processedTitle = useMemo(() => { let titleToWrap = ''; @@ -748,6 +772,10 @@ function MenuItem({ return; } + if (shouldSuppressCopyableTextRowPress(isTitleSelectable)) { + return; + } + if (event?.type === 'click') { (event.currentTarget as HTMLElement).blur(); } @@ -766,6 +794,9 @@ function MenuItem({ }; const secondaryInteraction = (event: GestureResponderEvent | MouseEvent) => { + if (isTitleSelectable && (shouldSuppressCopyableTextRowLongPress() || isPressStartOnCopyableText(event))) { + return; + } if (!copyValue) { return; } @@ -777,6 +808,7 @@ function MenuItem({ }); onSecondaryInteraction?.(event); }; + const secondaryInteractionHandler = copyable && !deviceHasHoverSupport ? secondaryInteraction : onSecondaryInteraction; const isIDPassed = !!iconReportID || !!iconAccountID || iconAccountID === CONST.DEFAULT_NUMBER_ID; @@ -813,9 +845,23 @@ function MenuItem({ {(isHovered) => ( shouldBlockSelection && shouldUseNarrowLayout && canUseTouchScreen() && ControlSelection.block()} + onMouseDown={(event) => { + markMouseDownOnCopyableText(event?.target, isTitleSelectable); + }} + onTouchStart={(event) => { + markTouchStartOnCopyableText(event, shouldEnableTextSelection && isPressStartOnCopyableText(event)); + }} + shouldAllowTextSelection={isTitleSelectable} + preventDefaultContextMenu={(event) => deviceHasHoverSupport || !isTitleSelectable || !isPressStartOnCopyableText(event)} + onPressIn={() => + handleCopyableTextRowPressIn({ + shouldEnableTextSelection, + shouldBlockSelection: shouldBlockSelection && shouldUseNarrowLayout && canUseTouchScreen(), + }) + } onPressOut={ControlSelection.unblock} - onSecondaryInteraction={copyable && !deviceHasHoverSupport ? secondaryInteraction : onSecondaryInteraction} + // Keep the native selection menu available through press-out; reset on the next pointer start. + onSecondaryInteraction={shouldEnableTextSelection && didTouchStartOnCopyableText ? undefined : secondaryInteractionHandler} wrapperStyle={outerWrapperStyle} activeOpacity={!interactive ? 1 : variables.pressDimValue} opacityAnimationDuration={variables.instantAnimationDuration} @@ -844,7 +890,7 @@ function MenuItem({ accessibilityLabel={accessibilityLabelWithContextMenuHint} accessibilityHint={accessibilityHint} accessible={shouldBeAccessible} - accessibilityState={role === CONST.ROLE.TAB ? {selected: focused} : undefined} + accessibilityState={accessibilityState ?? (role === CONST.ROLE.TAB ? {selected: focused} : undefined)} tabIndex={interactive ? tabIndex : -1} onFocus={onFocus} sentryLabel={sentryLabel} @@ -982,12 +1028,7 @@ function MenuItem({ )} {shouldShowDescriptionOnTop && renderDescriptionView()} {(!!title || !!shouldShowTitleIcon) && ( @@ -996,12 +1037,23 @@ function MenuItem({ fsClass={forwardedFSClass} > {!!title && (shouldRenderAsHTML || (shouldParseTitle && !!html.length)) && ( - + {/* Use Text instead of RenderHTML when the title is plain text. Titles with shouldRenderAsHTML use baseFontStyle, which differs from combinedTitleTextStyle below. */} {shouldRenderTitleAsHTML || shouldParseTitle ? ( - + ) : ( {convertToLTR(Parser.htmlToText(processedTitle))} )} @@ -1011,7 +1063,10 @@ function MenuItem({ {renderTitleContent()} @@ -1209,6 +1264,7 @@ function MenuItem({ shouldShowRedDotIndicator={!!shouldShowRedDotIndicator} message={errorText} style={[styles.menuItemError, errorTextStyle]} + dataSet={isTitleSelectable ? {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true} : undefined} shouldRenderMessageAsHTML={shouldRenderErrorAsHTML} /> )} @@ -1218,6 +1274,8 @@ function MenuItem({ shouldShowRedDotIndicator={false} message={hintText} style={styles.menuItemError} + messageStyle={hintTextStyle} + dataSet={isTitleSelectable ? {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true} : undefined} shouldRenderMessageAsHTML={shouldRenderHintAsHTML} /> )} diff --git a/src/components/MenuItem/MenuItemContext.tsx b/src/components/MenuItem/MenuItemContext.tsx index 8fbdb4f7b5f4..b0c0895ad5af 100644 --- a/src/components/MenuItem/MenuItemContext.tsx +++ b/src/components/MenuItem/MenuItemContext.tsx @@ -7,6 +7,9 @@ type MenuItemConfig = { /** Whether the row responds to interactions */ isInteractive: boolean; + + /** Whether the row opts into browser selection of explicitly marked values */ + shouldAllowTextSelection: boolean; }; /** Interaction state of the menu item row */ @@ -21,6 +24,7 @@ type MenuItemInteraction = { const DEFAULT_CONFIG: MenuItemConfig = { isDisabled: false, isInteractive: false, + shouldAllowTextSelection: false, }; const DEFAULT_INTERACTION: MenuItemInteraction = { diff --git a/src/components/MenuItem/layout/MenuItemRoot.tsx b/src/components/MenuItem/layout/MenuItemRoot.tsx index ce2105008840..39b0502756ba 100644 --- a/src/components/MenuItem/layout/MenuItemRoot.tsx +++ b/src/components/MenuItem/layout/MenuItemRoot.tsx @@ -6,6 +6,7 @@ import {MenuItemConfigContext, MenuItemInteractionContext} from '@components/Men import MenuItemSecondaryInteractionContext, {useMenuItemSecondaryInteractionRegistry} from '@components/MenuItem/MenuItemSecondaryInteractionContext'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; +import useCopyableTextRowPress from '@hooks/useCopyableTextRowPress'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -13,6 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import ControlSelection from '@libs/ControlSelection'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getButtonState from '@libs/getButtonState'; +import getPlatform from '@libs/getPlatform'; import variables from '@styles/variables'; @@ -47,17 +49,47 @@ type MenuItemRootProps = PropsWithChildren & */ style?: StyleProp; + /** Whether explicitly marked child text can start native browser text selection */ + shouldAllowTextSelection?: boolean; + /** Accessibility state for the row, e.g. `{expanded}`. Lands on the pressable, which is what a screen reader focuses. */ accessibilityState?: AccessibilityState; + + /** + * Whether the row is one element for screen readers. Turn it off for a row without `onPress` whose + * buttons must stay reachable, since an accessible parent hides its children on iOS. + */ + shouldBeAccessible?: boolean; }; -function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testID, accessibilityLabel, style, accessibilityState}: MenuItemRootProps) { +function MenuItemRoot({ + children, + onPress, + isDisabled = false, + sentryLabel, + testID, + accessibilityLabel, + style, + shouldAllowTextSelection = false, + accessibilityState, + shouldBeAccessible = true, +}: MenuItemRootProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const pressableRef = useRef>(null); const isCompactPopover = useIsCompactPopover(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const isInteractive = !!onPress; + const shouldEnableTextSelection = shouldAllowTextSelection && getPlatform() === CONST.PLATFORM.WEB; + const { + didTouchStartOnCopyableText, + handleCopyableTextRowPressIn, + isPressStartOnCopyableText, + markMouseDownOnCopyableText, + markTouchStartOnCopyableText, + shouldSuppressCopyableTextRowLongPress, + shouldSuppressCopyableTextRowPress, + } = useCopyableTextRowPress({shouldTrackTouchStart: true}); const {accessibilityLabel: rowAccessibilityLabel, accessibilityHint, registries: accessibilityRegistries} = useMenuItemAccessibility(accessibilityLabel); const {handler: registeredSecondaryInteraction, register: registerSecondaryInteraction} = useMenuItemSecondaryInteractionRegistry(); @@ -69,6 +101,10 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI return; } + if (shouldSuppressCopyableTextRowPress(shouldEnableTextSelection)) { + return; + } + // Prevent clicked menu items from retaining an unwanted focus outline on web, especially in Safari if (event?.type === 'click' && typeof HTMLElement !== 'undefined' && event.currentTarget instanceof HTMLElement) { event.currentTarget.blur(); @@ -82,19 +118,38 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI // Left undefined when no sub-component wants it, so the web keeps its native context menu on a plain row const onSecondaryInteractionAction = registeredSecondaryInteraction - ? (event: GestureResponderEvent | MouseEvent) => registeredSecondaryInteraction(event, pressableRef.current) + ? (event: GestureResponderEvent | MouseEvent) => { + if (shouldEnableTextSelection && (shouldSuppressCopyableTextRowLongPress() || isPressStartOnCopyableText(event))) { + return; + } + registeredSecondaryInteraction(event, pressableRef.current); + } : undefined; return ( - + {(isHovered) => ( !!onSecondaryInteractionAction && shouldUseNarrowLayout && canUseTouchScreen() && ControlSelection.block()} + onMouseDown={(event) => { + markMouseDownOnCopyableText(event?.target, shouldEnableTextSelection); + }} + onTouchStart={(event) => { + markTouchStartOnCopyableText(event, shouldEnableTextSelection && isPressStartOnCopyableText(event)); + }} + shouldAllowTextSelection={shouldEnableTextSelection} + preventDefaultContextMenu={(event) => !shouldEnableTextSelection || !isPressStartOnCopyableText(event)} + onPressIn={() => + handleCopyableTextRowPressIn({ + shouldEnableTextSelection, + shouldBlockSelection: !!onSecondaryInteractionAction && shouldUseNarrowLayout && canUseTouchScreen(), + }) + } onPressOut={ControlSelection.unblock} - onSecondaryInteraction={onSecondaryInteractionAction} + // RN Web prevents the native context menu whenever an onLongPress handler is attached. + // Reset on the next pointer start, since press-out can precede the browser's selection menu. + onSecondaryInteraction={shouldEnableTextSelection && didTouchStartOnCopyableText ? undefined : onSecondaryInteractionAction} activeOpacity={!isInteractive ? 1 : variables.pressDimValue} opacityAnimationDuration={variables.instantAnimationDuration} style={({pressed}) => @@ -114,7 +169,7 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI accessibilityLabel={rowAccessibilityLabel} accessibilityHint={accessibilityHint} accessibilityState={accessibilityState} - accessible + accessible={shouldBeAccessible} tabIndex={isInteractive ? 0 : -1} sentryLabel={sentryLabel} testID={testID} diff --git a/src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx b/src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx index fd203fcad6a0..d6968be987f1 100644 --- a/src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx +++ b/src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx @@ -5,6 +5,8 @@ import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import convertToLTR from '@libs/convertToLTR'; +import getPlatform from '@libs/getPlatform'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import CONST from '@src/CONST'; @@ -13,9 +15,17 @@ import React from 'react'; import type {BaseMenuItemTextProps, MenuItemPrimaryTextProps} from './types'; /** Base of the full-contrast leaves */ -function BaseMenuItemPrimaryText({children, accessibilityLabel, numberOfLines = 1, slot, style}: MenuItemPrimaryTextProps & BaseMenuItemTextProps) { +function BaseMenuItemPrimaryText({children, accessibilityLabel, numberOfLines = 1, isSelectable = false, slot, style}: MenuItemPrimaryTextProps & BaseMenuItemTextProps) { const styles = useThemeStyles(); const {isDisabled, isInteractive} = useMenuItemConfig(); + const shouldHideTextFromSelection = isInteractive && isDisabled; + const shouldAllowTextSelection = isSelectable && getPlatform() === CONST.PLATFORM.WEB && !shouldHideTextFromSelection; + let selectionDataSet: React.ComponentProps['dataSet']; + if (shouldHideTextFromSelection) { + selectionDataSet = {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}; + } else if (shouldAllowTextSelection) { + selectionDataSet = COPYABLE_TEXT_DATA_SET; + } useMenuItemAccessibilityLabel(slot, accessibilityLabel ?? String(children)); @@ -25,13 +35,14 @@ function BaseMenuItemPrimaryText({children, accessibilityLabel, numberOfLines = styles.flexShrink1, styles.popoverMenuText, numberOfLines === 1 ? styles.pre : styles.preWrap, - isInteractive && isDisabled && styles.userSelectNone, + shouldHideTextFromSelection && styles.userSelectNone, + shouldAllowTextSelection && styles.userSelectText, styles.ltr, styles.mw100, style, ]} numberOfLines={numberOfLines} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: isInteractive && isDisabled}} + dataSet={selectionDataSet} > {typeof children === 'string' ? convertToLTR(children) : children} diff --git a/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx b/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx index 7c7c5744a256..108e38060543 100644 --- a/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx +++ b/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx @@ -1,8 +1,11 @@ import {useMenuItemAccessibilityLabel} from '@components/MenuItem/MenuItemAccessibilityContext'; +import {useMenuItemConfig} from '@components/MenuItem/MenuItemContext'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + import React from 'react'; import type {BaseMenuItemTextProps, MenuItemSupportingTextProps} from './types'; @@ -10,13 +13,15 @@ import type {BaseMenuItemTextProps, MenuItemSupportingTextProps} from './types'; /** Base of the muted leaves */ function BaseMenuItemSupportingText({children, numberOfLines = 2, slot, style}: MenuItemSupportingTextProps & BaseMenuItemTextProps) { const styles = useThemeStyles(); + const {shouldAllowTextSelection} = useMenuItemConfig(); useMenuItemAccessibilityLabel(slot, String(children)); return ( {children} diff --git a/src/components/MenuItem/leaves/content/base/types.ts b/src/components/MenuItem/leaves/content/base/types.ts index bb1604686404..305432c6c63d 100644 --- a/src/components/MenuItem/leaves/content/base/types.ts +++ b/src/components/MenuItem/leaves/content/base/types.ts @@ -21,6 +21,9 @@ type MenuItemPrimaryTextProps = ( ) & { /** Defaults to 1. Anything else lets the text wrap, and `0` lets it grow unbounded */ numberOfLines?: number; + + /** Whether this text can start native browser text selection inside an interactive row */ + isSelectable?: boolean; }; /** Props of the muted leaves */ diff --git a/src/components/MenuItem/leaves/helpText/BaseMenuItemHelpText.tsx b/src/components/MenuItem/leaves/helpText/BaseMenuItemHelpText.tsx index e7ad90213e78..77dad679bbde 100644 --- a/src/components/MenuItem/leaves/helpText/BaseMenuItemHelpText.tsx +++ b/src/components/MenuItem/leaves/helpText/BaseMenuItemHelpText.tsx @@ -5,13 +5,15 @@ import React from 'react'; import type {BaseMenuItemHelpTextProps} from './types'; /** Base of the help line leaves */ -function BaseMenuItemHelpText({message, children, isError = false, style}: BaseMenuItemHelpTextProps) { +function BaseMenuItemHelpText({message, children, isError = false, style, messageStyle, dataSet}: BaseMenuItemHelpTextProps) { return ( {children} diff --git a/src/components/MenuItem/leaves/helpText/MenuItemHelpText.tsx b/src/components/MenuItem/leaves/helpText/MenuItemHelpText.tsx index f8adf00ad2b3..9f50033e2377 100644 --- a/src/components/MenuItem/leaves/helpText/MenuItemHelpText.tsx +++ b/src/components/MenuItem/leaves/helpText/MenuItemHelpText.tsx @@ -1,5 +1,9 @@ +import {useMenuItemConfig} from '@components/MenuItem/MenuItemContext'; + import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + import React from 'react'; import type {MenuItemHelpTextProps} from './types'; @@ -9,12 +13,15 @@ import BaseMenuItemHelpText from './BaseMenuItemHelpText'; /** The help line of a `MenuItem.Root`, rendered under `MenuItem.Row` and inside the row's press target */ function MenuItemHelpText({message, isError = false}: MenuItemHelpTextProps) { const styles = useThemeStyles(); + const {shouldAllowTextSelection} = useMenuItemConfig(); return ( ); } diff --git a/src/components/MenuItem/leaves/helpText/types.ts b/src/components/MenuItem/leaves/helpText/types.ts index 29ef14a9be37..4798b5379f52 100644 --- a/src/components/MenuItem/leaves/helpText/types.ts +++ b/src/components/MenuItem/leaves/helpText/types.ts @@ -1,5 +1,5 @@ import type {ReactNode} from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {StyleProp, TextStyle, ViewProps, ViewStyle} from 'react-native'; /** Props shared by the help line leaves */ type MenuItemHelpTextSharedProps = { @@ -29,6 +29,12 @@ type BaseMenuItemHelpTextProps = MenuItemHelpTextSharedProps & { /** Spacing of the line, which depends on where the leaf sits */ style?: StyleProp; + + /** Style applied to the help message text */ + messageStyle?: StyleProp; + + /** Data attributes applied to the help text container */ + dataSet?: ViewProps['dataSet']; }; export type {BaseMenuItemHelpTextProps, MenuItemHelpTextHTMLProps, MenuItemHelpTextProps}; diff --git a/src/components/MenuItem/presets/MenuItemField.tsx b/src/components/MenuItem/presets/MenuItemField.tsx index 889eeb5c9d75..2b32fe7fd4ea 100644 --- a/src/components/MenuItem/presets/MenuItemField.tsx +++ b/src/components/MenuItem/presets/MenuItemField.tsx @@ -25,6 +25,9 @@ type MenuItemFieldRowProps = PropsWithChildren<{ /** How many lines the value may take. Defaults to 1, and `0` lets it grow unbounded */ numberOfLinesValue?: number; + + /** Whether the field value can start native browser text selection */ + isValueSelectable?: boolean; }>; type MenuItemFieldProps = Omit & Omit; @@ -61,10 +64,19 @@ function MenuItemFieldContent({name, children}: MenuItemFieldContentProps) { * `MenuItemField` preset when the row needs siblings inside the same `Root` (an error or a hint * line under the row). */ -function MenuItemFieldRow({name, value, numberOfLinesValue, children}: MenuItemFieldRowProps) { +function MenuItemFieldRow({name, value, numberOfLinesValue, isValueSelectable = false, children}: MenuItemFieldRowProps) { return ( - {!!value && {value}} + + {!!value && ( + + {value} + + )} + {hasRenderableChildren(children) && {children}} ); @@ -74,18 +86,31 @@ function MenuItemFieldRow({name, value, numberOfLinesValue, children}: MenuItemF * Field preset: a field name plus its value. With no `value` the name takes over the row. * `children` land in the trailing cell, next to the chevron. */ -function MenuItemFieldPreset({name, value, numberOfLinesValue, children, onPress, isDisabled = false, sentryLabel, testID}: MenuItemFieldProps) { +function MenuItemFieldPreset({ + name, + value, + numberOfLinesValue, + isValueSelectable = false, + children, + onPress, + isDisabled = false, + sentryLabel, + testID, + shouldAllowTextSelection = isValueSelectable, +}: MenuItemFieldProps) { return ( {children} {!!onPress && } diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 86ece35927cb..4a8f82b2e46f 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -89,6 +89,7 @@ function BaseModal({ shouldKeepRightDockedBackdropInNarrowPane = false, shouldShowBackdrop = false, shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode = true, + shouldLimitHeightIfBottomDockedInLandscapeMode = true, }: BaseModalProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -408,7 +409,8 @@ function BaseModal({ modalPaddingStyles, !isVisible && styles.pointerEventsNone, sidePanelAnimatedStyle, - isBottomDockedModalInLandscapeMode && {maxHeight: windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE}, + isBottomDockedModalInLandscapeMode && + shouldLimitHeightIfBottomDockedInLandscapeMode && {maxHeight: windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE}, ]} ref={ref} fsClass={forwardedFSClass} diff --git a/src/components/Modal/Global/ExpensifyCardStatementPDFDownloadModalWrapper.tsx b/src/components/Modal/Global/ExpensifyCardStatementPDFDownloadModalWrapper.tsx new file mode 100644 index 000000000000..2bf99d7bcdfa --- /dev/null +++ b/src/components/Modal/Global/ExpensifyCardStatementPDFDownloadModalWrapper.tsx @@ -0,0 +1,34 @@ +import ExpensifyCardStatementPDFDownloadModal from '@components/ExpensifyCardStatementPDFDownloadModal'; + +import type {ExpensifyCardStatementParams} from '@libs/ExpensifyCardStatementUtils'; + +import React, {useState} from 'react'; + +import type {ModalProps} from './ModalContext'; + +import {ModalActions} from './ModalContext'; + +type ExpensifyCardStatementPDFDownloadModalWrapperProps = ModalProps & { + statementParams: ExpensifyCardStatementParams; +}; + +function ExpensifyCardStatementPDFDownloadModalWrapper({closeModal, statementParams}: ExpensifyCardStatementPDFDownloadModalWrapperProps) { + const [isVisible, setIsVisible] = useState(true); + + return ( + setIsVisible(false)} + onModalHide={() => { + if (isVisible) { + return; + } + closeModal({action: ModalActions.CLOSE}); + }} + /> + ); +} + +export default ExpensifyCardStatementPDFDownloadModalWrapper; +export type {ExpensifyCardStatementPDFDownloadModalWrapperProps}; diff --git a/src/components/Modal/Global/FeatureTrainingModalWrapper.tsx b/src/components/Modal/Global/FeatureTrainingModalWrapper.tsx new file mode 100644 index 000000000000..40c5eafcc14c --- /dev/null +++ b/src/components/Modal/Global/FeatureTrainingModalWrapper.tsx @@ -0,0 +1,48 @@ +import type {FeatureTrainingModalProps} from '@components/FeatureTrainingModal'; +import FeatureTrainingModal from '@components/FeatureTrainingModal'; +import withNavigationFallback from '@components/withNavigationFallback'; + +import React, {useRef} from 'react'; + +import type {ModalProps} from './ModalContext'; + +import {ModalActions} from './ModalContext'; + +type FeatureTrainingModalWrapperProps = ModalProps & Omit; + +// This wrapper bridges the FeatureTrainingModal API with the global modal system, resolving the caller's promise with CONFIRM or CLOSE. +function FeatureTrainingModalWrapper({closeModal, resolveModal, shouldCloseOnConfirm = true, ...props}: FeatureTrainingModalWrapperProps) { + const closeActionRef = useRef(ModalActions.CLOSE); + + // `closeModal` pops whatever sits on top of the stack, so it must only run once for this entry + const hasClosedRef = useRef(false); + + const close = () => { + if (hasClosedRef.current) { + return; + } + hasClosedRef.current = true; + closeModal({action: closeActionRef.current}); + }; + + const handleConfirm = () => { + closeActionRef.current = ModalActions.CONFIRM; + // When the modal closes on confirm, it reports onClose once it has hidden, and that pops the entry + if (shouldCloseOnConfirm) { + return; + } + close(); + }; + + return ( + + ); +} + +// The global modal stack renders above NavigationRoot, but the modal's confirm button reads navigation focus +export default withNavigationFallback(FeatureTrainingModalWrapper); diff --git a/src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx b/src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx index add2dedfe79a..c840f82eb70c 100644 --- a/src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx +++ b/src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx @@ -3,15 +3,15 @@ import type {GestureHandlerProps, SwipeDirection} from '@components/Modal/Reanim import CONST from '@src/CONST'; import type {PropsWithChildren} from 'react'; -import type {GestureStateChangeEvent, GestureType, PanGestureHandlerEventPayload} from 'react-native-gesture-handler'; +import type {PanGestureActiveEvent} from 'react-native-gesture-handler'; -import React, {useMemo} from 'react'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import React from 'react'; +import {GestureDetector, usePanGesture} from 'react-native-gesture-handler'; import {useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; function hasSwipeEnded( - e: GestureStateChangeEvent, + e: PanGestureActiveEvent, initialPosition: {x: number; y: number}, swipeThreshold: number, swipeDirection?: SwipeDirection | SwipeDirection[], @@ -55,18 +55,15 @@ function hasSwipeEnded( function GestureHandler({swipeDirection, onSwipeComplete, swipeThreshold = 100, children}: PropsWithChildren) { const initialTranslationX = useSharedValue(0); const initialTranslationY = useSharedValue(0); - const panGesture: GestureType = useMemo( - () => - Gesture.Pan() - .onStart((e) => { - initialTranslationX.set(e.translationX); - initialTranslationY.set(e.translationY); - }) - .onEnd((e) => { - hasSwipeEnded(e, {x: initialTranslationX.get(), y: initialTranslationY.get()}, swipeThreshold, swipeDirection, onSwipeComplete); - }), - [initialTranslationX, initialTranslationY, onSwipeComplete, swipeDirection, swipeThreshold], - ); + const panGesture = usePanGesture({ + onActivate: (e) => { + initialTranslationX.set(e.translationX); + initialTranslationY.set(e.translationY); + }, + onDeactivate: (e) => { + hasSwipeEnded(e, {x: initialTranslationX.get(), y: initialTranslationY.get()}, swipeThreshold, swipeDirection, onSwipeComplete); + }, + }); if (!swipeDirection || !swipeDirection?.length || !onSwipeComplete) { return children; diff --git a/src/components/Modal/ReanimatedModal/types.ts b/src/components/Modal/ReanimatedModal/types.ts index 187a39bd9527..ce286702a2c7 100644 --- a/src/components/Modal/ReanimatedModal/types.ts +++ b/src/components/Modal/ReanimatedModal/types.ts @@ -4,7 +4,7 @@ import type CONST from '@src/CONST'; import type {ComponentRef, ReactNode, Ref, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports -- type-only: the launcher union must cover every anchor shape popovers pass, including RN Text anchors -import type {NativeSyntheticEvent, StyleProp, Text, View, ViewProps, ViewStyle} from 'react-native'; +import type {NativeSyntheticEvent, StyleProp, Text, TextInput, View, ViewProps, ViewStyle} from 'react-native'; import type {SharedValue} from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; @@ -141,7 +141,7 @@ type ReanimatedModalProps = ViewProps & * The element that opened this modal, a popover's anchor. Used only when nothing held focus at activation time, * which is the case for triggers that blur themselves to avoid a focus ring (the FAB, the composer "+"). */ - launcherRef?: RefObject | ComponentRef | HTMLElement | null>; + launcherRef?: RefObject | ComponentRef | ComponentRef | HTMLElement | null>; shouldIgnoreBackHandlerDuringTransition?: boolean; }; diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index 2c3fc601c056..b4d6544af75e 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -141,6 +141,12 @@ type BaseModalProps = Partial & * Defaults to true. */ shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode?: boolean; + + /** + * Whether a bottom docked modal in landscape mode is capped at part of the window height. + * Turn off for content that sizes itself to the window, so the cap doesn't clip it. Defaults to true. + */ + shouldLimitHeightIfBottomDockedInLandscapeMode?: boolean; }; export default BaseModalProps; diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 941fbf534f52..232c7ee9d195 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -35,6 +35,7 @@ import usePolicy from '@hooks/usePolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +import useShareActions from '@hooks/useShareActions'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; @@ -156,7 +157,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); // While offline, keep pending-delete transactions so a queued-for-delete expense still counts as a real transaction until the delete syncs. const nonPendingDeleteTransactions = Object.values(reportTransactions).filter((t) => isOffline || t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - const allTransactions = Object.values(reportTransactions); + const moneyReportTransactions = Object.values(reportTransactions); const singleTransaction = nonPendingDeleteTransactions.length === 1 ? nonPendingDeleteTransactions.at(0) : undefined; const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(singleTransaction?.comment?.originalTransactionID)}`); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); @@ -173,7 +174,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); - const isAnyTransactionOnHold = hasHeldExpensesReportUtils(allTransactions); + const isAnyTransactionOnHold = hasHeldExpensesReportUtils(moneyReportTransactions); const existingB2BInvoiceReport = useParticipantsInvoiceReport(activePolicyID, CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, chatReport?.policyID); const getChatReportActions = usePayChatReportActions(chatReport, existingB2BInvoiceReport); @@ -204,11 +205,13 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo } else if (isInvoiceReport) { startAnimation(); payInvoice({ + bankAccountList, isASAPSubmitBetaEnabled, getCurrencyDecimals, paymentMethodType: type, chatReport, invoiceReport: moneyRequestReport, + invoiceReportTransactions: moneyReportTransactions, introSelected, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', @@ -229,11 +232,13 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo } else { startAnimation(); payMoneyRequest({ + bankAccountList, getCurrencyDecimals, isASAPSubmitBetaEnabled, paymentType: type, chatReport, iouReport: moneyRequestReport, + iouReportTransactions: moneyReportTransactions, introSelected, currentUserAccountID: accountID, currentUserLogin: currentUserLogin ?? '', @@ -283,9 +288,10 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, currentUserLogin ?? '', accountID, undefined, true, isChatReportArchived, invoiceReceiverPolicy); const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; - const hasOnlyPendingTransactions = allTransactions.length > 0 && allTransactions.every((t) => isPending(t)); + const hasOnlyPendingTransactions = moneyReportTransactions.length > 0 && moneyReportTransactions.every((t) => isPending(t)); const shouldShowApproveButton = - (canApproveIOU(moneyRequestReport, policy, reportMetadata, currentUserPersonalDetails.accountID, allTransactions) && !hasOnlyPendingTransactions) || isApprovedAnimationRunning; + (canApproveIOU(moneyRequestReport, policy, reportMetadata, currentUserPersonalDetails.accountID, moneyReportTransactions) && !hasOnlyPendingTransactions) || + isApprovedAnimationRunning; const isApproveDisabled = shouldShowApproveButton && !isAllowedToApproveExpenseReport(moneyRequestReport); const totalAmount = getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, CONST.REPORT.PRIMARY_ACTIONS.PAY, nonPendingDeleteTransactions, convertToDisplayString); @@ -388,6 +394,8 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo onPDFModalOpen: openPDFDownload, }); + const shareActions = useShareActions({reportID, policy}); + // Compute list of applicable secondary action keys const secondaryActions = moneyRequestReport ? getSecondaryReportActions({ @@ -425,6 +433,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo }, }, ...exportActionEntries, + ...shareActions, ...lifecycleActionEntries, ...expenseActions, ...holdRejectActions, diff --git a/src/components/MoneyReportHeaderModals.tsx b/src/components/MoneyReportHeaderModals.tsx index 08a71fb25087..f9a2681f2085 100644 --- a/src/components/MoneyReportHeaderModals.tsx +++ b/src/components/MoneyReportHeaderModals.tsx @@ -1,8 +1,8 @@ +import useAlertModals from '@hooks/useAlertModals'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDecisionModal from '@hooks/useDecisionModal'; import useHoldMenuModal from '@hooks/useHoldMenuModal'; -import useLocalize from '@hooks/useLocalize'; +import useMoneyReportHeaderEducationalModals from '@hooks/useMoneyReportHeaderEducationalModals'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; @@ -18,10 +18,8 @@ import type {ReactNode} from 'react'; import React, {useRef, useState} from 'react'; -import type {MoneyReportHeaderEducationalModalsHandle, RejectModalAction} from './MoneyReportHeaderEducationalModals'; import type {HoldMenuParams} from './MoneyReportHeaderModalsContext'; -import MoneyReportHeaderEducationalModals from './MoneyReportHeaderEducationalModals'; import MoneyReportHeaderModalsContext from './MoneyReportHeaderModalsContext'; import {MoneyReportTransactionThreadProvider} from './MoneyReportTransactionThreadContext'; import ReportPDFDownloadModal from './ReportPDFDownloadModal'; @@ -32,14 +30,22 @@ type MoneyReportHeaderModalsProps = { }; function MoneyReportHeaderModals({reportID, children}: MoneyReportHeaderModalsProps) { + const [moneyRequestReportID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: (report) => report?.reportID}); + + return ( + + {children} + + ); +} + +/** Rendered inside the transaction thread provider because the educational modals' follow-up actions need the transaction thread */ +function MoneyReportHeaderModalsContent({reportID, children}: MoneyReportHeaderModalsProps) { // PDF modal state const [isPDFModalVisible, setIsPDFModalVisible] = useState(false); // Callback invoked when the PDF modal is dismissed while still generating (e.g. Submit via PDF retracts the submit). const onPDFCancelRef = useRef<(() => void) | undefined>(undefined); - // Educational modals ref - const educationalModalsRef = useRef(null); - // Fetch data from IDs const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); @@ -63,24 +69,8 @@ function MoneyReportHeaderModals({reportID, children}: MoneyReportHeaderModalsPr // Imperative modals const {showHoldMenu} = useHoldMenuModal(); - const {showDecisionModal} = useDecisionModal(); - const {translate} = useLocalize(); - - const showOfflineModal = () => { - showDecisionModal({ - title: translate('common.youAppearToBeOffline'), - prompt: translate('common.offlinePrompt'), - secondOptionText: translate('common.buttonConfirm'), - }); - }; - - const showDownloadErrorModal = () => { - showDecisionModal({ - title: translate('common.downloadFailedTitle'), - prompt: translate('common.downloadFailedDescription'), - secondOptionText: translate('common.buttonConfirm'), - }); - }; + const {showOfflineModal, showDownloadErrorModal} = useAlertModals(); + const {openHoldEducational, openRejectModal} = useMoneyReportHeaderEducationalModals(moneyRequestReport?.reportID); const openHoldMenu = ({requestType, paymentType, methodID, onConfirm}: HoldMenuParams): Promise => { const open = () => @@ -115,29 +105,22 @@ function MoneyReportHeaderModals({reportID, children}: MoneyReportHeaderModalsPr onPDFCancelRef.current = options?.onCancel; setIsPDFModalVisible(true); }, - openHoldEducational: () => educationalModalsRef.current?.openHoldEducational(), - openRejectModal: (action: RejectModalAction) => educationalModalsRef.current?.openRejectModal(action), + openHoldEducational, + openRejectModal, showOfflineModal, showDownloadErrorModal, }; return ( - - {children} - - - - setIsPDFModalVisible(false)} - onCancel={() => onPDFCancelRef.current?.()} - /> - + {children} + + setIsPDFModalVisible(false)} + onCancel={() => onPDFCancelRef.current?.()} + /> ); } diff --git a/src/components/MoneyReportHeaderModalsContext.tsx b/src/components/MoneyReportHeaderModalsContext.tsx index 1e1e6821d362..417bbf7a7f53 100644 --- a/src/components/MoneyReportHeaderModalsContext.tsx +++ b/src/components/MoneyReportHeaderModalsContext.tsx @@ -1,11 +1,10 @@ import type {ActionHandledType} from '@hooks/useHoldMenuSubmit'; +import type {RejectModalAction} from '@hooks/useHoldRejectEducationalModal'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import {createContext, useContext} from 'react'; -import type {RejectModalAction} from './MoneyReportHeaderEducationalModals'; - type HoldMenuParams = { requestType?: ActionHandledType; paymentType?: PaymentMethodType; diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index 8da49d2a3f78..1bb26e04ee1e 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx @@ -141,11 +141,13 @@ function PayPrimaryAction({reportID, chatReportID, isDisabled}: PayPrimaryAction } else if (isInvoiceReport) { startAnimation(); payInvoice({ + bankAccountList, isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), getCurrencyDecimals, paymentMethodType: type, chatReport, invoiceReport: moneyRequestReport, + invoiceReportTransactions: transactions, introSelected, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', @@ -166,11 +168,13 @@ function PayPrimaryAction({reportID, chatReportID, isDisabled}: PayPrimaryAction } else { startAnimation(); payMoneyRequest({ + bankAccountList, isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), getCurrencyDecimals, paymentType: type, chatReport, iouReport: moneyRequestReport, + iouReportTransactions: transactions, introSelected, currentUserAccountID: accountID, currentUserLogin: currentUserLogin ?? '', diff --git a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx index 3a587617378e..58dde9b9352d 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx @@ -167,6 +167,7 @@ function SubmitPrimaryActionContent({reportID, isDisabled}: SubmitPrimaryActionP submitReport({ getCurrencyDecimals, expenseReport: moneyRequestReport, + expenseReportTransactions: transactions, policy, rules, currentUserAccountIDParam: accountID, diff --git a/src/components/MoneyRequestConfirmationList/ConfirmationFooterContent.tsx b/src/components/MoneyRequestConfirmationList/ConfirmationFooterContent.tsx index d91783de6cd6..e2d86394bfb1 100644 --- a/src/components/MoneyRequestConfirmationList/ConfirmationFooterContent.tsx +++ b/src/components/MoneyRequestConfirmationList/ConfirmationFooterContent.tsx @@ -156,7 +156,7 @@ function ConfirmationFooterContent({ }} enterKeyEventListenerPriority={1} useKeyboardShortcuts - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Using || because we want undefined and false to both be treated as falsy for isLoading + // Using || because we want undefined and false to both be treated as falsy for isLoading isLoading={isConfirmed || isConfirming} sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.CONFIRMATION_PAY_BUTTON} /> @@ -192,7 +192,7 @@ function ConfirmationFooterContent({ size={CONST.BUTTON_SIZE.LARGE} enterKeyEventListenerPriority={1} useKeyboardShortcuts - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Using || because we want undefined and false to both be treated as falsy for isLoading + // Using || because we want undefined and false to both be treated as falsy for isLoading isLoading={isConfirmed || isConfirming || isLoadingReceipt} sentryLabel={CONFIRMATION_SENTRY_LABEL_BY_IOU_TYPE[iouType] ?? CONST.SENTRY_LABEL.MONEY_REQUEST.CONFIRMATION_SUBMIT_BUTTON} /> diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 3069ddc4bcb1..85e648a587a6 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -4,6 +4,7 @@ import {isValidPerDiemExpenseAmount} from '@libs/actions/IOU/PerDiem'; import {getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; import {isCategoryMissing} from '@libs/CategoryUtils'; import {convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; +import {isP2PRecipient} from '@libs/IOUUtils'; import {isConfirmationAmountMissing, isConfirmationDateMissing, isTaxAmountInvalid, isValidMoneyRequestAmount, validateAmount} from '@libs/MoneyRequestUtils'; import type {getTagLists as getTagListsFn} from '@libs/PolicyUtils'; import {canSubmitPerDiemExpenseFromWorkspace, isAttendeeTrackingEnabled} from '@libs/PolicyUtils'; @@ -220,7 +221,9 @@ function useConfirmationValidation({ !isTimeRequest && !isDistanceRequest && !isEditingSplitBill && - !isValidMoneyRequestAmount(iouAmount, iouType, true, isP2P) + // P2P chats don't support negative amounts. The Scan flow lets the user pick a 1:1 recipient before entering + // the amount, so the participant picker's negative-amount guard never applies and the block has to live here. + !isValidMoneyRequestAmount(iouAmount, iouType, !isP2PRecipient(firstParticipant, currentUserPersonalDetails.accountID), isP2P) ) { return {errorKey: 'common.error.invalidAmount'}; } diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index c52c6c904544..2320d70644b8 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx @@ -19,7 +19,7 @@ import { } from '@libs/actions/IOU/MoneyRequest'; import {convertToBackendAmount, convertToFrontendAmountAsString, getLocalizedCurrencySymbol} from '@libs/CurrencyUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import {calculateAmount, isMovingTransactionFromTrackExpense, isParticipantP2P} from '@libs/IOUUtils'; +import {calculateAmount, isMovingTransactionFromTrackExpense, isP2PRecipient, isParticipantP2P} from '@libs/IOUUtils'; import {isConfirmationAmountMissing} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {shouldEnableNegative} from '@libs/ReportUtils'; @@ -140,7 +140,10 @@ function AmountField({ const shouldShowAutomaticHint = canEnterScanFieldsManually && !isAmountInputFocused && !hasAnyManuallyEnteredScanField(transactionSlice); // The hint and the buttons share the right-hand side, so the field shows one or the other. const shouldShowAmountButtons = !shouldShowAutomaticHint; - const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants); + // The route report doesn't reflect a recipient picked from the confirmation page's "To" field, so a 1:1 recipient is + // read from the transaction participants: P2P chats don't support negative amounts. + const allowNegative = + shouldEnableNegative(report, policy, iouType, transactionSlice?.participants) && !isP2PRecipient(transactionSlice?.participants?.at(0), currentUserPersonalDetails.accountID); // `autoFocus` on our TextInput only runs on mount. Closing and reopening the RHP often keeps the same mounted // instance, so autofocus does not run again. We re-focus when the parent-owned participant picker closes diff --git a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx index ddb169fd0752..c8b50b53127b 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx @@ -42,7 +42,7 @@ type AttendeeFieldProps = { function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, action, iouType, reportID, formError}: AttendeeFieldProps) { const {shouldUseDropdownRows} = useExpenseFormLayout(); - const {translate, localeCompare} = useLocalize(); + const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const personalDetailsList = usePersonalDetails(); const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); const shouldDisplayAttendeesError = formError === 'violations.missingAttendees'; @@ -57,7 +57,7 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a iouAttendees?.length && iouAttendees.length > 1 && formattedAmountPerAttendee ? `${translate('iou.attendees')} · ${formattedAmountPerAttendee} ${translate('common.perPerson')}` : translate('iou.attendees'); - const attendeesAccessibilityLabel = `${translate('iou.attendees')}, ${Array.isArray(iouAttendees) ? getAttendeesListDisplayString(iouAttendees) : ''}`; + const attendeesAccessibilityLabel = `${translate('iou.attendees')}, ${Array.isArray(iouAttendees) ? getAttendeesListDisplayString(iouAttendees, formatPhoneNumber) : ''}`; const attendeePills = Array.isArray(iouAttendees) ? ( ({ diff --git a/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx b/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx index 582240122314..a06c412c848d 100644 --- a/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx @@ -1,17 +1,20 @@ +import {CATEGORY_PICKER_HEADER_HEIGHT} from '@components/CategoryPicker/CategoryPickerModal'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {useConfirmationFields} from '@components/MoneyRequestConfirmationFields/context'; import usePolicyCategoriesForConfirmation from '@components/MoneyRequestConfirmationList/hooks/usePolicyCategoriesForConfirmation'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; +import useReopenAfterReturn from '@hooks/useReopenAfterReturn'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; -import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; +import {canCreateCategoryInSitu} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import type {IOUAction, IOUType} from '@src/CONST'; @@ -21,8 +24,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {useFocusEffect, useIsFocused} from '@react-navigation/native'; -import React, {useEffect, useRef} from 'react'; +import React, {useRef} from 'react'; import type {ExpenseFieldDropdownHandle} from './ExpenseFieldDropdown'; @@ -32,9 +34,6 @@ import {useExpenseFormLayout} from './ExpenseFormLayoutContext'; import {categoryStateSelector} from './selectors'; import useTransactionSelector from './useTransactionSelector'; -/** idle → departing (row pressed) → away (left for the workspace step) → returned (back on the form) → idle */ -type WorkspaceStep = 'idle' | 'departing' | 'away' | 'returned'; - type CategoryFieldProps = { isCategoryRequired: boolean; didConfirm: boolean; @@ -70,11 +69,8 @@ function CategoryField({ const styles = useThemeStyles(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Sparkles']); + const currentUserLogin = useCurrentUserPersonalDetails().login; const dropdownRef = useRef(null); - const isFocused = useIsFocused(); - // Where the user is in the workspace step the row sent them to. Scoped to that one departure and return: it - // disarms when they leave the form again, so a later unrelated focus cannot open the list by itself. - const workspaceStepRef = useRef('idle'); const categoryState = useTransactionSelector(transactionID, categoryStateSelector); const policyCategories = usePolicyCategoriesForConfirmation(policy?.id); @@ -115,57 +111,26 @@ function CategoryField({ // The one condition both the row and the effect below open on, so the effect never asks the row to open a // list it would refuse and fall back to the full page for. const canOpenListInPlace = shouldOpenInDropdown && !isReadOnly && !didConfirm; + const canAddCategory = canCreateCategoryInSitu(policy, currentUserLogin); const canOpenInDropdownAfterWorkspaceStep = canUseAnchoredFieldDropdowns && shouldUseDropdownRows && isCreatingExpense; - // Blurs move the step along: the first is the departure to the workspace step, the next after returning means - // the user left the form for something else, and the arm must not survive that. - useEffect(() => { - if (isFocused) { - return; - } - if (workspaceStepRef.current === 'departing') { - workspaceStepRef.current = 'away'; - } else if (workspaceStepRef.current === 'returned') { - workspaceStepRef.current = 'idle'; - } - }, [isFocused]); - // The row sent the user to create or pick a workspace, and the upgrade step was told to only go back (`shouldReturnToConfirmation`). - // Once the form is focused again with a workspace, wait for the RHP to finish closing, then open the list in place. - useFocusEffect(() => { - if (workspaceStepRef.current === 'away') { - workspaceStepRef.current = 'returned'; - } - if (workspaceStepRef.current !== 'returned') { - return; - } - - // The user backed out without a workspace, so there is still nothing to list. - if (shouldNavigateToUpgradePath || shouldSelectPolicy) { - workspaceStepRef.current = 'idle'; - return; - } - - if (!canOpenListInPlace) { - return; - } - - const handle = TransitionTracker.runAfterTransitions({ - callback: () => { - workspaceStepRef.current = 'idle'; - dropdownRef.current?.open(); - }, - waitForUpcomingTransition: 'navigation', - }); - return () => handle.cancel(); + // Once the form is focused again with a workspace, open the list in place. Backing out without one leaves nothing to list. + const {setIsDeparting} = useReopenAfterReturn({ + onReturn: () => dropdownRef.current?.open(), + shouldCancel: shouldNavigateToUpgradePath || shouldSelectPolicy, + canReopen: canOpenListInPlace, }); + const getCategoryStepRoute = (currentTransactionID: string) => + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({action, iouType, transactionID: currentTransactionID, reportID, reportActionID})); + const openCategoryPage = () => { if (!transactionID) { return; } - workspaceStepRef.current = (shouldNavigateToUpgradePath || (!policy && shouldSelectPolicy)) && canOpenInDropdownAfterWorkspaceStep ? 'departing' : 'idle'; + setIsDeparting((shouldNavigateToUpgradePath || (!policy && shouldSelectPolicy)) && canOpenInDropdownAfterWorkspaceStep); if (shouldNavigateToUpgradePath) { Navigation.navigate( createDynamicRoute( @@ -174,15 +139,7 @@ function CategoryField({ iouType, transactionID, reportID, - upgradeBackTo: createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action, - iouType, - transactionID, - reportID, - reportActionID, - }), - ), + upgradeBackTo: getCategoryStepRoute(transactionID), upgradePath: CONST.UPGRADE_PATHS.CATEGORIES, shouldReturnToConfirmation: canOpenInDropdownAfterWorkspaceStep, }), @@ -190,22 +147,10 @@ function CategoryField({ ); } else if (!policy && shouldSelectPolicy) { // Without `navigateTo`, the page goes back to this form once a workspace is picked, and the list opens here. - const categoryStepRoute = canOpenInDropdownAfterWorkspaceStep - ? undefined - : createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({action, iouType, transactionID, reportID, reportActionID})); + const categoryStepRoute = canOpenInDropdownAfterWorkspaceStep ? undefined : getCategoryStepRoute(transactionID); Navigation.navigate(ROUTES.SET_DEFAULT_WORKSPACE.getRoute(categoryStepRoute)); } else { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action, - iouType, - transactionID, - reportID, - reportActionID, - }), - ), - ); + Navigation.navigate(getCategoryStepRoute(transactionID)); } }; @@ -225,6 +170,7 @@ function CategoryField({ errorText={shouldDisplayCategoryError ? translate(formError as TranslationPaths) : ''} onPress={openCategoryPage} shouldOpenInDropdown={canOpenListInPlace} + listHeaderHeight={canAddCategory ? CATEGORY_PICKER_HEADER_HEIGHT : 0} renderDropdown={(dropdownProps) => !!transactionID && ( ) } diff --git a/src/components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown.tsx b/src/components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown.tsx index 064fcd2e6574..4907de7498a2 100644 --- a/src/components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown.tsx @@ -6,8 +6,13 @@ import type {ListItem} from '@components/SelectionList/types'; import useOnyx from '@hooks/useOnyx'; import useUpdateTransactionCategory from '@hooks/useUpdateTransactionCategory'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -25,6 +30,9 @@ type CategoryFieldDropdownProps = ExpenseFieldDropdownRenderProps & { /** Category the expense already holds, so the list can mark it */ selectedCategory: string; + + /** Whether the user can add a category to the workspace from the list */ + canAddCategory: boolean; }; /** @@ -32,8 +40,8 @@ type CategoryFieldDropdownProps = ExpenseFieldDropdownRenderProps & { * opened, keeping its Onyx subscriptions off the form's first render, and saves through the same hook the * full-page selector uses so both leave the expense in the same state. */ -function CategoryFieldDropdown({transactionID, policy, selectedCategory, onClose, ...popoverProps}: CategoryFieldDropdownProps) { - const {reportID, isEditingSplitBill, action} = useConfirmationFields(); +function CategoryFieldDropdown({transactionID, policy, selectedCategory, canAddCategory, onClose, ...popoverProps}: CategoryFieldDropdownProps) { + const {reportID, isEditingSplitBill, action, iouType} = useConfirmationFields(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); @@ -50,6 +58,14 @@ function CategoryFieldDropdown({transactionID, policy, selectedCategory, onClose isEditingSplit: isEditingSplitBill, }); + const openAddCategory = () => { + onClose(); + TransitionTracker.runAfterTransitions({ + callback: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY_CREATE.getRoute({action, iouType, transactionID, reportID}))), + waitForUpcomingTransition: true, + }); + }; + const handleSelected = (item: ListItem) => { // `CategoryPickerModal` hands back an empty item when the selected category is tapped again, clearing it. updateCategory(item.searchText ?? ''); @@ -63,6 +79,8 @@ function CategoryFieldDropdown({transactionID, policy, selectedCategory, onClose policyID={policy?.id} selectedCategory={selectedCategory} onSelected={handleSelected} + onAddCategory={canAddCategory ? openAddCategory : undefined} + addCategorySentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.ADD_CATEGORY_BUTTON} /> ); } diff --git a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx index 86bd69fc6a79..79f326f08973 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx @@ -131,7 +131,8 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, if (!isReadOnly) { return ( - + // The bordered rows carry their own vertical margin, so an extra bottom margin here would stack with it. + void; + /** Height of a header the list shows above its options, e.g. an add button, added to the container's limits so it still fits as many options */ + listHeaderHeight?: number; + /** Lets the field open its list without a press, e.g. once the user is back from a step the press sent them to */ ref?: Ref; }; @@ -103,7 +106,7 @@ type ExpenseFieldDropdownHandle = { * pop-over matches the row's width and opens below it, or above when there isn't room, capped so it is never * clipped. Knows nothing about any particular field: each passes its own list in through `renderDropdown`. */ -function ExpenseFieldDropdown({renderDropdown, shouldOpenInDropdown, onPress, ref, ...rowProps}: ExpenseFieldDropdownProps) { +function ExpenseFieldDropdown({renderDropdown, shouldOpenInDropdown, onPress, listHeaderHeight = 0, ref, ...rowProps}: ExpenseFieldDropdownProps) { const {windowHeight} = useWindowDimensions(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth -- must match PopoverWithMeasuredContent's dock decision, which is on isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -120,6 +123,9 @@ function ExpenseFieldDropdown({renderDropdown, shouldOpenInDropdown, onPress, re shouldOpenAbove: false, }); + const maxContainerHeight = MAX_CONTAINER_HEIGHT + listHeaderHeight; + const minUsableHeight = MIN_USABLE_HEIGHT + listHeaderHeight; + const closeDropdown = () => setIsVisible(false); const openDropdown = () => { @@ -132,10 +138,10 @@ function ExpenseFieldDropdown({renderDropdown, shouldOpenInDropdown, onPress, re anchorRef.current?.measureInWindow((x, y, width, height) => { const spaceBelow = windowHeight - (y + height + CONTAINER_GAP); const spaceAbove = y - CONTAINER_GAP - (safeAreaTop + contentHeaderHeight); - const shouldOpenAbove = spaceBelow < MAX_CONTAINER_HEIGHT && spaceAbove > spaceBelow; + const shouldOpenAbove = spaceBelow < maxContainerHeight && spaceAbove > spaceBelow; const availableHeight = (shouldOpenAbove ? spaceAbove : spaceBelow) - CONTAINER_BORDER; - if (availableHeight < MIN_USABLE_HEIGHT) { + if (availableHeight < minUsableHeight) { onPress(); return; } @@ -144,7 +150,7 @@ function ExpenseFieldDropdown({renderDropdown, shouldOpenInDropdown, onPress, re horizontal: x, vertical: shouldOpenAbove ? y - CONTAINER_GAP : y + height + CONTAINER_GAP, width, - height: Math.min(MAX_CONTAINER_HEIGHT, availableHeight), + height: Math.min(maxContainerHeight, availableHeight), shouldOpenAbove, }); setHasEverOpened(true); diff --git a/src/components/MoneyRequestConfirmationList/sections/selectors.ts b/src/components/MoneyRequestConfirmationList/sections/selectors.ts index cac073eb44ba..4a7f2a3945b1 100644 --- a/src/components/MoneyRequestConfirmationList/sections/selectors.ts +++ b/src/components/MoneyRequestConfirmationList/sections/selectors.ts @@ -236,7 +236,7 @@ const taxSliceSelector = (t: OnyxEntry): TaxSlice | undefined => { // --- ConfirmationFieldList: useFooterDerivedFlags --- -type DerivedFlagsSlice = Pick; +type DerivedFlagsSlice = Pick; const derivedFlagsSliceSelector = (t: OnyxEntry): OnyxEntry => { if (!t) { @@ -248,6 +248,7 @@ const derivedFlagsSliceSelector = (t: OnyxEntry): OnyxEntry([]); + const showMoreFields = !!transactionID && revealedTransactionIDs.includes(transactionID); + const setShowMoreFields = (shouldShowMoreFields: boolean) => { + if (!transactionID) { + return; + } + setRevealedTransactionIDs((previousIDs) => { + if (previousIDs.includes(transactionID) === shouldShowMoreFields) { + return previousIDs; + } + return shouldShowMoreFields ? [...previousIDs, transactionID] : previousIDs.filter((id) => id !== transactionID); + }); + }; + + // Reveal the collapsed fields when the user already filled one of them in, so those values aren't hidden behind + // "Show more", or when one of them raises an inline error, or opening the section and pressing Create looks like + // it did nothing. Done during render so the expense never paints collapsed first and it survives a remount. // In a multi-scan the error stays set while another receipt is partially filled, so only expand the receipt it belongs to. + const hasManuallyEnteredFields = canEnterScanFieldsManually && hasAnyManuallyEnteredScanField(transaction); const doesFormErrorBelongToThisReceipt = !partiallyManuallyFilledScanID || partiallyManuallyFilledScanID === transactionID; - if (INLINE_FIELD_ERROR_KEYS.has(data.footerProps.errorState.formError) && doesFormErrorBelongToThisReceipt && !showMoreFields) { - setShowMoreFields(true); + const hasInlineFieldError = INLINE_FIELD_ERROR_KEYS.has(data.footerProps.errorState.formError) && doesFormErrorBelongToThisReceipt; + if (transactionID && !showMoreFields && (hasManuallyEnteredFields || hasInlineFieldError)) { + setRevealedTransactionIDs([...revealedTransactionIDs, transactionID]); } - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- reset show more on transaction change - setShowMoreFields(false); - }, [transactionID]); - const isCompactMode = !showMoreFields && !isInLandscapeMode; return ( diff --git a/src/components/MoneyRequestConfirmationListFooter/ExpenseFormFooterLayout.tsx b/src/components/MoneyRequestConfirmationListFooter/ExpenseFormFooterLayout.tsx new file mode 100644 index 000000000000..64634ce26098 --- /dev/null +++ b/src/components/MoneyRequestConfirmationListFooter/ExpenseFormFooterLayout.tsx @@ -0,0 +1,64 @@ +import ExpenseFormLayoutContext from '@components/MoneyRequestConfirmationList/sections/ExpenseFormLayoutContext'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import type * as OnyxTypes from '@src/types/onyx'; + +import type {ReactNode} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; +import {View} from 'react-native'; + +import type {ReceiptOptions} from './fieldGroupTypes'; + +import useAddReceiptLayout from './hooks/useAddReceiptLayout'; +import ReceiptSection from './sections/ReceiptSection'; + +type ExpenseFormFooterLayoutProps = { + /** The policy the expense is being created on */ + policy: OnyxEntry; + + /** Options for the receipt preview shown above the fields */ + receiptOptions: ReceiptOptions; + + /** Whether the participant section is shown above the footer, which needs a divider to separate it */ + hasParticipantSection: boolean; + + /** The variant-specific content rendered below the receipt preview */ + children: ReactNode; +}; + +/** + * The layout shared by the confirmation footers that offer the compact add-receipt button (manual, time, manual + * distance and odometer): the participant divider, the receipt preview and its spacing, and the form layout their + * fields read. Each variant renders only what is unique to it as `children`. + */ +function ExpenseFormFooterLayout({policy, receiptOptions, hasParticipantSection, children}: ExpenseFormFooterLayoutProps) { + const styles = useThemeStyles(); + + // The add-receipt button and the spacing around the preview both key off whether the section shows a receipt. + const {expenseFormLayout, hasReceipt} = useAddReceiptLayout(policy, receiptOptions); + + return ( + + + {hasParticipantSection && } + + {/* The receipt preview carries no margin of its own, so the 8px that keeps it clear of the divider + above and of the first field below it goes here. */} + + + + + {children} + + + ); +} + +export default ExpenseFormFooterLayout; diff --git a/src/components/MoneyRequestConfirmationListFooter/hooks/useFooterDerivedFlags.ts b/src/components/MoneyRequestConfirmationListFooter/hooks/useFooterDerivedFlags.ts index aee9429b667e..e7a361ceef2c 100644 --- a/src/components/MoneyRequestConfirmationListFooter/hooks/useFooterDerivedFlags.ts +++ b/src/components/MoneyRequestConfirmationListFooter/hooks/useFooterDerivedFlags.ts @@ -5,7 +5,7 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import {isBillableEnabledOnPolicy} from '@libs/MoneyRequestReportUtils'; import {shouldShowConfirmationDate} from '@libs/MoneyRequestUtils'; -import {isSubmitPolicy} from '@libs/PolicyUtils'; +import {canShowReimbursableToggleForPolicy, isSubmitPolicy} from '@libs/PolicyUtils'; import {hasEnabledTags} from '@libs/TagsOptionsListUtils'; import {getCurrency, isManagedCardTransaction, isScanRequest, shouldShowAttendees as shouldShowAttendeesTransactionUtils} from '@libs/TransactionUtils'; @@ -95,7 +95,11 @@ function useFooterDerivedFlags({ // A flag for showing the billable field const shouldShowBillable = isBillableEnabledOnPolicy(policy); const shouldShowReimbursable = - (isPolicyExpenseChat || isTrackExpense) && !!policy && policy?.disabledFields?.reimbursable !== true && !isManagedCardTransaction(transaction) && !isTypeInvoice; + (isPolicyExpenseChat || isTrackExpense) && + !!policy && + canShowReimbursableToggleForPolicy(policy, transaction?.reimbursable) && + !isManagedCardTransaction(transaction) && + !isTypeInvoice; // Submit workspaces ship Categories/Distance enabled by default, so never route their fields to the upgrade gate. const isSubmitWorkspace = isSubmitPolicy(policy); const shouldNavigateToUpgradePath = !isSubmitWorkspace && canNavigateToUpgradePath; diff --git a/src/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts b/src/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts index 0044a4c2499a..5fa08b6222f0 100644 --- a/src/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts +++ b/src/components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource.ts @@ -51,11 +51,11 @@ function useReceiptThumbnailSource({transaction, receiptPath, receiptFilename}: upgradeCount: 0, }); if (isLocalFile && (initialLocalSource.source === undefined || initialLocalSource.resolvedImage !== resolvedReceiptImageStr || initialLocalSource.upgradeCount !== upgradeCount)) { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string fallback is intentional: we want to skip the empty-string case in the OR chain, not treat it as a valid source + // empty string fallback is intentional: we want to skip the empty-string case in the OR chain, not treat it as a valid source setInitialLocalSource({source: thumbnailUri || resolvedReceiptImageStr || '', resolvedImage: resolvedReceiptImageStr, upgradeCount}); } - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string fallback is intentional: we want to skip the empty-string case in the OR chain, not treat it as a valid source + // empty string fallback is intentional: we want to skip the empty-string case in the OR chain, not treat it as a valid source const effectiveReceiptSource = isLocalFile ? initialLocalSource.source || '' : resolvedThumbnail || resolvedReceiptImage || ''; const hasReceiptImageOrThumbnail = !!(receiptImage ?? receiptThumbnail); diff --git a/src/components/MoneyRequestConfirmationListFooter/variants/DistanceManualFooter.tsx b/src/components/MoneyRequestConfirmationListFooter/variants/DistanceManualFooter.tsx index a15fffc627a7..c8b2f255dd00 100644 --- a/src/components/MoneyRequestConfirmationListFooter/variants/DistanceManualFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/variants/DistanceManualFooter.tsx @@ -1,14 +1,9 @@ -import ExpenseFormLayoutContext from '@components/MoneyRequestConfirmationList/sections/ExpenseFormLayoutContext'; import ConfirmationFieldList from '@components/MoneyRequestConfirmationListFooter/ConfirmationFieldList'; +import ExpenseFormFooterLayout from '@components/MoneyRequestConfirmationListFooter/ExpenseFormFooterLayout'; import DistanceDetailsFields from '@components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/DistanceDetailsFields'; -import useAddReceiptLayout from '@components/MoneyRequestConfirmationListFooter/hooks/useAddReceiptLayout'; -import ReceiptSection from '@components/MoneyRequestConfirmationListFooter/sections/ReceiptSection'; import type {DistanceFooterProps} from '@components/MoneyRequestConfirmationListFooter/types'; -import useThemeStyles from '@hooks/useThemeStyles'; - import React from 'react'; -import {View} from 'react-native'; /** Footer for manually entered distance expenses. */ function DistanceManualFooter({ @@ -23,47 +18,32 @@ function DistanceManualFooter({ toggleHandlers = {}, receiptOptions, }: DistanceFooterProps) { - const styles = useThemeStyles(); - - // The add-receipt button and the spacing around the preview both key off whether the section shows a receipt. - const {expenseFormLayout, hasReceipt} = useAddReceiptLayout(policy, receiptOptions); - return ( - - - {visibilityFlags.hasParticipantSection && } - - {/* The receipt preview carries no margin of its own, so the 8px that keeps it clear of the divider - above and of the first field below it goes here. */} - - - - - + + - - - - + /> + + ); } diff --git a/src/components/MoneyRequestConfirmationListFooter/variants/DistanceOdometerFooter.tsx b/src/components/MoneyRequestConfirmationListFooter/variants/DistanceOdometerFooter.tsx index 9bbc5109fec6..d938646ff35f 100644 --- a/src/components/MoneyRequestConfirmationListFooter/variants/DistanceOdometerFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/variants/DistanceOdometerFooter.tsx @@ -1,9 +1,7 @@ import FormHelpMessage from '@components/FormHelpMessage'; -import ExpenseFormLayoutContext from '@components/MoneyRequestConfirmationList/sections/ExpenseFormLayoutContext'; import ConfirmationFieldList from '@components/MoneyRequestConfirmationListFooter/ConfirmationFieldList'; +import ExpenseFormFooterLayout from '@components/MoneyRequestConfirmationListFooter/ExpenseFormFooterLayout'; import DistanceDetailsFields from '@components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/DistanceDetailsFields'; -import useAddReceiptLayout from '@components/MoneyRequestConfirmationListFooter/hooks/useAddReceiptLayout'; -import ReceiptSection from '@components/MoneyRequestConfirmationListFooter/sections/ReceiptSection'; import type {DistanceOdometerFooterProps} from '@components/MoneyRequestConfirmationListFooter/types'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -29,51 +27,38 @@ function DistanceOdometerFooter({ }: DistanceOdometerFooterProps) { const styles = useThemeStyles(); - // The add-receipt button and the spacing around the preview both key off whether the section shows a receipt. - const {expenseFormLayout, hasReceipt} = useAddReceiptLayout(policy, receiptOptions); - return ( - - - {visibilityFlags.hasParticipantSection && } - - {/* The receipt preview carries no margin of its own, so the 8px that keeps it clear of the divider - above and of the first field below it goes here. */} - - + + {!!receiptStitchError && ( + + + )} - {!!receiptStitchError && ( - - - - )} - - + - - - - + /> + + ); } diff --git a/src/components/MoneyRequestConfirmationListFooter/variants/ManualFooter.tsx b/src/components/MoneyRequestConfirmationListFooter/variants/ManualFooter.tsx index 1adc4bede920..55a2acbf82c1 100644 --- a/src/components/MoneyRequestConfirmationListFooter/variants/ManualFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/variants/ManualFooter.tsx @@ -1,14 +1,9 @@ -import ExpenseFormLayoutContext from '@components/MoneyRequestConfirmationList/sections/ExpenseFormLayoutContext'; import ConfirmationFieldList from '@components/MoneyRequestConfirmationListFooter/ConfirmationFieldList'; +import ExpenseFormFooterLayout from '@components/MoneyRequestConfirmationListFooter/ExpenseFormFooterLayout'; import ManualDetailsFields from '@components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/ManualDetailsFields'; -import useAddReceiptLayout from '@components/MoneyRequestConfirmationListFooter/hooks/useAddReceiptLayout'; -import ReceiptSection from '@components/MoneyRequestConfirmationListFooter/sections/ReceiptSection'; import type {ManualFooterProps} from '@components/MoneyRequestConfirmationListFooter/types'; -import useThemeStyles from '@hooks/useThemeStyles'; - import React from 'react'; -import {View} from 'react-native'; /** * Footer for manually entered expenses, and the dispatcher's residual case: it also serves pay, per-diem being @@ -19,46 +14,31 @@ import {View} from 'react-native'; * providing the layout its fields read, rather than by handing the decision to each of them. */ function ManualFooter({policy, policyTags, selectedParticipants, amountDisplay, requiredFlags, visibilityFlags, errorState, toggleHandlers = {}, receiptOptions}: ManualFooterProps) { - const styles = useThemeStyles(); - - // The add-receipt button and the spacing around the preview both key off whether the section shows a receipt. - const {expenseFormLayout, hasReceipt} = useAddReceiptLayout(policy, receiptOptions); - return ( - - - {visibilityFlags.hasParticipantSection && } - - {/* The receipt preview carries no margin of its own, so the 8px that keeps it clear of the divider - above and of the first field below it goes here. */} - - - - - + + - - - - + isParticipantPickerVisible={visibilityFlags.isParticipantPickerVisible} + /> + + ); } diff --git a/src/components/MoneyRequestConfirmationListFooter/variants/TimeFooter.tsx b/src/components/MoneyRequestConfirmationListFooter/variants/TimeFooter.tsx index 9608909d8818..997593e433b0 100644 --- a/src/components/MoneyRequestConfirmationListFooter/variants/TimeFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/variants/TimeFooter.tsx @@ -1,55 +1,36 @@ -import ExpenseFormLayoutContext from '@components/MoneyRequestConfirmationList/sections/ExpenseFormLayoutContext'; import ConfirmationFieldList from '@components/MoneyRequestConfirmationListFooter/ConfirmationFieldList'; +import ExpenseFormFooterLayout from '@components/MoneyRequestConfirmationListFooter/ExpenseFormFooterLayout'; import TimeDetailsFields from '@components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/TimeDetailsFields'; -import useAddReceiptLayout from '@components/MoneyRequestConfirmationListFooter/hooks/useAddReceiptLayout'; -import ReceiptSection from '@components/MoneyRequestConfirmationListFooter/sections/ReceiptSection'; import type {TimeFooterProps} from '@components/MoneyRequestConfirmationListFooter/types'; -import useThemeStyles from '@hooks/useThemeStyles'; - import React from 'react'; -import {View} from 'react-native'; function TimeFooter({policy, policyTags, selectedParticipants, amountDisplay, requiredFlags, visibilityFlags, errorState, toggleHandlers = {}, receiptOptions}: TimeFooterProps) { - const styles = useThemeStyles(); - - // The add-receipt button and the spacing around the preview both key off whether the section shows a receipt. - const {expenseFormLayout, hasReceipt} = useAddReceiptLayout(policy, receiptOptions); - return ( - - - {visibilityFlags.hasParticipantSection && } - - {/* The receipt preview carries no margin of its own, so the 8px that keeps it clear of the divider - above and of the first field below it goes here. */} - - - - + + - - - - + /> + + ); } diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index 1512eb5f643e..4331b8988d79 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -15,7 +15,7 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {isMarkAsResolvedAction} from '@libs/ReportPrimaryActionUtils'; -import {isSelfDM, isSettled as isSettledReportUtils} from '@libs/ReportUtils'; +import {isOneTransactionReport, isSelfDM, isSettled as isSettledReportUtils} from '@libs/ReportUtils'; import { hasPendingRTERViolation as hasPendingRTERViolationTransactionUtils, isBrokenConnectionViolation, @@ -100,11 +100,16 @@ function MoneyRequestHeader({reportID: reportIDProp, onBackButtonPress}: MoneyRe const reportID = report?.reportID; const isReportInRHP = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT; const isFromReviewDuplicates = !!route.params.backTo && /\/duplicates\/review\/[^/]+$/.test(route.params.backTo.replaceAll(/\?.*/g, '')); - const shouldDisplayTransactionNavigation = !!(reportID && isReportInRHP); // `transaction` is resolved through this thread's parent report action, which isn't necessarily loaded yet on // a cold open (e.g. opening an expense from the Spend page right after clearing the cache). The route carries // the expense the screen was opened for, so fall back to it rather than hiding the carousel until data lands. const anchorTransactionIDFromRoute = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT ? route.params.anchorTransactionID : undefined; + // A one-expense report *is* that expense's view (Spend opens it directly and the carousel lives in its header), + // so its transaction thread is a step deeper than the carousel, e.g. a thread started from the report's "paid" action. + // Paging from there would swap the thread for an unrelated expense, so hide the carousel there. Carousel flows that + // open the thread itself (Home "Recently added", "Review N flagged expenses", duplicate review) anchor the route to the + // expense and are kept on the thread by OneTransactionThreadRedirectHandler, so they keep their arrows. + const shouldDisplayTransactionNavigation = !!(reportID && isReportInRHP) && (!!anchorTransactionIDFromRoute || !isOneTransactionReport(parentReport)); const carouselTransactionID = transaction?.transactionID ?? anchorTransactionIDFromRoute; const shouldOpenParentReportInCurrentTab = !isSelfDM(parentReport); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine() && (wideRHPRouteKeys.length === 0 || isSmallScreenWidth); diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index 3b9876e6b464..9343c5caf75f 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -8,6 +8,9 @@ import useDeleteTransactions from '@hooks/useDeleteTransactions'; import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction'; import useHasMultipleSplitChildren from '@hooks/useHasMultipleSplitChildren'; +import useHoldRejectEducationalModal from '@hooks/useHoldRejectEducationalModal'; +import type {RejectModalAction} from '@hooks/useHoldRejectEducationalModal'; +import useHoldSubmitterEducationalModal from '@hooks/useHoldSubmitterEducationalModal'; import useIsInSidePanel from '@hooks/useIsInSidePanel'; import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -91,14 +94,12 @@ import {hasSeenTourSelector} from '@selectors/Onboarding'; import {loginSelector} from '@selectors/PersonalDetails'; import {billingRestrictionPolicySelector, createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; -import React, {useMemo, useRef, useState} from 'react'; +import React, {useMemo, useRef} from 'react'; import type {ButtonWithDropdownMenuRef, DropdownOption} from './ButtonWithDropdownMenu/types'; import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from './DelegateNoAccessModalProvider'; -import HoldOrRejectEducationalModal from './HoldOrRejectEducationalModal'; -import HoldSubmitterEducationalModal from './HoldSubmitterEducationalModal'; import {ModalActions} from './Modal/Global/ModalContext'; import {usePersonalDetails} from './OnyxListItemProvider'; import {useSearchQueryContext, useSearchSelectionActions} from './Search/SearchContext'; @@ -222,11 +223,6 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const isReportInSearch = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT; const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); - // State - const [isHoldEducationalModalVisible, setIsHoldEducationalModalVisible] = useState(false); - const [rejectModalAction, setRejectModalAction] = useState | null>(null); const dropdownMenuRef = useRef(null); // Derived computations @@ -330,7 +326,6 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money }; const dismissModalAndUpdateUseHold = () => { - setIsHoldEducationalModalVisible(false); setNameValuePair(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, true, false, !shouldFailAllRequests); if (parentReportAction) { changeMoneyRequestHoldStatus( @@ -348,7 +343,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money } }; - const dismissRejectModalBasedOnAction = () => { + const dismissRejectModalBasedOnAction = (rejectModalAction: RejectModalAction) => { if (rejectModalAction === CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD) { dismissRejectUseExplanation(); if (parentReportAction) { @@ -371,9 +366,11 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money rejectMoneyRequestReason(parentReportAction); } } - setRejectModalAction(null); }; + const {showHoldOrRejectEducationalModal} = useHoldRejectEducationalModal(); + const {showHoldSubmitterEducationalModal} = useHoldSubmitterEducationalModal(); + // Secondary actions const secondaryActions = (() => { if (!transaction || !parentReportAction || !parentReport) { @@ -456,9 +453,9 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money rules, ); } else if (shouldShowHoldEducationalModal) { - setIsHoldEducationalModalVisible(true); + showHoldSubmitterEducationalModal({isDM: isParentChatReportDM}).finally(dismissModalAndUpdateUseHold); } else { - setRejectModalAction(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD); + showHoldOrRejectEducationalModal().finally(() => dismissRejectModalBasedOnAction(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD)); } }, }, @@ -668,7 +665,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money rejectMoneyRequestReason(parentReportAction); } } else { - setRejectModalAction(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT); + showHoldOrRejectEducationalModal().finally(() => dismissRejectModalBasedOnAction(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT)); } }, }, @@ -744,31 +741,16 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const shouldPopoverUseScrollView = getShouldPopoverUseScrollView(applicableSecondaryActions); return ( - <> - {}} - shouldAlwaysShowDropdownMenu - customText={translate('common.more')} - options={applicableSecondaryActions} - shouldPopoverUseScrollView={shouldPopoverUseScrollView} - isSplitButton={false} - wrapperStyle={!isNarrowButton && !hasPrimaryAction ? [styles.flexGrow4] : undefined} - /> - {!!rejectModalAction && ( - - )} - {!!isHoldEducationalModalVisible && ( - - )} - + {}} + shouldAlwaysShowDropdownMenu + customText={translate('common.more')} + options={applicableSecondaryActions} + shouldPopoverUseScrollView={shouldPopoverUseScrollView} + isSplitButton={false} + wrapperStyle={!isNarrowButton && !hasPrimaryAction ? [styles.flexGrow4] : undefined} + /> ); } diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 5344b559e761..490513e7e1e4 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -6,7 +6,9 @@ import useNewTransactions from '@hooks/useNewTransactions'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useThemeStyles from '@hooks/useThemeStyles'; import useUnreadMarker from '@hooks/useUnreadMarker'; @@ -17,7 +19,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; -import {getLatestConciergeFeedbackActionID, getOneTransactionThreadReportID, hasNextActionMadeBySameActor} from '@libs/ReportActionsUtils'; +import {getFeedbackPromptActionIDs, getOneTransactionThreadReportID, hasNextActionMadeBySameActor} from '@libs/ReportActionsUtils'; import { canUserPerformWriteAction, chatIncludesChronosWithID, @@ -40,11 +42,14 @@ import type SCREENS from '@src/SCREENS'; import {getStableReportSelector} from '@src/selectors/Report'; import {pendingNewTransactionIDsSelector} from '@src/selectors/ReportMetaData'; import type * as OnyxTypes from '@src/types/onyx'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {LayoutChangeEvent} from 'react-native'; import {useIsFocused, useRoute} from '@react-navigation/native'; +import {getCustomAgentAccountIDs} from '@selectors/AgentZeroChat'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {accountIDSelector} from '@selectors/Session'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -93,6 +98,7 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon // Self-subscribe to report, policy, metadata, actions, transactions const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`); const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`, {selector: getStableReportSelector}); + const reportsParentHierarchy = useReportsParentHierarchy(reportIDFromRoute); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportIDFromRoute}`); @@ -149,9 +155,17 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon const conciergeFeedbackForReportActionID = reportNameValuePairs?.conciergeFeedbackForReportActionID; + const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); + + // An agent account can be opened through Copilot, and the agent must not rate its own replies + const actorAccountIDs = [...new Set(visibleReportActionsNewestFirst.map((action) => action.actorAccountID))].filter((accountID) => accountID !== currentUserAccountID); + const [customAgentAccountIDs = getEmptyArray()] = usePersonalDetailsByIDs(actorAccountIDs, getCustomAgentAccountIDs); + // Skip inside the thread the backend opens after a thumbs down, while a Concierge answer is still streaming, and while newer actions are not loaded because the newest reply may not be in the list yet - const latestConciergeFeedbackActionID = - conciergeFeedbackForReportActionID || isDraftPendingCompletion || hasNewerActions ? undefined : getLatestConciergeFeedbackActionID(visibleReportActionsNewestFirst, reportActionIDs); + const latestConciergeFeedbackActionIDs = + conciergeFeedbackForReportActionID || isDraftPendingCompletion || hasNewerActions + ? getEmptyArray() + : getFeedbackPromptActionIDs(visibleReportActionsNewestFirst, reportActionIDs, customAgentAccountIDs); const {onStartReached, onEndReached} = useMoneyRequestReportPagination({ reportID, @@ -252,14 +266,15 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon linkedReportActionID={linkedReportActionID} isHarvestCreatedExpenseReport={shouldShowHarvestCreatedAction} shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} - isLatestConciergeFeedbackAction={!!latestConciergeFeedbackActionID && latestConciergeFeedbackActionID === reportAction.reportActionID} + isLatestConciergeFeedbackAction={latestConciergeFeedbackActionIDs.includes(reportAction.reportActionID)} + reportsParentHierarchy={reportsParentHierarchy} /> ); }; - const reportActionsExtraData = [draftReportActionID, isDraftPendingCompletion, latestConciergeFeedbackActionID]; + const reportActionsExtraData = [draftReportActionID, isDraftPendingCompletion, latestConciergeFeedbackActionIDs]; /** * Runs when the FlatList finishes laying out @@ -293,13 +308,20 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon rules, }); + // The narrow layout puts its selection controls in a header above the list, while the wide layout floats a bar over + // the end of it. The floating bar has to follow the list here rather than lead it, or Tab reaches it only after the + // rest of the app has been walked through and wrapped back around. + const selectionToolbar = ( + + ); + return ( - + {shouldUseNarrowLayout && selectionToolbar} )} + {!shouldUseNarrowLayout && selectionToolbar} ); } diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportEmptyStateView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportEmptyStateView.tsx index 729a87232a79..3892322c0d79 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportEmptyStateView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportEmptyStateView.tsx @@ -27,7 +27,12 @@ function MoneyRequestReportEmptyStateView({report, policy, onLayout}: MoneyReque const styles = useThemeStyles(); return ( - + // `pt5` matches the 20px the unified list puts between the report header and its first row. + // `handled` lets a tap on another report field or a dropdown's clear button land while the keyboard is open, matching the unified list. + { if (!wasRecentlyEditingCell) { @@ -180,14 +183,69 @@ function MoneyRequestReportTransactionItemBody({ const handleMouseDown = (e?: React.MouseEvent) => { wasEditingOnMouseDownRef.current = isEditingCell; + const isCopyableTarget = markMouseDownOnCopyableText(e?.target); - if (!isEditingCell) { + if (!isEditingCell && !isCopyableTarget) { e?.preventDefault(); } }; const handleHoverIn = () => setShouldDisableHoverStyle(false); + const handlePress: React.ComponentProps['onPress'] = () => { + if (shouldSuppressCopyableTextRowPress()) { + return; + } + + // Prevent row press from firing while a cell is being inline-edited (e.g. pressing Space would otherwise open the expense) + // See https://github.com/Expensify/App/issues/88646 for more details + if (isEditingCell) { + return; + } + // If a cell was being edited when the user tapped the row, suppress navigation + // so the second tap doesn't immediately open the transaction detail. + if (wasEditingOnMouseDownRef.current || wasEditingOnPressInRef.current) { + wasEditingOnMouseDownRef.current = false; + wasEditingOnPressInRef.current = false; + return; + } + handleOnPress(transaction.transactionID); + }; + + const handleTouchStart: React.ComponentProps['onTouchStart'] = (event) => { + const isCopyableTextTouchStart = isPressStartOnCopyableText(event); + wasTouchStartOnCopyableTextRef.current = markTouchStartOnCopyableText(event, isCopyableTextTouchStart); + }; + + const handlePressIn: React.ComponentProps['onPressIn'] = () => { + wasEditingOnPressInRef.current = isEditingCell; + // Selection only needs to be blocked for touch interactions; desktop mouse selection is handled by onMouseDown. + if (!canUseTouchScreen()) { + return; + } + + // Let copyable values use native long-press/drag selection instead of applying the global selection blocker. + if (wasTouchStartOnCopyableTextRef.current) { + return; + } + + // Preserve the existing row behavior for non-copyable touch targets. + ControlSelection.block(); + }; + + const handlePressableLongPress = () => { + // Let touch-web long presses on copyable values complete native text selection without opening row actions. + if (wasTouchStartOnCopyableTextRef.current) { + return; + } + handleLongPress(transaction.transactionID); + }; + + const handlePressOut = () => { + wasTouchStartOnCopyableTextRef.current = false; + ControlSelection.unblock(); + }; + // The last row keeps its bottom border but hides it, so the row height stays the same when a row is added below it. // FlashList ignores size changes of 1px or less, so a border that appears later would be covered by the next row. const lastRowBorderStyle = isLastItem && styles.borderTransparent; @@ -201,24 +259,12 @@ function MoneyRequestReportTransactionItemBody({ > { - // Prevent row press from firing while a cell is being inline-edited (e.g. pressing Space would otherwise open the expense) - // See https://github.com/Expensify/App/issues/88646 for more details - if (isEditingCell) { - return; - } - // If a cell was being edited when the user tapped the row, suppress navigation - // so the second tap doesn't immediately open the transaction detail. - if (wasEditingOnMouseDownRef.current) { - wasEditingOnMouseDownRef.current = false; - return; - } - handleOnPress(transaction.transactionID); - }} + onPress={handlePress} accessibilityLabel={translate('iou.viewDetails')} sentryLabel={CONST.SENTRY_LABEL.REPORT.MONEY_REQUEST_REPORT_TRANSACTION_ITEM} role={CONST.ROLE.BUTTON} isNested + shouldAllowTextSelection id={transaction.transactionID} style={[ styles.transactionListItemStyle, @@ -227,19 +273,13 @@ function MoneyRequestReportTransactionItemBody({ : styles.noBorderRadius, ]} hoverStyle={[isSelectable && !shouldDisableHoverStyle && styles.hoveredComponentBG, isSelected && styles.activeComponentBG]} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} + dataSet={COPYABLE_ROW_DATA_SET} onMouseDown={handleMouseDown} + onTouchStart={handleTouchStart} onHoverIn={handleHoverIn} - onPressIn={() => { - wasEditingOnMouseDownRef.current = wasEditingOnMouseDownRef.current || isEditingCell; - if (canUseTouchScreen()) { - ControlSelection.block(); - } - }} - onPressOut={() => ControlSelection.unblock()} - onLongPress={() => { - handleLongPress(transaction.transactionID); - }} + onPressIn={handlePressIn} + onPressOut={handlePressOut} + onLongPress={handlePressableLongPress} disabled={!isSelectable} wrapperStyle={[animatedHighlightStyle, styles.userSelectNone, shouldUseNarrowLayout && [StyleUtils.getSelectedBorderBottomStyle(isSelected), lastRowBorderStyle]]} > diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 39cfa7edcbef..928218180730 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -1,7 +1,7 @@ import LinkButton from '@components/Button/composed/LinkButton'; import type FlatListRefType from '@components/FlashList/types'; -import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; +import useCopySelectionEvent from '@hooks/useCopySelectionEvent'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNavigateToTransactionThread from '@hooks/useNavigateToTransactionThread'; @@ -198,7 +198,7 @@ function MoneyRequestReportTransactionList({ isLoadingInitialActions, listFooterComponent, }: MoneyRequestReportTransactionListProps) { - useCopySelectionHelper(); + useCopySelectionEvent(); const styles = useThemeStyles(); const theme = useTheme(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Columns']); diff --git a/src/components/MoneyRequestReportView/MoneyRequestViewReportFields.tsx b/src/components/MoneyRequestReportView/MoneyRequestViewReportFields.tsx index 2bb4bd1aacee..f86f158685b8 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestViewReportFields.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestViewReportFields.tsx @@ -1,16 +1,18 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; +import useSaveReportField from '@hooks/useSaveReportField'; import useThemeStyles from '@hooks/useThemeStyles'; -import {clearReportFieldKeyErrors} from '@libs/actions/Report'; +import {clearReportFieldKeyErrors, deleteReportField} from '@libs/actions/Report'; import {resolveReportFieldValue} from '@libs/Formula'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; -import Navigation from '@libs/Navigation/Navigation'; import { getFieldViolation, getFieldViolationTranslation, @@ -26,16 +28,17 @@ import type {ThemeStyles} from '@styles/index'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Policy, PolicyReportField, Report, ReportViolationName} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import {Str} from 'expensify-common'; import React, {useMemo} from 'react'; import {View} from 'react-native'; +import ReportFieldInlineInput from './ReportFieldInlineInput'; + type MoneyRequestViewReportFieldsProps = { report: OnyxEntry; @@ -44,53 +47,98 @@ type MoneyRequestViewReportFieldsProps = { /** Indicates whether we have any pending actions from parent component */ pendingAction?: PendingAction; + + /** Extra styles for the block. Each call site sits in a container with its own padding, so it sets the spacing it needs. */ + style?: StyleProp; + + /** + * Stacks the fields one per row at any width, instead of filling rows of `CONST.REPORT_FIELDS_PER_ROW`. + * A one-expense report reads as a single expense rather than a table, so its fields stay vertical there. + * @default false + */ + shouldUseSingleColumn?: boolean; }; type EnrichedPolicyReportField = { fieldValue: string; isFieldDisabled: boolean; + + /** Whether the field was deleted from the workspace but still holds a value on this report, so it can be removed */ + isFieldDeletable: boolean; fieldKey: string; violation: ReportViolationName | undefined; violationTranslation: string; } & PolicyReportField; -function ReportFieldView(reportField: EnrichedPolicyReportField, report: OnyxEntry, styles: ThemeStyles, pendingAction?: PendingAction) { +function ReportFieldView( + reportField: EnrichedPolicyReportField, + report: OnyxEntry, + policy: OnyxEntry, + styles: ThemeStyles, + onSaveValue: (reportField: PolicyReportField, value: string) => void, + onDelete: (reportField: EnrichedPolicyReportField) => Promise, + fieldPendingAction?: PendingAction, +) { return ( - clearReportFieldKeyErrors(report?.reportID, reportField.fieldKey)} + - { - if (!report?.policyID) { - return; + clearReportFieldKeyErrors(report?.reportID, reportField.fieldKey)} + > + onSaveValue(reportField, value)} + onDelete={ + reportField.isFieldDeletable + ? () => { + onDelete(reportField); + } + : undefined } - - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.EDIT_REPORT_FIELD.getRoute(report.policyID, reportField.fieldID))); - }} - shouldShowRightIcon={!reportField.isFieldDisabled} - wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} - shouldGreyOutWhenDisabled={false} - numberOfLinesTitle={0} - interactive={!reportField.isFieldDisabled} - titleWithTooltips={[]} - brickRoadIndicator={reportField.violation ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={reportField.violationTranslation} - /> - + /> + + ); } -function MoneyRequestViewReportFields({report, policy, pendingAction}: MoneyRequestViewReportFieldsProps) { +function MoneyRequestViewReportFields({report, policy, pendingAction, style, shouldUseSingleColumn = false}: MoneyRequestViewReportFieldsProps) { const styles = useThemeStyles(); + // The report view is a RightModalNavigator screen shown as a wide RHP, where `useResponsiveLayout` reports a narrow layout at any pane width. + const {shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP(); + const saveReportField = useSaveReportField(report, policy); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getNonEmptyStringOnyxID(report?.reportID)}`); const {getCurrencyDecimals} = useCurrencyListActions(); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + + const deleteField = async (reportField: EnrichedPolicyReportField) => { + const result = await showConfirmModal({ + title: translate('workspace.reportFields.delete'), + prompt: translate('workspace.reportFields.deleteConfirmation'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + shouldEnableNewFocusManagement: true, + }); + if (result.action !== ModalActions.CONFIRM || !report?.reportID) { + return; + } + // The failure data restores whatever field is passed here, so it gets the field as stored on the report rather + // than the copy enriched for display. + deleteReportField(report.reportID, report.fieldList?.[reportField.fieldKey] ?? reportField); + }; const sortedPolicyReportFields = useMemo((): EnrichedPolicyReportField[] => { const {fieldValues, fieldsByName} = getReportFieldMaps(report, policy?.fieldList ?? {}, reportNameValuePairs); @@ -101,10 +149,19 @@ function MoneyRequestViewReportFields({report, policy, pendingAction}: MoneyRequ .filter((reportField) => !shouldHideSingleReportField(reportField)) .sort(({orderWeight: firstOrderWeight}, {orderWeight: secondOrderWeight}) => firstOrderWeight - secondOrderWeight) .map((field): EnrichedPolicyReportField => { - const fieldValue = resolveReportFieldValue(field, report, policy, fieldValues, fieldsByName, getCurrencyDecimals); const isFieldDisabled = isReportFieldDisabledForUser(report, field, policy, currentUserAccountID, rules); const isDeletedFormulaField = field.type === CONST.REPORT_FIELD_TYPES.FORMULA && field.deletable; const fieldKey = getReportFieldKey(field.fieldID); + const isFieldOnPolicy = !!(policy?.fieldList?.[fieldKey] ?? policy?.fieldList?.[field.fieldID]); + const isEditable = !isFieldDisabled || isDeletedFormulaField; + + // An input the user can type into is seeded with the raw value, the same as the report field editor page. + // Seeding it with the computed value would save the resolved text over a `{field:X}` default the first + // time it was edited, so the field would stop following the field it refers to. + const shouldUseRawValue = isEditable && field.type !== CONST.REPORT_FIELD_TYPES.FORMULA; + const fieldValue = shouldUseRawValue + ? (field.value ?? field.defaultValue ?? '') + : resolveReportFieldValue(field, report, policy, fieldValues, fieldsByName, getCurrencyDecimals); const violation = isFieldDisabled ? undefined : getFieldViolation(field); const violationTranslation = getFieldViolationTranslation(field, violation); @@ -112,7 +169,11 @@ function MoneyRequestViewReportFields({report, policy, pendingAction}: MoneyRequ return { ...field, fieldValue, - isFieldDisabled: isFieldDisabled && !isDeletedFormulaField, + isFieldDisabled: !isEditable, + // Only fields that are no longer on the workspace can be deleted. Their `deletable` flag is not + // checked: once the workspace drops a field, whether it was required no longer applies, and the + // report's copy of a date field can still be flagged as not deletable. + isFieldDeletable: field.fieldID !== CONST.REPORT_FIELD_TITLE_FIELD_ID && !isFieldOnPolicy && isEditable, fieldKey, violation, violationTranslation, @@ -128,10 +189,47 @@ function MoneyRequestViewReportFields({report, policy, pendingAction}: MoneyRequ return null; } + const pendingFieldsByKey = new Map(Object.entries(report?.pendingFields ?? {})); + const columnCount = shouldUseNarrowLayout || shouldUseSingleColumn ? 1 : CONST.REPORT_FIELDS_PER_ROW; + const fieldRows: EnrichedPolicyReportField[][] = []; + for (let index = 0; index < sortedPolicyReportFields.length; index += columnCount) { + fieldRows.push(sortedPolicyReportFields.slice(index, index + columnCount)); + } + return ( - - {sortedPolicyReportFields.map((reportField) => { - return ReportFieldView(reportField, report, styles, pendingAction); + + {fieldRows.map((fieldRow) => { + const rowKey = `reportFieldRow-${fieldRow.at(0)?.fieldKey}`; + + return ( + + {fieldRow.map((reportField) => + ReportFieldView( + reportField, + report, + policy, + styles, + saveReportField, + deleteField, + // Need to return undefined when we have pendingAction to avoid the duplicate pending action + pendingAction ? undefined : pendingFieldsByKey.get(reportField.fieldKey), + ), + )} + {/* A partly filled last row is padded out so its fields stay the same width as the rows above it. */} + {Array.from({length: columnCount - fieldRow.length}, (_unused, index) => ( + + ))} + + ); })} ); diff --git a/src/components/MoneyRequestReportView/ReportFieldInlineInput.tsx b/src/components/MoneyRequestReportView/ReportFieldInlineInput.tsx new file mode 100644 index 000000000000..33d566ceb5a5 --- /dev/null +++ b/src/components/MoneyRequestReportView/ReportFieldInlineInput.tsx @@ -0,0 +1,305 @@ +/** + * Renders a single report field in the report view as an editable input instead of a row that navigates to the + * report field editor page. Text and formula fields are typed into directly, dates open the calendar in place, and + * list fields open the option list in a dropdown anchored under the input, so changing a value never takes the user + * off the report. + */ + +import DatePicker from '@components/DatePicker'; +import Icon from '@components/Icon'; +import {PressableWithFeedback} from '@components/Pressable'; +import FilterPopupButton from '@components/Search/FilterDropdowns/FilterPopupButton'; +import type {FilterPopupButtonProps} from '@components/Search/FilterDropdowns/FilterPopupButton'; +import TextInput from '@components/TextInput'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; + +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; + +import {hasCircularReferences} from '@libs/Formula'; +import type {FieldList} from '@libs/Formula'; +import StringUtils from '@libs/StringUtils'; + +import EditReportFieldDropdown from '@pages/EditReportFieldDropdown'; + +import CONST from '@src/CONST'; +import type {PolicyReportField} from '@src/types/onyx'; + +import type {GestureResponderEvent} from 'react-native'; + +import {Str} from 'expensify-common'; +import React, {useRef, useState} from 'react'; +import {View} from 'react-native'; + +type ReportFieldInlineInputProps = { + reportField: PolicyReportField; + + /** Key the field is stored under, used as the input ID */ + fieldKey: string; + + /** Value currently shown for the field */ + value: string; + + /** Whether the current user is allowed to change the value */ + isDisabled: boolean; + + /** Message shown under the input, for example a field violation */ + errorText?: string; + + /** Policy field list, used to reject values that make a formula refer to itself */ + fieldList?: FieldList; + + onSaveValue: (value: string) => void; + + /** + * Removes the field from the report. Passed only for a field that was deleted from the workspace, so the field + * shows a delete button on its right edge. Without it the stale value could never be cleared off the report. + */ + onDelete?: () => void; +}; + +function ReportFieldInlineInput({reportField, fieldKey, value, isDisabled, errorText, fieldList, onSaveValue, onDelete}: ReportFieldInlineInputProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {windowHeight} = useWindowDimensions(); + const isInLandscapeMode = useIsInLandscapeMode(); + const {isInNarrowPaneModal} = useResponsiveLayout(); + const icons = useMemoizedLazyExpensifyIcons(['DownArrow', 'Trashcan']); + + const [draftValue, setDraftValue] = useState(value); + const [previousValue, setPreviousValue] = useState(value); + const [validationError, setValidationError] = useState(''); + + // Tracks the value that was last sent to the server so a second save attempt for the same value, for example when + // the input is focused and blurred again before the update comes back, is skipped while it is still in flight. + const [lastSavedValue, setLastSavedValue] = useState(value); + + // On Android a tap on the list field's input reports the same press twice, once as `onPress` and once as + // `onPressOut`, and the dropdown trigger toggles. Remembering the last press lets each tap open the list once + // instead of opening and then closing it. + const lastOptionsPressEventRef = useRef(null); + + const textInputRef = useRef(null); + + // The saved value changes while this input is mounted whenever the field is updated elsewhere, for example from + // another device or by an optimistic update, so the draft follows it. + if (value !== previousValue) { + setPreviousValue(value); + setDraftValue(value); + setValidationError(''); + setLastSavedValue(value); + } + + const label = Str.UCFirst(reportField.name); + const isReadOnly = isDisabled || reportField.type === CONST.REPORT_FIELD_TYPES.FORMULA; + + const saveDraftValue = () => { + const draftWithoutLineBreaks = StringUtils.lineBreaksToSpaces(draftValue); + const trimmedValue = draftWithoutLineBreaks.trim(); + + // Whitespace alone is saved as an empty value, so clearing a field never stores the spaces left behind. + const valueToSave = trimmedValue === '' ? '' : draftWithoutLineBreaks; + + // Only a text field can be cleared, the same as on OldDot, so any other field goes back to its saved value. + const canClearField = reportField.type === CONST.REPORT_FIELD_TYPES.TEXT; + + if (trimmedValue === lastSavedValue.trim() || (trimmedValue === '' && !canClearField)) { + setValidationError(''); + // Nothing is sent, so the saved value never changes and the resync above that follows it never runs. + // Without this the field would keep displaying the whitespace the user left behind until it remounted, + // at which point the text would visibly snap back to the value that was really stored. + setDraftValue(lastSavedValue); + return; + } + + if (hasCircularReferences(trimmedValue, reportField.name, fieldList)) { + setValidationError(translate('workspace.reportFields.circularReferenceError')); + return; + } + + setValidationError(''); + setLastSavedValue(valueToSave); + onSaveValue(valueToSave); + }; + + const saveSelectedOption = (selectedValue: string) => { + if (selectedValue === lastSavedValue) { + return; + } + setLastSavedValue(selectedValue); + onSaveValue(selectedValue); + }; + + // The input's right corners are squared off so the delete button can sit flush against it, and the input's own + // right border becomes the divider between the two. + const textInputContainerStyles = onDelete ? styles.noRightBorderRadius : undefined; + + const renderWithDeleteButton = (field: React.ReactNode) => { + if (!onDelete) { + return field; + } + + return ( + // The button keeps the input's height and stays at the top, so an error message under the input grows the + // field downwards without stretching the button. + + {field} + + + + + ); + }; + + if (reportField.type === CONST.REPORT_FIELD_TYPES.DATE && !isReadOnly) { + return renderWithDeleteButton( + saveSelectedOption(selectedDate)} + // The grid puts its own gap between the fields, so the default vertical margin would push a date + // input below the plain text inputs sharing its row. + wrapperStyle={styles.mv0} + shouldDeferShowUntilPositioned + shouldHideClearButton + // The Month/Year pickers are right-docked, and a right-docked modal opened from the RHP drops its + // backdrop by default, so the report behind them would not be dimmed. + shouldEnableMonthYearBackdropInNarrowPane={isInNarrowPaneModal} + textInputContainerStyles={textInputContainerStyles} + />, + ); + } + + if (reportField.type === CONST.REPORT_FIELD_TYPES.LIST && !isReadOnly) { + const enabledOptions = reportField.values.filter((_option: string, index: number) => !reportField.disabledOptions.at(index)); + + // A short list is quicker to scan than to search, so the search input is dropped and its height is not reserved. + const shouldShowSearchInput = enabledOptions.length >= CONST.REPORT_FIELD_LIST_SEARCH_THRESHOLD; + + // A short list is easier to read in a stable alphabetical order than one that moves the current value to the + // top, so only a long list is worth reordering to save scrolling. + const shouldPinSelectedOption = enabledOptions.length >= CONST.STANDARD_LIST_ITEM_LIMIT; + + // FilterPopupButton calls this as a plain function during its own render, so it must not use hooks. Everything + // it needs is read from this component's scope. + const renderOptionsPopup: FilterPopupButtonProps['PopoverComponent'] = ({closeOverlay}) => ( + + { + closeOverlay(); + saveSelectedOption(form[fieldKey] ?? ''); + }} + /> + + ); + + return renderWithDeleteButton( + // No `popoverWidth`, so the dropdown falls back to `CONST.POPOVER_DROPDOWN_WIDTH`, the same width every + // Spend filter dropdown uses. Matching the field's own width instead made narrow fields open odd, cramped + // popovers and wide ones open oversized. + { + const openOptions = (event: GestureResponderEvent | KeyboardEvent) => { + if (event === lastOptionsPressEventRef.current) { + return; + } + lastOptionsPressEventRef.current = event; + onPress(); + }; + + return ( + + + + ); + }} + />, + ); + } + + return renderWithDeleteButton( + textInputRef.current?.blur()} + ref={textInputRef} + textInputContainerStyles={textInputContainerStyles} + />, + ); +} + +export default ReportFieldInlineInput; diff --git a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx index 326535b4fdac..fa044fb39432 100644 --- a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx +++ b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx @@ -1,6 +1,4 @@ -import DecisionModal from '@components/DecisionModal'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; -import HoldOrRejectEducationalModal from '@components/HoldOrRejectEducationalModal'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import useShouldShowReportBulkActionBar from '@components/MoneyRequestReportView/useShouldShowReportBulkActionBar'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -10,7 +8,9 @@ import BulkDuplicateHandler from '@components/Search/BulkDuplicateHandler'; import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import useConfirmModal from '@hooks/useConfirmModal'; +import useDecisionModal from '@hooks/useDecisionModal'; import useFilterSelectedTransactions from '@hooks/useFilterSelectedTransactions'; +import useHoldRejectEducationalModal from '@hooks/useHoldRejectEducationalModal'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetworkWithOfflineStatus from '@hooks/useNetworkWithOfflineStatus'; @@ -38,10 +38,8 @@ import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; -import type {ValueOf} from 'type-fest'; - import {useFocusEffect, useRoute} from '@react-navigation/native'; -import React, {useState} from 'react'; +import React from 'react'; import {View} from 'react-native'; import SelectAllCheckbox from './SelectAllCheckbox'; @@ -91,10 +89,23 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool const isMobileSelectionModeEnabled = useMobileSelectionMode(); const {showConfirmModal} = useConfirmModal(); + const {showDecisionModal} = useDecisionModal(); + + const showOfflineModal = () => { + showDecisionModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.offlinePrompt'), + secondOptionText: translate('common.buttonConfirm'), + }); + }; - const [offlineModalVisible, setOfflineModalVisible] = useState(false); - const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); - const [rejectModalAction, setRejectModalAction] = useState | null>(null); + const showDownloadErrorModal = () => { + showDecisionModal({ + title: translate('common.downloadFailedTitle'), + prompt: translate('common.downloadFailedDescription'), + secondOptionText: translate('common.buttonConfirm'), + }); + }; const transactionsWithoutPendingDelete = transactions.filter((t) => !isTransactionPendingDelete(t)); // Select All skips rejected expenses as well as ones being deleted, since their checkboxes are disabled. The list header and group headers use the same filter. @@ -102,7 +113,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool const beginExportWithTemplate = (templateName: string, templateType: string, transactionIDList: string[], exportName: string) => { if (isOffline) { - setOfflineModalVisible(true); + showOfflineModal(); return; } @@ -164,8 +175,8 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool reportActions, allTransactionsLength: transactions.length, session, - onExportFailed: () => setIsDownloadErrorModalVisible(true), - onExportOffline: () => setOfflineModalVisible(true), + onExportFailed: showDownloadErrorModal, + onExportOffline: showOfflineModal, policy, beginExportWithTemplate: (templateName, templateType, transactionIDList, exportName) => beginExportWithTemplate(templateName, templateType, transactionIDList, exportName), onDeleteSelected, @@ -202,6 +213,19 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool selectedTransactionIDs, }); + const dismissRejectModalAndOpenBulkReject = () => { + dismissRejectUseExplanation(); + if (report?.reportID) { + Navigation.navigate( + ROUTES.SEARCH_MONEY_REQUEST_REPORT_REJECT_TRANSACTIONS.getRoute({ + reportID: report.reportID, + }), + ); + } + }; + + const {showHoldOrRejectEducationalModal} = useHoldRejectEducationalModal(); + const selectedTransactionsOptions = (() => { const mappedOptions = originalSelectedTransactionsOptions.map((option) => { if (option.value === CONST.REPORT.SECONDARY_ACTIONS.REJECT) { @@ -216,7 +240,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool if (dismissedRejectUseExplanation) { option.onSelected?.(); } else { - setRejectModalAction(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK); + showHoldOrRejectEducationalModal().finally(dismissRejectModalAndOpenBulkReject); } }, }; @@ -232,20 +256,6 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool const popoverUseScrollView = shouldPopoverUseScrollView(selectedTransactionsOptions); - const dismissRejectModalBasedOnAction = () => { - if (rejectModalAction === CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK) { - dismissRejectUseExplanation(); - if (report?.reportID) { - Navigation.navigate( - ROUTES.SEARCH_MONEY_REQUEST_REPORT_REJECT_TRANSACTIONS.getRoute({ - reportID: report.reportID, - }), - ); - } - } - setRejectModalAction(null); - }; - const {reportPendingAction} = getReportOfflinePendingActionAndErrors(report); // Same check that decides whether Submit, Approve and Pay are offered, so the checkbox reads fully checked exactly when they are. const isSelectAllChecked = isEveryReportTransactionSelected(transactions, selectedTransactionIDs); @@ -306,30 +316,6 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool onClearSelection={() => clearSelectedTransactions(true)} /> )} - setIsDownloadErrorModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isDownloadErrorModalVisible} - onClose={() => setIsDownloadErrorModalVisible(false)} - /> - setOfflineModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={offlineModalVisible} - onClose={() => setOfflineModalVisible(false)} - /> - {!!rejectModalAction && ( - - )} {isHoldMenuVisible && requestType !== undefined && ( ; /** If there is a pager wrapping the canvas, we need to disable the pan gesture in case the pager is swiping */ - pagerRef?: ForwardedRef; // TODO: For TS migration: Exclude + pagerRef?: AttachmentCarouselPagerStateContextType['pagerRef']; // TODO: For TS migration: Exclude isUsedInCarousel: boolean; diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx index 8b0453a4538f..ec7bceaf11d9 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx @@ -66,6 +66,15 @@ type MultifactorAuthenticationContextProviderProps = { function MultifactorAuthenticationContextProvider({children}: MultifactorAuthenticationContextProviderProps) { const state = useMultifactorAuthenticationState(); const {dispatch} = useMultifactorAuthenticationActions(); + const actionControllerRef = useRef(undefined); + + useEffect( + () => () => { + actionControllerRef.current?.abort(); + actionControllerRef.current = undefined; + }, + [state.scenario], + ); const biometrics = useBiometrics(); const {accountID} = useCurrentUserPersonalDetails(); @@ -358,12 +367,30 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } // Call backend with signed challenge - const scenarioAPIResponse = await processScenarioAction(scenario.action, { - signedChallenge: result.signedChallenge, - authenticationMethod: result.authenticationMethod.marqetaValue, - ...payload, + const controller = new AbortController(); + actionControllerRef.current = controller; + const scenarioAPIResponse = await processScenarioAction( + scenario.action, + { + signedChallenge: result.signedChallenge, + authenticationMethod: result.authenticationMethod.marqetaValue, + ...payload, + }, + {signal: controller.signal}, + ).catch((scenarioActionError: unknown) => { + if (controller.signal.aborted || actionControllerRef.current !== controller) { + return undefined; + } + actionControllerRef.current = undefined; + throw scenarioActionError; }); + // A canceled action can finish after the next flow has already started. + if (!scenarioAPIResponse || controller.signal.aborted || actionControllerRef.current !== controller) { + return; + } + actionControllerRef.current = undefined; + addMFABreadcrumb( 'Scenario action completed', scenarioAPIResponse.success @@ -504,6 +531,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent * close the modal directly instead to avoid getting stuck. */ const cancel = useCallback(async () => { + actionControllerRef.current?.abort(); if (!state.scenario || isOffline) { addMFABreadcrumb('Flow cancelled - closing directly', {hasScenario: !!state.scenario, isOffline}, 'warning'); dispatch({type: 'CLOSE_MODAL'}); diff --git a/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts new file mode 100644 index 000000000000..34cb4e5c6a66 --- /dev/null +++ b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts @@ -0,0 +1,65 @@ +/** Native wallet provisioning starts only after the MFA provider signs a challenge. */ +import type {MultifactorAuthenticationScenarioCustomConfig} from '@components/MultifactorAuthentication/config/types'; + +import {getPaymentMethods} from '@libs/actions/PaymentMethods'; +import getPlatform from '@libs/getPlatform'; +import Navigation from '@libs/Navigation/Navigation'; +import {handleAddCardToWallet} from '@libs/Wallet'; +import WalletProvisioningError from '@libs/Wallet/WalletProvisioningError'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Card} from '@src/types/onyx'; + +type Payload = { + card: Card; + cardHolderName: string; + cardDescription: string; +}; + +export default { + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM], + // Hermes V1 does not suspend at await inside async arrow functions with destructured parameters, so destructure in the body. + action: async (params, context) => { + const {card, cardHolderName, cardDescription, signedChallenge, authenticationMethod} = params; + try { + const status = await handleAddCardToWallet(card, cardHolderName, cardDescription, { + authentication: {signedChallenge, authenticationMethod}, + signal: context?.signal, + }); + if (status === 'success' && !context?.signal.aborted) { + return {httpStatusCode: 200, reason: undefined, message: undefined}; + } + return { + httpStatusCode: undefined, + reason: + status === 'canceled' || context?.signal.aborted + ? CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED + : CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNRECOGNIZED, + message: undefined, + }; + } catch (error) { + if (error instanceof WalletProvisioningError) { + return error.response; + } + // Native exceptions can contain card data. The shared MFA failure UI only needs a reason. + return { + httpStatusCode: undefined, + reason: context?.signal.aborted ? CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED : CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNHANDLED_EXCEPTION, + message: undefined, + }; + } + }, + callback: async (isSuccessful, _input, payload) => { + if (!isSuccessful || !payload || !('card' in payload)) { + return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SHOW_OUTCOME_SCREEN; + } + getPaymentMethods(); + if (getPlatform() === CONST.PLATFORM.IOS) { + Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_ADDED_TO_WALLET.getRoute(String(payload.card.cardID))); + } + return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SKIP_OUTCOME_SCREEN; + }, +} as const satisfies MultifactorAuthenticationScenarioCustomConfig; + +export type {Payload}; diff --git a/src/components/MultifactorAuthentication/config/scenarios/index.ts b/src/components/MultifactorAuthentication/config/scenarios/index.ts index 076b6bf8cf5f..c00fc86ceed5 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/index.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/index.ts @@ -4,12 +4,14 @@ import CONST from '@src/CONST'; import type {EmptyObject} from 'type-fest'; +import type {Payload as AddToDigitalWalletPayload} from './AddToDigitalWallet'; import type {Payload as AuthorizeTransactionPayload} from './AuthorizeTransaction'; import type {Payload as ChangePINPayload} from './ChangePIN'; import type {Payload as RevealPINPayload} from './RevealPIN'; import type {Payload as SetPersonalDetailsAndRevealCardDetailsPayload} from './SetPersonalDetailsAndRevealCardDetails'; import type {Payload as SetPINOrderCardPayload} from './SetPINOrderCard'; +import AddToDigitalWallet from './AddToDigitalWallet'; import AuthorizeTransaction from './AuthorizeTransaction'; import BiometricsTest from './BiometricsTest'; import ChangePIN from './ChangePIN'; @@ -23,6 +25,7 @@ import SetPINOrderCard from './SetPINOrderCard'; * Each scenario that requires additional parameters should have its payload type defined here. */ type Payloads = { + [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET]: AddToDigitalWalletPayload; [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST]: EmptyObject; [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.SET_PIN_ORDER_CARD]: SetPINOrderCardPayload; [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION]: AuthorizeTransactionPayload; @@ -35,6 +38,7 @@ type Payloads = { * Configuration records for all multifactor authentication scenarios. */ const Configs = { + [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET]: customConfig(AddToDigitalWallet), [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST]: customConfig(BiometricsTest), [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.SET_PIN_ORDER_CARD]: customConfig(SetPINOrderCard), [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION]: customConfig(AuthorizeTransaction), diff --git a/src/components/MultifactorAuthentication/config/scenarios/names.ts b/src/components/MultifactorAuthentication/config/scenarios/names.ts index d6cd1270e1fb..1d90cb4fff5a 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/names.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/names.ts @@ -12,6 +12,7 @@ const SCENARIO_NAMES = { AUTHORIZE_TRANSACTION: 'AUTHORIZE-TRANSACTION', REVEAL_PIN: 'REVEAL-PIN', CHANGE_PIN: 'CHANGE-PIN', + ADD_TO_DIGITAL_WALLET: 'ADD-TO-DIGITAL-WALLET', SET_PERSONAL_DETAILS_AND_REVEAL_CARD_DETAILS: 'SET-PERSONAL-DETAILS-AND-REVEAL-CARD-DETAILS', } as const; diff --git a/src/components/MultifactorAuthentication/config/types.ts b/src/components/MultifactorAuthentication/config/types.ts index 40fd533b4bba..ab56dd0dd4a9 100644 --- a/src/components/MultifactorAuthentication/config/types.ts +++ b/src/components/MultifactorAuthentication/config/types.ts @@ -82,6 +82,7 @@ type MultifactorAuthenticationScenarioResponse = { */ type MultifactorAuthenticationScenarioPureMethod> = ( params: MultifactorAuthenticationActionParams, + context?: {signal: AbortSignal}, ) => Promise; /** diff --git a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx index e884e8de7956..9f7fb4fe2015 100644 --- a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx @@ -1,18 +1,18 @@ import {PressableWithFeedback} from '@components/Pressable'; +import useInboxTabIndicatorStatus from '@hooks/useInboxTabIndicatorStatus'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useRootNavigationState from '@hooks/useRootNavigationState'; -import {useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getTabNavigatorStateKey, isReportsTabPreloaded} from '@libs/Navigation/helpers/tabNavigatorUtils'; import Navigation, {startOpenReportSpan} from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import {isDeletedAction} from '@libs/ReportActionsUtils'; -import {getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import {getSpan} from '@libs/telemetry/activeSpans'; +import {startNavigateToInboxTabSpan} from '@libs/telemetry/startTabNavigationSpans'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -30,23 +30,10 @@ import React, {useEffect, useRef} from 'react'; import getLastRoute from './getLastRoute'; import getReusableReportsTabStateKey from './getReusableReportsTabStateKey'; import getStringParam from './getStringParam'; +import navigateToInboxTab from './navigateToInboxTab'; import NAVIGATION_TABS from './NAVIGATION_TABS'; import TabBarItem from './TabBarItem'; -function startNavigateToInboxTabSpan({isWideLayout}: {isWideLayout: boolean}) { - startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, { - name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, - op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, - forceTransaction: true, - // Read before the tab navigation is dispatched, because jumping to the tab drops its preloaded key. - attributes: { - [CONST.TELEMETRY.ATTRIBUTE_WIDE_LAYOUT]: isWideLayout, - [CONST.TELEMETRY.ATTRIBUTE_IS_PRELOADED]: isReportsTabPreloaded(navigationRef.getRootState()), - [CONST.TELEMETRY.ATTRIBUTE_WAITED_ON_OPEN_REPORT]: false, - }, - }); -} - function markNavigateToInboxTabWaitedOnOpenReport() { getSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB)?.setAttribute(CONST.TELEMETRY.ATTRIBUTE_WAITED_ON_OPEN_REPORT, true); } @@ -217,18 +204,10 @@ function WideInboxTabButton({selectedTab, statusIndicatorColor, accessibilityLab function InboxTabButton({selectedTab, isWideLayout}: InboxTabButtonProps) { const styles = useThemeStyles(); - const theme = useTheme(); const {translate} = useLocalize(); - const {chatTabBrickRoad} = useSidebarOrderedReportsState(); + const {status: chatTabBrickRoad, indicatorColor: statusIndicatorColor} = useInboxTabIndicatorStatus(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Inbox']); - let statusIndicatorColor: string | undefined; - if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO) { - statusIndicatorColor = theme.iconSuccessFill; - } else if (chatTabBrickRoad) { - statusIndicatorColor = theme.danger; - } - const accessibilityLabel = chatTabBrickRoad ? `${translate('common.inbox')}. ${translate('common.yourReviewIsRequired')}` : translate('common.inbox'); if (isWideLayout) { @@ -245,9 +224,7 @@ function InboxTabButton({selectedTab, isWideLayout}: InboxTabButtonProps) { if (selectedTab === NAVIGATION_TABS.INBOX) { return; } - - startNavigateToInboxTabSpan({isWideLayout: false}); - Navigation.navigate(ROUTES.INBOX); + navigateToInboxTab(); }; return ( @@ -256,7 +233,7 @@ function InboxTabButton({selectedTab, isWideLayout}: InboxTabButtonProps) { role={CONST.ROLE.TAB} accessibilityLabel={accessibilityLabel} accessibilityState={{selected: selectedTab === NAVIGATION_TABS.INBOX}} - wrapperStyle={styles.flex1} + wrapperStyle={styles.navigationTabBarSlot} style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.INBOX} > diff --git a/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx b/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx index 19d2e6ecb5dd..0042091fab77 100644 --- a/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx @@ -67,7 +67,7 @@ function InsightsTabButton({selectedTab, isWideLayout}: InsightsTabButtonProps) role={CONST.ROLE.TAB} accessibilityLabel={translate('common.insights')} accessibilityState={{selected: isSelected}} - wrapperStyle={styles.flex1} + wrapperStyle={styles.navigationTabBarSlot} style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.INSIGHTS} > diff --git a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx index 72308f304f1f..274dabf14b6e 100644 --- a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx @@ -8,8 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import clearSelectedText from '@libs/clearSelectedText/clearSelectedText'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; -import {startSpan} from '@libs/telemetry/activeSpans'; -import {startNavigateToReportsSpans} from '@libs/telemetry/navigateToReportsSpans'; +import {startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; import navigationRef from '@navigation/navigationRef'; @@ -44,12 +43,7 @@ function SearchTabButton({selectedTab, isWideLayout}: SearchTabButtonProps) { } clearSelectedText(); interceptAnonymousUser(() => { - startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { - name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, - op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, - forceTransaction: true, - }); - startNavigateToReportsSpans(); + startNavigateToReportsTabSpans(); Navigation.navigate(getSearchTabRoute(navigationRef.getRootState(), lastSearchParams, lastExpensesSearchQuery)); }); @@ -83,7 +77,7 @@ function SearchTabButton({selectedTab, isWideLayout}: SearchTabButtonProps) { role={CONST.ROLE.TAB} accessibilityLabel={translate('common.spend')} accessibilityState={searchAccessibilityState} - wrapperStyle={styles.flex1} + wrapperStyle={styles.navigationTabBarSlot} style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.REPORTS} > diff --git a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx index 6d75e37b5ea0..dcf891ed9d51 100644 --- a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx +++ b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx @@ -1,6 +1,7 @@ import Icon from '@components/Icon'; import Text from '@components/Text'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -33,15 +34,17 @@ function getIconFill(isSelected: boolean, isHovered: boolean, theme: ReturnType< function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicatorColor, numberOfLines = 2}: TabBarItemProps) { const theme = useTheme(); const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const iconSize = shouldUseNarrowLayout ? variables.iconFloatingTabBar : variables.iconBottomBar; return ( - <> + {!!statusIndicatorColor && ( @@ -49,11 +52,17 @@ function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicator {label} - + ); } diff --git a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx index f996c54f0522..507ec3a3239a 100644 --- a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx @@ -61,7 +61,7 @@ function WorkspacesTabButton({selectedTab, isWideLayout}: WorkspacesTabButtonPro role={CONST.ROLE.TAB} accessibilityLabel={`${translate('common.workspacesTabTitle')}${workspacesTabIndicatorStatus ? `. ${translate('common.yourReviewIsRequired')}` : ''}`} accessibilityState={workspacesAccessibilityState} - wrapperStyle={styles.flex1} + wrapperStyle={styles.navigationTabBarSlot} style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.WORKSPACES} > diff --git a/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts b/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts index e76c98b3398b..bfbc7bb71b85 100644 --- a/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts +++ b/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts @@ -14,6 +14,19 @@ import type {OnyxEntry} from 'react-native-onyx'; import getLastRoute from './getLastRoute'; +/** The latest Spend search kept in Onyx, for a Spend tab that has not shown a search yet. */ +function getStoredSearchTabParams(lastSearchParams: OnyxEntry, lastExpensesSearchQuery: string | undefined) { + const lastQueryJSON = lastSearchParams?.queryJSON; + const lastQueryFromOnyx = lastQueryJSON ? buildSearchQueryString(lastQueryJSON) : undefined; + if (lastQueryFromOnyx) { + // The persisted search key belongs to the persisted query, so it only travels with it. + return {query: lastQueryFromOnyx, searchKey: lastSearchParams?.searchKey}; + } + + const defaultSearchQuery = buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + return {query: getValidLastQuery(lastExpensesSearchQuery, defaultSearchQuery), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES}; +} + function getSearchTabRoute(rootState: NavigationState, lastSearchParams: OnyxEntry, lastExpensesSearchQuery: string | undefined) { const lastSearchRoute = getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT); @@ -28,15 +41,8 @@ function getSearchTabRoute(rootState: NavigationState, lastSearchParams: OnyxEnt } } - const lastQueryJSON = lastSearchParams?.queryJSON; - const lastQueryFromOnyx = lastQueryJSON ? buildSearchQueryString(lastQueryJSON) : undefined; - if (lastQueryFromOnyx) { - // The persisted search key belongs to the persisted query, so it only travels with it. - return ROUTES.SEARCH_ROOT.getRoute({query: lastQueryFromOnyx, searchKey: lastSearchParams?.searchKey}); - } - - const defaultSearchQuery = buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); - return ROUTES.SEARCH_ROOT.getRoute({query: getValidLastQuery(lastExpensesSearchQuery, defaultSearchQuery), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES}); + return ROUTES.SEARCH_ROOT.getRoute(getStoredSearchTabParams(lastSearchParams, lastExpensesSearchQuery)); } export default getSearchTabRoute; +export {getStoredSearchTabParams}; diff --git a/src/components/Navigation/NavigationTabBar/index.tsx b/src/components/Navigation/NavigationTabBar/index.tsx index f934146a96da..0f988d857280 100644 --- a/src/components/Navigation/NavigationTabBar/index.tsx +++ b/src/components/Navigation/NavigationTabBar/index.tsx @@ -161,7 +161,7 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig onPress={navigateToNewDotHome} role={CONST.ROLE.TAB} accessibilityLabel={translate('common.home')} - wrapperStyle={styles.flex1} + wrapperStyle={styles.navigationTabBarSlot} style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.HOME} > @@ -169,6 +169,7 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig icon={expensifyIcons.Home} label={translate('common.home')} isSelected={selectedTab === NAVIGATION_TABS.HOME} + numberOfLines={1} /> ) => getDefaultChatEnabledPolicySelection(policies, email, activePolicyID), - }); - const defaultChatEnabledPolicyID = defaultChatEnabledPolicySelection?.defaultChatEnabledPolicyID; - const hasMultipleChatEnabledPolicies = !!defaultChatEnabledPolicySelection?.hasMultipleChatEnabledPolicies; - const [defaultChatEnabledPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(defaultChatEnabledPolicyID)}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const shouldShowEmptyReportConfirmationForDefaultChatEnabledPolicy = useShouldShowEmptyReportConfirmation(defaultChatEnabledPolicyID); - const travelEnabledPolicy = useMemo( () => Object.values(allPolicies ?? {}).find((policy) => isWorkspaceProvisionedForTravel(policy?.travelSettings) && isPolicyAccessible(policy, email ?? '')), [allPolicies, email], @@ -94,8 +75,8 @@ function QuickCreationActionsBar() { }, [travelEnabledPolicy, isBlockedFromSpotnanaTravel, primaryContactMethod, travelSettings]); const handleCreateWorkspaceReport = useCallback( - (shouldDismissEmptyReportsConfirmation?: boolean) => { - if (!defaultChatEnabledPolicy?.id) { + (policy: OnyxEntry, shouldDismissEmptyReportsConfirmation?: boolean) => { + if (!policy?.id) { return; } @@ -103,7 +84,7 @@ function QuickCreationActionsBar() { currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, - defaultChatEnabledPolicy, + policy, isTrackIntentUser, getCurrencyDecimals, rules, @@ -117,13 +98,12 @@ function QuickCreationActionsBar() { Navigation.navigate(getCreateReportRoute({reportID: createdReportID})); }); }, - [currentUserPersonalDetails, hasViolations, defaultChatEnabledPolicy, isASAPSubmitBetaEnabled, isTrackIntentUser, getCurrencyDecimals, rules], + [currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, getCurrencyDecimals, rules], ); - const {openCreateReportConfirmation} = useCreateEmptyReportConfirmation({ - policyID: defaultChatEnabledPolicyID, - policyName: defaultChatEnabledPolicy?.name ?? '', - onConfirm: handleCreateWorkspaceReport, + const {createReport, isVisible: isCreateReportReady} = useCreateReport({ + onCreateReport: handleCreateWorkspaceReport, + onNavigateToWorkspaceSelection: navigateToCreateReportWorkspaceSelection, shouldHandleNavigationBack: false, }); @@ -135,63 +115,6 @@ function QuickCreationActionsBar() { [draftTransactionIDs], ); - const handleReport = useCallback( - () => - interceptAnonymousUser(() => { - if (shouldNavigateToUpgradePath) { - const freshReportID = generateReportID(); - const freshTransactionID = generateReportID(); - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ - action: CONST.IOU.ACTION.CREATE, - iouType: CONST.IOU.TYPE.CREATE, - transactionID: freshTransactionID, - reportID: freshReportID, - upgradePath: CONST.UPGRADE_PATHS.REPORTS, - }), - ), - ); - return; - } - - const workspaceIDForReportCreation = defaultChatEnabledPolicyID; - - if ( - !workspaceIDForReportCreation || - (shouldRestrictUserBillableActions(defaultChatEnabledPolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserPersonalDetails.accountID) && - hasMultipleChatEnabledPolicies) - ) { - navigateToCreateReportWorkspaceSelection(); - return; - } - - if (!shouldRestrictUserBillableActions(defaultChatEnabledPolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserPersonalDetails.accountID)) { - if (shouldShowEmptyReportConfirmationForDefaultChatEnabledPolicy) { - openCreateReportConfirmation(); - } else { - handleCreateWorkspaceReport(false); - } - return; - } - - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(workspaceIDForReportCreation)); - }), - [ - shouldNavigateToUpgradePath, - defaultChatEnabledPolicyID, - userBillingGracePeriodEnds, - ownerBillingGracePeriodEnd, - amountOwed, - defaultChatEnabledPolicy, - hasMultipleChatEnabledPolicies, - shouldShowEmptyReportConfirmationForDefaultChatEnabledPolicy, - openCreateReportConfirmation, - handleCreateWorkspaceReport, - currentUserPersonalDetails.accountID, - ], - ); - const handleDistance = useCallback( () => interceptAnonymousUser(() => { @@ -229,7 +152,8 @@ function QuickCreationActionsBar() { + + + ); +} + +export default SearchNLFilterContent; diff --git a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx index 6242f00b45ae..077606301b62 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx @@ -15,7 +15,7 @@ import {FILTER_VIEW_MAP} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; -import type {ComponentRef} from 'react'; +import type {ComponentRef, ReactNode} from 'react'; import type {TextInput as RNTextInput, StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -30,6 +30,12 @@ type TextInputFilterContentProps = { autoFocus?: boolean; style?: StyleProp; buttonText?: string; + buttonStyles?: StyleProp; + sentryLabel?: string; + + /** Renders extra controls under the input, inside the scrollable area. Receives the current negation state, so the content can change when the user negates the filter. */ + renderBelowInput?: (isNegated: boolean) => ReactNode; + onChange: (value: string | undefined, isNegated: boolean) => void; }; @@ -37,7 +43,19 @@ function isTextInput(element: BaseTextInputRef | ComponentRef { @@ -59,6 +77,7 @@ function TextInputFilterContent({baseFilterKey, value: initialValue, isNegated: } onChange(value, isNegated); }} + sentryLabel={sentryLabel} > {buttonText ?? translate('common.confirm')} @@ -93,6 +112,7 @@ function TextInputFilterContent({baseFilterKey, value: initialValue, isNegated: containerStyles={[styles.ph5]} /> + {renderBelowInput?.(isNegated)} {shouldButtonBeInScrollView && button} {!shouldButtonBeInScrollView && button} diff --git a/src/components/Search/FilterComponents/BankAccountSelector.tsx b/src/components/Search/FilterComponents/BankAccountSelector.tsx index 2c84f984b3a6..9dea29e9ac3b 100644 --- a/src/components/Search/FilterComponents/BankAccountSelector.tsx +++ b/src/components/Search/FilterComponents/BankAccountSelector.tsx @@ -179,6 +179,7 @@ function BankAccountSelector({value = [], selectionListTextInputStyle, selection sections={sections} ListItem={MultiSelectListItem} + titleNumberOfLines={2} onSelectRow={updateSelection} shouldPreventDefaultFocusOnSelectRow={false} shouldShowTextInput={shouldShowSearchInput} diff --git a/src/components/Search/FilterComponents/CardSelector.tsx b/src/components/Search/FilterComponents/CardSelector.tsx index 1d2eb56d7d2f..5fa6873de9f0 100644 --- a/src/components/Search/FilterComponents/CardSelector.tsx +++ b/src/components/Search/FilterComponents/CardSelector.tsx @@ -149,6 +149,7 @@ function CardSelector({value = [], selectionListTextInputStyle, selectionListSty sections={sections} ListItem={CardListItem} + titleNumberOfLines={2} onSelectRow={updateNewCards} shouldPreventDefaultFocusOnSelectRow={false} shouldShowTextInput={shouldShowSearchInput} diff --git a/src/components/Search/FilterComponents/ExportedToSelector.tsx b/src/components/Search/FilterComponents/ExportedToSelector.tsx index 8df80a2dec46..436a34abc549 100644 --- a/src/components/Search/FilterComponents/ExportedToSelector.tsx +++ b/src/components/Search/FilterComponents/ExportedToSelector.tsx @@ -72,7 +72,8 @@ function ExportedToSelector({value = [], policyID, selectionListTextInputStyle, const integrationConnectionNamesSet = new Set(integrationConnectionNames); const connectedIntegrationPickerItems = integrationConnectionNames - .filter((connectionName) => connectedIntegrationNames.has(connectionName)) + // Zoho Books is hidden from the export filter until the exported-status cell supports it + .filter((connectionName) => connectionName !== CONST.POLICY.CONNECTIONS.NAME.ZOHO_BOOKS && connectedIntegrationNames.has(connectionName)) .flatMap((connectionName) => { const searchValues = getExportLabelsForConnection(connectionName, policiesToLoadTemplatesFrom); return searchValues.map((searchValue) => { diff --git a/src/components/Search/FilterComponents/InSelector.tsx b/src/components/Search/FilterComponents/InSelector.tsx index 8081238ae99c..4da10764527e 100644 --- a/src/components/Search/FilterComponents/InSelector.tsx +++ b/src/components/Search/FilterComponents/InSelector.tsx @@ -55,7 +55,7 @@ function getSelectedOptionData(option: Option & Pick): O } function InSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, ready = true, footer, onChange}: InSelectorProps) { - const {translate, dateFnsLocale} = useLocalize(); + const {translate, dateFnsLocale, formatPhoneNumber} = useLocalize(); const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const {convertToDisplayString, convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); const personalDetails = usePersonalDetails(); @@ -119,6 +119,7 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle reportAttributesDerived, isTrackIntentUser, pendingDeleteMemberAccountIDs: pendingDeleteMemberAccountIDsByReportID?.[id], + formatPhoneNumber, }), ), isSelected, @@ -143,6 +144,7 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle translate, currentUserAccountID, rules, + formatPhoneNumber, }, ); return {...report, alternateText}; @@ -180,6 +182,7 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle translate, getReportByID, rules, + formatPhoneNumber, }).options; const chatOptions = filterAndOrderOptions({ @@ -198,6 +201,7 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle currentUserAccountID, }, translate, + formatPhoneNumber, rules, activePolicyID, }); diff --git a/src/components/Search/FilterComponents/ListFilterContent.tsx b/src/components/Search/FilterComponents/ListFilterContent.tsx index b6c51e39154e..5ff18f53fd99 100644 --- a/src/components/Search/FilterComponents/ListFilterContent.tsx +++ b/src/components/Search/FilterComponents/ListFilterContent.tsx @@ -49,6 +49,8 @@ type ListFilterContentProps = SearchFilterCommonProps & { @@ -250,6 +252,8 @@ function ListFilterContent({ } case CONST.SEARCH.SYNTAX_FILTER_KEYS.BILLABLE: case CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE: + case CONST.SEARCH.SYNTAX_FILTER_KEYS.DECLINED: + case CONST.SEARCH.SYNTAX_FILTER_KEYS.HELD: case CONST.SEARCH.SYNTAX_FILTER_KEYS.TRANSACTION_STATUS: case CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_TYPE: { const isSingleSelectFilterValue = (v: ListFilterContentProps['value']): v is SingleSelectListFilterContentProps['value'] => { diff --git a/src/components/Search/FilterComponents/MerchantMatchTypeSelector.tsx b/src/components/Search/FilterComponents/MerchantMatchTypeSelector.tsx new file mode 100644 index 000000000000..cbcf743be352 --- /dev/null +++ b/src/components/Search/FilterComponents/MerchantMatchTypeSelector.tsx @@ -0,0 +1,62 @@ +/** + * Shared Merchant match type selector for search filter surfaces. + */ +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import type {MerchantMatchType} from '@src/types/form/SearchAdvancedFiltersForm'; + +import React from 'react'; +import {View} from 'react-native'; + +type MerchantMatchTypeSelectorProps = { + /** Currently selected Merchant match type. */ + value: MerchantMatchType; + + /** Updates the selected Merchant match type. */ + onChange: (value: MerchantMatchType) => void; +}; + +function MerchantMatchTypeSelector({value, onChange}: MerchantMatchTypeSelectorProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const items: Array> = [ + { + keyForList: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + text: translate('search.filters.merchant.contains'), + isSelected: value === CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + }, + { + keyForList: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + text: translate('search.filters.merchant.equalTo'), + isSelected: value === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }, + ]; + + return ( + <> + + {translate('search.filters.merchant.matchType')} + + {/* Inside a ScrollView a flex list can shrink to zero height when the keyboard is open, so reserve room for every row */} + + onChange(item.keyForList)} + shouldSingleExecuteRowSelect + /> + + + ); +} + +export default MerchantMatchTypeSelector; diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index 07ecf2109c26..e239e315a7b7 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -128,6 +128,8 @@ function MultiSelect({ isSelected: !!selectedItems.find((i) => i.value === item.value), icons: item.icons, leftElement: item.leftElement, + // Without vertical padding a two line title still fits the estimated row height + itemStyle: item.alternateText ? undefined : styles.pv0, })); const shouldShowSearchSpinner = isSearching && listData.length === 0; @@ -201,6 +203,7 @@ function MultiSelect({ shouldShowLoadingPlaceholder={shouldShowLoadingPlaceholder} data={listData} ListItem={MultiSelectListItem} + titleNumberOfLines={2} onSelectRow={updateSelectedItems} textInputOptions={textInputOptions} style={{contentContainerStyle: [styles.pb0], ...selectionListStyle}} diff --git a/src/components/Search/FilterComponents/SingleSelect.tsx b/src/components/Search/FilterComponents/SingleSelect.tsx index 8db195bff577..1059804efd58 100644 --- a/src/components/Search/FilterComponents/SingleSelect.tsx +++ b/src/components/Search/FilterComponents/SingleSelect.tsx @@ -85,6 +85,9 @@ function SingleSelectImpl({ const orderedItems = moveInitialSelectionToTop(items, initialSelectedValues); const rowHeight = itemHeight ?? variables.optionRowHeightCompact; + // Without vertical padding a two line title still fits the row height + const rowStyle = [{minHeight: rowHeight}, styles.pv0]; + const {options, noResultsFound} = (() => { if (isSearchable) { const searchLower = debouncedSearchTerm.toLowerCase(); @@ -94,7 +97,7 @@ function SingleSelectImpl({ text: item.text, keyForList: item.value, isSelected: selectedItem?.value === item.value, - itemStyle: {minHeight: rowHeight}, + itemStyle: rowStyle, })); const isEmpty = allOptions.length === 0; return { @@ -108,7 +111,7 @@ function SingleSelectImpl({ text: item.text, keyForList: item.value, isSelected: item.value === selectedItem?.value, - itemStyle: {minHeight: rowHeight}, + itemStyle: rowStyle, })), noResultsFound: false, }; @@ -154,6 +157,7 @@ function SingleSelectImpl({ data={options} shouldSingleExecuteRowSelect ListItem={SingleSelectListItem} + titleNumberOfLines={2} onSelectRow={updateSelectedItem} textInputOptions={textInputOptions} style={{contentContainerStyle: [styles.pb0], ...selectionListStyle}} diff --git a/src/components/Search/FilterComponents/WorkspaceSelector.tsx b/src/components/Search/FilterComponents/WorkspaceSelector.tsx index 399686a82b54..abc9ed6ecbf2 100644 --- a/src/components/Search/FilterComponents/WorkspaceSelector.tsx +++ b/src/components/Search/FilterComponents/WorkspaceSelector.tsx @@ -220,6 +220,7 @@ function WorkspaceSelector({value = [], selectionListTextInputStyle, selectionLi sections={sections} ListItem={MultiSelectListItem} + titleNumberOfLines={2} onSelectRow={updateSelectedItems} shouldPreventDefaultFocusOnSelectRow={false} shouldShowTextInput={shouldShowWorkspaceSearchInput} diff --git a/src/components/Search/FilterDropdowns/DisplayPopup.tsx b/src/components/Search/FilterDropdowns/DisplayPopup.tsx index 7e454063d78b..597aefe1541b 100644 --- a/src/components/Search/FilterDropdowns/DisplayPopup.tsx +++ b/src/components/Search/FilterDropdowns/DisplayPopup.tsx @@ -12,6 +12,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {close} from '@libs/actions/Modal'; +import clearSelectedText from '@libs/clearSelectedText/clearSelectedText'; import Navigation from '@libs/Navigation/Navigation'; import {getGroupBySections, getSearchColumnTranslationKey, getValidGroupBy, getViewOptions} from '@libs/SearchUIUtils'; @@ -189,6 +190,9 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayP onBackButtonPress={goBack} onChange={(item) => { const newValue = item?.value; + if (newValue !== queryJSON.groupBy) { + clearSelectedText(); + } if (!newValue) { updateFilterForm({groupBy: undefined, groupCurrency: undefined}); } else { diff --git a/src/components/Search/FilterDropdowns/FilterPopupButton.tsx b/src/components/Search/FilterDropdowns/FilterPopupButton.tsx index 5bf8eff98616..351f81a43ee1 100644 --- a/src/components/Search/FilterDropdowns/FilterPopupButton.tsx +++ b/src/components/Search/FilterDropdowns/FilterPopupButton.tsx @@ -67,7 +67,11 @@ function FilterPopupButton({ const isFocused = useIsFocused(); const styles = useThemeStyles(); const {isKeyboardActive} = useKeyboardState(); - const bottomSafeAreaPaddingStyle = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding: isSmallScreenWidth && !isKeyboardActive}); + // The style is applied inside the popover, which never renders an offline indicator, so don't reserve space for one + const bottomSafeAreaPaddingStyle = useBottomSafeSafeAreaPaddingStyle({ + addBottomSafeAreaPadding: isSmallScreenWidth && !isKeyboardActive, + addOfflineIndicatorBottomSafeAreaPadding: false, + }); const StyleUtils = useStyleUtils(); const {windowHeight} = useWindowDimensions(); const triggerRef = useRef | null>(null); diff --git a/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx b/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx new file mode 100644 index 000000000000..0aa56da3d8ab --- /dev/null +++ b/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx @@ -0,0 +1,116 @@ +/** + * Edits the Merchant filter value and match type from its search filter chip. + */ +import ScrollView from '@components/ScrollView'; +import MerchantMatchTypeSelector from '@components/Search/FilterComponents/MerchantMatchTypeSelector'; +import NegatableFilter from '@components/Search/FilterComponents/NegatableFilter'; +import useTextFilterValidation from '@components/Search/hooks/useTextFilterValidation'; +import TextInput from '@components/TextInput'; + +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import useKeyboardState from '@hooks/useKeyboardState'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; + +import {BOTTOM_SHEET_TOP_PADDING} from '@libs/getBottomSheetHeight'; +import {getMerchantFilterFormValues} from '@libs/SearchQueryUtils'; +import {getFilterNegatableValue} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; +import type {MerchantMatchType, SearchAdvancedFiltersForm} from '@src/types/form/SearchAdvancedFiltersForm'; + +import React, {useState} from 'react'; +import {View} from 'react-native'; + +import type {PopoverComponentProps} from './FilterPopupButton'; + +import BasePopup from './BasePopup'; + +type MerchantFilterPopupProps = { + /** The Merchant filter key. */ + baseFilterKey: typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT; + /** The current search filter form values. */ + values: Partial | undefined; + /** The translated filter label. */ + label: string; + /** Closes the filter popup. */ + closeOverlay: PopoverComponentProps['closeOverlay']; + /** Applies the updated Merchant filter values. */ + updateFilterForm: (value: Partial) => void; +}; + +// The controls are rendered directly in BasePopup, like TextFilterPopup, because the popover sizes to its content. +// The advanced filters page content uses a flex ScrollView, which collapses to zero height in a content-sized popover on native. +// A content-sized ScrollView (flexShrink1, not flex1) under a max height only shrinks and scrolls when the controls don't fit, +// for example in landscape or with the keyboard open, so the Apply button stays visible. +function MerchantFilterPopup({baseFilterKey, values, label, updateFilterForm, closeOverlay}: MerchantFilterPopupProps) { + const styles = useThemeStyles(); + const {windowHeight} = useWindowDimensions(); + const isInLandscapeMode = useIsInLandscapeMode(); + const {isKeyboardActive, keyboardActiveHeight} = useKeyboardState(); + const {paddingTop: safeAreaTop} = useSafeAreaPaddings(true); + + // The popover avoids the keyboard by padding its bottom, so while the keyboard is open only the room above it is available. + const {maxHeight: popoverMaxHeight} = styles.getPopoverMaxHeight(windowHeight, isInLandscapeMode); + const maxHeight = isKeyboardActive ? Math.min(popoverMaxHeight, windowHeight - keyboardActiveHeight - safeAreaTop - BOTTOM_SHEET_TOP_PADDING) : popoverMaxHeight; + + const {isNegated: initialIsNegated, value: initialValue} = getFilterNegatableValue(baseFilterKey, values); + const [isNegated, setIsNegated] = useState(initialIsNegated); + const [value, setValue] = useState(initialValue); + const [merchantOperator, setMerchantOperator] = useState(values?.[FILTER_KEYS.MERCHANT_OPERATOR] ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); + + const error = useTextFilterValidation(baseFilterKey, value); + + const applyChanges = () => { + if (error) { + return; + } + updateFilterForm(getMerchantFilterFormValues(value, isNegated, merchantOperator)); + closeOverlay(); + }; + + return ( + + + + + + {/* A negated Merchant filter always uses the not-equal operator, so the match type is hidden. */} + {!isNegated && ( + + + + )} + + + ); +} + +export default MerchantFilterPopup; diff --git a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/MerchantFilterContentPopupWrapper.tsx b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/MerchantFilterContentPopupWrapper.tsx new file mode 100644 index 000000000000..cbb0c74e9c1c --- /dev/null +++ b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/MerchantFilterContentPopupWrapper.tsx @@ -0,0 +1,24 @@ +import MerchantFilterContent from '@components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent'; +import type {MerchantFilterContentWrapperProps} from '@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +function MerchantFilterContentPopupWrapper({baseFilterKey, value, isNegated, merchantOperator, buttonText, onChange}: MerchantFilterContentWrapperProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default MerchantFilterContentPopupWrapper; diff --git a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx index d44cb288afb1..bea9ad4a791d 100644 --- a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx @@ -1,21 +1,35 @@ +import Icon from '@components/Icon'; +import {PressableWithFeedback} from '@components/Pressable'; import SafeTriangle from '@components/SafeTriangle'; import FilterList from '@components/Search/FilterComponents/AdvancedFilters/FilterList'; import SearchAdvancedFiltersContent from '@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent'; +import SearchNLFilterContent from '@components/Search/FilterComponents/AdvancedFilters/SearchNLFilterContent'; import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; import type {SearchQueryJSON} from '@components/Search/types'; +import SpacerView from '@components/SpacerView'; +import Text from '@components/Text'; import {useDebounceWithControls} from '@hooks/useDebounce'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import usePointerMovement from '@hooks/usePointerMovement'; import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import getButtonState from '@libs/getButtonState'; +import Navigation from '@libs/Navigation/Navigation'; import {hasFilterContentValuesChanged} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; +import variables from '@styles/variables'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type {ComponentRef} from 'react'; @@ -26,11 +40,15 @@ import {View} from 'react-native'; import AmountFilterContentPopupWrapper from './AmountFilterContentPopupWrapper'; import DateFilterContentPopupWrapper from './DateFilterContentPopupWrapper'; import ListFilterContentPopupWrapper from './ListFilterContentPopupWrapper'; +import MerchantFilterContentPopupWrapper from './MerchantFilterContentPopupWrapper'; import ReportFieldFilterContentPopupWrapper from './ReportFieldFilterContentPopupWrapper'; import TextInputFilterContentPopupWrapper from './TextInputFilterContentPopupWrapper'; type SearchAdvancedFiltersPopupProps = { queryJSON: SearchQueryJSON; + + /** Closes the filters popover overlay */ + closeOverlay: () => void; }; /** Which filter contents are mounted and what each was given. */ @@ -56,6 +74,7 @@ type MountedFilterState = { const filterComponents = { List: ListFilterContentPopupWrapper, + Merchant: MerchantFilterContentPopupWrapper, Text: TextInputFilterContentPopupWrapper, Amount: AmountFilterContentPopupWrapper, Date: DateFilterContentPopupWrapper, @@ -106,12 +125,18 @@ function MountedFilterContent({filterKey, values, ready, onChange}: MountedFilte ); } -function SearchAdvancedFiltersPopup({queryJSON}: SearchAdvancedFiltersPopupProps) { +function SearchAdvancedFiltersPopup({queryJSON, closeOverlay}: SearchAdvancedFiltersPopupProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const theme = useTheme(); + const {translate} = useLocalize(); const {windowHeight} = useWindowDimensions(); + const {isBetaEnabled} = usePermissions(); + const canUseNLFilters = isBetaEnabled(CONST.BETAS.NL_FILTERS); + const [isDescribeMode, setIsDescribeMode] = useState(false); const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const filterContentRef = useRef>(null); + const icons = useMemoizedLazyExpensifyIcons(['Sparkles', 'ArrowRight']); const [mountedFilterState, setMountedFilterState] = useState(() => ({ activeFilter: INITIAL_FILTER, mountedFilters: [INITIAL_FILTER], @@ -164,6 +189,7 @@ function SearchAdvancedFiltersPopup({queryJSON}: SearchAdvancedFiltersPopupProps const {invoke: debouncedMarkShownFilterReady, cancel: cancelReadyWait} = useDebounceWithControls(markShownFilterReady, CONST.TIMING.SEARCH_FILTER_HOVER_INTENT_DELAY); const hoverFilter = (filterKey: SearchFilter['key']) => { + setIsDescribeMode(false); showFilter(filterKey); debouncedMarkShownFilterReady(); }; @@ -185,6 +211,7 @@ function SearchAdvancedFiltersPopup({queryJSON}: SearchAdvancedFiltersPopupProps // Moving the focus is deliberate and never passes over rows on the way, so nothing is withheld from it. const focusFilter = (filterKey: SearchFilter['key']) => { + setIsDescribeMode(false); cancelReadyWait(); showFilter(filterKey); markShownFilterReady(); @@ -192,37 +219,94 @@ function SearchAdvancedFiltersPopup({queryJSON}: SearchAdvancedFiltersPopupProps const mayDeriveContent = (filterKey: SearchFilter['key']) => !DEFERRED_CONTENT_FILTERS.has(filterKey) || readyFilters.includes(filterKey); + const getDescribeButtonBackground = (pressed: boolean) => { + if (pressed) { + return styles.buttonHoveredBG; + } + if (isDescribeMode) { + return styles.hoveredComponentBG; + } + return undefined; + }; + + const handleNLSuccess = (route: Route) => { + closeOverlay(); + Navigation.navigate(route); + }; + return ( - + + {canUseNLFilters && ( + <> + [styles.typeFilterMenu, getDescribeButtonBackground(pressed)]} + accessible + accessibilityLabel={translate('search.filters.describeSearch.title')} + role={CONST.ROLE.BUTTON} + sentryLabel="SearchAdvancedFiltersPopup-DescribeSearch" + onHoverIn={() => setIsDescribeMode(true)} + onFocus={() => setIsDescribeMode(true)} + onPress={() => setIsDescribeMode(true)} + > + {({pressed}) => ( + <> + + {translate('search.filters.describeSearch.title')} + + + )} + + + + )} + + - {mountedFilters.map((filterKey) => ( - // `Activity` keeps a hidden content mounted while taking it out of layout and unmounting its - // effects, so it holds no Onyx subscriptions until it is shown again. - - - - ))} + {isDescribeMode ? ( + + ) : ( + mountedFilters.map((filterKey) => ( + // `Activity` keeps a hidden content mounted while taking it out of layout and unmounting its + // effects, so it holds no Onyx subscriptions until it is shown again. + + + + )) + )} diff --git a/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx b/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx index 1db5eddc90a5..99e80e9375f4 100644 --- a/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx +++ b/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx @@ -7,7 +7,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getFooterTotalItems} from '@libs/SearchUIUtils'; +import {getFooterCountItems, getFooterTotalItems} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -84,10 +84,7 @@ function SearchFooterPopup({ const styles = useThemeStyles(); const [selectedFooterFilter, setSelectedFooterFilter] = useState(null); - const countItems: Array> = [ - {text: translate('common.expenses'), value: CONST.SEARCH.FOOTER_COUNT.EXPENSES}, - {text: translate('common.reports'), value: CONST.SEARCH.FOOTER_COUNT.REPORTS}, - ]; + const countItems: Array> = getFooterCountItems(translate); const totalItems = getFooterTotalItems(translate); if (!selectedFooterFilter) { @@ -104,9 +101,7 @@ function SearchFooterPopup({ )} {shouldShowTotalSelector && !!totalType && ( item.value === totalType)?.text} @@ -115,7 +110,7 @@ function SearchFooterPopup({ /> )} ; + baseFilterKey: Exclude; values: Partial | undefined; label: string; closeOverlay: PopoverComponentProps['closeOverlay']; diff --git a/src/components/Search/SearchAutocompleteInput.tsx b/src/components/Search/SearchAutocompleteInput.tsx index 400cac4f2fe8..e934222b6c7f 100644 --- a/src/components/Search/SearchAutocompleteInput.tsx +++ b/src/components/Search/SearchAutocompleteInput.tsx @@ -1,5 +1,4 @@ import FormHelpMessage from '@components/FormHelpMessage'; -import type {AnimatedTextInputRef} from '@components/RNTextInput'; import TextInput from '@components/TextInput'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; @@ -99,7 +98,7 @@ function SearchAutocompleteInput({ const {translate} = useLocalize(); const {isOffline} = useNetwork(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const inputRef = useRef(null); + const inputRef = useRef(null); const autoFocusAfterNav = useFocusAfterNav(inputRef, shouldDelayFocus); const {currencyList} = useCurrencyListState(); const currencyAutocompleteList = Object.keys(currencyList).filter((currencyCode) => !currencyList[currencyCode]?.retired); @@ -132,8 +131,9 @@ function SearchAutocompleteInput({ return focusedSharedValue.get() ? restWrapperFocusedStyle : (restWrapperStyle ?? {}); }); const wrapperBorderColorAnimatedStyle = useAnimatedStyle(() => { + const borderColors = [wrapperBorderColor, focusedBorderColor].map((color) => (typeof color === 'string' ? color : defaultBorderColor)); return { - borderColor: interpolateColor(focusedSharedValue.get() ? 1 : 0, [0, 1], [wrapperBorderColor as string, focusedBorderColor as string], 'RGB'), + borderColor: interpolateColor(focusedSharedValue.get() ? 1 : 0, [0, 1], borderColors, 'RGB'), }; }); @@ -230,7 +230,7 @@ function SearchAutocompleteInput({ return; } - inputRef.current = element as AnimatedTextInputRef; + inputRef.current = element; if (typeof ref === 'function') { ref(element); diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 413871add079..4c24694fabc2 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -387,6 +387,7 @@ function SearchAutocompleteList({ translate, getReportByID, rules, + formatPhoneNumber, }).options; const optionsByReportID = new Map(options.recentReports.map((option) => [option.reportID, option])); return orderedReportIDs.map((reportID) => optionsByReportID.get(reportID)).filter((option): option is OptionData => !!option && !option.isSelfDM); @@ -411,6 +412,7 @@ function SearchAutocompleteList({ translate, getReportByID, rules, + formatPhoneNumber, ]); const [isInitialRender, setIsInitialRender] = useState(true); diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index c71c395dd111..6e3351afc27a 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -1,10 +1,6 @@ import BulkActionBar from '@components/BulkActionBar'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; -import DecisionModal from '@components/DecisionModal'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; -import ExpensifyCardStatementPDFDownloadModal from '@components/ExpensifyCardStatementPDFDownloadModal'; -import HoldOrRejectEducationalModal from '@components/HoldOrRejectEducationalModal'; -import HoldSubmitterEducationalModal from '@components/HoldSubmitterEducationalModal'; import KYCWall from '@components/KYCWall'; import {KYCWallContext} from '@components/KYCWall/KYCWallContext'; import type {ContinueActionParams} from '@components/KYCWall/types'; @@ -51,9 +47,7 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { const {pageGutter} = useLayoutSpacing(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - // We need isSmallScreenWidth (not just shouldUseNarrowLayout) because DecisionModal requires it for correct modal type - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const {selectedTransactions, excludedTransactions = getEmptyObject(), selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); const {clearSelectedTransactions} = useSearchSelectionActions(); const {currentSearchResults} = useSearchResultsContext(); @@ -80,22 +74,6 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { selectedReportIDs, businessBankAccountOptions, confirmPayment, - isOfflineModalVisible, - isDownloadErrorModalVisible, - isHoldEducationalModalVisible, - areAllTransactionsFromDMReports, - rejectModalAction, - emptyReportsCount, - handleOfflineModalClose, - handleDownloadErrorModalClose, - isExpensifyCardStatementPDFModalVisible, - setIsExpensifyCardStatementPDFModalVisible, - expensifyCardStatementPDFParams, - handleExpensifyCardStatementPDFModalHide, - isExpensifyCardStatementMultiFeedAlertVisible, - handleExpensifyCardStatementMultiFeedAlertClose, - dismissModalAndUpdateUseHold, - dismissRejectModalBasedOnAction, isDuplicateOptionVisible, setDuplicateHandler, isDuplicateReportOptionVisible, @@ -273,54 +251,6 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { ) } - - - {!!expensifyCardStatementPDFParams && ( - setIsExpensifyCardStatementPDFModalVisible(false)} - onModalHide={handleExpensifyCardStatementPDFModalHide} - /> - )} - - {!!rejectModalAction && ( - - )} - {!!isHoldEducationalModalVisible && ( - - )} ); } diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index ec1f71b01e35..b2ed4a50c3dc 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -3,6 +3,8 @@ import type {ChartDataPoint} from '@components/Charts'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; import {sanitizeCurrencyCode} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -18,7 +20,7 @@ import {format} from 'date-fns'; import React from 'react'; import {View} from 'react-native'; -import type {SearchChartModel} from './buildChartSeries'; +import type {ChartComparison, SearchChartModel} from './buildChartSeries'; import type {ChartView, GroupedItem, SearchGroupBy, SearchQueryJSON} from './types'; import {buildChartSeries} from './buildChartSeries'; @@ -44,12 +46,18 @@ type SearchChartViewProps = { /** Whether a bar chart labels its bars and a donut chart shows its legend. Line chart labels always show. */ shouldShowGroupLabels?: boolean; + /** Whether labels adapt to a constrained width: bar labels wrap to two centered lines and the donut legend uses the responsive side-by-side/stacked layout. Used by the Home widget. */ + shouldUseResponsiveLabels?: boolean; + /** Whether pressing a bar, point or slice opens Search filtered to it */ shouldDrillDownOnPress?: boolean; /** Whether a line chart with a single point is drawn as a bar */ shouldShowSinglePointAsBar?: boolean; + /** Rows the chart is compared against, drawn as a second series */ + comparison?: ChartComparison; + /** Renders the details of the plotted groups below the chart */ renderDetails?: (model: SearchChartModel) => React.ReactNode; @@ -68,29 +76,51 @@ function SearchChartView({ data, isLoading, shouldShowGroupLabels = true, + shouldUseResponsiveLabels = false, shouldDrillDownOnPress = true, shouldShowSinglePointAsBar = false, + comparison, renderDetails, chartContainerStyle, }: SearchChartViewProps) { + const theme = useTheme(); const {preferredLocale, translate, dateFnsLocale} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const {getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); const {currentSearchKey} = useSearchQueryContext(); - const {getLabel, getShortLabel, getFilterQuery} = CHART_GROUP_BY_CONFIG[groupBy]; + const {getLabel, getShortLabel, getLineChartLabel, getFilterQuery, getDateRange, axisLabelSteps} = CHART_GROUP_BY_CONFIG[groupBy]; const today = format(new Date(), CONST.DATE.FNS_FORMAT_STRING); const dateFilterRange = queryJSON ? getDateFilterRange(queryJSON) : {}; const {series, rows} = buildChartSeries({ data, + comparison, + groupBy, view, getLabel, - getShortLabel, + getShortLabel: view === CONST.SEARCH.VIEW.LINE ? (getLineChartLabel ?? getShortLabel) : getShortLabel, getCurrencyDecimals, getInProgressLabel: (item) => getInProgressBucketLabel({groupBy, item, today, dateFnsLocale, dateFilterRange, translate}), + isLaterPeriod: (item) => { + const range = getDateRange?.(item); + if (!range) { + return false; + } + + // A date filter that starts partway through a group starts the group there too, e.g. Oct 10 for a range from Oct 10 to Dec 31 + const start = dateFilterRange.start && dateFilterRange.start > range.start ? dateFilterRange.start : range.start; + return start > today; + }, + translate, + dateFnsLocale, + colorScheme: theme.colorScheme, }); const points = rows.map((row) => row.point); + // On narrow layout, a single step spanning the whole axis labels only the first and last points + const labelSteps = shouldUseNarrowLayout && axisLabelSteps ? [Math.max(1, points.length - 1)] : axisLabelSteps; + const handleItemPress = (dataPoint: ChartDataPoint, index: number) => { const item = rows.at(index)?.item; if (!item || !queryJSON) { @@ -126,6 +156,7 @@ function SearchChartView({ yAxisUnit={unit} yAxisUnitPosition={unitPosition} shouldShowLabels={shouldShowGroupLabels} + shouldWrapLabels={shouldUseResponsiveLabels} /> ), [CONST.SEARCH.VIEW.LINE]: @@ -144,6 +175,7 @@ function SearchChartView({ series={series} isLoading={isLoading} onPointPress={onItemPress} + labelSteps={labelSteps} yAxisUnit={unit} yAxisUnitPosition={unitPosition} /> @@ -157,6 +189,7 @@ function SearchChartView({ valueUnit={unit.value} valueUnitPosition={unitPosition} shouldShowLegend={shouldShowGroupLabels} + shouldUseResponsiveLegend={shouldUseResponsiveLabels} /> ), }; diff --git a/src/components/Search/SearchList/BaseSearchList/index.native.tsx b/src/components/Search/SearchList/BaseSearchList/index.native.tsx index 8164250b667d..97fe20695996 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.native.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.native.tsx @@ -1,3 +1,4 @@ +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; import {FlashList} from '@shopify/flash-list'; @@ -23,6 +24,7 @@ function BaseSearchList({ stickyHeaderIndices, getItemType, }: BaseSearchListProps) { + const tabRootScrollProps = useTabRootScrollProps(contentContainerStyle); const renderItemWithoutKeyboardFocus = useCallback( ({item, index}: {item: SearchListItem; index: number}) => { return renderItem(item, index, false, undefined); @@ -45,7 +47,7 @@ function BaseSearchList({ onLayout={onLayout} removeClippedSubviews drawDistance={250} - contentContainerStyle={contentContainerStyle} + {...tabRootScrollProps} maintainVisibleContentPosition={{disabled: true}} stickyHeaderIndices={stickyHeaderIndices} getItemType={getItemType} diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index 3fcad84abb92..cf573939637c 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -1,4 +1,5 @@ import {useEditingCellState} from '@components/EditableCell'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; @@ -74,6 +75,7 @@ function BaseSearchList({ disabledIndexes, overrideItemLayout, }: BaseSearchListProps) { + const tabRootScrollProps = useTabRootScrollProps(contentContainerStyle); const hasKeyBeenPressed = useRef(false); const isFocused = useIsFocused(); const {focusedCellId, isEditingCell} = useEditingCellState(); @@ -193,7 +195,7 @@ function BaseSearchList({ CellRendererComponent={CellRendererComponent} removeClippedSubviews drawDistance={250} - contentContainerStyle={contentContainerStyle} + {...tabRootScrollProps} maintainVisibleContentPosition={{disabled: true}} stickyHeaderIndices={stickyHeaderIndices} stickyHeaderConfig={stickyHeaderConfig} diff --git a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx index 32c772abe7f4..332668f96653 100644 --- a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx +++ b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx @@ -135,11 +135,13 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, shouldDisab allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(shouldUseB2BInvoiceReport ? existingB2BInvoiceReport?.reportID : chatReport?.reportID)}`]; payInvoice({ + bankAccountList, isASAPSubmitBetaEnabled, getCurrencyDecimals, paymentMethodType: type, chatReport, invoiceReport: iouReport, + invoiceReportTransactions: transactions, introSelected, currentUserAccountIDParam: currentUserAccountID, currentUserEmailParam: email ?? '', @@ -172,12 +174,14 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, shouldDisab } payMoneyRequest({ + bankAccountList, isASAPSubmitBetaEnabled, getCurrencyDecimals, paymentType: type, chatReport: chatReportForPayment, isFallbackChatReport, iouReport, + iouReportTransactions: transactions, introSelected, currentUserAccountID, currentUserLogin: currentUserLogin ?? '', diff --git a/src/components/Search/SearchList/ListItem/AttendeesCell.tsx b/src/components/Search/SearchList/ListItem/AttendeesCell.tsx index 667e7b20b94d..7746d82fcbb6 100644 --- a/src/components/Search/SearchList/ListItem/AttendeesCell.tsx +++ b/src/components/Search/SearchList/ListItem/AttendeesCell.tsx @@ -63,8 +63,9 @@ function AttendeesCell({attendees, isHovered, isPressed}: AttendeesCellProps) { return ( {[...icons].splice(0, maxAvatarsPerRow).map((icon, index) => ( ; + + /** Whether the text can be selected inside a copyable row */ + isSelectable?: boolean; }; -function AvatarWithTextCell({reportName, icon, isLargeScreenWidth, textStyle}: AvatarWithTextCellProps) { +function AvatarWithTextCell({reportName, icon, isLargeScreenWidth, textStyle, isSelectable = false}: AvatarWithTextCellProps) { const styles = useThemeStyles(); if (!reportName || !icon) { return null; } + const avatar = ( + + ); + return ( - {!!icon && ( - - )} - - {!!reportName && ( - - {reportName} - + {avatar} + + ) : ( + avatar )} + + + {reportName} + ); } diff --git a/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx b/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx index fca5e334d005..524194411de8 100644 --- a/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx @@ -121,6 +121,7 @@ function BaseListItemHeaderImpl({ @@ -167,6 +168,7 @@ function BaseListItemHeaderImpl({ diff --git a/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx b/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx index 7b8ce7c47b66..0df830787f92 100644 --- a/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx @@ -106,6 +106,7 @@ function CardListItemHeaderImpl({ text={cardItem.formattedCardName ?? ''} numberOfLines={2} style={[styles.preWrap]} + isSelectable /> @@ -119,6 +120,7 @@ function CardListItemHeaderImpl({ text={cardItem.formattedFeedName ?? ''} numberOfLines={2} style={[styles.lineHeightLarge, styles.preWrap]} + isSelectable /> ), @@ -169,11 +171,13 @@ function CardListItemHeaderImpl({ diff --git a/src/components/Search/SearchList/ListItem/ChatListItem.tsx b/src/components/Search/SearchList/ListItem/ChatListItem.tsx index 2918e07d7b62..4f5a1bfd7283 100644 --- a/src/components/Search/SearchList/ListItem/ChatListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ChatListItem.tsx @@ -4,6 +4,7 @@ import type {ListItem} from '@components/SelectionList/types'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; import useOnyx from '@hooks/useOnyx'; +import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -39,6 +40,7 @@ function ChatListItem({ const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportActionItem?.reportID}`, {selector: getStableReportSelector}); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportActionItem?.childReportID}`); const [chatReportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportStable?.chatReportID)}`, {selector: getStableReportSelector}); + const reportsParentHierarchy = useReportsParentHierarchy(reportActionItem?.reportID); const styles = useThemeStyles(); const {pageGutterMargin} = useLayoutSpacing(); const StyleUtils = useStyleUtils(); @@ -91,6 +93,7 @@ function ChatListItem({ isFirstVisibleReportAction={false} shouldDisplayContextMenu={false} shouldShowBorder + reportsParentHierarchy={reportsParentHierarchy} /> diff --git a/src/components/Search/SearchList/ListItem/DateCell.tsx b/src/components/Search/SearchList/ListItem/DateCell.tsx index d0aaa4b12a35..05da53973ba6 100644 --- a/src/components/Search/SearchList/ListItem/DateCell.tsx +++ b/src/components/Search/SearchList/ListItem/DateCell.tsx @@ -32,9 +32,10 @@ function DateCell({date, showTooltip, isLargeScreenWidth, suffixText, shouldUseL const styles = useThemeStyles(); const {isInNarrowPaneModal} = useResponsiveLayout(); const {getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); - const {isEditing, anchorRef, isPopoverVisible, popoverPosition, isInverted, startEditing, cancelEditing, handleSave} = usePopoverEditState({ + const {isEditing, anchorRef, isPopoverVisible, popoverPosition, anchorAlignment, shouldOpenAbove, startEditing, cancelEditing, handleSave} = usePopoverEditState({ canEdit, value: date, + popoverHeight: CONST.POPOVER_DATE_MAX_HEIGHT, onSave, }); @@ -57,6 +58,7 @@ function DateCell({date, showTooltip, isLargeScreenWidth, suffixText, shouldUseL text={displayText} shouldShowTooltip={showTooltip} style={[styles.lineHeightLarge, styles.pre, styles.justifyContentCenter, isLargeScreenWidth ? undefined : styles.mutedNormalTextLabel, !!suffixText && styles.flexShrink1]} + isSelectable /> ); @@ -73,11 +75,11 @@ function DateCell({date, showTooltip, isLargeScreenWidth, suffixText, shouldUseL onClose={cancelEditing} onSelected={handleSave} anchorPosition={popoverPosition} - anchorAlignment={{ - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, - }} - shouldPositionFromTop={!isInverted} + anchorAlignment={anchorAlignment} + shouldMeasureContentHeight + windowMargin={CONST.MODAL.POPOVER_MENU_PADDING} + shouldPositionFromTop={!shouldOpenAbove} + shouldSwitchPositionIfOverflow={false} isDateSelectable={DateUtils.isWithinCalendarPickerRange} inputID="EditableDateCell" shouldEnableMonthYearBackdropInNarrowPane={isEditing && isInNarrowPaneModal} diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index 46bbc1ac3876..6464e4cbd6b1 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -37,7 +37,7 @@ import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils'; import {getNonHeldAndFullAmount, isInvoiceReport, isOpenExpenseReport, isProcessingReport, isReportPendingDelete, shouldShowMarkAsDone} from '@libs/ReportUtils'; import {hasVisibleViolations} from '@libs/SearchUIUtils'; import shouldBreakAccessibilityGrouping from '@libs/shouldBreakAccessibilityGrouping'; -import {isOnHold, isViolationDismissed, shouldShowViolation, showHeldExpensesBlockModal, showPendingCardTransactionsBlockModal} from '@libs/TransactionUtils'; +import {getAttendees, isOnHold, isViolationDismissed, shouldShowViolation, showHeldExpensesBlockModal, showPendingCardTransactionsBlockModal} from '@libs/TransactionUtils'; import variables from '@styles/variables'; @@ -205,7 +205,7 @@ function ExpenseReportListItemInner({ relevantViolations, policyCategories, transaction.category ?? '', - transaction.attendees, + getAttendees(transaction), currentUserDetails, isAttendeeTrackingEnabled(policyForViolations), policyForViolations.type === CONST.POLICY.TYPE.CORPORATE, @@ -264,6 +264,7 @@ function ExpenseReportListItemInner({ delegateEmail, delegateAccountID, conciergeChat, + bankAccountList, } = useReportPaymentContext({ chatReportPolicyID: chatReport?.policyID, }); @@ -337,6 +338,7 @@ function ExpenseReportListItemInner({ rules, personalDetailsByLogins: getPersonalDetailsByLogin(), conciergeChat, + bankAccountList, }); }, [ currentSearchHash, @@ -382,6 +384,7 @@ function ExpenseReportListItemInner({ rules, getPersonalDetailsByLogin, conciergeChat, + bankAccountList, shouldShowMarkAsDoneCopy, confirmSubmitReportViolations, ]); @@ -434,7 +437,10 @@ function ExpenseReportListItemInner({ const getDescription = useMemo(() => { if (reportItem?.isRejectedReport) { return ( - + ({ return; } return ( - + ({ styles.mr1, styles.textMicro, styles.mutedNormalTextLabel, + styles.userSelectNone, isLargeScreenWidth, expensifyIcons.DotIndicator, theme.danger, @@ -492,6 +502,7 @@ function ExpenseReportListItemInner({ accessibilityRole={canSelectMultiple ? CONST.ROLE.GROUP : undefined} accessibilityLabel={rowAccessibilityLabel} shouldUseOptionRole={false} + shouldAllowTextSelection pressableStyle={listItemPressableStyle} isFocused={isFocused} shouldShowTooltip={showTooltip} @@ -522,6 +533,7 @@ function ExpenseReportListItemInner({ stateNum={reportItem.stateNum} statusNum={reportItem.statusNum} isSelected={isSelected} + shouldAllowStatusTextSelection /> )} diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemAvatar.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemAvatar.tsx index b4fb3ddb3998..fc209871b6bf 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemAvatar.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemAvatar.tsx @@ -68,7 +68,14 @@ function ExpenseReportListItemAvatar({item}: ExpenseReportListItemAvatarProps) { ); } - return {avatarContent}; + return ( + + {avatarContent} + + ); } export default ExpenseReportListItemAvatar; diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx index 834a3668d4b0..07dd64f32dd7 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx @@ -6,6 +6,7 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import {getShiftKeyFromEvent} from '@libs/shiftRangeSelection'; import CONST from '@src/CONST'; @@ -43,15 +44,35 @@ function ExpenseReportListItemRowNarrow({item, onCheckboxPress = () => {}, canSe {item.reportName ?? ''} - {amountText} + + {amountText} + - {formattedDate} - {expenseCountText} + + {formattedDate} + + + {expenseCountText} + diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx index b31183b44ef8..b516f8fc4269 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -18,6 +18,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getBase62ReportID from '@libs/getBase62ReportID'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import {getShiftKeyFromEvent} from '@libs/shiftRangeSelection'; import variables from '@styles/variables'; @@ -136,7 +137,11 @@ function ExpenseReportListItemRowWide({ ), [CONST.SEARCH.TABLE_COLUMNS.STATUS]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.ACTION]: ( - + + >(null); + const {markMouseDownOnCopyableText, markTouchStartOnCopyableText, shouldSuppressCopyableTextRowFocus, shouldSuppressCopyableTextRowLongPress, shouldSuppressCopyableTextRowPress} = + useCopyableTextRowPress(); useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); @@ -366,6 +373,10 @@ function GroupHeader({ const shouldDisplayEmptyView = isEmpty && isExpenseReportType; const handlePress = (event?: ModifiedMouseEvent) => { + if (shouldSuppressCopyableTextRowPress()) { + return; + } + if (isExpenseReportType) { onSelectRow(withOriginalKey(item), transactionPreviewData, event); } @@ -375,6 +386,9 @@ function GroupHeader({ }; const handleLongPress = () => { + if (shouldSuppressCopyableTextRowLongPress()) { + return; + } onLongPressRow?.(withOriginalKey(item), isExpenseReportType ? undefined : groupItem.transactions); }; @@ -389,14 +403,39 @@ function GroupHeader({ accessibilityLabel={item.text ?? ''} role={isCashBackWithdrawal ? undefined : CONST.ROLE.BUTTON} isNested + shouldAllowTextSelection={!isCashBackWithdrawal} interactive={!isCashBackWithdrawal} focusable={!isCashBackWithdrawal} pressDimmingValue={isCashBackWithdrawal ? 1 : undefined} hoverStyle={[!isExpanded && !item.isDisabled && !isCashBackWithdrawal && styles.hoveredComponentBG, isItemSelected && styles.activeComponentBG]} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}} - onMouseDown={(e) => e.preventDefault()} + dataSet={{ + ...(isCashBackWithdrawal ? {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true} : COPYABLE_ROW_DATA_SET), + [CONST.INNER_BOX_SHADOW_ELEMENT]: true, + }} + onMouseDown={(e) => { + if (isCashBackWithdrawal) { + e.preventDefault(); + return; + } + const isCopyableTarget = markMouseDownOnCopyableText(e?.target); + if (isCopyableTarget) { + return; + } + e.preventDefault(); + }} + onTouchStart={(event) => { + if (isCashBackWithdrawal) { + return; + } + markTouchStartOnCopyableText(event, isPressStartOnCopyableText(event)); + }} id={item.keyForList ?? ''} - onFocus={onFocus} + onFocus={(event) => { + if (shouldSuppressCopyableTextRowFocus()) { + return; + } + onFocus?.(event); + }} style={[ pressableStyle, isFocused && StyleUtils.getItemBackgroundColorStyle(!!isItemSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG), diff --git a/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx b/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx index da2b4cfa1a76..c2906dce4d3d 100644 --- a/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx @@ -97,10 +97,12 @@ function MemberListItemHeaderImpl({ @@ -154,10 +156,12 @@ function MemberListItemHeaderImpl({ diff --git a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx index 0a555954b5b8..5ad38d5815aa 100644 --- a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx @@ -175,6 +175,8 @@ function HeaderFirstRow({ style={[{maxWidth: 700}]} transactions={reportItem.transactions} avatarBorderColor={avatarBorderColor} + // Grouped report headers sit inside pressable rows, so their title needs the selection marker too. + isTitleSelectable /> @@ -205,7 +207,10 @@ function HeaderFirstRow({ )} {isLargeScreenWidth && ( - + ({ delegateEmail, delegateAccountID, conciergeChat, + bankAccountList, } = useReportPaymentContext({ chatReportPolicyID: chatReport?.policyID, }); @@ -366,6 +372,7 @@ function ReportListItemHeaderInner({ rules, personalDetailsByLogins: getPersonalDetailsByLogin(), conciergeChat, + bankAccountList, }); }; return !isLargeScreenWidth ? ( diff --git a/src/components/Search/SearchList/ListItem/TaskListItem.tsx b/src/components/Search/SearchList/ListItem/TaskListItem.tsx index bc409a6176a5..66552b72a0f2 100644 --- a/src/components/Search/SearchList/ListItem/TaskListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TaskListItem.tsx @@ -78,6 +78,7 @@ function TaskListItem({ return ( ); } @@ -76,6 +77,7 @@ function DescriptionCell({taskItem, showTooltip, isLargeScreenWidth}: TaskCellPr text={taskItem.description} numberOfLines={2} style={[styles.lineHeightLarge, styles.preWrap, styles.justifyContentCenter, isLargeScreenWidth ? undefined : [styles.textMicro, styles.textSupporting]]} + isSelectable /> ); } @@ -134,8 +136,7 @@ function TaskListItemRow({item, containerStyle, showTooltip}: TaskListItemRowPro const theme = useTheme(); const {isLargeScreenWidth} = useResponsiveLayout(); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const shouldDisplayCompactArrowIcon = !!(item.parentReportIcon || item.parentReportName); + const shouldDisplayCompactArrowIcon = !!(item.parentReportIcon ?? item.parentReportName); if (!isLargeScreenWidth) { return ( @@ -165,6 +166,7 @@ function TaskListItemRow({item, containerStyle, showTooltip}: TaskListItemRowPro reportName={item?.parentReportName} icon={item?.parentReportIcon} isLargeScreenWidth={isLargeScreenWidth} + isSelectable /> @@ -194,12 +196,17 @@ function TaskListItemRow({item, containerStyle, showTooltip}: TaskListItemRowPro {!!item.assignee.accountID && ( - + + + )} diff --git a/src/components/Search/SearchList/ListItem/TextCell.tsx b/src/components/Search/SearchList/ListItem/TextCell.tsx index 95f1c9cc9e2d..f6a5d37bde6a 100644 --- a/src/components/Search/SearchList/ListItem/TextCell.tsx +++ b/src/components/Search/SearchList/ListItem/TextCell.tsx @@ -18,6 +18,7 @@ function TextCell({text = '', isLargeScreenWidth = true}: TextCellProps) { shouldShowTooltip numberOfLines={isLargeScreenWidth ? 1 : 2} style={[isLargeScreenWidth ? styles.lineHeightLarge : styles.lh20, !isLargeScreenWidth && styles.preWrap, styles.justifyContentCenter]} + isSelectable /> ); } diff --git a/src/components/Search/SearchList/ListItem/TextWithIconCell.tsx b/src/components/Search/SearchList/ListItem/TextWithIconCell.tsx index 4f989317523a..1c28df93eaca 100644 --- a/src/components/Search/SearchList/ListItem/TextWithIconCell.tsx +++ b/src/components/Search/SearchList/ListItem/TextWithIconCell.tsx @@ -41,6 +41,7 @@ export default function TextWithIconCell({icon, iconSize = 12, text, showTooltip shouldShowTooltip={showTooltip} numberOfLines={numberOfLines} style={[styles.label, numberOfLines > 1 ? styles.preWrap : styles.pre, styles.justifyContentCenter, styles.textMicro, styles.textSupporting, styles.flexShrink1, textStyle]} + isSelectable /> ); diff --git a/src/components/Search/SearchList/ListItem/TotalCell.tsx b/src/components/Search/SearchList/ListItem/TotalCell.tsx index 9e1d95cd603c..f1c49e841950 100644 --- a/src/components/Search/SearchList/ListItem/TotalCell.tsx +++ b/src/components/Search/SearchList/ListItem/TotalCell.tsx @@ -23,6 +23,7 @@ function TotalCell({total, currency, isScanning = false}: TotalCellProps) { shouldShowTooltip text={displayText} style={[styles.optionDisplayName, styles.pre, styles.justifyContentCenter, styles.textBold, styles.textAlignRight, styles.fontWeightNormal]} + isSelectable /> ); } diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index a320342b65f3..968185e62171 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -11,6 +11,7 @@ import Text from '@components/Text'; import TransactionItemRow from '@components/TransactionItemRow'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; +import useCopyableTextRowPress, {isPressStartOnCopyableText} from '@hooks/useCopyableTextRowPress'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; @@ -33,6 +34,7 @@ import {getReportAction} from '@libs/ReportActionsUtils'; import {getReportOrDraftReport} from '@libs/ReportUtils'; import {queryHasViolationFilter} from '@libs/SearchQueryUtils'; import {createAndOpenSearchTransactionThread, getColumnsToShow, getGroupColumnWidthFlags, getGroupTableScrollLayout} from '@libs/SearchUIUtils'; +import {COPYABLE_ROW_DATA_SET} from '@libs/SelectionScraper'; import {isDeletedTransaction, isTransactionPendingDelete} from '@libs/TransactionUtils'; import type {TransactionPreviewData} from '@userActions/Search'; @@ -170,6 +172,7 @@ function TransactionGroupListExpandedImpl({ const currentOffset = transactionsSnapshotMetadata?.offset ?? 0; const shouldShowLoadingOnSearch = !!(!transactions?.length && transactionsSnapshotMetadata?.isLoading) || currentOffset > 0; const shouldDisplayLoadingIndicator = !isExpenseReportType && !!transactionsSnapshotMetadata?.isLoading && shouldShowLoadingOnSearch; + const {markMouseDownOnCopyableText, markTouchStartOnCopyableText, shouldSuppressCopyableTextRowLongPress, shouldSuppressCopyableTextRowPress} = useCopyableTextRowPress(); const {isLargeScreenWidth} = useResponsiveLayout(); const StyleUtils = useStyleUtils(); @@ -301,6 +304,10 @@ function TransactionGroupListExpandedImpl({ } const handleOnPress = (transaction: TransactionListItemType, event?: ModifiedMouseEvent) => { + if (shouldSuppressCopyableTextRowPress()) { + return; + } + // A deleted transaction has no report to open, so a row press toggles its selection instead of dead-ending in navigation. if (isMobileSelectionModeEnabled || isDeletedTransaction(transaction) || isTransactionPendingDelete(transaction)) { onSelectionButtonPress?.(transaction as ListItem); @@ -354,14 +361,29 @@ function TransactionGroupListExpandedImpl({ handleOnPress(transaction, event)} disabled={isTransactionPendingDelete(transaction) && !transaction.isSelected} - onLongPress={() => onLongPress?.(transaction)} + onLongPress={() => { + if (shouldSuppressCopyableTextRowLongPress()) { + return; + } + onLongPress?.(transaction); + }} accessibilityRole={CONST.ROLE.BUTTON} accessibilityLabel={transaction.text ?? ''} isNested - onMouseDown={(e) => e.preventDefault()} + shouldAllowTextSelection + onMouseDown={(e) => { + const isCopyableTarget = markMouseDownOnCopyableText(e?.target); + if (isCopyableTarget) { + return; + } + e.preventDefault(); + }} + onTouchStart={(event) => { + markTouchStartOnCopyableText(event, isPressStartOnCopyableText(event)); + }} hoverStyle={[!transaction.isDisabled && styles.hoveredComponentBG, transaction.isSelected && styles.activeComponentBG]} wrapperStyle={isDeletedOrPendingDelete ? styles.cursorDisabled : undefined} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}} + dataSet={{...COPYABLE_ROW_DATA_SET, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}} id={transaction.transactionID} sentryLabel={CONST.SENTRY_LABEL.SEARCH.EXPANDED_TRANSACTION_ROW} > diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index ba50ac0cadcc..195d2285e7e8 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -6,6 +6,7 @@ import {useRowSelection} from '@components/Search/SearchSelectionProvider'; import type {SearchGroupBy} from '@components/Search/types'; import type {ListItem} from '@components/SelectionList/types'; +import useCopyableTextRowPress, {isPressStartOnCopyableText} from '@hooks/useCopyableTextRowPress'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; import useNetwork from '@hooks/useNetwork'; @@ -22,6 +23,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; import {isCashBackWithdrawalGroup, isTransactionDayGroupListItemType} from '@libs/SearchUIUtils'; +import {COPYABLE_ROW_DATA_SET} from '@libs/SelectionScraper'; import {getVisibleTransactionViolations, isTransactionPendingDelete} from '@libs/TransactionUtils'; import variables from '@styles/variables'; @@ -223,6 +225,8 @@ function TransactionGroupListItemImpl({ isItemSelected && styles.activeComponentBG, ]; const pressableRef = useRef>(null); + const {markMouseDownOnCopyableText, markTouchStartOnCopyableText, shouldSuppressCopyableTextRowFocus, shouldSuppressCopyableTextRowLongPress, shouldSuppressCopyableTextRowPress} = + useCopyableTextRowPress(); useEffect(() => { if (!newTransactionID || !isExpanded) { @@ -281,6 +285,10 @@ function TransactionGroupListItemImpl({ const isCashBackWithdrawal = isCashBackWithdrawalGroup(groupItem); const onPress = (event?: ModifiedMouseEvent) => { + if (shouldSuppressCopyableTextRowPress()) { + return; + } + // A cash back row has no children to drill into. if (isCashBackWithdrawal) { return; @@ -295,6 +303,9 @@ function TransactionGroupListItemImpl({ }; const onLongPress = () => { + if (shouldSuppressCopyableTextRowLongPress()) { + return; + } onLongPressRow?.(item, isExpenseReportType ? undefined : transactions); }; @@ -559,18 +570,43 @@ function TransactionGroupListItemImpl({ accessibilityLabel={item.text ?? ''} role={isCashBackWithdrawal ? undefined : CONST.ROLE.BUTTON} isNested + shouldAllowTextSelection={!isCashBackWithdrawal} interactive={!isCashBackWithdrawal} focusable={!isCashBackWithdrawal} pressDimmingValue={isCashBackWithdrawal ? 1 : undefined} hoverStyle={[!isExpanded && !item.isDisabled && !isCashBackWithdrawal && styles.hoveredComponentBG, isItemSelected && styles.activeComponentBG]} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}} - onMouseDown={(e) => e.preventDefault()} + dataSet={{ + ...(isCashBackWithdrawal ? {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true} : COPYABLE_ROW_DATA_SET), + [CONST.INNER_BOX_SHADOW_ELEMENT]: true, + }} + onMouseDown={(e) => { + if (isCashBackWithdrawal) { + e.preventDefault(); + return; + } + const isCopyableTarget = markMouseDownOnCopyableText(e?.target); + if (isCopyableTarget) { + return; + } + e.preventDefault(); + }} + onTouchStart={(event) => { + if (isCashBackWithdrawal) { + return; + } + markTouchStartOnCopyableText(event, isPressStartOnCopyableText(event)); + }} id={item.keyForList ?? ''} style={[ pressableStyle, isFocused && StyleUtils.getItemBackgroundColorStyle(!!isItemSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG), ]} - onFocus={onFocus} + onFocus={(event) => { + if (shouldSuppressCopyableTextRowFocus()) { + return; + } + onFocus?.(event); + }} wrapperStyle={[ pageGutterMargin, StyleUtils.getSearchRowBackgroundStyle(!!isItemSelected), diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx index a10f4d0b1d45..0a7bd7b0d9db 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx @@ -6,12 +6,15 @@ import {useRowSelection} from '@components/Search/SearchSelectionProvider'; import type {ListItem} from '@components/SelectionList/types'; import TransactionItemRow from '@components/TransactionItemRow'; +import useCopyableTextRowPress, {isPressStartOnCopyableText} from '@hooks/useCopyableTextRowPress'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {COPYABLE_ROW_DATA_SET} from '@libs/SelectionScraper'; + import CONST from '@src/CONST'; import type {ComponentRef} from 'react'; @@ -51,12 +54,18 @@ function TransactionListItemNarrow({ const theme = useTheme(); const StyleUtils = useStyleUtils(); const pressableRef = useRef>(null); + const {markMouseDownOnCopyableText, markTouchStartOnCopyableText, shouldSuppressCopyableTextRowFocus, shouldSuppressCopyableTextRowLongPress, shouldSuppressCopyableTextRowPress} = + useCopyableTextRowPress(); useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); const transactionItem = item as unknown as TransactionListItemType; const {isSelected} = useRowSelection(item.keyForList, transactionItem.selectionGroupKey); const handleOnPress: React.ComponentProps['onPress'] = (event) => { + if (shouldSuppressCopyableTextRowPress()) { + return; + } + // A deleted transaction has no report to open, so a row press toggles its selection instead of dead-ending in navigation. if (isDeletedTransaction) { if (canSelectMultiple) { @@ -83,14 +92,20 @@ function TransactionListItemNarrow({ onLongPressRow?.(item)} + onLongPress={() => { + if (shouldSuppressCopyableTextRowLongPress()) { + return; + } + onLongPressRow?.(item); + }} onPress={handleOnPress} disabled={isDisabled && !isSelected} accessibilityLabel={item.text ?? ''} role={!isDeletedTransaction ? CONST.ROLE.BUTTON : 'none'} isNested + shouldAllowTextSelection hoverStyle={[!item.isDisabled && styles.hoveredComponentBG, isSelected && styles.activeComponentBG]} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}} + dataSet={{...COPYABLE_ROW_DATA_SET, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}} id={item.keyForList ?? ''} sentryLabel={CONST.SENTRY_LABEL.SEARCH.TRANSACTION_LIST_ITEM} style={[ @@ -98,7 +113,22 @@ function TransactionListItemNarrow({ isFocused && StyleUtils.getItemBackgroundColorStyle(isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG), isDeletedTransaction && styles.cursorDefault, ]} - onFocus={onFocus} + onFocus={(event) => { + if (shouldSuppressCopyableTextRowFocus()) { + return; + } + onFocus?.(event); + }} + onMouseDown={(event) => { + const isCopyableTarget = markMouseDownOnCopyableText(event?.target); + if (isCopyableTarget) { + return; + } + event.preventDefault(); + }} + onTouchStart={(event) => { + markTouchStartOnCopyableText(event, isPressStartOnCopyableText(event)); + }} wrapperStyle={[ pageGutterMargin, styles.flex1, @@ -117,6 +147,7 @@ function TransactionListItemNarrow({ stateNum={transactionItem.report?.stateNum} statusNum={transactionItem.report?.statusNum} isSelected={isSelected} + shouldAllowStatusTextSelection /> ({ const theme = useTheme(); const StyleUtils = useStyleUtils(); const pressableRef = useRef>(null); + const {markMouseDownOnCopyableText, markTouchStartOnCopyableText, shouldSuppressCopyableTextRowFocus, shouldSuppressCopyableTextRowLongPress, shouldSuppressCopyableTextRowPress} = + useCopyableTextRowPress(); useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); const transactionItem = item as unknown as TransactionListItemType; @@ -95,6 +100,10 @@ function TransactionListItemWide({ }); const handleOnPress: React.ComponentProps['onPress'] = (event) => { + if (shouldSuppressCopyableTextRowPress()) { + return; + } + // Consume the tap that dismissed an editing cell — a second tap will open the row. // We check the ref rather than isEditingCell because blur fires before onPress and resets the state. if (wasEditingOnMouseDownRef.current) { @@ -117,9 +126,10 @@ function TransactionListItemWide({ const handleOnMouseDown = (e?: React.MouseEvent) => { wasEditingOnMouseDownRef.current = isEditingCell; + const isCopyableTarget = markMouseDownOnCopyableText(e?.target); // Skip preventDefault when editing so the browser naturally blurs the input (triggering save/cancel). - if (!isEditingCell) { + if (!isEditingCell && !isCopyableTarget) { e?.preventDefault(); } }; @@ -149,16 +159,25 @@ function TransactionListItemWide({ onLongPressRow?.(item)} + onLongPress={() => { + if (shouldSuppressCopyableTextRowLongPress()) { + return; + } + onLongPressRow?.(item); + }} onPress={handleOnPress} disabled={isDisabled && !isSelected} accessibilityLabel={item.text ?? ''} role={!isDeletedTransaction ? CONST.ROLE.BUTTON : 'none'} isNested + shouldAllowTextSelection onMouseDown={handleOnMouseDown} + onTouchStart={(event) => { + markTouchStartOnCopyableText(event, isPressStartOnCopyableText(event)); + }} onHoverIn={handleOnHoverIn} hoverStyle={[!item.isDisabled && !shouldDisableHoverStyle && styles.hoveredComponentBG, isSelected && styles.activeComponentBG]} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}} + dataSet={{...COPYABLE_ROW_DATA_SET, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}} id={item.keyForList ?? ''} sentryLabel={CONST.SENTRY_LABEL.SEARCH.TRANSACTION_LIST_ITEM} style={[ @@ -166,7 +185,12 @@ function TransactionListItemWide({ isFocused && StyleUtils.getItemBackgroundColorStyle(isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG), isDeletedTransaction && styles.cursorDefault, ]} - onFocus={onFocus} + onFocus={(event) => { + if (shouldSuppressCopyableTextRowFocus()) { + return; + } + onFocus?.(event); + }} wrapperStyle={[ pageGutterMargin, styles.flex1, diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx index ca079a27c302..08ccdac9350d 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx @@ -29,6 +29,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils'; import {isInvoiceReport, shouldShowMarkAsDone} from '@libs/ReportUtils'; import { + getAttendees, isDeletedTransaction as isDeletedTransactionUtil, isViolationDismissed, mergeProhibitedViolations, @@ -114,9 +115,7 @@ function TransactionListItemInner({ // Use report's policyID as fallback when transaction doesn't have policyID directly // Use moving-expense policy as final fallback for SelfDM tracking expenses. // NOTE: Using || instead of ?? to treat empty string "" as falsy - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const explicitPolicyID = transactionItem.policyID || snapshotReport?.policyID; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let policyID = explicitPolicyID || activePolicyIDFromOnyx; if (!policyID && transactionItem.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { policyID = policyForMovingExpensesID; @@ -163,6 +162,7 @@ function TransactionListItemInner({ delegateEmail, delegateAccountID, conciergeChat, + bankAccountList, } = useReportPaymentContext({ chatReportPolicyID: chatReport?.policyID, }); @@ -212,7 +212,7 @@ function TransactionListItemInner({ onyxViolations, policyCategories, transaction?.category ?? transactionItem.category ?? '', - transaction?.comment?.attendees ?? transactionItem.attendees, + getAttendees(transaction ?? transactionItem), currentUserDetails, isAttendeeTrackingEnabled(policyForViolations), policyForViolations?.type === CONST.POLICY.TYPE.CORPORATE, @@ -281,6 +281,7 @@ function TransactionListItemInner({ rules, personalDetailsByLogins: getPersonalDetailsByLogin(), conciergeChat, + bankAccountList, }); }; diff --git a/src/components/Search/SearchList/ListItem/UserInfoAndActionButtonRow.tsx b/src/components/Search/SearchList/ListItem/UserInfoAndActionButtonRow.tsx index cef02c8f61b1..c60c19fff292 100644 --- a/src/components/Search/SearchList/ListItem/UserInfoAndActionButtonRow.tsx +++ b/src/components/Search/SearchList/ListItem/UserInfoAndActionButtonRow.tsx @@ -5,6 +5,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getReportStatusColorStyle, getReportStatusTooltipTranslation, getReportStatusTranslation, getStatusBadgeBackgroundColor} from '@libs/ReportUtils'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import {isDeletedTransaction} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; @@ -29,6 +30,7 @@ function UserInfoAndActionButtonRow({ stateNum, statusNum, isSelected, + shouldAllowStatusTextSelection = false, }: { item: TransactionReportGroupListItemType | TransactionListItemType | ExpenseReportListItemType; shouldShowUserInfo: boolean; @@ -36,6 +38,7 @@ function UserInfoAndActionButtonRow({ stateNum: ExpenseReportListItemType['stateNum']; statusNum: ExpenseReportListItemType['statusNum']; isSelected?: boolean; + shouldAllowStatusTextSelection?: boolean; }) { const styles = useThemeStyles(); const theme = useTheme(); @@ -64,12 +67,17 @@ function UserInfoAndActionButtonRow({ /> )} {!!statusText && !!reportStatusColorStyle && ( - + + + )} ); diff --git a/src/components/Search/SearchList/ListItem/UserInfoCell.tsx b/src/components/Search/SearchList/ListItem/UserInfoCell.tsx index 51970ee0d867..ec1786566948 100644 --- a/src/components/Search/SearchList/ListItem/UserInfoCell.tsx +++ b/src/components/Search/SearchList/ListItem/UserInfoCell.tsx @@ -4,6 +4,7 @@ import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import {isCorrectSearchUserName} from '@libs/SearchUIUtils'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import type {AvatarSizeName} from '@styles/utils'; @@ -35,16 +36,23 @@ function UserInfoCell({avatar, accountID, displayName, avatarSize, containerStyl return ( - + + + {displayName} diff --git a/src/components/Search/SearchList/ListItem/UserInfoCellsWithArrow.tsx b/src/components/Search/SearchList/ListItem/UserInfoCellsWithArrow.tsx index f0995613543a..2c2785ff2d78 100644 --- a/src/components/Search/SearchList/ListItem/UserInfoCellsWithArrow.tsx +++ b/src/components/Search/SearchList/ListItem/UserInfoCellsWithArrow.tsx @@ -9,6 +9,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {AvatarSizeName} from '@styles/utils'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import type {PersonalDetails} from '@src/types/onyx'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; @@ -80,6 +81,8 @@ function UserInfoCellsWithArrow({ {translate('common.conjunctionTo')} diff --git a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx index f6e2f6593a39..6e30ed6b3020 100644 --- a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx @@ -134,6 +134,7 @@ function WithdrawalIDListItemHeaderImpl({ ), @@ -147,6 +148,7 @@ function WithdrawalIDListItemHeaderImpl({ ), @@ -166,6 +168,7 @@ function WithdrawalIDListItemHeaderImpl({ ), @@ -243,12 +246,14 @@ function WithdrawalIDListItemHeaderImpl({ {statusBadge} diff --git a/src/components/Search/SearchList/ListItem/WorkspaceCell.tsx b/src/components/Search/SearchList/ListItem/WorkspaceCell.tsx index 3d3e07775f5e..2f0f347dccd2 100644 --- a/src/components/Search/SearchList/ListItem/WorkspaceCell.tsx +++ b/src/components/Search/SearchList/ListItem/WorkspaceCell.tsx @@ -44,6 +44,7 @@ function WorkspaceCell({policyID, report}: WorkspaceCellProps) { text={name ?? ''} shouldShowTooltip style={[styles.flex1, styles.flexGrow1]} + isSelectable /> ); diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index 8debb6e1bfcf..25a0db59e6f8 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -20,7 +20,6 @@ import type { TransactionViolation, TransactionViolations, } from '@src/types/onyx'; -import type {Attendee} from '@src/types/onyx/IOU'; import type {Errors, Icon} from '@src/types/onyx/OnyxCommon'; import type { SearchCardGroup, @@ -173,9 +172,6 @@ type TransactionListItemType = ListItem & /** The name of the file used for a receipt */ filename?: string; - /** Attendees in the transaction */ - attendees?: Attendee[]; - /** Precomputed violations */ violations?: TransactionViolation[]; @@ -428,8 +424,11 @@ type TransactionMemberGroupListItemType = TransactionGroupListItemType & {groupe type TransactionDayGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.DAY} & SearchDayGroup & { formattedDay: string; - /** Compact "day" value used where space is tight, e.g. chart axis labels */ + /** Compact "day" value used where space is tight, e.g. bar chart axis labels, adding the year when the groups span several years */ shortFormattedDay: string; + + /** Day label for line chart axes, e.g. "Aug 15", adding the year only outside the current year */ + lineChartFormattedDay: string; }; type TransactionMonthGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.MONTH} & SearchMonthGroup & { @@ -470,8 +469,11 @@ type TransactionTagGroupListItemType = TransactionGroupListItemType & {groupedBy type TransactionWeekGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.WEEK} & SearchWeekGroup & { formattedWeek: string; - /** Compact "week" value used where space is tight, e.g. chart axis labels */ + /** Compact week range used where space is tight, e.g. bar chart axis labels, adding the year when the groups span several years */ shortFormattedWeek: string; + + /** Week start label for line chart axes, e.g. "Sep 6", adding the year only outside the current year */ + lineChartFormattedWeek: string; }; type TransactionYearGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.YEAR} & SearchYearGroup & { diff --git a/src/components/Search/SearchPageFooter.tsx b/src/components/Search/SearchPageFooter.tsx index f4f2b77a4bbc..253a62ab3def 100644 --- a/src/components/Search/SearchPageFooter.tsx +++ b/src/components/Search/SearchPageFooter.tsx @@ -12,7 +12,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getFooterTotalItems} from '@libs/SearchUIUtils'; +import {getFooterCountItems, getFooterTotalItems} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -227,7 +227,7 @@ function SearchPageFooter({ /> ); - const countLabel = translate(countType === CONST.SEARCH.FOOTER_COUNT.REPORTS ? 'common.reports' : 'common.expenses'); + const countLabel = getFooterCountItems(translate).find((item) => item.value === (countType ?? CONST.SEARCH.FOOTER_COUNT.EXPENSES))?.text; // The default reads as "Total spend" in the footer but as plain "Spend" inside the menu, where the row it sits in is // already labelled Total. diff --git a/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx b/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx index 769c6c09d018..416d398483f9 100644 --- a/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx +++ b/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx @@ -2,40 +2,33 @@ import Button from '@components/Button'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import PopoverMenu from '@components/PopoverMenu'; -import useCreateEmptyReportConfirmation from '@hooks/useCreateEmptyReportConfirmation'; +import useCreateReport from '@hooks/useCreateReport'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; -import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePopoverPosition from '@hooks/usePopoverPosition'; -import useShouldShowEmptyReportConfirmation from '@hooks/useShouldShowEmptyReportConfirmation'; import useThemeStyles from '@hooks/useThemeStyles'; import {startDistanceRequest, startMoneyRequest} from '@libs/actions/IOU/MoneyRequest'; import {createNewReport} from '@libs/actions/Report'; import getIconForAction from '@libs/getIconForAction'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; -import {getDefaultChatEnabledPolicySelection} from '@libs/PolicyUtils'; import {generateReportID, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; -import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {ComponentRef} from 'react'; -import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {emailSelector} from '@selectors/Session'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React, {useCallback, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -51,7 +44,6 @@ function SearchActionsBarCreateButton() { const {calculatePopoverPosition} = usePopoverPosition(); const [session] = useOnyx(ONYXKEYS.SESSION); - const [email] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); const {isBetaEnabled} = usePermissions(); @@ -59,26 +51,12 @@ function SearchActionsBarCreateButton() { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {getCurrencyDecimals} = useCurrencyListActions(); const hasViolations = hasViolationsReportUtils(undefined, transactionViolations, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? ''); - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); - const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); - const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); - const {shouldNavigateToUpgradePath} = usePolicyForMovingExpenses(); - // scalar selector keeps useOnyx from deep-comparing thousands of policy objects - const [defaultChatEnabledPolicySelection] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { - selector: (policies: OnyxCollection) => getDefaultChatEnabledPolicySelection(policies, email, activePolicyID), - }); - const defaultChatEnabledPolicyID = defaultChatEnabledPolicySelection?.defaultChatEnabledPolicyID; - const hasMultipleChatEnabledPolicies = !!defaultChatEnabledPolicySelection?.hasMultipleChatEnabledPolicies; - const [defaultChatEnabledPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(defaultChatEnabledPolicyID)}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const shouldShowEmptyReportConfirmationForDefaultChatEnabledPolicy = useShouldShowEmptyReportConfirmation(defaultChatEnabledPolicyID, !isCreateMenuActive); - const handleCreateWorkspaceReport = useCallback( - (shouldDismissEmptyReportsConfirmation?: boolean) => { - if (!defaultChatEnabledPolicy?.id) { + (policy: OnyxEntry, shouldDismissEmptyReportsConfirmation?: boolean) => { + if (!policy?.id) { return; } @@ -86,7 +64,7 @@ function SearchActionsBarCreateButton() { currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, - defaultChatEnabledPolicy, + policy, isTrackIntentUser, getCurrencyDecimals, rules, @@ -101,13 +79,12 @@ function SearchActionsBarCreateButton() { ); }); }, - [currentUserPersonalDetails, hasViolations, defaultChatEnabledPolicy, isASAPSubmitBetaEnabled, isTrackIntentUser, getCurrencyDecimals, rules], + [currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, getCurrencyDecimals, rules], ); - const {openCreateReportConfirmation} = useCreateEmptyReportConfirmation({ - policyID: defaultChatEnabledPolicyID, - policyName: defaultChatEnabledPolicy?.name ?? '', - onConfirm: handleCreateWorkspaceReport, + const {createReport, isVisible: isCreateReportVisible} = useCreateReport({ + onCreateReport: handleCreateWorkspaceReport, + shouldSkipEmptyReportConfirmation: !isCreateMenuActive, }); const hideCreateMenu = useCallback(() => setIsCreateMenuActive(false), []); @@ -142,86 +119,17 @@ function SearchActionsBarCreateButton() { startDistanceRequest(CONST.IOU.TYPE.CREATE, generateReportID(), draftTransactionIDs); }), }, - { - icon: expensifyIcons.Document, - text: translate('report.newReport.createReport'), - onSelected: () => - interceptAnonymousUser(() => { - // No valid policy at all → upgrade + create workspace flow - if (shouldNavigateToUpgradePath) { - const freshReportID = generateReportID(); - const freshTransactionID = generateReportID(); - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ - action: CONST.IOU.ACTION.CREATE, - iouType: CONST.IOU.TYPE.CREATE, - transactionID: freshTransactionID, - reportID: freshReportID, - upgradePath: CONST.UPGRADE_PATHS.REPORTS, - }), - ), - ); - return; - } - - const workspaceIDForReportCreation = defaultChatEnabledPolicyID; - - // No default or restricted with multiple workspaces → workspace selector - if ( - !workspaceIDForReportCreation || - (shouldRestrictUserBillableActions( - defaultChatEnabledPolicy, - ownerBillingGracePeriodEnd, - userBillingGracePeriodEnds, - amountOwed, - currentUserPersonalDetails.accountID, - ) && - hasMultipleChatEnabledPolicies) - ) { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_REPORT_WORKSPACE_SELECTION.path)); - return; - } - - // Default workspace is not restricted → create report directly - if ( - !shouldRestrictUserBillableActions( - defaultChatEnabledPolicy, - ownerBillingGracePeriodEnd, - userBillingGracePeriodEnds, - amountOwed, - currentUserPersonalDetails.accountID, - ) - ) { - // Check if empty report confirmation should be shown - if (shouldShowEmptyReportConfirmationForDefaultChatEnabledPolicy) { - openCreateReportConfirmation(); - } else { - handleCreateWorkspaceReport(false); - } - return; - } - - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(workspaceIDForReportCreation)); - }), - }, - ], - [ - translate, - expensifyIcons, - draftTransactionIDs, - shouldNavigateToUpgradePath, - hasMultipleChatEnabledPolicies, - defaultChatEnabledPolicyID, - shouldShowEmptyReportConfirmationForDefaultChatEnabledPolicy, - ownerBillingGracePeriodEnd, - userBillingGracePeriodEnds, - openCreateReportConfirmation, - handleCreateWorkspaceReport, - amountOwed, - currentUserPersonalDetails.accountID, - defaultChatEnabledPolicy, + ...(isCreateReportVisible + ? [ + { + icon: expensifyIcons.Document, + text: translate('report.newReport.createReport'), + onSelected: createReport, + }, + ] + : []), ], + [translate, expensifyIcons, draftTransactionIDs, createReport, isCreateReportVisible], ); return ( diff --git a/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx b/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx index 3315b1b0c135..d6ab3390e4b9 100644 --- a/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx +++ b/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx @@ -2,7 +2,7 @@ import Button from '@components/Button'; import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; import FilterPopupButton from '@components/Search/FilterDropdowns/FilterPopupButton'; -import type {ButtonComponentProps} from '@components/Search/FilterDropdowns/FilterPopupButton'; +import type {ButtonComponentProps, PopoverComponentProps} from '@components/Search/FilterDropdowns/FilterPopupButton'; import SearchAdvancedFiltersPopup from '@components/Search/FilterDropdowns/SearchAdvancedFiltersPopup'; import type {SearchQueryJSON} from '@components/Search/types'; @@ -93,7 +93,12 @@ function SearchAdvancedFiltersButton({queryJSON}: SearchAdvancedFiltersButtonPro ); - const filtersPopup = () => ; + const filtersPopup = ({closeOverlay}: PopoverComponentProps) => ( + + ); return ( + ); + } + if (isTextFilterKey(baseFilterKey)) { return ( 0; // For to-do searches, always return a valid SearchResults object (even with empty data) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 3a4ebdd856cd..82c357525e9d 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -261,6 +261,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla }, isTrackIntentUser, pendingDeleteMemberAccountIDs: contextualReportPendingDeleteMemberAccountIDs, + formatPhoneNumber, }); reportForContextualSearch = option; } @@ -334,6 +335,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla dateFnsLocale, currentUserAccountID, convertToDisplayString, + formatPhoneNumber, convertToDisplayStringWithoutCurrency, rules, contextualReportPendingDeleteMemberAccountIDs, diff --git a/src/components/Search/SearchRouter/SearchRouterUtils.ts b/src/components/Search/SearchRouter/SearchRouterUtils.ts index dc69694b7804..4499a5cc4e77 100644 --- a/src/components/Search/SearchRouter/SearchRouterUtils.ts +++ b/src/components/Search/SearchRouter/SearchRouterUtils.ts @@ -2,8 +2,6 @@ import type {SearchQueryItem} from '@components/Search/SearchList/ListItem/Searc import {getPolicyNameWithFallback, sanitizeSearchValue} from '@libs/SearchQueryUtils'; -import type {ReportsSplitNavigatorParamList} from '@navigation/types'; - import CONST from '@src/CONST'; import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -50,8 +48,9 @@ function getContextualReportData(state: NavigationState | undefined): Contextual } if (maybeReportRoute?.name === SCREENS.REPORT || maybeReportRoute?.name === SCREENS.RIGHT_MODAL.EXPENSE_REPORT) { - // We're guaranteed that the type of params is of SCREENS.REPORT - return {contextualReportID: (maybeReportRoute?.params as ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]).reportID, isSearchRouterScreen}; + const params = maybeReportRoute.params; + const contextualReportID = params && 'reportID' in params && typeof params.reportID === 'string' ? params.reportID : undefined; + return {contextualReportID, isSearchRouterScreen}; } return {contextualReportID: undefined, isSearchRouterScreen}; } diff --git a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts index d125e7d6e61d..6678437b7aeb 100644 --- a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts +++ b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts @@ -54,7 +54,7 @@ function buildSubstitutionsMap( const substitutionKeyOccurrences = new Map(); - const substitutionsMap = searchAutocompleteQueryRanges.reduce((map, range) => { + const substitutionsMap = searchAutocompleteQueryRanges.reduce((map, range) => { const {key: filterKey, value: filterValue} = range; if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { @@ -116,7 +116,7 @@ function buildSubstitutionsMap( } return map; - }, {} as SubstitutionMap); + }, {}); return substitutionsMap; } diff --git a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts index c221b69fca07..9c26002b8cde 100644 --- a/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts +++ b/src/components/Search/SearchRouter/useCreateNavigationSuggestions.ts @@ -23,7 +23,7 @@ import getCreateReportRoute, {getReportsRootRoute, navigateToCreateReportWorkspa import Navigation from '@libs/Navigation/Navigation'; import {openTravelDotLink} from '@libs/openTravelDotLink'; // eslint-disable-next-line no-restricted-imports -- TravelDot booking requires a paid workspace, matching the existing FAB behavior. -import {canSendInvoice, getDefaultChatEnabledPolicy, getGroupPoliciesWhereReportCanBeCreated, hasAcceptedTravelTerms, isPaidGroupPolicy, shouldShowPolicy} from '@libs/PolicyUtils'; +import {canSendInvoice, hasAcceptedTravelTerms, isPaidGroupPolicy, shouldShowPolicy} from '@libs/PolicyUtils'; import {generateReportID} from '@libs/ReportUtils'; import isOnSearchMoneyRequestReportPage from '@navigation/helpers/isOnSearchMoneyRequestReportPage'; @@ -37,8 +37,11 @@ import {primaryLoginSelector} from '@src/selectors/Account'; import {isTrackIntentUserSelector} from '@src/selectors/Onboarding'; import {emailSelector} from '@src/selectors/Session'; import {validTransactionDraftIDsSelector} from '@src/selectors/TransactionDraft'; +import type {Policy} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; +import type {OnyxEntry} from 'react-native-onyx'; + import {Str} from 'expensify-common'; import {useState} from 'react'; @@ -107,13 +110,10 @@ function useCreateNavigationSuggestions(query = ''): NavigationSuggestionSourceI const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); const [travelSettings] = useOnyx(ONYXKEYS.NVP_TRAVEL_SETTINGS); - // Match Quick Creation and Search actions by using shared report eligibility rules. - const groupPoliciesWithChatEnabled = getGroupPoliciesWhereReportCanBeCreated(allPolicies ?? null, sessionEmail); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [isLoading = false] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const defaultChatEnabledPolicy = getDefaultChatEnabledPolicy([...groupPoliciesWithChatEnabled], activePolicy); const isInvoiceVisible = canSendInvoice(allPolicies ?? null, sessionEmail); const isTravelVisible = !!activePolicy?.isTravelEnabled; const isBlockedFromSpotnanaTravel = isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL); @@ -141,8 +141,8 @@ function useCreateNavigationSuggestions(query = ''): NavigationSuggestionSourceI ); const {createReport, isVisible: isCreateReportVisible} = useCreateReport({ - onCreateReport: (shouldDismissEmptyReportsConfirmation?: boolean) => { - if (!defaultChatEnabledPolicy?.id) { + onCreateReport: (policy: OnyxEntry, shouldDismissEmptyReportsConfirmation?: boolean) => { + if (!policy?.id) { return; } @@ -156,7 +156,7 @@ function useCreateNavigationSuggestions(query = ''): NavigationSuggestionSourceI currentUserPersonalDetails, false, isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), - defaultChatEnabledPolicy, + policy, isTrackIntentUser, getCurrencyDecimals, rules, @@ -170,7 +170,6 @@ function useCreateNavigationSuggestions(query = ''): NavigationSuggestionSourceI Navigation.navigate(getCreateReportRoute({reportID: createdReportID}), {forceReplace: isReportInSearch}); }); }, - groupPoliciesWithChatEnabled, onNavigateToWorkspaceSelection: () => navigateToCreateReportWorkspaceSelection({forceReplace: isOnSearchMoneyRequestReportPage()}), shouldHandleNavigationBack: false, shouldSkipEmptyReportConfirmation: !shouldPrepareCreateReport, diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index 219e9cb181b5..c51a7ff18f56 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -49,6 +49,7 @@ import {isAdminSelector} from '@src/selectors/Domain'; import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; import {emailSelector} from '@src/selectors/Session'; import type * as OnyxTypes from '@src/types/onyx'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type IconAsset from '@src/types/utils/IconAsset'; import type {ReactNode} from 'react'; @@ -134,6 +135,9 @@ type BuildWorkspaceNavigationItemsParams = { /** Login used to evaluate Workspace membership and permissions. */ currentUserLogin: string | undefined; + /** Personal details used to evaluate Workspace membership and permissions. */ + currentUserPersonalDetails: CurrentUserPersonalDetails; + /** Icons consumed by the shared Workspace menu source. */ icons: Parameters[0]['icons']; @@ -145,6 +149,9 @@ type BuildWorkspaceNavigationItemsParams = { /** Whether the Merge ATS beta gating the Recruiting feature is enabled. */ isRecruitingBetaEnabled: boolean; + /** Whether the company agents beta is enabled. */ + isCompanyAgentsBetaEnabled: boolean; + /** Whether the unified Connections beta replaces the separate connection pages. */ isUnifiedConnectionsBetaEnabled: boolean; @@ -292,10 +299,12 @@ function buildWorkspaceNavigationItems({ policies, policyCategories, currentUserLogin, + currentUserPersonalDetails, icons, isOffline, isVendorMatchingBetaEnabled, isRecruitingBetaEnabled, + isCompanyAgentsBetaEnabled, isUnifiedConnectionsBetaEnabled, shouldUseNarrowLayout, convertToDisplayString, @@ -312,10 +321,12 @@ function buildWorkspaceNavigationItems({ policy, policyID: policy.id, currentUserLogin, + currentUserPersonalDetails, icons, policyCategories: policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`], isVendorMatchingBetaEnabled, isRecruitingBetaEnabled, + isCompanyAgentsBetaEnabled, isUnifiedConnectionsBetaEnabled, convertToDisplayString, }); @@ -449,10 +460,12 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) policies: allPolicies, policyCategories, currentUserLogin, + currentUserPersonalDetails, icons, isOffline: !!isOffline, isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), isRecruitingBetaEnabled: isBetaEnabled(CONST.BETAS.MERGE_ATS), + isCompanyAgentsBetaEnabled: isBetaEnabled(CONST.BETAS.COMPANY_AGENTS), isUnifiedConnectionsBetaEnabled, shouldUseNarrowLayout, convertToDisplayString, diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 9edff463bad5..8a9c5378cc55 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -15,10 +15,12 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchResults} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {accountIDSelector} from '@selectors/Session'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; import type {SearchFooterCount, SearchFooterTotal, SelectedTransactionInfo, SelectedTransactions} from './types'; @@ -28,6 +30,9 @@ import SearchPageFooter from './SearchPageFooter'; type SearchSelectionFooterProps = { /** The (sorting-aware) results the page is displaying; source of the footer's totals metadata. */ searchResults: OnyxEntry; + + /** Styles for the footer's container */ + style?: StyleProp; }; type FooterCurrencyState = { @@ -42,6 +47,9 @@ type FooterCurrencyState = { /** The query's currency when the pick was made. Once the query moves off it, the query's own currency wins */ queryCurrency: string | undefined; + + /** The currency the pick wrote into the query. The default is left out, so a Reset writes none */ + writtenQueryCurrency: string | undefined; }; const EMPTY_FOOTER_SELECTION = {footerCount: undefined, footerTotal: undefined, footerCurrency: undefined}; @@ -151,7 +159,7 @@ function areAllSelectedReportsConverted(selectedReportIDs: string[], isReportFre // Self-subscribing footer leaf. Owns the `selectedTransactions` read so a checkbox press re-renders only this // footer — not SearchPage and the list it contains. -function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { +function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProps) { const {selectedTransactions, excludedTransactions = getEmptyObject(), areAllMatchingItemsSelected, selectedReports} = useSearchSelectionContext(); const {currentSearchResults, currentSearchTransactionsByReportID, shouldUseLiveData} = useSearchResultsContext(); const {currentSearchHash, currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); @@ -166,6 +174,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { selectedCurrency: undefined, defaultCurrency: undefined, queryCurrency: undefined, + writtenQueryCurrency: undefined, }); // Whether the backend is still answering the breakdown the footer asked for. The request keys no hash of its own — // it refreshes the figures of the search already on screen — so the wait is held here and ends with the response. @@ -178,9 +187,10 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const pendingReportConversionSources = useRef>>({}); const isCurrentFooterState = footerCurrencyState.searchHash === currentSearchHash; // The query carries the currency across a reload and into a saved search, so it is what an untouched footer starts - // from. This session's own pick bridges the moment until the query catches up with it. Once the query's currency - // moves (the pick landing, an edit, or a saved search on the same hash), the query wins. - const isPickAheadOfQuery = isCurrentFooterState && footerCurrencyState.queryCurrency === footerSelection.footerCurrency; + // from. This session's own pick holds while the query is on the currency it was made on or the one it wrote. Once + // the query moves anywhere else (an edit, or a saved search on the same hash), the query wins. + const isPickAheadOfQuery = + isCurrentFooterState && (footerCurrencyState.queryCurrency === footerSelection.footerCurrency || footerCurrencyState.writtenQueryCurrency === footerSelection.footerCurrency); const selectedCurrency = (isPickAheadOfQuery ? footerCurrencyState.selectedCurrency : undefined) ?? footerSelection.footerCurrency; const defaultFooterCurrency = isCurrentFooterState ? footerCurrencyState.defaultCurrency : undefined; @@ -662,13 +672,15 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { }; const handleFooterCurrencyChange = (currency: string) => { + const writtenQueryCurrency = currency === searchTargetCurrency ? undefined : currency; setFooterCurrencyState({ searchHash: currentSearchHash, selectedCurrency: currency, defaultCurrency: effectiveDefaultCurrency, queryCurrency: footerSelection.footerCurrency, + writtenQueryCurrency, }); - applyFooterSelection({footerCurrency: currency}); + applyFooterSelection({footerCurrency: writtenQueryCurrency}); }; const handleFooterTotalChange = (nextTotalType: SearchFooterTotal) => { @@ -846,10 +858,16 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { // The reports a selection covers. The server's report count describes the whole search, so a selection needs its own: // on a Reports search that is the selected reports, elsewhere the distinct reports the selected expenses sit on. - // An unreported expense sits on no report — it carries the unreported placeholder ID — so it adds none. + // An unreported expense sits on no report — it carries the unreported placeholder ID — so it adds none. Neither does + // an IOU report, which the server's count leaves out, so the count holds once the server's figures take over. + const isIOUReportID = (reportID: string) => currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.type === CONST.REPORT.TYPE.IOU; const selectedReportCount = isReportsSearch - ? selectedReports.length - : new Set(selectedTransactionsKeys.map((key) => selectedTransactions[key]?.reportID).filter((reportID) => !!reportID && reportID !== CONST.REPORT.UNREPORTED_REPORT_ID)).size; + ? selectedReports.filter((report) => report.type !== CONST.REPORT.TYPE.IOU).length + : new Set( + selectedTransactionsKeys + .map((key) => selectedTransactions[key]?.reportID) + .filter((reportID): reportID is string => !!reportID && reportID !== CONST.REPORT.UNREPORTED_REPORT_ID && !isIOUReportID(reportID)), + ).size; // The count follows whatever the footer is describing: the selection's own reports when rows were hand-picked, the // server's report count otherwise. footerData.count already carries the expense side of both cases. @@ -857,20 +875,22 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const footerReportCount = hasPartialSelection ? selectedReportCount : allMatchingReportCount; const footerCount = footerCountType === CONST.SEARCH.FOOTER_COUNT.REPORTS ? footerReportCount : footerData.count; return ( - + + + ); } diff --git a/src/components/Search/SearchTableHeader.tsx b/src/components/Search/SearchTableHeader.tsx index b1477a3c0545..82029790529d 100644 --- a/src/components/Search/SearchTableHeader.tsx +++ b/src/components/Search/SearchTableHeader.tsx @@ -136,6 +136,10 @@ const getExpenseHeaders = (groupBy?: SearchGroupBy): SearchColumnConfig[] => [ columnName: CONST.SEARCH.TABLE_COLUMNS.BILLABLE, translationKey: 'common.billable', }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.HELD, + translationKey: 'search.filters.held', + }, { columnName: CONST.SEARCH.TABLE_COLUMNS.MCC, translationKey: 'common.mcc', diff --git a/src/components/Search/SortableTableHeader.tsx b/src/components/Search/SortableTableHeader.tsx index e370e4558e3a..868623144a45 100644 --- a/src/components/Search/SortableTableHeader.tsx +++ b/src/components/Search/SortableTableHeader.tsx @@ -83,7 +83,8 @@ function SortableTableHeader({ const isSortable = shouldShowSorting && isColumnSortable; const sortByColumnName = sortColumnName ?? columnName; const isActive = sortBy === sortByColumnName; - const isReimbursableOrBillableColumn = columnName === CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE || columnName === CONST.SEARCH.TABLE_COLUMNS.BILLABLE; + const isYesNoColumn = + columnName === CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE || columnName === CONST.SEARCH.TABLE_COLUMNS.BILLABLE || columnName === CONST.SEARCH.TABLE_COLUMNS.HELD; const isConversionAmountColumn = columnName === CONST.SEARCH.TABLE_COLUMNS.GROUP_AMOUNT_DEBITED || columnName === CONST.SEARCH.TABLE_COLUMNS.GROUP_AMOUNT_REIMBURSED || @@ -91,7 +92,7 @@ function SortableTableHeader({ columnName === CONST.SEARCH.TABLE_COLUMNS.AMOUNT_REIMBURSED; const textStyle = [ columnName === CONST.SEARCH.TABLE_COLUMNS.RECEIPT ? StyleUtils.getTextOverflowStyle('clip') : null, - isReimbursableOrBillableColumn || isConversionAmountColumn ? styles.flexShrink1 : null, + isYesNoColumn || isConversionAmountColumn ? styles.flexShrink1 : null, ]; return ( diff --git a/src/components/Search/buildChartSeries.ts b/src/components/Search/buildChartSeries.ts index 87b1a7422c76..e6e4ded279cc 100644 --- a/src/components/Search/buildChartSeries.ts +++ b/src/components/Search/buildChartSeries.ts @@ -1,25 +1,55 @@ import type {ChartDataPoint, ChartSeries} from '@components/Charts'; import getPrimaryValue from '@components/Charts/utils/getPrimaryValue'; import VictoryTheme from '@components/Charts/VictoryTheme'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import {isShareWorthDrawing} from '@libs/PercentageUtils'; import StringUtils from '@libs/StringUtils'; +import type {ColorScheme} from '@styles/index'; + import CONST from '@src/CONST'; import type {NonEmptyTuple} from 'type-fest'; -import type {ChartView, GroupedItem, SearchChartDataRow} from './types'; +import type {ChartBucketRange} from './chartGroupByConfig'; +import type {ChartComparisonPeriods, TimeGroupBy} from './comparisonBuckets'; +import type {ChartView, GroupedItem, SearchChartDataRow, SearchGroupBy} from './types'; + +import CHART_GROUP_BY_CONFIG from './chartGroupByConfig'; +import {formatDates, getComparedBucketLabels, getCounterpartBucketRange, getPositionInPrimaryPeriod, getUnpairedBucketLabels, isTimeGroupBy} from './comparisonBuckets'; const CHART_SERIES_KEY = { PRIMARY: 'primary', + COMPARISON: 'comparison', } as const; +/** Rows drawn as a second series against the primary one */ +type ChartComparison = { + /** The compared rows, paired with the chart's own rows by key, or time buckets by where they sit in their period when `periods` is set */ + data: GroupedItem[]; + + /** Names the series in the tooltip, like "Average". Left out, each row names the dates it covers. */ + label?: string; + + /** Color of the compared line. Compared bars take a shade of their own bar's color instead. */ + color: string; + + /** Set when the rows are another period's */ + periods?: ChartComparisonPeriods; +}; + type BuildChartSeriesParams = { /** Grouped search results, in the order the search returned them */ data: GroupedItem[]; + /** Rows the chart is compared against, drawn as a second series */ + comparison?: ChartComparison; + + /** Decides how compared groups pair up, and whether a missing one plots as nothing or as 0 */ + groupBy: SearchGroupBy; + /** The chart type the rows are plotted on, which decides how groups are colored */ view: ChartView; @@ -32,8 +62,20 @@ type BuildChartSeriesParams = { /** Returns how many decimals a currency is displayed with */ getCurrencyDecimals: (currency: string) => number; - /** Returns the label of a group whose period hasn't ended yet, or undefined for a finished one */ + /** Returns the label of the group whose period includes today, or undefined for any other group */ getInProgressLabel?: (item: GroupedItem) => string | undefined; + + /** Returns whether a group's period starts after today, as it does for future-dated expenses */ + isLaterPeriod?: (item: GroupedItem) => boolean; + + /** Formats labels shared by paired time buckets */ + translate: LocaleContextProps['translate']; + + /** Locale the names of compared periods are formatted in */ + dateFnsLocale: LocaleContextProps['dateFnsLocale']; + + /** Decides the shade of a compared bar */ + colorScheme: ColorScheme; }; type SearchChartModel = { @@ -61,44 +103,140 @@ function getSliceColorsByDataIndex(data: ChartDataPoint[]): Array { - const decimals = getCurrencyDecimals(item.currency ?? CONST.CURRENCY.USD); +function buildChartSeries({ + data, + comparison, + groupBy, + view, + getLabel, + getShortLabel, + getCurrencyDecimals, + getInProgressLabel, + isLaterPeriod, + translate, + dateFnsLocale, + colorScheme, +}: BuildChartSeriesParams): SearchChartModel { + const {getDateRange} = CHART_GROUP_BY_CONFIG[groupBy]; + const periods = comparison?.periods; + const getAmount = (item: GroupedItem) => convertToFrontendAmountAsInteger(item.total ?? 0, getCurrencyDecimals(item.currency ?? CONST.CURRENCY.USD)); + // A time bucket's counterpart covers other dates, so compared time buckets are keyed by start date. Other groups match by their own key. + const comparedByKey = new Map(comparison?.data.map((item) => [(periods && getDateRange?.(item)?.start) ?? item.keyForList, item])); + + /** Returns a group's compared value and share, plus its bucket labels when it is compared against another period */ + const getCompared = (item: GroupedItem) => { + const bucketRange = getDateRange?.(item); + if (periods && isTimeGroupBy(groupBy) && bucketRange) { + const counterpartRange = getCounterpartBucketRange(bucketRange, periods, groupBy); + const comparedItem = counterpartRange && comparedByKey.get(counterpartRange.start); + return { + value: counterpartRange && (comparedItem ? getAmount(comparedItem) : 0), + percentOfTotal: comparedItem?.percentOfTotal, + bucketLabels: getComparedBucketLabels(bucketRange, counterpartRange, periods, groupBy, translate, dateFnsLocale), + counterpartStart: counterpartRange?.start, + sortDate: bucketRange.start, + }; + } + + const comparedItem = comparedByKey.get(item.keyForList); + // No compared match for a time bucket means its counterpart doesn't exist, but for any other group it means the group had no spend. + const missingValue = getDateRange ? undefined : 0; + return {value: comparedItem ? getAmount(comparedItem) : missingValue, percentOfTotal: comparedItem?.percentOfTotal}; + }; + + const pairedRows = data.map((item) => { const label = StringUtils.normalize(getLabel(item)); const shortLabel = getShortLabel?.(item); const inProgressLabel = getInProgressLabel?.(item); + const isInProgress = inProgressLabel !== undefined; + const compared = comparison ? getCompared(item) : undefined; + const bucketLabels = compared?.bucketLabels; + const primarySeriesLabel = bucketLabels?.seriesLabels.primary; + const point: ChartDataPoint = { - label, - shortLabel, - values: {[CHART_SERIES_KEY.PRIMARY]: convertToFrontendAmountAsInteger(item.total ?? 0, decimals)}, + label: bucketLabels?.label ?? inProgressLabel ?? label, + shortLabel: bucketLabels?.shortLabel ?? (isInProgress ? (shortLabel ?? label) : shortLabel), + values: { + [CHART_SERIES_KEY.PRIMARY]: getAmount(item), + ...(compared?.value !== undefined && {[CHART_SERIES_KEY.COMPARISON]: compared.value}), + }, percentOfTotal: item.percentOfTotal, count: item.count, + ...(bucketLabels && { + seriesLabels: { + [CHART_SERIES_KEY.PRIMARY]: isInProgress && primarySeriesLabel ? translate('search.periodSoFar', {period: primarySeriesLabel}) : primarySeriesLabel, + [CHART_SERIES_KEY.COMPARISON]: bucketLabels.seriesLabels.comparison, + }, + }), + ...(compared && {seriesPercentOfTotal: {[CHART_SERIES_KEY.PRIMARY]: item.percentOfTotal, [CHART_SERIES_KEY.COMPARISON]: compared.percentOfTotal}}), + isInProgress: isInProgress || !!isLaterPeriod?.(item), }; - if (inProgressLabel !== undefined) { - point.label = inProgressLabel; - point.shortLabel = shortLabel ?? label; - point.isInProgress = true; - } - return {point, item}; + return {row: {point, item}, counterpartStart: compared?.counterpartStart, sortDate: compared?.sortDate ?? ''}; }); + /** Builds a point for a compared bucket no current bucket pairs with, like January 31 against February, which only the compared line reaches */ + const buildUnpairedRow = (comparedItem: GroupedItem, range: ChartBucketRange, comparisonPeriods: ChartComparisonPeriods, timeGroupBy: TimeGroupBy) => { + const bucketLabels = getUnpairedBucketLabels(range, comparisonPeriods, timeGroupBy, translate, dateFnsLocale); + const point: ChartDataPoint = { + label: bucketLabels.label, + shortLabel: bucketLabels.shortLabel, + values: {[CHART_SERIES_KEY.COMPARISON]: getAmount(comparedItem)}, + seriesLabels: {[CHART_SERIES_KEY.COMPARISON]: bucketLabels.seriesLabel}, + seriesPercentOfTotal: {[CHART_SERIES_KEY.COMPARISON]: comparedItem.percentOfTotal}, + isInProgress: false, + }; + return {row: {point}, sortDate: getPositionInPrimaryPeriod(range, comparisonPeriods, timeGroupBy)}; + }; + + const pairedStarts = new Set(pairedRows.map((pairedRow) => pairedRow.counterpartStart)); + const unpairedRows = + comparison && periods && isTimeGroupBy(groupBy) + ? comparison.data.flatMap((comparedItem) => { + const range = getDateRange?.(comparedItem); + return range && !pairedStarts.has(range.start) ? buildUnpairedRow(comparedItem, range, periods, groupBy) : []; + }) + : []; + + // Sorted by where each bucket sits in the current period. Stable, so a current bucket stays ahead of a compared one sharing its date. + const rows: SearchChartDataRow[] = [...pairedRows, ...unpairedRows] + .sort((first, second) => { + if (first.sortDate === second.sortDate) { + return 0; + } + return first.sortDate < second.sortDate ? -1 : 1; + }) + .map(({row}) => row); + const pieColors = view === CONST.SEARCH.VIEW.PIE ? getSliceColorsByDataIndex(rows.map((row) => row.point)) : undefined; return { - series: [{key: CHART_SERIES_KEY.PRIMARY}], + series: [ + {key: CHART_SERIES_KEY.PRIMARY, label: comparison?.periods && formatDates(comparison.periods.primary, dateFnsLocale)}, + ...(comparison + ? [ + { + key: CHART_SERIES_KEY.COMPARISON, + color: comparison.color, + label: comparison.label ?? (comparison.periods && formatDates(comparison.periods.comparison, dateFnsLocale)), + }, + ] + : []), + ], rows: rows.map((row, index) => { - let color; if (pieColors) { - color = pieColors.at(index); - } else if (view === CONST.SEARCH.VIEW.BAR) { - color = VictoryTheme.colors.getColor(index); + return {...row, point: {...row.point, color: pieColors.at(index)}}; + } + if (view !== CONST.SEARCH.VIEW.BAR) { + return row; } - return {...row, point: {...row.point, color}}; + const color = VictoryTheme.colors.getColor(index); + const seriesColors = {[CHART_SERIES_KEY.PRIMARY]: color, ...(!!comparison && {[CHART_SERIES_KEY.COMPARISON]: VictoryTheme.colors.getComparisonColor(color, colorScheme)})}; + return {...row, point: {...row.point, color, seriesColors}}; }), }; } export {buildChartSeries, getSliceColorsByDataIndex, CHART_SERIES_KEY}; -export type {SearchChartModel}; +export type {ChartComparison, SearchChartModel}; diff --git a/src/components/Search/chartGroupByConfig.ts b/src/components/Search/chartGroupByConfig.ts index 5fa25d24f51f..ce188f09e56b 100644 --- a/src/components/Search/chartGroupByConfig.ts +++ b/src/components/Search/chartGroupByConfig.ts @@ -16,6 +16,9 @@ import type { } from './SearchList/ListItem/types'; import type {GroupedItem, SearchGroupBy} from './types'; +/** The dates a time bucket covers */ +type ChartBucketRange = {start: string; end: string}; + type ChartGroupByConfig = { /** Name of the icon rendered next to the chart title */ titleIconName: 'Users' | 'CreditCard' | 'Send' | 'Folder' | 'Basket' | 'Tag' | 'Calendar'; @@ -26,11 +29,17 @@ type ChartGroupByConfig = { /** Returns the compact label for chart axes, or undefined to fall back to `getLabel` */ getShortLabel?: (item: GroupedItem) => string | undefined; + /** Returns the line chart axis label, or undefined to fall back to `getShortLabel` */ + getLineChartLabel?: (item: GroupedItem) => string | undefined; + + /** Intervals, in groups, the line chart may label its x-axis at. The smallest one that fits is used. */ + axisLabelSteps?: readonly number[]; + /** Builds the query fragment appended to the current query to drill into a group's transactions */ getFilterQuery: (item: GroupedItem) => string; /** Returns the dates a time-based group covers, as yyyy-MM-dd strings */ - getDateRange?: (item: GroupedItem) => {start: string; end: string} | undefined; + getDateRange?: (item: GroupedItem) => ChartBucketRange | undefined; }; /** @@ -62,7 +71,7 @@ const CHART_GROUP_BY_CONFIG: Record = { [CONST.SEARCH.GROUP_BY.MERCHANT]: { titleIconName: 'Basket', getLabel: (item: GroupedItem) => (item as TransactionMerchantGroupListItemType).formattedMerchant ?? '', - getFilterQuery: (item: GroupedItem) => `merchant:"${(item as TransactionMerchantGroupListItemType).merchant}"`, + getFilterQuery: (item: GroupedItem) => `merchant="${(item as TransactionMerchantGroupListItemType).merchant}"`, }, [CONST.SEARCH.GROUP_BY.TAG]: { titleIconName: 'Tag', @@ -73,6 +82,8 @@ const CHART_GROUP_BY_CONFIG: Record = { titleIconName: 'Calendar', getLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.DAY ? item.formattedDay : ''), getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.DAY ? item.shortFormattedDay : undefined), + getLineChartLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.DAY ? item.lineChartFormattedDay : undefined), + axisLabelSteps: [1, 2, 7, 14], getFilterQuery: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.DAY ? `date>=${item.day} date<=${item.day}` : ''), getDateRange: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.DAY ? {start: item.day, end: item.day} : undefined), }, @@ -80,6 +91,7 @@ const CHART_GROUP_BY_CONFIG: Record = { titleIconName: 'Calendar', getLabel: (item: GroupedItem) => (item as TransactionMonthGroupListItemType).formattedMonth ?? '', getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.MONTH ? item.shortFormattedMonth : undefined), + axisLabelSteps: [1, 2, 3, 6], getFilterQuery: (item: GroupedItem) => { const monthItem = item as TransactionMonthGroupListItemType; const {start, end} = DateUtils.getMonthDateRange(monthItem.year, monthItem.month); @@ -91,6 +103,8 @@ const CHART_GROUP_BY_CONFIG: Record = { titleIconName: 'Calendar', getLabel: (item: GroupedItem) => (item as TransactionWeekGroupListItemType).formattedWeek ?? '', getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.WEEK ? item.shortFormattedWeek : undefined), + getLineChartLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.WEEK ? item.lineChartFormattedWeek : undefined), + axisLabelSteps: [1, 2, 4], getFilterQuery: (item: GroupedItem) => { const weekItem = item as TransactionWeekGroupListItemType; const {start, end} = DateUtils.getWeekDateRange(weekItem.week); @@ -101,6 +115,7 @@ const CHART_GROUP_BY_CONFIG: Record = { [CONST.SEARCH.GROUP_BY.YEAR]: { titleIconName: 'Calendar', getLabel: (item: GroupedItem) => (item as TransactionYearGroupListItemType).formattedYear ?? '', + axisLabelSteps: [1, 2, 5], getFilterQuery: (item: GroupedItem) => { const yearItem = item as TransactionYearGroupListItemType; const {start, end} = DateUtils.getYearDateRange(yearItem.year); @@ -112,6 +127,7 @@ const CHART_GROUP_BY_CONFIG: Record = { titleIconName: 'Calendar', getLabel: (item: GroupedItem) => (item as TransactionQuarterGroupListItemType).formattedQuarter ?? '', getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER ? item.shortFormattedQuarter : undefined), + axisLabelSteps: [1, 2, 4], getFilterQuery: (item: GroupedItem) => { const quarterItem = item as TransactionQuarterGroupListItemType; const {start, end} = DateUtils.getQuarterDateRange(quarterItem.year, quarterItem.quarter); @@ -122,3 +138,4 @@ const CHART_GROUP_BY_CONFIG: Record = { }; export default CHART_GROUP_BY_CONFIG; +export type {ChartBucketRange}; diff --git a/src/components/Search/comparisonBuckets.ts b/src/components/Search/comparisonBuckets.ts new file mode 100644 index 000000000000..4ad3c3d1ab1b --- /dev/null +++ b/src/components/Search/comparisonBuckets.ts @@ -0,0 +1,261 @@ +/** + * Pairs the time buckets of two compared periods and names them, so a chart can draw one period against the other. + */ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import DateUtils from '@libs/DateUtils'; + +import CONST from '@src/CONST'; + +import { + addDays, + addMonths, + addQuarters, + addYears, + differenceInCalendarDays, + differenceInCalendarMonths, + differenceInCalendarQuarters, + differenceInCalendarYears, + endOfMonth, + endOfQuarter, + endOfYear, + format, + getDate, + getDay, + isSameDay, + isSameMonth, + parseISO, + startOfMonth, + startOfQuarter, + startOfYear, + subMonths, +} from 'date-fns'; + +import type {ChartBucketRange} from './chartGroupByConfig'; +import type {SearchGroupBy} from './types'; + +const DAYS_IN_WEEK = 7; + +/** A group-by that buckets time */ +type TimeGroupBy = + | typeof CONST.SEARCH.GROUP_BY.DAY + | typeof CONST.SEARCH.GROUP_BY.WEEK + | typeof CONST.SEARCH.GROUP_BY.MONTH + | typeof CONST.SEARCH.GROUP_BY.QUARTER + | typeof CONST.SEARCH.GROUP_BY.YEAR; + +/** Bucket offset from the period start. Weeks count from the week holding the first day, whatever weekday weeks start on. */ +const BUCKET_OFFSET: Record number> = { + [CONST.SEARCH.GROUP_BY.DAY]: differenceInCalendarDays, + [CONST.SEARCH.GROUP_BY.WEEK]: (bucketStart, periodStart) => Math.ceil(differenceInCalendarDays(bucketStart, periodStart) / DAYS_IN_WEEK), + [CONST.SEARCH.GROUP_BY.MONTH]: differenceInCalendarMonths, + [CONST.SEARCH.GROUP_BY.QUARTER]: differenceInCalendarQuarters, + [CONST.SEARCH.GROUP_BY.YEAR]: differenceInCalendarYears, +}; + +/** Moves a date forward by a number of buckets */ +const BUCKET_ADD: Record Date> = { + [CONST.SEARCH.GROUP_BY.DAY]: addDays, + [CONST.SEARCH.GROUP_BY.WEEK]: (date, amount) => addDays(date, amount * DAYS_IN_WEEK), + [CONST.SEARCH.GROUP_BY.MONTH]: addMonths, + [CONST.SEARCH.GROUP_BY.QUARTER]: addQuarters, + [CONST.SEARCH.GROUP_BY.YEAR]: addYears, +}; + +/** Labels for paired buckets that share no calendar name, numbered by position unless the bucket is the only one */ +const BUCKET_LABEL = { + [CONST.SEARCH.GROUP_BY.DAY]: 'insightsPage.compare.dayLabel', + [CONST.SEARCH.GROUP_BY.WEEK]: 'insightsPage.compare.weekLabel', + [CONST.SEARCH.GROUP_BY.MONTH]: 'insightsPage.compare.monthLabel', + [CONST.SEARCH.GROUP_BY.QUARTER]: 'insightsPage.compare.quarterLabel', + [CONST.SEARCH.GROUP_BY.YEAR]: 'insightsPage.compare.yearLabel', +} as const satisfies Record; + +/** Returns whether a group-by buckets time, so its groups can be paired across periods */ +function isTimeGroupBy(groupBy: SearchGroupBy): groupBy is TimeGroupBy { + return groupBy in BUCKET_OFFSET; +} + +/** A period's first and last day */ +type ChartPeriod = {start: string; end: string}; + +/** The two periods being compared, whose buckets pair up by their offset from each period's start */ +type ChartComparisonPeriods = { + primary: ChartPeriod; + comparison: ChartPeriod; + + /** Set when the periods compare month for month, so a day pairs with the same date that many months before */ + monthsApart?: number; +}; + +/** + * Returns date-fns patterns naming paired buckets by what they share, like "January" for January 2026 and January 2025, and each by what differs, + * like "2026" and "2025". Undefined when two buckets in the period would share a name, like "5th" for October 5 and November 5. + */ +function getCalendarNamePatterns(groupBy: TimeGroupBy, periodStart: Date, periodEnd: Date) { + switch (groupBy) { + case CONST.SEARCH.GROUP_BY.DAY: + if (differenceInCalendarDays(periodEnd, periodStart) < DAYS_IN_WEEK) { + return {label: 'EEE', shortLabel: 'EEE', seriesLabel: 'MMM d, yyyy'}; + } + if (isSameMonth(periodStart, periodEnd)) { + return {label: 'do', shortLabel: 'do', seriesLabel: 'LLLL yyyy'}; + } + return periodEnd < addYears(periodStart, 1) ? {label: 'MMM d', shortLabel: 'MMM d', seriesLabel: 'yyyy'} : undefined; + case CONST.SEARCH.GROUP_BY.MONTH: + return differenceInCalendarMonths(periodEnd, periodStart) < 12 ? {label: 'LLLL', shortLabel: 'LLL', seriesLabel: 'yyyy'} : undefined; + case CONST.SEARCH.GROUP_BY.QUARTER: + return differenceInCalendarQuarters(periodEnd, periodStart) < 4 ? {label: 'QQQ', shortLabel: 'QQQ', seriesLabel: 'yyyy'} : undefined; + default: + return undefined; + } +} + +/** Returns the start of the bucket holding a date. `weekStart` is the start of any week bucket, which tells the weekday weeks begin on. */ +function getBucketStart(groupBy: TimeGroupBy, date: Date, weekStart: Date): Date { + switch (groupBy) { + case CONST.SEARCH.GROUP_BY.WEEK: + return addDays(date, -((getDay(date) - getDay(weekStart) + DAYS_IN_WEEK) % DAYS_IN_WEEK)); + case CONST.SEARCH.GROUP_BY.MONTH: + return startOfMonth(date); + case CONST.SEARCH.GROUP_BY.QUARTER: + return startOfQuarter(date); + case CONST.SEARCH.GROUP_BY.YEAR: + return startOfYear(date); + default: + return date; + } +} + +/** + * Returns the bucket a current bucket is compared with, or undefined when the comparison period has none, like October 31 + * against September, February 29 against a year without one, or a sixth week against a month of five. + */ +function getCounterpartBucketRange(bucketRange: ChartBucketRange, periods: ChartComparisonPeriods, groupBy: TimeGroupBy): ChartBucketRange | undefined { + const bucketStart = parseISO(bucketRange.start); + const primaryStart = parseISO(periods.primary.start); + const comparisonStart = parseISO(periods.comparison.start); + + let counterpartStart: Date; + if (groupBy === CONST.SEARCH.GROUP_BY.DAY && periods.monthsApart !== undefined) { + counterpartStart = subMonths(bucketStart, periods.monthsApart); + // Going back a month turns October 31 into September 30, since September has no 31st, so that day has no counterpart. + if (getDate(counterpartStart) !== getDate(bucketStart)) { + return undefined; + } + } else { + const dateInBucket = BUCKET_ADD[groupBy](comparisonStart, BUCKET_OFFSET[groupBy](bucketStart, primaryStart)); + counterpartStart = getBucketStart(groupBy, dateInBucket, bucketStart); + } + + const start = format(counterpartStart, CONST.DATE.FNS_FORMAT_STRING); + if (start > periods.comparison.end) { + return undefined; + } + return {start, end: format(addDays(BUCKET_ADD[groupBy](counterpartStart, 1), -1), CONST.DATE.FNS_FORMAT_STRING)}; +} + +/** Clips a bucket to its period, since a week or month can start before the period or end after it */ +function clipToPeriod(range: ChartBucketRange, period: ChartBucketRange): ChartBucketRange { + return { + start: range.start > period.start ? range.start : period.start, + end: range.end < period.end ? range.end : period.end, + }; +} + +/** Names a range of dates, by its calendar name when it spans a whole one, like "October 2026", or else by its dates */ +function formatDates({start, end}: ChartPeriod, dateFnsLocale: LocaleContextProps['dateFnsLocale']): string { + const startDate = parseISO(start); + const endDate = parseISO(end); + const isWholeUnit = (toStart: (date: Date) => Date, toEnd: (date: Date) => Date) => isSameDay(startDate, toStart(startDate)) && isSameDay(endDate, toEnd(startDate)); + + if (isSameDay(startDate, endDate)) { + return format(startDate, 'MMM d, yyyy', {locale: dateFnsLocale}); + } + if (isWholeUnit(startOfYear, endOfYear)) { + return format(startDate, 'yyyy', {locale: dateFnsLocale}); + } + if (isWholeUnit(startOfQuarter, endOfQuarter)) { + return format(startDate, 'QQQ yyyy', {locale: dateFnsLocale}); + } + if (isWholeUnit(startOfMonth, endOfMonth)) { + return format(startDate, 'LLLL yyyy', {locale: dateFnsLocale}); + } + return DateUtils.getFormattedDateRangeForSearch(start, end, dateFnsLocale); +} + +/** + * Returns a time bucket's labels: the calendar name it shares with its counterpart, like "January", or else its position, like "Week 2". + * Each period's bucket is then named by what that label leaves out, like its year, or else by its dates. + */ +function getComparedBucketLabels( + bucketRange: ChartBucketRange, + counterpartRange: ChartBucketRange | undefined, + periods: ChartComparisonPeriods, + groupBy: TimeGroupBy, + translate: LocaleContextProps['translate'], + dateFnsLocale: LocaleContextProps['dateFnsLocale'], +) { + const bucketDate = parseISO(bucketRange.start); + const primaryStart = parseISO(periods.primary.start); + const primaryEnd = parseISO(periods.primary.end); + const patterns = getCalendarNamePatterns(groupBy, primaryStart, primaryEnd); + const sharedPatterns = patterns && (!counterpartRange || format(bucketDate, patterns.label) === format(parseISO(counterpartRange.start), patterns.label)) ? patterns : undefined; + + const formatSeriesLabel = (range: ChartBucketRange, period: ChartPeriod) => + sharedPatterns ? format(parseISO(range.start), sharedPatterns.seriesLabel, {locale: dateFnsLocale}) : formatDates(clipToPeriod(range, period), dateFnsLocale); + const seriesLabels = { + primary: formatSeriesLabel(bucketRange, periods.primary), + comparison: counterpartRange && formatSeriesLabel(counterpartRange, periods.comparison), + }; + + if (sharedPatterns) { + return { + label: format(bucketDate, sharedPatterns.label, {locale: dateFnsLocale}), + shortLabel: format(bucketDate, sharedPatterns.shortLabel, {locale: dateFnsLocale}), + seriesLabels, + }; + } + const isLoneBucket = BUCKET_OFFSET[groupBy](getBucketStart(groupBy, primaryEnd, bucketDate), primaryStart) === 0; + const positionLabel = translate(BUCKET_LABEL[groupBy], isLoneBucket ? undefined : BUCKET_OFFSET[groupBy](bucketDate, primaryStart) + 1); + return {label: positionLabel, shortLabel: positionLabel, seriesLabels}; +} + +/** Returns the date in the current period a compared bucket lines up with, the counterpart lookup in reverse, so the two can be sorted together */ +function getPositionInPrimaryPeriod(range: ChartBucketRange, periods: ChartComparisonPeriods, groupBy: TimeGroupBy): string { + const bucketStart = parseISO(range.start); + const position = + groupBy === CONST.SEARCH.GROUP_BY.DAY && periods.monthsApart !== undefined + ? addMonths(bucketStart, periods.monthsApart) + : BUCKET_ADD[groupBy](parseISO(periods.primary.start), BUCKET_OFFSET[groupBy](bucketStart, parseISO(periods.comparison.start))); + return format(position, CONST.DATE.FNS_FORMAT_STRING); +} + +/** + * Returns the labels of a compared bucket no current bucket pairs with, named like the current buckets beside it: + * by calendar name when they have one, like "29th", or else by position in the compared period, like "Week 6". + */ +function getUnpairedBucketLabels( + range: ChartBucketRange, + periods: ChartComparisonPeriods, + groupBy: TimeGroupBy, + translate: LocaleContextProps['translate'], + dateFnsLocale: LocaleContextProps['dateFnsLocale'], +) { + const bucketDate = parseISO(range.start); + const patterns = getCalendarNamePatterns(groupBy, parseISO(periods.primary.start), parseISO(periods.primary.end)); + // Days paired by date always share a name. Others take a calendar name only if it matches their spot, as paired buckets do. + const isPairedByDate = groupBy === CONST.SEARCH.GROUP_BY.DAY && periods.monthsApart !== undefined; + if (patterns && (isPairedByDate || format(bucketDate, patterns.label) === format(parseISO(getPositionInPrimaryPeriod(range, periods, groupBy)), patterns.label))) { + return { + label: format(bucketDate, patterns.label, {locale: dateFnsLocale}), + shortLabel: format(bucketDate, patterns.shortLabel, {locale: dateFnsLocale}), + seriesLabel: format(bucketDate, patterns.seriesLabel, {locale: dateFnsLocale}), + }; + } + const positionLabel = translate(BUCKET_LABEL[groupBy], BUCKET_OFFSET[groupBy](bucketDate, parseISO(periods.comparison.start)) + 1); + return {label: positionLabel, shortLabel: positionLabel, seriesLabel: formatDates(clipToPeriod(range, periods.comparison), dateFnsLocale)}; +} + +export {formatDates, getComparedBucketLabels, getCounterpartBucketRange, getPositionInPrimaryPeriod, getUnpairedBucketLabels, isTimeGroupBy}; +export type {ChartComparisonPeriods, ChartPeriod, TimeGroupBy}; diff --git a/src/components/Search/hooks/useUpdateFilterQuery.tsx b/src/components/Search/hooks/useUpdateFilterQuery.tsx index 5752151e3967..810496cb6230 100644 --- a/src/components/Search/hooks/useUpdateFilterQuery.tsx +++ b/src/components/Search/hooks/useUpdateFilterQuery.tsx @@ -41,13 +41,7 @@ function useUpdateFilterQuery(queryJSON: SearchQueryJSON | undefined) { } function setFilterQueryParams(values: Partial) { - const queryString = - buildFilterQueryWithSortDefaults( - values, - {view: searchAdvancedFiltersForm.view, groupBy: searchAdvancedFiltersForm.groupBy}, - {sortBy: queryJSON?.sortBy, sortOrder: queryJSON?.sortOrder}, - policies, - ) ?? ''; + const queryString = buildFilterQueryWithSortDefaults(values, {view: searchAdvancedFiltersForm.view, groupBy: searchAdvancedFiltersForm.groupBy}, queryJSON ?? {}, policies) ?? ''; if (!queryString) { return; } diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index b439cae7cbf0..262d9cdacfca 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,5 +1,6 @@ import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; @@ -159,6 +160,7 @@ function Search({ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, shouldUseNarrowLayout, isLargeScreenWidth, isInLandscapeMode} = useResponsiveLayout(); const styles = useThemeStyles(); + const chartTabRootScrollProps = useTabRootScrollProps(styles.flexGrow1, {fillsVisibleArea: true}); const navigation = useNavigation>(); const isFocused = useIsFocused(); @@ -226,6 +228,8 @@ function Search({ const isAllMatchingItemsCountMissing = isExpenseAllMatchingSelection && typeof searchResults?.search?.count !== 'number'; const shouldCalculateExpenseTotals = useSearchShouldCalculateTotals(currentSearchKey, offset === 0 || isAllMatchingItemsCountMissing, isExpenseAllMatchingSelection); const shouldCalculateTotals = (areAllMatchingItemsSelected && !isExpenseAllMatchingSelection) || shouldCalculateExpenseTotals; + // Live to-do pages load without totals. A response with totals restarts the counted reports, so a later page would drop page 1's. + const shouldCalculateNextPageTotals = shouldCalculateTotals && !shouldUseLiveData; const previousShouldCalculateTotals = usePrevious(shouldCalculateTotals); const searchRequestOffset = getSearchRequestOffsetForMissingAllMatchingCount(offset, searchResults?.search?.offset, isAllMatchingItemsCountMissing); // For an expense-report "select all matching", the total the bulk-actions button waits for is the server @@ -1015,7 +1019,14 @@ function Search({ // a reload strands an adopted page in loading, so re-ask it once; search() drops it if it is still running if (isLivePageAdopted && !isOffline) { setIsLivePageAdopted(false); - handleSearch({queryJSON, searchKey: currentSearchKey, offset: nextOffset, shouldCalculateTotals, prevReportsLength: filteredDataLength, isLoading: false}); + handleSearch({ + queryJSON, + searchKey: currentSearchKey, + offset: nextOffset, + shouldCalculateTotals: shouldCalculateNextPageTotals, + prevReportsLength: filteredDataLength, + isLoading: false, + }); } return; } @@ -1038,7 +1049,7 @@ function Search({ queryJSON, searchKey: currentSearchKey, offset: nextOffset, - shouldCalculateTotals, + shouldCalculateTotals: shouldCalculateNextPageTotals, prevReportsLength: filteredDataLength, isLoading: false, }); @@ -1065,7 +1076,7 @@ function Search({ handleSearch, queryJSON, currentSearchKey, - shouldCalculateTotals, + shouldCalculateNextPageTotals, filteredDataLength, ]); @@ -1424,7 +1435,7 @@ function Search({ = { diff --git a/src/components/SelectionList/ListItem/CardListItem.tsx b/src/components/SelectionList/ListItem/CardListItem.tsx index c83086e99942..2fdf3e3d35ca 100644 --- a/src/components/SelectionList/ListItem/CardListItem.tsx +++ b/src/components/SelectionList/ListItem/CardListItem.tsx @@ -20,7 +20,7 @@ import {Str} from 'expensify-common'; import React from 'react'; import {View} from 'react-native'; -import type {ListItem, SelectableListItemProps} from './types'; +import type {ListItem, ListItemProps, SelectableListItemProps} from './types'; import SelectableListItem from './SelectableListItem'; @@ -33,7 +33,7 @@ type AdditionalCardProps = { cardName?: string; plaidUrl?: string; }; -type CardListItemProps = SelectableListItemProps; +type CardListItemProps = SelectableListItemProps & Pick, 'titleNumberOfLines'>; /** * A row with a bank/card icon (or owner avatar with card miniature), card name, and last-four @@ -51,6 +51,7 @@ function CardListItem({ onDismissError, onFocus, shouldSyncFocus, + titleNumberOfLines = 1, }: CardListItemProps) { const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const styles = useThemeStyles(); @@ -71,8 +72,11 @@ function CardListItem({ `${item.cardName ? ` ${CONST.DOT_SEPARATOR} ${item.cardName}` : ''}` + `${item.isVirtual ? ` ${CONST.DOT_SEPARATOR} ${translate('workspace.expensifyCard.virtual')}` : ''}`; + // The subtitle takes the second line, so the title stays on one + const cardTitleNumberOfLines = subtitleText ? 1 : titleNumberOfLines; + // The bold heading style is the primitive's default; non-bold items reset the weight and color. - const titleStyle = [item.alternateText ? styles.mb1 : null, item.isBold === false && [styles.fontWeightNormal, styles.textSupporting]]; + const titleStyle = [cardTitleNumberOfLines > 1 && styles.preWrap, item.alternateText ? styles.mb1 : null, item.isBold === false && [styles.fontWeightNormal, styles.textSupporting]]; return ( ({ {!!subtitleText && } diff --git a/src/components/SelectionList/ListItem/InviteMemberListItem.tsx b/src/components/SelectionList/ListItem/InviteMemberListItem.tsx index 7a2c5f1f774f..801362826385 100644 --- a/src/components/SelectionList/ListItem/InviteMemberListItem.tsx +++ b/src/components/SelectionList/ListItem/InviteMemberListItem.tsx @@ -36,7 +36,6 @@ function InviteMemberListItem({ const firstItemIconID = Number(item?.icons?.at(0)?.id); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const accountID = !item.reportID ? item.accountID || firstItemIconID : undefined; const shouldShowSelectionButton = !item.shouldHideSelectionButton && !(item.isDisabled && !item.isSelected); diff --git a/src/components/SelectionList/ListItem/MultiSelectListItem.tsx b/src/components/SelectionList/ListItem/MultiSelectListItem.tsx index 2598c3c7d533..97533bc25ada 100644 --- a/src/components/SelectionList/ListItem/MultiSelectListItem.tsx +++ b/src/components/SelectionList/ListItem/MultiSelectListItem.tsx @@ -44,7 +44,8 @@ function MultiSelectListItem({ onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} wrapperStyle={icon ? [styles.pv0, styles.mnh13] : styles.optionRowCompact} - titleNumberOfLines={titleNumberOfLines} + // Rows are capped at two lines, so a subtitle keeps the title on one + titleNumberOfLines={item.alternateText ? 1 : titleNumberOfLines} alternateTextNumberOfLines={alternateTextNumberOfLines} /> ); diff --git a/src/components/SelectionList/ListItem/UserListItemContent.tsx b/src/components/SelectionList/ListItem/UserListItemContent.tsx index 779cfe0ac970..973a7ced31b3 100644 --- a/src/components/SelectionList/ListItem/UserListItemContent.tsx +++ b/src/components/SelectionList/ListItem/UserListItemContent.tsx @@ -39,13 +39,13 @@ function UserListItemContent({item, forwardedFSClass}: U const {translate, formatPhoneNumber} = useLocalize(); const {shouldShowTooltip, shouldDisableAccessibleGrouping} = useListItemContext(); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- some utils that are used to get reportID return empty string "", which would make subscription to the whole collection with nullish coalescing operator, example of this could be found in NewChatPage.tsx where some hooks return reportID as empty strings + // some utils that are used to get reportID return empty string "", which would make subscription to the whole collection with nullish coalescing operator, example of this could be found in NewChatPage.tsx where some hooks return reportID as empty strings const [isReportInOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${item.reportID || undefined}`, { selector: reportExistsSelector, }); const reportExists = isReportInOnyx && !!item.reportID; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- accountID being 0 is also not valid, so we prefer to use the icon ID if it exists + // accountID being 0 is also not valid, so we prefer to use the icon ID if it exists const itemAccountID = Number(item.accountID || item.icons?.at(1)?.id) || 0; const isThereOnlyWorkspaceIcon = item.icons?.length === 1 && item.icons?.at(0)?.type === CONST.ICON_TYPE_WORKSPACE; diff --git a/src/components/SelectionList/ListItem/UserSelectionListItem.tsx b/src/components/SelectionList/ListItem/UserSelectionListItem.tsx index 71c651b205c0..6420f79cf043 100644 --- a/src/components/SelectionList/ListItem/UserSelectionListItem.tsx +++ b/src/components/SelectionList/ListItem/UserSelectionListItem.tsx @@ -1,10 +1,8 @@ import ListItemComposed from '@components/SelectionList/ListItemComposed'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {areEmailsFromSamePrivateDomain} from '@libs/LoginUtils'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -17,7 +15,7 @@ import type {ListItem, ListItemProps} from './types'; import SelectableListItem from './SelectableListItem'; /** - * A compact single-line row with avatar, display name, and handle side by side. Used for + * A compact row with avatar, display name, and primary login below it. Used for * user selection in search participant filters. */ function UserSelectionListItem({ @@ -35,20 +33,19 @@ function UserSelectionListItem({ shouldSyncFocus, }: ListItemProps) { const styles = useThemeStyles(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {formatPhoneNumber, translate} = useLocalize(); - const login = item.login ?? ''; - - // When the emails share a private domain we can strip the domain and show just the username. Otherwise show the full email. - const userHandle = areEmailsFromSamePrivateDomain(login, currentUserPersonalDetails.login ?? '') ? login.split('@').at(0) : formatPhoneNumber(login); - const userDisplayName = getDisplayNameForParticipant({ accountID: item.accountID ?? CONST.DEFAULT_NUMBER_ID, formatPhoneNumber, hiddenTranslation: translate('common.hidden'), }); + const primaryLogin = formatPhoneNumber(item.login ?? ''); + + // Users without a name already show their login as the display name + const subtitle = primaryLogin !== userDisplayName ? primaryLogin : ''; + const icon = item.icons?.at(0); return ( @@ -67,21 +64,18 @@ function UserSelectionListItem({ onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} > - + {!!icon && } - + - {!!userHandle && ( - - )} - + {!!subtitle && } + ); diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts index 7fa31da33c90..b6362fe09f4c 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -191,6 +191,10 @@ type ListItemPressableProps = PropsWithChildren<{ shouldSyncFocus?: boolean; shouldHighlightSelectedItem?: boolean; shouldDisableHoverStyle?: boolean; + + /** Whether descendants can use native text selection inside the row */ + shouldAllowTextSelection?: boolean; + pressableStyle?: StyleProp; pressableWrapperStyle?: StyleProp>; diff --git a/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx b/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx index 56b079b0104a..6456b70d6e09 100644 --- a/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx +++ b/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx @@ -5,6 +5,7 @@ import {ListItemContext, ListItemHoverContext} from '@components/SelectionList/L import getListItemAccessibilityProps from '@components/SelectionList/utils/getListItemAccessibilityProps'; import isListItemSelected from '@components/SelectionList/utils/isListItemSelected'; +import useCopyableTextRowPress, {isPressStartOnCopyableText} from '@hooks/useCopyableTextRowPress'; import useHover from '@hooks/useHover'; import {useMouseActions, useMouseState} from '@hooks/useMouseContext'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -12,6 +13,8 @@ import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {COPYABLE_ROW_DATA_SET} from '@libs/SelectionScraper'; + import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -45,6 +48,7 @@ function ListItemPressable({ onLongPressRow, shouldHighlightSelectedItem = false, shouldDisableHoverStyle, + shouldAllowTextSelection = false, accessible, accessibilityLabel, accessibilityRole = CONST.ROLE.BUTTON, @@ -62,6 +66,8 @@ function ListItemPressable({ const {isMouseDownOnInput} = useMouseState(); const {setMouseUp} = useMouseActions(); const pressableRef = useRef>(null); + const {markMouseDownOnCopyableText, markTouchStartOnCopyableText, shouldSuppressCopyableTextRowFocus, shouldSuppressCopyableTextRowLongPress, shouldSuppressCopyableTextRowPress} = + useCopyableTextRowPress(); // Sync focus on an item useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); @@ -122,10 +128,13 @@ function ListItemPressable({ lang={item.lang} accessibilityLanguage={item.lang} onLongPress={() => { + if (shouldAllowTextSelection && shouldSuppressCopyableTextRowLongPress()) { + return; + } onLongPressRow?.(item); }} onPress={(e) => { - if (isMouseDownOnInput) { + if (shouldSuppressCopyableTextRowPress() || isMouseDownOnInput) { e?.stopPropagation(); // Preventing the click action return; } @@ -137,17 +146,25 @@ function ListItemPressable({ disabled={isDisabled && !isRowSelected} interactive={item.isInteractive} isNested + shouldAllowTextSelection={shouldAllowTextSelection} hoverDimmingValue={1} pressDimmingValue={item.isInteractive === false ? 1 : variables.pressDimValue} hoverStyle={!shouldDisableHoverStyle ? [(!item.isDisabled || isRowSelected) && item.isInteractive !== false && styles.hoveredComponentBG, hoverStyle] : undefined} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}} + dataSet={{ + ...(shouldAllowTextSelection ? COPYABLE_ROW_DATA_SET : {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}), + [CONST.INNER_BOX_SHADOW_ELEMENT]: true, + }} onMouseDown={(e) => { const target = e?.target; - if (target instanceof HTMLElement && target.tagName === CONST.ELEMENT_NAME.INPUT) { + const isCopyableTarget = markMouseDownOnCopyableText(target); + if ((target instanceof HTMLElement && target.tagName === CONST.ELEMENT_NAME.INPUT) || isCopyableTarget) { return; } e.preventDefault(); }} + onTouchStart={(event) => { + markTouchStartOnCopyableText(event, shouldAllowTextSelection && isPressStartOnCopyableText(event)); + }} id={item.keyForList ?? ''} testID={`${CONST.BASE_LIST_ITEM_TEST_ID}${item.keyForList}`} style={[ @@ -161,7 +178,12 @@ function ListItemPressable({ theme.hoverComponentBG, ), ]} - onFocus={onFocus} + onFocus={(event) => { + if (shouldSuppressCopyableTextRowFocus()) { + return; + } + onFocus(event); + }} role={role} tabIndex={tabIndex} {...accessibleAndAccessibilityLabel} diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 5d360090ab17..e43ca335501f 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -87,6 +87,7 @@ function BaseSelectionListWithSectionsImpl({ shouldSingleExecuteRowSelect = false, shouldPreventDefaultFocusOnSelectRow = false, shouldPreventAutoScrollOnSelect = false, + shouldAllowArrowUpToSearchInput = false, titleNumberOfLines, shouldDisableHoverStyle, selectionButtonPosition, @@ -123,10 +124,17 @@ function BaseSelectionListWithSectionsImpl({ debouncedScrollToIndex, announceProgrammaticScroll: () => listRef.current?.announceProgrammaticScroll(), setShouldDisableHoverStyle, + shouldAllowArrowUpToSearchInput, + onArrowUpToSearchInput: focusSearchInputFromList, }); const {innerTextInputRef, isTextInputFocusedRef, focusTextInput, textInputKeyPress} = useSelectionListTextInput(setHasKeyBeenPressed); + // A Tab-focused row keeps DOM focus after ArrowUp clears the focused index, so move it back to the search input + function focusSearchInputFromList() { + focusTextInput(); + } + const getFocusedItem = useCallback((): ListItem | undefined => { if (focusedIndex < 0 || focusedIndex >= flattenedData.length) { return; @@ -268,6 +276,7 @@ function BaseSelectionListWithSectionsImpl({ accessibilityLabel={textInputOptions?.label} options={textInputOptions} onSubmit={selectFocusedItem} + onPress={shouldAllowArrowUpToSearchInput ? () => setFocusedIndex(-1) : undefined} dataLength={itemsCount} isLoading={isLoadingNewOptions} onFocusChange={(v: boolean) => (isTextInputFocusedRef.current = v)} diff --git a/src/components/SelectionList/SelectionListWithSections/types.ts b/src/components/SelectionList/SelectionListWithSections/types.ts index f89012a01848..e810695ccaf7 100644 --- a/src/components/SelectionList/SelectionListWithSections/types.ts +++ b/src/components/SelectionList/SelectionListWithSections/types.ts @@ -39,6 +39,12 @@ type SelectionListWithSectionsProps = BaseSelectionListP /** Whether to prevent auto-scrolling to the first index when selecting an item in multi-select mode */ shouldPreventAutoScrollOnSelect?: boolean; + /** + * Whether ArrowUp from the first row, or pressing the search input, clears the focused row so focus returns to the search input. + * With no focused row, plain Enter in the search input reaches the confirm button instead of toggling a row. + */ + shouldAllowArrowUpToSearchInput?: boolean; + /** * Passed straight to the underlying list. Defaults to `always`, which a list whose own text input drives its * rows needs: a row press has to land while that input still holds focus. A list whose rows are inputs of their @@ -71,7 +77,6 @@ type SectionHeader = { }; type SectionListItem = TItem & { - flatIndex: number; type: typeof CONST.SECTION_LIST_ITEM_TYPE.ROW; /** Unique key for FlashList rendering, containing section info */ flatListKey: string; diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 39db41b44844..591820f50008 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -38,6 +38,9 @@ type TextInputProps = { /** Callback function called when the text input is submitted */ onSubmit?: () => void; + /** Lets a list clear its focused row on press, so plain Enter in the search input reaches the confirm button instead of toggling a row */ + onPress?: () => void; + onKeyPress?: (event: TextInputKeyPressEvent) => void; /** Function called when the text input focus changes */ @@ -59,6 +62,7 @@ function TextInput({ isLoading = false, dataLength, onSubmit, + onPress, onKeyPress, onFocusChange, shouldShowLoadingPlaceholder, @@ -170,6 +174,7 @@ function TextInput({ selectTextOnFocus spellCheck={false} onSubmitEditing={onSubmit} + onPress={onPress} submitBehavior={dataLength ? 'blurAndSubmit' : 'submit'} isLoading={isLoading} shouldHideClearButton={false} diff --git a/src/components/SelectionList/hooks/useFlattenedSections.ts b/src/components/SelectionList/hooks/useFlattenedSections.ts index 100b81b746cd..332a9adee6be 100644 --- a/src/components/SelectionList/hooks/useFlattenedSections.ts +++ b/src/components/SelectionList/hooks/useFlattenedSections.ts @@ -3,8 +3,6 @@ import type {FlattenedItem, Section, SectionListItem} from '@components/Selectio import CONST from '@src/CONST'; -import {useMemo} from 'react'; - function isItemSelected(item: TItem): boolean { return item?.isSelected ?? false; } @@ -15,88 +13,80 @@ function isItemSelected(item: TItem): boolean { * Selected items remain interactive even when marked as disabled. */ function shouldTreatItemAsDisabled(item: TItem | FlattenedItem): boolean { - return !!item?.isDisabled && !isItemSelected(item as TItem); + return !!item?.isDisabled && !('isSelected' in item && isItemSelected(item)); } -type UseFlattenedSectionsResult = { - flattenedData: Array>; - disabledIndexes: number[]; - itemsCount: number; - selectedItems: ListItem[]; - initialFocusedIndex: number; - firstFocusableIndex: number; -}; - /** - * Non-generic implementation so OXC's React Compiler can memoize the hook. - * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). + * Hook that flattens sections with headers and items into a single array for FlashList. + * Also computes disabled indexes, selected items, and initial focus index. */ -function useFlattenedSectionsImpl(sections: Array>, initiallyFocusedItemKey?: string | null): UseFlattenedSectionsResult { - return useMemo(() => { - const data: Array> = []; - const selectedOptions: ListItem[] = []; - const disabledIndices: number[] = []; - let focusedIndex = -1; - let firstNonHeaderIndex = -1; - let itemsTotalCount = 0; - - for (const section of sections) { - const sectionDataLength = section.data?.length ?? 0; - itemsTotalCount += sectionDataLength; - const hasHeader = sectionDataLength > 0 && (section.customHeader ?? section.title); +function useFlattenedSections(sections: Array>, initiallyFocusedItemKey?: string | null): UseFlattenedSectionsResult { + // Force memoization because this hook does not call other React hooks. + 'use memo'; + + const data: Array> = []; + const selectedOptions: TItem[] = []; + const disabledIndices: number[] = []; + let focusedIndex = -1; + let firstNonHeaderIndex = -1; + let itemsTotalCount = 0; + + for (const section of sections) { + const sectionDataLength = section.data?.length ?? 0; + itemsTotalCount += sectionDataLength; + const hasHeader = sectionDataLength > 0 && (section.customHeader ?? section.title); + + if (hasHeader) { + disabledIndices.push(data.length); + data.push({ + type: CONST.SECTION_LIST_ITEM_TYPE.HEADER, + keyForList: `header-${section.sectionIndex}`, + isDisabled: true, + ...(section.title && {title: section.title}), + ...(section.customHeader && {customHeader: section.customHeader}), + }); + } - if (hasHeader) { - disabledIndices.push(data.length); - data.push({ - type: CONST.SECTION_LIST_ITEM_TYPE.HEADER, - keyForList: `header-${section.sectionIndex}`, - isDisabled: true, - ...(section.title && {title: section.title}), - ...(section.customHeader && {customHeader: section.customHeader}), - }); + for (const item of section.data ?? []) { + const currentIndex = data.length; + const itemData: SectionListItem = { + ...item, + type: CONST.SECTION_LIST_ITEM_TYPE.ROW, + isDisabled: section.isDisabled === true || item.isDisabled === true, + flatListKey: `${section.sectionIndex}-${item.keyForList}`, + }; + data.push(itemData); + + if (firstNonHeaderIndex === -1) { + firstNonHeaderIndex = currentIndex; } - for (const item of section.data ?? []) { - const currentIndex = data.length; - const itemData = { - ...item, - type: CONST.SECTION_LIST_ITEM_TYPE.ROW, - isDisabled: section.isDisabled === true || item.isDisabled === true, - flatListKey: `${section.sectionIndex}-${item.keyForList}`, - } as SectionListItem; - data.push(itemData); - - if (firstNonHeaderIndex === -1) { - firstNonHeaderIndex = currentIndex; - } - - if (item.keyForList === initiallyFocusedItemKey && focusedIndex === -1) { - focusedIndex = currentIndex; - } + if (item.keyForList === initiallyFocusedItemKey && focusedIndex === -1) { + focusedIndex = currentIndex; + } - if (item.isSelected) { - selectedOptions.push(itemData); - } + if (item.isSelected) { + selectedOptions.push(itemData); + } - const isDisabled = section.isDisabled === true || shouldTreatItemAsDisabled(item); - if (isDisabled) { - disabledIndices.push(currentIndex); - } + const isDisabled = section.isDisabled === true || shouldTreatItemAsDisabled(item); + if (isDisabled) { + disabledIndices.push(currentIndex); } } - - return { - flattenedData: data, - disabledIndexes: disabledIndices, - itemsCount: itemsTotalCount, - selectedItems: selectedOptions, - initialFocusedIndex: focusedIndex, - firstFocusableIndex: firstNonHeaderIndex === -1 ? 0 : firstNonHeaderIndex, - }; - }, [initiallyFocusedItemKey, sections]); + } + + return { + flattenedData: data, + disabledIndexes: disabledIndices, + itemsCount: itemsTotalCount, + selectedItems: selectedOptions, + initialFocusedIndex: focusedIndex, + firstFocusableIndex: firstNonHeaderIndex === -1 ? 0 : firstNonHeaderIndex, + }; } -type UseFlattenedSectionsResultGeneric = { +type UseFlattenedSectionsResult = { flattenedData: Array>; disabledIndexes: number[]; itemsCount: number; @@ -105,13 +95,5 @@ type UseFlattenedSectionsResultGeneric = { firstFocusableIndex: number; }; -/** - * Hook that flattens sections with headers and items into a single array for FlashList. - * Also computes disabled indexes, selected items, and initial focus index. - */ -function useFlattenedSections(sections: Array>, initiallyFocusedItemKey?: string | null): UseFlattenedSectionsResultGeneric { - return useFlattenedSectionsImpl(sections as Array>, initiallyFocusedItemKey) as UseFlattenedSectionsResultGeneric; -} - export default useFlattenedSections; export {isItemSelected, shouldTreatItemAsDisabled}; diff --git a/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts b/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts index 176ed6162cba..6220fba212bd 100644 --- a/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts +++ b/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts @@ -20,6 +20,12 @@ type UseSelectionListKeyboardFocusParams = { debouncedScrollToIndex: ScrollToIndex; announceProgrammaticScroll: () => void; setShouldDisableHoverStyle: (shouldDisableHoverStyle: boolean) => void; + + /** Whether ArrowUp from the first row moves to index -1 (the search input) instead of wrapping to the last row */ + shouldAllowArrowUpToSearchInput?: boolean; + + /** Called when ArrowUp leaves the first row for the search input, so DOM focus can follow */ + onArrowUpToSearchInput?: () => void; }; type UseSelectionListKeyboardFocusResult = { @@ -42,6 +48,8 @@ function useSelectionListKeyboardFocus({ debouncedScrollToIndex, announceProgrammaticScroll, setShouldDisableHoverStyle, + shouldAllowArrowUpToSearchInput = false, + onArrowUpToSearchInput, }: UseSelectionListKeyboardFocusParams): UseSelectionListKeyboardFocusResult { const hasKeyBeenPressed = useRef(false); const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false); @@ -72,6 +80,11 @@ function useSelectionListKeyboardFocus({ disabledIndexes, isActive, onFocusedIndexChange: (index, shouldScrollHint) => { + // Only an arrow-key move sets shouldScrollHint, so a press on the search input (which already holds focus) is skipped + if (shouldAllowArrowUpToSearchInput && shouldScrollHint && index < 0) { + onArrowUpToSearchInput?.(); + } + if (!shouldScrollHint || !shouldScrollToFocusedIndex) { return; } @@ -80,6 +93,8 @@ function useSelectionListKeyboardFocus({ }, setHasKeyBeenPressed, isFocused, + // Negative indexes let ArrowUp leave the first row (even past a disabled section header at index 0) while ArrowDown still wraps + allowNegativeIndexes: shouldAllowArrowUpToSearchInput, onArrowUpDownCallback: () => { setShouldDisableHoverStyle(true); announceProgrammaticScroll(); diff --git a/src/components/SelectionScreen.tsx b/src/components/SelectionScreen.tsx index f582c57facb4..21112f626f39 100644 --- a/src/components/SelectionScreen.tsx +++ b/src/components/SelectionScreen.tsx @@ -144,6 +144,12 @@ function SelectionScreen({ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const isConnectionEmpty = isEmpty(policy?.connections?.[connectionName]); + // Searchable selectors pin the selected option to the top upstream (in useSelectionListSearch or the caller's own + // search logic). These anti-jump list props keep that pinned row visible: mount-scroll is off because the pinned + // row is already at the top, and maintaining scroll position is disabled so clearing the search returns to the top. + // This only applies to searchable selectors. + const isSearchableList = !!(shouldShowTextInput ?? textInputOptions?.label); + return ( ({ shouldShowTextInput={shouldShowTextInput} listFooterContent={listFooterContent} shouldSingleExecuteRowSelect={shouldSingleExecuteRowSelect} - shouldUpdateFocusedIndex={shouldUpdateFocusedIndex} + shouldUpdateFocusedIndex={isSearchableList || shouldUpdateFocusedIndex} + shouldScrollToFocusedIndexOnMount={!isSearchableList} + disableMaintainingScrollPosition={isSearchableList} alternateNumberOfSupportedLines={2} titleNumberOfLines={titleNumberOfLines} addBottomSafeAreaPadding diff --git a/src/components/SettlementAccountSelector.tsx b/src/components/SettlementAccountSelector.tsx index 6a1dcddc0543..6d902553026b 100644 --- a/src/components/SettlementAccountSelector.tsx +++ b/src/components/SettlementAccountSelector.tsx @@ -2,8 +2,6 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {BankName} from '@src/types/onyx/Bank'; - import React from 'react'; import {View} from 'react-native'; @@ -31,7 +29,11 @@ type SettlementAccountSelectorProps = { initiallyFocusedItemKey?: string; }; -function BankAccountListItemLeftElement({bankName}: {bankName: BankName}) { +type BankAccountListItemLeftElementProps = { + bankName: string; +}; + +function BankAccountListItemLeftElement({bankName}: BankAccountListItemLeftElementProps) { const styles = useThemeStyles(); const {icon, iconSize, iconStyles} = getBankIcon({bankName, styles}); diff --git a/src/components/SettlementButton/index.tsx b/src/components/SettlementButton/index.tsx index eb50e9824e1d..eba33a956bb1 100644 --- a/src/components/SettlementButton/index.tsx +++ b/src/components/SettlementButton/index.tsx @@ -30,6 +30,7 @@ import {getLastPolicyBankAccountID, getLastPolicyPaymentMethod} from '@libs/acti import {isBankAccountPartiallySetup, showUnlockAlreadyRequestedModal} from '@libs/BankAccountUtils'; import Navigation from '@libs/Navigation/Navigation'; import {formatPaymentMethods, getActivePaymentType, getBusinessBankAccountOptions, matchesCurrency} from '@libs/PaymentUtils'; +import {canAccessPolicyBankAccount} from '@libs/PolicyPaymentUtils'; import {isPaidGroupPolicy, isPolicyAdmin, sortPoliciesByName} from '@libs/PolicyUtils'; import {hasRequestFromCurrentAccount} from '@libs/ReportActionsUtils'; import { @@ -56,7 +57,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {TupleToUnion} from 'type-fest'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import truncate from 'lodash/truncate'; import React, {useContext} from 'react'; import {View} from 'react-native'; @@ -153,12 +154,14 @@ function SettlementButton({ const formattedPaymentMethods = formatPaymentMethods(bankAccountList ?? {}, fundList ?? {}, styles, translate); const hasIntentToPay = ((formattedPaymentMethods.length === 1 && isIOUReport(iouReport)) || - policy?.achAccount?.state === CONST.BANK_ACCOUNT.STATE.OPEN || - policy?.achAccount?.state === CONST.BANK_ACCOUNT.STATE.LOCKED) && + (canAccessPolicyBankAccount(policy, bankAccountList) && + (policy?.achAccount?.state === CONST.BANK_ACCOUNT.STATE.OPEN || policy?.achAccount?.state === CONST.BANK_ACCOUNT.STATE.LOCKED))) && !lastPaymentMethod; const {isBetaEnabled} = usePermissions(); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; // eslint-disable-next-line rulesdir/no-default-id-values const [unlockRequestedAt] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${policy?.achAccount?.bankAccountID ?? CONST.DEFAULT_NUMBER_ID}`); const [initiatingBankAccountUnlock] = useOnyx(ONYXKEYS.INITIATING_BANK_ACCOUNT_UNLOCK); @@ -177,7 +180,7 @@ function SettlementButton({ const {showConfirmModal} = useConfirmModal(); const shouldShowPayWithExpensifyOption = !shouldHidePaymentOptions; const shouldShowPayElsewhereOption = !shouldHidePaymentOptions && !isInvoiceReport; - const isBankAccountLocked = policy?.achAccount?.state === CONST.BANK_ACCOUNT.STATE.LOCKED; + const isBankAccountLocked = canAccessPolicyBankAccount(policy, bankAccountList) && policy?.achAccount?.state === CONST.BANK_ACCOUNT.STATE.LOCKED; function getLatestPersonalBankAccount() { return formattedPaymentMethods.filter((ba) => (ba.accountData as AccountData)?.type === CONST.BANK_ACCOUNT.TYPE.PERSONAL); @@ -208,7 +211,7 @@ function SettlementButton({ return; } pressLockedBankAccount(policy?.achAccount?.bankAccountID, translate, conciergeReportID, delegateAccountID, initiatingBankAccountUnlock); - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails}); }); } return true; diff --git a/src/components/SidePanel/SidePanelButton/index.native.tsx b/src/components/SidePanel/SidePanelButton/index.native.tsx index f6fc09d089b5..9cee69b60a24 100644 --- a/src/components/SidePanel/SidePanelButton/index.native.tsx +++ b/src/components/SidePanel/SidePanelButton/index.native.tsx @@ -18,7 +18,7 @@ import {navigateToConciergeChat} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React from 'react'; import type SidePanelButtonProps from './types'; @@ -32,7 +32,9 @@ function SidePanelButton({style}: SidePanelButtonProps) { const {Concierge} = useMemoizedLazyExpensifyIcons(['Concierge']); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); if (shouldHideHelpButton) { @@ -56,6 +58,7 @@ function SidePanelButton({style}: SidePanelButtonProps) { introSelected, currentUserAccountID, isSelfTourViewed, + hasCompletedGuidedSetupFlow, conciergePersonalDetails, sourceReportID: sourceReportID && sourceReportID !== conciergeReportID ? sourceReportID : undefined, }); diff --git a/src/components/StateSelector.tsx b/src/components/StateSelector.tsx index 75f58943de90..a57f9dc9ae78 100644 --- a/src/components/StateSelector.tsx +++ b/src/components/StateSelector.tsx @@ -83,7 +83,6 @@ function StateSelector({errorText, onBlur, value: stateCode, label, onInputChang shouldShowRightIcon title={title} // Label can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing description={label || translate('common.state')} brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={errorText} diff --git a/src/components/StatusBadge.tsx b/src/components/StatusBadge.tsx index 8b2b58487337..58d6990d3d07 100644 --- a/src/components/StatusBadge.tsx +++ b/src/components/StatusBadge.tsx @@ -25,15 +25,12 @@ function StatusBadge({text, backgroundColor, textColor, badgeStyles, tooltipText const theme = useTheme(); const StyleUtils = useStyleUtils(); - const bgColor = (backgroundColor ?? theme.transparent) as string; - const txtColor = (textColor ?? theme.text) as string; - const badge = ( ); diff --git a/src/components/SubStepForms/ConfirmationStep.tsx b/src/components/SubStepForms/ConfirmationStep.tsx index 4ff39adc4c23..c82500181f18 100644 --- a/src/components/SubStepForms/ConfirmationStep.tsx +++ b/src/components/SubStepForms/ConfirmationStep.tsx @@ -1,6 +1,7 @@ import Button from '@components/Button'; import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; +import MenuItemAvatarNavigation from '@components/MenuItem/presets/MenuItemAvatarNavigation'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import RenderHTML from '@components/RenderHTML'; import ScrollView from '@components/ScrollView'; @@ -19,16 +20,70 @@ import CONST from '@src/CONST'; import {View} from 'react-native'; type SummaryItem = { + /** Unique key of the row */ id: string; + + /** The label shown above the answer */ description: string; + + /** The answer shown in the row */ title: string; + + /** Whether the chevron is shown on the right of the row */ shouldShowRightIcon: boolean; + + /** Called when the row is pressed */ onPress: () => void; + + /** False for a row that opens nothing, so it is not announced as a button. Defaults to true. */ + interactive?: boolean; + + /** The brick road indicator shown on the row */ brickRoadIndicator?: BrickRoad; + + /** The error shown under the row */ errorText?: string; + + /** The test ID of the row */ testID?: string; }; +/** A row for one entry of a repeated group, such as an owner, led by an avatar */ +type SummaryAvatarRow = { + /** Tells the row apart from an answer row */ + kind: 'avatar'; + + /** Unique key of the row */ + id: string; + + /** The entry's name */ + title: string; + + /** The line under the name */ + description: string; + + /** The avatar shown on the left */ + avatarSource?: string; + + /** Called when the row is pressed */ + onPress: () => void; +}; + +/** One row of a group: an answer, or an entry of a repeated group */ +type SummaryGroupRow = SummaryItem | SummaryAvatarRow; + +/** Answers under one heading. Used instead of `summaryItems` when the confirmation is split by section. */ +type SummaryGroup = { + /** Unique key of the group */ + id: string; + + /** The heading, left out when empty */ + name: string; + + /** The rows listed under the heading */ + rows: SummaryGroupRow[]; +}; + type ConfirmationStepProps = SubPageProps & ForwardedFSClassProps & { pageTitle: string; @@ -36,7 +91,10 @@ type ConfirmationStepProps = SubPageProps & /** Text shown below the page title */ pageSubtitle?: string; - summaryItems: SummaryItem[]; + summaryItems?: SummaryItem[]; + + /** Sections with their own heading, each holding field rows and avatar rows for repeated entries */ + groups?: SummaryGroup[]; /** Whether show additional section with Onfido terms etc. */ showOnfidoLinks: boolean; @@ -55,7 +113,8 @@ type ConfirmationStepProps = SubPageProps & function ConfirmationStep({ pageTitle, pageSubtitle, - summaryItems, + summaryItems = [], + groups = [], showOnfidoLinks, onfidoLinksTitle, isLoading, @@ -69,6 +128,42 @@ function ConfirmationStep({ const {paddingBottom: safeAreaInsetPaddingBottom} = useSafeAreaPaddings(); + const renderRow = (row: SummaryGroupRow) => { + if ('kind' in row) { + // MenuItemAvatarNavigation takes no FullStory class, so the mask goes on a View around it + return ( + + + + ); + } + const {id, description, title, shouldShowRightIcon, onPress, interactive, brickRoadIndicator, errorText, testID} = row; + return ( + + ); + }; + return ( {pageTitle} {!!pageSubtitle && {pageSubtitle}} - {summaryItems.map(({id, description, title, shouldShowRightIcon, onPress, brickRoadIndicator, errorText, testID}) => ( - + {summaryItems.map(renderRow)} + + {groups.map((group, index) => ( + + {index > 0 && } + {!!group.name && ( + + {group.name} + + )} + {group.rows.map(renderRow)} + ))} {showOnfidoLinks && ( @@ -119,3 +217,4 @@ function ConfirmationStep({ } export default ConfirmationStep; +export type {SummaryGroup, SummaryGroupRow}; diff --git a/src/components/Switch.tsx b/src/components/Switch.tsx index 4f45b6fae04c..50720f415d33 100644 --- a/src/components/Switch.tsx +++ b/src/components/Switch.tsx @@ -35,6 +35,12 @@ type SwitchProps = { /** Whether the switch is nested inside another pressable */ isNested?: boolean; + + /** Animate the thumb on any `isOn` change, not only when the switch itself is pressed. */ + shouldAnimateOnExternalChange?: boolean; + + /** Whether the switch is a keyboard tab stop. */ + focusable?: boolean; }; const OFFSET_X = { @@ -42,7 +48,7 @@ const OFFSET_X = { ON: 20, }; -function Switch({isOn, onToggle, accessibilityLabel, disabled, pending = false, showLockIcon, disabledAction, isNested}: SwitchProps) { +function Switch({isOn, onToggle, accessibilityLabel, disabled, pending = false, showLockIcon, disabledAction, isNested, shouldAnimateOnExternalChange = false, focusable}: SwitchProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const offsetX = useSharedValue(isOn ? OFFSET_X.ON : OFFSET_X.OFF); @@ -58,8 +64,8 @@ function Switch({isOn, onToggle, accessibilityLabel, disabled, pending = false, if (prevIsOn.current === isOn) { return; } - if (hasUserToggled.current) { - // User just toggled - animate to new position + if (hasUserToggled.current || shouldAnimateOnExternalChange) { + // User just toggled (or the caller opted in to animate external changes) - animate to new position offsetX.set(withTiming(targetOffsetX, {duration: 300})); hasUserToggled.current = false; } else { @@ -69,7 +75,7 @@ function Switch({isOn, onToggle, accessibilityLabel, disabled, pending = false, offsetX.set(targetOffsetX); } prevIsOn.current = isOn; - }, [isOn, offsetX, targetOffsetX]); + }, [isOn, offsetX, targetOffsetX, shouldAnimateOnExternalChange]); const handleSwitchPress = () => { requestAnimationFrame(() => { @@ -114,6 +120,7 @@ function Switch({isOn, onToggle, accessibilityLabel, disabled, pending = false, { if (!isNested) { @@ -158,3 +165,4 @@ function Switch({isOn, onToggle, accessibilityLabel, disabled, pending = false, } export default Switch; +export type {SwitchProps}; diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 1e178fda0227..42b5de198b71 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -13,12 +13,13 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useVerticalScrollbarWidth from '@hooks/useVerticalScrollbarWidth'; import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import {canUseCssPositionSticky} from '@libs/DeviceCapabilities'; import getPlatform from '@libs/getPlatform'; import {canMeasureText} from '@libs/measureTextWidth'; import {acquireBackgroundInputFocusSuppression} from '@libs/ModalFocusManager'; import CONST from '@src/CONST'; -import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; +import type {StoredTableColumnWidths} from '@src/types/onyx/TableColumnWidths'; import type {FlashListRef} from '@shopify/flash-list'; import type {ReactElement} from 'react'; @@ -43,6 +44,7 @@ import useSelection from './middlewares/selection'; import useSorting from './middlewares/sorting'; import {shouldUseTableSemantics} from './tableAccessibility'; import {doesBodyRenderWhenEmpty} from './TableBody'; +import TableBulkActionBar from './TableBulkActionBar'; import TableContext from './TableContext'; import TableEmptyState from './TableEmptyStates/TableEmptyState'; import TableNoResultsState from './TableEmptyStates/TableNoResultsState'; @@ -145,8 +147,8 @@ function createTableHandle = TableProps & { - /** Stored dragged widths for this table's `columnResizingID`. */ - columnWidthOverrides?: ColumnWidthOverrides; + /** Layout the last drag saved for this `columnResizingID`. */ + storedTableColumnWidths?: StoredTableColumnWidths; }; function TableContent({ @@ -170,7 +172,7 @@ function TableContent({ columns, data, @@ -299,14 +303,16 @@ function TableContent 0; const isColumnHeaderHiddenInNarrowLayout = shouldUseNarrowTableLayout && !title; - // Resizable tables always get the scroller: a drag can overflow without a render, and moving the header mid-drag would remount the handle. - const hasHorizontalScrollContainer = isColumnResizingEnabled || !!dynamicScrollWidth; + const canStickColumnHeaderInScroller = canUseCssPositionSticky(); + + // The bar is only ever a wide layout affordance, since the narrow one selects rows through its own header. Whether + // it is on screen is decided here rather than in the bar, because the list has to reserve the space it floats over + // and the two must not disagree about when that is. + const hasBulkActionBarChild = childrenArray.some((child) => React.isValidElement(child) && child.type === TableBulkActionBar); + const isBulkActionBarVisible = hasBulkActionBarChild && !shouldUseNarrowLayout && selectedKeys.length > 0; const tableListMetadata = useMemo( () => @@ -362,9 +373,9 @@ function TableContent( props: TableProps & {columnResizingID: string}, ) { - const columnWidthOverrides = useStoredColumnWidths(props.columnResizingID); + const storedTableColumnWidths = useStoredColumnWidths(props.columnResizingID); return ( ); } diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 9a4db2f7aead..4f2d9a97fc86 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -28,6 +28,7 @@ import { rendersColumnHeaderAsStickyRow, rendersColumnHeaderInListHeader, } from './buildTableListData'; +import {TABLE_COLUMN_SCROLLER_DATA_SET} from './columnResize/columnWidthExpressions'; import dismissKeyboardOnDrag from './dismissKeyboardOnDrag'; import {getRowGroupAccessibilityProps, getTableContainerAccessibilityProps, getVirtualizedRowSemanticID, shouldUseTableSemantics} from './tableAccessibility'; import {TableRowSemanticIDContext, useTableContext} from './TableContext'; @@ -57,7 +58,7 @@ type ColumnScrollOverrideProps = Record & { function getColumnScrollOverrideProps(overflowXAutoStyle: ViewStyle, overrideProps: ColumnScrollOverrideProps = {}) { return { ...overrideProps, - dataSet: {...overrideProps.dataSet, tableColumnScroller: true}, + dataSet: {...overrideProps.dataSet, ...TABLE_COLUMN_SCROLLER_DATA_SET}, style: [overrideProps.style, overflowXAutoStyle], }; } @@ -150,7 +151,6 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. processedData: filteredAndSortedData, listProps, listRef, - scrollbarWidth, measureScrollbarRef, listContainerRef, trackScrollOffset, @@ -164,6 +164,8 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. noResultsStateElement, tableListMetadata, isEmptyResult, + isBulkActionBarVisible, + isDefaultViewEmpty, scrollWidth, tableWidth, } = useTableContext(); @@ -315,8 +317,9 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. ) : null; - // While the columns scroll the column header sits here rather than in FlashList's sticky-row overlay, so the - // scroller carries it sideways. Also one copy instead of the overlay's two, so no duplicate to hide from readers. + // The column header sits here rather than in FlashList's sticky-row overlay so that the scroller holds it, which keeps + // it clear of the scrollbar and carries it sideways. Also one copy instead of the overlay's two, so no duplicate to + // hide from readers. const listHeaderContent = shouldRenderColumnHeaderInListHeader ? ( <> {pageHeaderElement} @@ -337,8 +340,10 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. ); - const emptyStateContent = - tableListMetadata.hasPageHeader && isEmptyResult ? (noResultsStateElement ?? EmptyResultComponent) : (emptyStateElement ?? renderListComponent(ListEmptyComponent)); + // When the default view hides every row (e.g. all workspaces are archived), no search or filter is active, + // so show the empty state rather than "No results". + const shouldShowNoResultsState = tableListMetadata.hasPageHeader && isEmptyResult && !isDefaultViewEmpty; + const emptyStateContent = shouldShowNoResultsState ? (noResultsStateElement ?? EmptyResultComponent) : (emptyStateElement ?? renderListComponent(ListEmptyComponent)); const footerElement = renderListComponent(ListFooterComponent); // Consumer footer flex growth is useful below normal rows, but inside the combined empty-state // footer it can expand over the page header. Preserve the remaining style while disabling growth. @@ -418,13 +423,7 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. isAccessibilityHidden, }); - // The list draws its stuck header as a sibling of the scroller rather than inside it, so the header - // spans a width the rows below never get. Holding it off the scrollbar keeps the two edges in line. - if (info.target !== 'StickyHeader' || scrollbarWidth === 0) { - return headerElement; - } - - return {headerElement}; + return headerElement; } case 'data': default: { @@ -476,7 +475,7 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. measureScrollbarRef(instance); }} data={listData} - style={[styles.flex1, styles.mnh0]} + style={[styles.flex1, styles.mnh0, isBulkActionBarVisible && styles.bulkActionBarScrollPadding]} maintainVisibleContentPosition={{disabled: true}} ListHeaderComponent={listHeaderContent} ListHeaderComponentStyle={[ @@ -495,6 +494,7 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. listContentContainerStyle, tableBodyContentContainerStyle, contentContainerStyle, + isBulkActionBarVisible && styles.bulkActionBarListSpacing, // Absolutely positioned rows don't widen the scroller's content, so hold the scroll extent open. isColumnScrollEnabled && StyleUtils.getMinimumWidth(scrollWidth), shouldRenderEmptyStateInList && styles.flexGrow1, diff --git a/src/components/Table/TableBulkActionBar.tsx b/src/components/Table/TableBulkActionBar.tsx new file mode 100644 index 000000000000..8e7da80193fc --- /dev/null +++ b/src/components/Table/TableBulkActionBar.tsx @@ -0,0 +1,35 @@ +import BulkActionBar from '@components/BulkActionBar'; +import type {BulkActionBarProps} from '@components/BulkActionBar/types'; + +import React from 'react'; + +import type {TableData} from './types'; + +import {useTableContext} from './TableContext'; + +type TableBulkActionBarProps = Omit, 'selectedCount' | 'onClearSelection'> & { + /** Replaces what the bar's close button does, which is otherwise to clear the table's own selection. */ + onClearSelection?: () => void; +}; + +/** + * The floating bulk action bar for the table's selection. Render it as a child of `` and it mounts itself + * whenever the wide layout has rows selected, with the list reserving the space it floats over. + */ +function TableBulkActionBar({onClearSelection, ...bulkActionBarProps}: TableBulkActionBarProps) { + const {selectedKeys, tableMethods, isBulkActionBarVisible} = useTableContext(); + + if (!isBulkActionBarVisible) { + return null; + } + + return ( + + ); +} + +export default TableBulkActionBar; diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index c761dfacfafe..8b7d42be7026 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -43,10 +43,7 @@ type TableContextValue | null>; - /** Width the list's vertical scrollbar takes from the rows, so the sticky header can stop short of it. 0 when the bar overlays. */ - scrollbarWidth: number; - - /** Attached to the list alongside `listRef` so `scrollbarWidth` tracks the element that scrolls. */ + /** Attached to the list alongside `listRef` so the table can measure the element that scrolls. */ measureScrollbarRef: (instance: ScrollableNodeHolder | null) => void; /** Ref for the view wrapping the table list; its top is the anchor used when scrolling a focused input above the keyboard. */ @@ -64,6 +61,15 @@ type TableContextValue = { listRef: React.createRef(), - scrollbarWidth: 0, measureScrollbarRef: () => {}, listContainerRef: React.createRef(), trackScrollOffset: () => {}, @@ -182,6 +187,8 @@ const defaultTableContextValue: TableContextValue = { }, isEmptyResult: false, isDefaultViewEmpty: false, + selectedKeys: [], + isBulkActionBarVisible: false, shouldUseNarrowTableLayout: false, isMobileSelectionEnabled: false, }; diff --git a/src/components/Table/TableEmptyStates/TableNoResultsState.tsx b/src/components/Table/TableEmptyStates/TableNoResultsState.tsx index 60a93ca4a2c6..30c49a66fe35 100644 --- a/src/components/Table/TableEmptyStates/TableNoResultsState.tsx +++ b/src/components/Table/TableEmptyStates/TableNoResultsState.tsx @@ -21,10 +21,11 @@ type TableNoResultsStateProps = Omit { + if (isDisabled || !interactive) { + return; + } + + if (!selectionUsesNarrowLayout || !isMobileSelectionEnabled || !selectionEnabled) { + onPress?.(event); + return; + } + + if (item.disabled) { + return; + } + + if (!item.isSelectionDisabled) { + handleCheckboxPress(event); + } + }, + {shouldCheck: shouldAllowTextSelection}, + ); + }; - if (item.disabled) { + const handleRowLongPress = () => { + if (shouldSuppressCopyableTextRowLongPress(shouldAllowTextSelection)) { return; } - if (!item.isSelectionDisabled) { - handleCheckboxPress(event); - } - }; - - const handleRowLongPress = () => { if (isDisabled || item.disabled || !selectionEnabled || isMobileSelectionEnabled || !shouldEnableMobileSelectionLongPress || !interactive || item.isSelectionDisabled) { return; } @@ -328,17 +343,23 @@ export default function TableRow({ style={tableRowPressableStyles} sentryLabel={sentryLabel} interactive={interactive} + shouldAllowTextSelection={shouldAllowTextSelection} disabled={isDisabled} hoverStyle={tableRowPressableHoverStyle} onHoverIn={enableHoverStyle} pressDimmingValue={!interactive ? undefined : 1} role={interactive ? CONST.ROLE.BUTTON : CONST.ROLE.PRESENTATION} - dataSet={TABLE_ROW_DATA_SET} {...getRowAccessibilityProps(isTableSemanticsEnabled, rowIndex, false, semanticTableHasHeader)} onMouseDown={(e) => { captureEditingOnMouseDown(); const target = e?.target; + const isCopyableTextMouseDown = shouldAllowTextSelection && isPressStartOnCopyableText(e); + const isCopyableTarget = markMouseDownOnCopyableText(target, isCopyableTextMouseDown, {shouldSuppressNextPress: e.detail > 1}); + + if (isCopyableTarget) { + return; + } // Inputs must receive the mousedown so they can take focus. if (target instanceof HTMLElement && target.tagName === CONST.ELEMENT_NAME.INPUT) { @@ -350,6 +371,10 @@ export default function TableRow({ e.preventDefault(); } }} + onTouchStart={(e) => { + const isCopyableTextTouchStart = shouldAllowTextSelection && isPressStartOnCopyableText(e); + markTouchStartOnCopyableText(e, isCopyableTextTouchStart); + }} onPress={(event) => handleRowPress(event)} onPressIn={(event) => { captureEditingOnPressIn(); @@ -358,6 +383,7 @@ export default function TableRow({ onLongPress={handleRowLongPress} {...props} {...inertProps} + dataSet={rowDataSet} focusable={isAccessibilityHidden ? false : focusable} fullDisabled={isAccessibilityHidden || fullDisabled} tabIndex={isAccessibilityHidden ? -1 : tabIndex} diff --git a/src/components/Table/buildTableListData.ts b/src/components/Table/buildTableListData.ts index 335ec82dc223..40d9fcb874a9 100644 --- a/src/components/Table/buildTableListData.ts +++ b/src/components/Table/buildTableListData.ts @@ -31,10 +31,11 @@ type ColumnHeaderPlacementParams = { isColumnHeaderHiddenInNarrowLayout: boolean; /** - * Whether the columns sit in a horizontal scroller because they overflow or are resizable. FlashList's sticky-row overlay - * can't follow them sideways, so the column header moves into the list header instead (see `TableBody`). + * Whether the platform can pin the column header inside the scroller with `position: sticky`. FlashList's sticky-row + * overlay is a sibling of the scroller instead, so it paints over the scrollbar and cannot follow the columns + * sideways (see `TableBody`). */ - hasHorizontalScrollContainer: boolean; + canStickColumnHeaderInScroller: boolean; }; type TableListMetadataParams = Omit & { @@ -47,7 +48,7 @@ function getColumnHeaderPlacement({ hasPageHeader, hasRows, isColumnHeaderHiddenInNarrowLayout, - hasHorizontalScrollContainer, + canStickColumnHeaderInScroller, }: ColumnHeaderPlacementParams): ColumnHeaderPlacement { if (!hasColumnHeaderElement) { return CONST.TABLES.COLUMN_HEADER_PLACEMENT.NONE; @@ -63,7 +64,7 @@ function getColumnHeaderPlacement({ return CONST.TABLES.COLUMN_HEADER_PLACEMENT.OUTSIDE_LIST; } - return hasHorizontalScrollContainer ? CONST.TABLES.COLUMN_HEADER_PLACEMENT.LIST_HEADER : CONST.TABLES.COLUMN_HEADER_PLACEMENT.STICKY_ROW; + return canStickColumnHeaderInScroller ? CONST.TABLES.COLUMN_HEADER_PLACEMENT.LIST_HEADER : CONST.TABLES.COLUMN_HEADER_PLACEMENT.STICKY_ROW; } /** Whether the table has a column header at all, wherever it ends up. Drives `aria-rowindex`/`aria-rowcount`. */ diff --git a/src/components/Table/columnResize/ColumnResizeHandle/index.tsx b/src/components/Table/columnResize/ColumnResizeHandle/index.tsx index d127af019355..f9625332a3c6 100644 --- a/src/components/Table/columnResize/ColumnResizeHandle/index.tsx +++ b/src/components/Table/columnResize/ColumnResizeHandle/index.tsx @@ -1,31 +1,55 @@ -import {RESIZE_INDICATOR_HEIGHT_VARIABLE, RESIZE_INDICATOR_OPACITY_VARIABLE, RESIZE_INDICATOR_TOP_VARIABLE} from '@components/Table/columnResize/columnWidthExpressions'; +import Hoverable from '@components/Hoverable'; +import { + RESIZE_GRIP_HOVER_VARIABLE, + RESIZE_INDICATOR_DATA_ATTRIBUTE, + RESIZE_INDICATOR_HEIGHT_VARIABLE, + RESIZE_INDICATOR_OPACITY_VARIABLE, + RESIZE_INDICATOR_TOP_VARIABLE, +} from '@components/Table/columnResize/columnWidthExpressions'; import type {ColumnResizeHandleProps} from '@components/Table/columnResize/types'; import {useTableContext} from '@components/Table/TableContext'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import React from 'react'; -// A DOM `div` style, which the React Native style system can't type because of the custom property expressions. -const INDICATOR_STYLE: React.CSSProperties = { +// DOM `div` styles, which the React Native style system can't type because of the custom property expressions. +// Both bars sit at the handle's left, which is flush against the column's edge. +const BAR_STYLE: React.CSSProperties = { position: 'absolute', + left: 0, + width: CONST.TABLES.COLUMN_RESIZE.INDICATOR_WIDTH, +}; + +const GRIP_STYLE: React.CSSProperties = { + ...BAR_STYLE, + top: `${((1 - CONST.TABLES.COLUMN_RESIZE.GRIP_HEIGHT_RATIO) / 2) * 100}%`, + height: `${CONST.TABLES.COLUMN_RESIZE.GRIP_HEIGHT_RATIO * 100}%`, + borderRadius: CONST.TABLES.COLUMN_RESIZE.INDICATOR_WIDTH / 2, + // Hidden while the line shows, since the line grew out of it. + opacity: `calc(var(${RESIZE_GRIP_HOVER_VARIABLE}, 0) * (1 - var(${RESIZE_INDICATOR_OPACITY_VARIABLE}, 0)))`, + transition: `opacity ${CONST.TABLES.COLUMN_RESIZE.INDICATOR_MORPH_DURATION}ms`, +}; + +const INDICATOR_STYLE: React.CSSProperties = { + ...BAR_STYLE, // Spans heading row to lowest row, which the handle's box doesn't match, so both are measured. top: `var(${RESIZE_INDICATOR_TOP_VARIABLE}, 0px)`, height: `var(${RESIZE_INDICATOR_HEIGHT_VARIABLE}, 100%)`, - left: '50%', - marginLeft: -CONST.TABLES.COLUMN_RESIZE.INDICATOR_WIDTH / 2, - width: CONST.TABLES.COLUMN_RESIZE.INDICATOR_WIDTH, opacity: `var(${RESIZE_INDICATOR_OPACITY_VARIABLE}, 0)`, + // Keeps its height while hidden, so it would otherwise hand the rows below the header to the handle. pointerEvents: 'none', }; /** - * Drag the strip over a column's right edge. Carries the indicator line, so it moves with the column through drags and scrolls. + * Drag the strip over a column's right edge. Carries the grip and the indicator line, so they move with the column through drags and scrolls. * A plain `div` because it relies on DOM pointer capture. */ function ColumnResizeHandle({columnKey}: ColumnResizeHandleProps) { + const theme = useTheme(); const styles = useThemeStyles(); const {columnResize} = useTableContext(); const handleProps = columnResize?.getHandleProps(columnKey); @@ -35,12 +59,22 @@ function ColumnResizeHandle({columnKey}: ColumnResizeHandleProps) { } return ( -
-
-
+ + {(isHovered) => ( +
+
+
+
+ )} + ); } diff --git a/src/components/Table/columnResize/applyColumnWidthOverrides.ts b/src/components/Table/columnResize/applyColumnWidthOverrides.ts deleted file mode 100644 index 8760523dd51d..000000000000 --- a/src/components/Table/columnResize/applyColumnWidthOverrides.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** Merges stored drag widths into a table's resolved column widths, and decides which column edges can be dragged. */ -import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; - -import {getColumnWidthValue} from './columnWidthExpressions'; - -/** What resizing needs to know about a column, whatever lays the columns out. */ -type ColumnWidthOverrideColumn = { - /** Key a stored width is filed under. */ - key: string; - - /** Headless columns hold fixed-size content like an icon, checkbox or arrow, so they get no edge. */ - label: string; - - /** Columns with a declared width aren't resizable. */ - hasDeclaredWidth: boolean; -}; - -type ApplyColumnWidthOverridesParams = { - /** Every column, in render order. */ - columns: ColumnWidthOverrideColumn[]; - - /** Widths before stored overrides are applied. */ - baseColumnWidths: Record; - - columnWidthOverrides: ColumnWidthOverrides | undefined; -}; - -type AppliedColumnWidthOverrides = { - /** Widths after stored overrides, which drags start from. */ - columnWidths: Record; - - /** Each column's custom property CSS value with `columnWidths` as the fallback, in render order. */ - columnWidthValues: string[]; - - /** Keys of the columns whose right edge the user can drag, in render order. */ - resizableColumnKeys: string[]; -}; - -/** Whole px, deliberately not clamped: stored widths may legitimately fall outside drag bounds. */ -function getStoredColumnWidth(width: number): number { - return Math.max(Math.round(width), 0); -} - -/** Layout-agnostic, so it serves grid tracks and flex basis alike. */ -function applyColumnWidthOverrides({columns, baseColumnWidths, columnWidthOverrides}: ApplyColumnWidthOverridesParams): AppliedColumnWidthOverrides { - const columnWidths = {...baseColumnWidths}; - const resizableColumnKeys: string[] = []; - - for (const column of columns) { - // Only headed, content-sized columns drag. Columns with a declared width, like a switch, status or count, hold fixed-size content. - if (!column.label || column.hasDeclaredWidth) { - continue; - } - - const overriddenWidth = columnWidthOverrides?.[column.key]; - - if (overriddenWidth !== undefined) { - columnWidths[column.key] = getStoredColumnWidth(overriddenWidth); - } - - resizableColumnKeys.push(column.key); - } - - const columnWidthValues = columns.map((column) => getColumnWidthValue(column.key, columnWidths[column.key] ?? 0)); - - return {columnWidths, columnWidthValues, resizableColumnKeys}; -} - -export default applyColumnWidthOverrides; -export type {ColumnWidthOverrideColumn}; diff --git a/src/components/Table/columnResize/columnWidthExpressions.ts b/src/components/Table/columnResize/columnWidthExpressions.ts index e5374af6acbd..c50a32dc85e0 100644 --- a/src/components/Table/columnResize/columnWidthExpressions.ts +++ b/src/components/Table/columnResize/columnWidthExpressions.ts @@ -3,6 +3,9 @@ /** Prefix of the custom property a resizable column reads its width from. */ const COLUMN_WIDTH_VARIABLE_PREFIX = '--table-column-width-'; +/** Floor for the scroll extent mid-drag, so shrinking a column can't pull the scroll position, and the filter bar pinned to it, sideways. */ +const SCROLL_EXTENT_FLOOR_VARIABLE = '--table-scroll-extent-floor'; + /** Top of a handle's line: the heading row's top, relative to the handle. */ const RESIZE_INDICATOR_TOP_VARIABLE = '--table-resize-indicator-top'; @@ -12,11 +15,22 @@ const RESIZE_INDICATOR_HEIGHT_VARIABLE = '--table-resize-indicator-height'; /** Opacity of a handle's line, set on that handle so hovering never re-renders the table. */ const RESIZE_INDICATOR_OPACITY_VARIABLE = '--table-resize-indicator-opacity'; +/** `1` while the pointer is over a handle's heading cell, `0` otherwise. The grip shows while this is set and the line isn't. */ +const RESIZE_GRIP_HOVER_VARIABLE = '--table-resize-grip-hover'; + +/** Marks a handle's line, so a reveal can grow it out of the grip. */ +const RESIZE_INDICATOR_DATA_ATTRIBUTE = 'data-resize-indicator'; + /** Marks the header row and the data rows, so the line can find where the table's rows start and end. */ const TABLE_ROW_DATA_SET = {tableRow: true}; const TABLE_ROW_SELECTOR = '[data-table-row]'; +/** Marks the scroller a table with a pinned filter bar scrolls its columns in. `web/index.html` matches it too. */ +const TABLE_COLUMN_SCROLLER_DATA_SET = {tableColumnScroller: true}; + +const TABLE_COLUMN_SCROLLER_SELECTOR = '[data-table-column-scroller="true"]'; + /** Column keys may hold characters a CSS custom property name can't, so those are replaced. */ function getColumnWidthVariableName(columnKey: string): string { return `${COLUMN_WIDTH_VARIABLE_PREFIX}${columnKey.replaceAll(/[^\w-]/g, '_')}`; @@ -42,9 +56,14 @@ function getColumnsWidthExpression(columnWidthValues: string[], chromeWidth: num } export { + RESIZE_GRIP_HOVER_VARIABLE, + RESIZE_INDICATOR_DATA_ATTRIBUTE, RESIZE_INDICATOR_HEIGHT_VARIABLE, RESIZE_INDICATOR_OPACITY_VARIABLE, RESIZE_INDICATOR_TOP_VARIABLE, + SCROLL_EXTENT_FLOOR_VARIABLE, + TABLE_COLUMN_SCROLLER_DATA_SET, + TABLE_COLUMN_SCROLLER_SELECTOR, TABLE_ROW_DATA_SET, TABLE_ROW_SELECTOR, getColumnWidthValue, diff --git a/src/components/Table/columnResize/getAbsorbedColumnWidths.ts b/src/components/Table/columnResize/getAbsorbedColumnWidths.ts new file mode 100644 index 000000000000..838d6a7efa52 --- /dev/null +++ b/src/components/Table/columnResize/getAbsorbedColumnWidths.ts @@ -0,0 +1,55 @@ +/** Splits a column's resize among the columns to its right, so the row keeps its width instead of overflowing. */ +import CONST from '@src/CONST'; + +const {MIN_WIDTH} = CONST.TABLES.COLUMN_RESIZE; + +/** A later column that absorbs another column's resize. */ +type ColumnAbsorber = { + columnKey: string; + + /** The layout's minimum, so a drag and a reload land on the same widths. */ + minWidth: number; + + /** Widest it may grow to, so a column meant to truncate long values never takes another column's freed width past its cap. */ + maxWidth: number; +}; + +/** + * Splits a resize delta equally among the absorbers. An absorber that runs out of room between its minimum and maximum + * stops absorbing and its share moves to the rest. Whatever none of them can absorb is left over, so the row overflows + * and scrolls, or falls short and the growable column fills the gap. Shares are whole px and sum exactly. Absorbers without a start width are skipped. Returns each absorber's new width by column key. + */ +function getAbsorbedColumnWidths(absorbers: ColumnAbsorber[], startWidths: Record, delta: number): Record { + const absorbersWithRoom = absorbers.flatMap(({columnKey, minWidth, maxWidth}) => { + const startWidth = startWidths[columnKey]; + + if (startWidth === undefined) { + return []; + } + + const roundedStartWidth = Math.round(startWidth); + + // How far it can move in the delta's direction: shrinking down to its minimum, or growing up to its maximum. + const room = delta > 0 ? Math.max(roundedStartWidth - Math.max(Math.round(minWidth), MIN_WIDTH), 0) : Math.max(Math.round(maxWidth) - roundedStartWidth, 0); + + return [{columnKey, startWidth: roundedStartWidth, room}]; + }); + + // Settled from the least room up, so an absorber hitting its minimum leaves its unabsorbed share to the rest. Stable, so ties keep render order. + const settleOrder = absorbersWithRoom.toSorted((first, second) => first.room - second.room); + const absorbedWidths: Record = {}; + let remainingDelta = Math.round(delta); + + for (const [position, absorber] of settleOrder.entries()) { + const equalShare = Math.round(remainingDelta / (settleOrder.length - position)); + const share = Math.max(Math.min(equalShare, absorber.room), -absorber.room); + + absorbedWidths[absorber.columnKey] = absorber.startWidth - share; + remainingDelta -= share; + } + + return absorbedWidths; +} + +export default getAbsorbedColumnWidths; +export type {ColumnAbsorber}; diff --git a/src/components/Table/columnResize/getColumnsWidthSum.ts b/src/components/Table/columnResize/getColumnsWidthSum.ts new file mode 100644 index 000000000000..56ef26e512f2 --- /dev/null +++ b/src/components/Table/columnResize/getColumnsWidthSum.ts @@ -0,0 +1,6 @@ +/** Sums a row's column widths, counting a column without a width as 0. */ +function getColumnsWidthSum(columns: Array<{key: string}>, columnWidths: Record): number { + return columns.reduce((total, column) => total + (columnWidths[column.key] ?? 0), 0); +} + +export default getColumnsWidthSum; diff --git a/src/components/Table/columnResize/getFrozenColumnWidths.ts b/src/components/Table/columnResize/getFrozenColumnWidths.ts new file mode 100644 index 000000000000..748162d06209 --- /dev/null +++ b/src/components/Table/columnResize/getFrozenColumnWidths.ts @@ -0,0 +1,50 @@ +/** Repaints a saved layout, with the columns the user never dragged absorbing room that changed since the save. */ +import type {StoredTableColumnWidths} from '@src/types/onyx/TableColumnWidths'; + +import type {ResizableColumn} from './resolveDraggedColumnWidths'; + +import getAbsorbedColumnWidths from './getAbsorbedColumnWidths'; +import getColumnsWidthSum from './getColumnsWidthSum'; +import {getAbsorbedDelta, getColumnAbsorbers} from './resolveDraggedColumnWidths'; + +type GetFrozenColumnWidthsParams = { + /** Every column, in render order. A column added since the save starts at its content width. */ + columns: ResizableColumn[]; + + storedTableColumnWidths: StoredTableColumnWidths; + + /** Measured like the saved one, so they can be compared. */ + availableColumnsWidth: number; +}; + +/** + * Room gained or lost since the save, from the table width or from added and hidden columns, first offsets the saved + * overflow or short row, like a drag does. The rest is split equally among the auto-sized columns. Widths are recomputed + * from the save every time, so resizing the window back restores them exactly. + */ +function getFrozenColumnWidths({ + columns, + storedTableColumnWidths: {availableColumnsWidth: savedAvailableColumnsWidth, columnWidths: savedColumnWidths, resizedColumnKeys}, + availableColumnsWidth, +}: GetFrozenColumnWidthsParams): Record { + const baseColumnWidths: Record = {}; + + for (const column of columns) { + baseColumnWidths[column.key] = column.declaredWidth ?? savedColumnWidths[column.key] ?? Math.min(column.contentWidth, column.maxWidth); + } + + // Every saved column, hidden ones included, so a hidden column's width counts as freed room. + const savedColumnsWidth = Object.values(savedColumnWidths).reduce((total, width) => total + width, 0); + const extraRoom = availableColumnsWidth - savedAvailableColumnsWidth + savedColumnsWidth - getColumnsWidthSum(columns, baseColumnWidths); + const absorbers = getColumnAbsorbers( + columns.filter((column) => savedColumnWidths[column.key] !== undefined), + resizedColumnKeys, + ); + + // Negated because a positive delta shrinks absorbers. + const absorbedDelta = getAbsorbedDelta(-extraRoom, savedColumnsWidth - savedAvailableColumnsWidth); + + return {...baseColumnWidths, ...getAbsorbedColumnWidths(absorbers, baseColumnWidths, absorbedDelta)}; +} + +export default getFrozenColumnWidths; diff --git a/src/components/Table/columnResize/getResizableColumnLayout.ts b/src/components/Table/columnResize/getResizableColumnLayout.ts index 3822c9980c4b..f4b00824a5e4 100644 --- a/src/components/Table/columnResize/getResizableColumnLayout.ts +++ b/src/components/Table/columnResize/getResizableColumnLayout.ts @@ -1,11 +1,11 @@ /** Lays a resizable table's columns out as custom property expressions, so a drag repaints the header, rows and scroller without a render. */ -import type {TableColumn, TableData} from '@components/Table/types'; - import CONST from '@src/CONST'; -import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; -import applyColumnWidthOverrides from './applyColumnWidthOverrides'; -import {getColumnsWidthExpression, getGrowableColumnTrack} from './columnWidthExpressions'; +import type {ResizableColumn} from './resolveDraggedColumnWidths'; + +import {SCROLL_EXTENT_FLOOR_VARIABLE, getColumnWidthValue, getColumnsWidthExpression, getGrowableColumnTrack} from './columnWidthExpressions'; +import getColumnsWidthSum from './getColumnsWidthSum'; +import resolveDraggedColumnWidths, {isColumnResizable} from './resolveDraggedColumnWidths'; /** Row space that isn't column width, so it can be added back around the column sum. */ type RowChromeWidths = { @@ -22,14 +22,15 @@ type RowChromeWidths = { rowPaddingWidth: number; }; -type GetResizableColumnLayoutParams = { +type GetResizableColumnLayoutParams = { /** Every column, in render order. */ - columns: Array>; + columns: ResizableColumn[]; - /** Widths before stored overrides are applied, fixed columns included. */ + /** Before the growable column takes the leftover. Fixed columns included. */ resolvedColumnWidths: Record; - columnWidthOverrides: ColumnWidthOverrides | undefined; + /** Columns the user dragged, which never absorb another column's drag. */ + resizedColumnKeys: string[]; /** Measured width of the area the table renders into. */ tableWidth: number; @@ -50,51 +51,50 @@ type ResizableColumnLayout = { /** Keys of the columns whose right edge the user can drag, in render order. */ resizableColumnKeys: string[]; - /** Width drags start from: stored overrides applied, and the growable column at its painted width. */ + /** The dragged column's width and the widths of the columns that absorb it, painted mid-drag. */ + getResizedColumnWidths: (columnKey: string, width: number) => Record; + + /** Width drags start from, with the growable column at its painted width. */ resolvedColumnWidths: Record; /** The narrowest width a drag may take a column to, for columns tighter than the default drag bound. */ dragMinWidths: Record; }; -function getColumnsWidthSum(columns: Array>, columnWidths: Record): number { - return columns.reduce((total, column) => total + (columnWidths[column.key] ?? 0), 0); +/** Table width minus selection column, gaps, margin and padding. Floored because tracks are whole px. */ +function getAvailableColumnsWidth(tableWidth: number, {selectionColumnWidth, totalGapWidth, rowMarginWidth, rowPaddingWidth}: RowChromeWidths): number { + return Math.floor(tableWidth - rowMarginWidth - selectionColumnWidth - totalGapWidth - rowPaddingWidth); } -function getResizableColumnLayout({ - columns, - resolvedColumnWidths, - columnWidthOverrides, - tableWidth, - rowChromeWidths: {selectionColumnWidth, totalGapWidth, rowMarginWidth, rowPaddingWidth}, -}: GetResizableColumnLayoutParams): ResizableColumnLayout { +function getResizableColumnLayout({columns, resolvedColumnWidths, resizedColumnKeys, tableWidth, rowChromeWidths}: GetResizableColumnLayoutParams): ResizableColumnLayout { + const {selectionColumnWidth, totalGapWidth, rowMarginWidth, rowPaddingWidth} = rowChromeWidths; // Absorbs leftover width, so trailing headless columns like an arrow, menu or icon keep their size and stay pinned right. const growableColumnKey = columns.findLast((column) => !!column.label)?.key; + const availableColumnsWidth = getAvailableColumnsWidth(tableWidth, rowChromeWidths); + const columnWidths = {...resolvedColumnWidths}; + const resizableColumnKeys = columns.filter(isColumnResizable).map((column) => column.key); + const isGrowableColumnResizable = !!growableColumnKey && resizableColumnKeys.includes(growableColumnKey); + + // Absorbs wider columns to its left from the room it paints, leftover included, so the first pixels of a drag on it aren't dead. + // A fixed-width column only fills the leftover through its track, so a drag takes that room before the row overflows. + // Floored so the row never overflows the table by a fraction of a pixel. + if (growableColumnKey && isGrowableColumnResizable) { + const leftoverWidth = Math.floor(availableColumnsWidth - getColumnsWidthSum(columns, resolvedColumnWidths)); + columnWidths[growableColumnKey] = (columnWidths[growableColumnKey] ?? 0) + Math.max(leftoverWidth, 0); + } - const {columnWidths, columnWidthValues, resizableColumnKeys} = applyColumnWidthOverrides({ - columns: columns.map((column) => ({ - key: column.key, - label: column.label, - hasDeclaredWidth: typeof column.width === 'number', - })), - baseColumnWidths: resolvedColumnWidths, - columnWidthOverrides, - }); + const columnWidthValues = columns.map((column) => getColumnWidthValue(column.key, columnWidths[column.key] ?? 0)); // Row width sums the widths, not the tracks, so the growable track only grows into real leftover room. const gridTemplateColumns = columnWidthValues.map((widthValue, index) => (columns.at(index)?.key === growableColumnKey ? getGrowableColumnTrack(widthValue) : widthValue)); const rowWidthValues = selectionColumnWidth > 0 ? [`${selectionColumnWidth}px`, ...columnWidthValues] : columnWidthValues; - const rowContentWidth = getColumnsWidthSum(columns, columnWidths) + selectionColumnWidth + totalGapWidth + rowPaddingWidth; - const overflowWidth = rowContentWidth - (tableWidth - rowMarginWidth); - const dragStartWidths = {...columnWidths}; const dragMinWidths: Record = {}; - if (growableColumnKey && resizableColumnKeys.includes(growableColumnKey)) { - // Drags start from the painted track, which includes the leftover it grew into, so the first pixels of travel aren't dead. - const paintedWidth = (columnWidths[growableColumnKey] ?? 0) + Math.max(-overflowWidth, 0); - dragStartWidths[growableColumnKey] = paintedWidth; + if (growableColumnKey && isGrowableColumnResizable) { + const paintedWidth = columnWidths[growableColumnKey] ?? 0; + const overflowWidth = getColumnsWidthSum(columns, columnWidths) - availableColumnsWidth; // Shrinking may only take back the overflow. Anything more would stretch the trailing headless columns or leave // empty room at the row's end. Rounded up so the row never ends a fraction of a pixel short of the table. @@ -104,16 +104,21 @@ function getResizableColumnLayout + resolveDraggedColumnWidths({columns, startWidths: columnWidths, resizedColumnKeys, availableColumnsWidth, columnKey, width}); + + // Scroll at the live column sum, so a drag that exhausts the absorbers starts scrolling mid-drag. return { gridTemplateColumns, - scrollWidth: getColumnsWidthExpression(rowWidthValues, totalGapWidth + rowMarginWidth + rowPaddingWidth, '100%'), + scrollWidth: getColumnsWidthExpression(rowWidthValues, totalGapWidth + rowMarginWidth + rowPaddingWidth, `max(100%, var(${SCROLL_EXTENT_FLOOR_VARIABLE}, 0px))`), // Without the outer margin. Floored at px because `100%` resolves against the list cell, which includes the margin. rowWidth: getColumnsWidthExpression(rowWidthValues, totalGapWidth + rowPaddingWidth, `${tableWidth - rowMarginWidth}px`), resizableColumnKeys, - resolvedColumnWidths: dragStartWidths, + getResizedColumnWidths, + resolvedColumnWidths: columnWidths, dragMinWidths, }; } export default getResizableColumnLayout; +export {getAvailableColumnsWidth}; diff --git a/src/components/Table/columnResize/resolveDraggedColumnWidths.ts b/src/components/Table/columnResize/resolveDraggedColumnWidths.ts new file mode 100644 index 000000000000..326c38dabf1f --- /dev/null +++ b/src/components/Table/columnResize/resolveDraggedColumnWidths.ts @@ -0,0 +1,85 @@ +/** Works out what a drag paints: the dragged column at its new width, with the change taken out of the auto-sized columns to its right. */ +import type {ColumnAbsorber} from './getAbsorbedColumnWidths'; + +import getAbsorbedColumnWidths from './getAbsorbedColumnWidths'; +import getColumnsWidthSum from './getColumnsWidthSum'; + +/** What resizing needs to know about a column, whatever lays the columns out. */ +type ResizableColumn = { + /** Key a stored width is filed under. */ + key: string; + + /** Headless columns hold fixed-size content like an icon, checkbox or arrow, so they never resize. */ + label: string; + + /** Width the column declares, which it always keeps, so it doesn't share the row. `undefined` when content-sized. */ + declaredWidth: number | undefined; + + /** Width its content and header need. `0` when not measured. */ + contentWidth: number; + + /** The layout's minimum, which absorbing never goes below. */ + minWidth: number; + + /** Width absorbing another column's resize never grows it past. `Infinity` when uncapped. */ + maxWidth: number; +}; + +type ResolveDraggedColumnWidthsParams = { + /** Every column, in render order. */ + columns: ResizableColumn[]; + + /** Painted widths when the drag started. */ + startWidths: Record; + + /** Columns the user dragged, which keep their width. */ + resizedColumnKeys: string[]; + + /** Width the row has for its columns, which absorbers keep it at. */ + availableColumnsWidth: number; + + columnKey: string; + + /** Width the pointer puts the dragged column at. */ + width: number; +}; + +/** Only headed, content-sized columns share the row. Columns with a declared width, like a switch, status or count, hold fixed-size content. */ +function isColumnResizable(column: ResizableColumn): boolean { + return !!column.label && column.declaredWidth === undefined; +} + +/** The columns that give or take room when another width changes: resizable and never dragged by the user. */ +function getColumnAbsorbers(columns: ResizableColumn[], resizedColumnKeys: string[]): ColumnAbsorber[] { + return columns + .filter((column) => isColumnResizable(column) && !resizedColumnKeys.includes(column.key)) + .map((column) => ({columnKey: column.key, minWidth: column.minWidth, maxWidth: column.maxWidth})); +} + +/** + * The part of a resize the absorbers take: only what would move the row away from the available width. Widening first + * fills leftover room, and narrowing first takes back overflow, so a column narrowed in an overflowing row shrinks the + * scroll width instead of growing the columns after it. + */ +function getAbsorbedDelta(delta: number, overflowWidth: number): number { + const overflowAfterResize = overflowWidth + delta; + + return delta > 0 ? Math.min(Math.max(overflowAfterResize, 0), delta) : Math.max(Math.min(overflowAfterResize, 0), delta); +} + +/** + * Columns left of the dragged one stay put, so its edge stays under the pointer. The change is split equally between + * the auto-sized columns to its right, within their minimum and maximum. Returns the dragged column and every absorber that moved. + */ +function resolveDraggedColumnWidths({columns, startWidths, resizedColumnKeys, availableColumnsWidth, columnKey, width}: ResolveDraggedColumnWidthsParams): Record { + const draggedIndex = columns.findIndex((column) => column.key === columnKey); + const delta = width - (startWidths[columnKey] ?? 0); + const overflowWidth = getColumnsWidthSum(columns, startWidths) - availableColumnsWidth; + const absorbers = getColumnAbsorbers(columns.slice(draggedIndex + 1), resizedColumnKeys); + + return {...getAbsorbedColumnWidths(absorbers, startWidths, getAbsorbedDelta(delta, overflowWidth)), [columnKey]: width}; +} + +export default resolveDraggedColumnWidths; +export {getAbsorbedDelta, getColumnAbsorbers, isColumnResizable}; +export type {ResizableColumn}; diff --git a/src/components/Table/columnResize/useColumnResize/index.ts b/src/components/Table/columnResize/useColumnResize/index.ts index 12d6048dc236..5341783ea1dd 100644 --- a/src/components/Table/columnResize/useColumnResize/index.ts +++ b/src/components/Table/columnResize/useColumnResize/index.ts @@ -1,12 +1,13 @@ /** - * Web column resizing: dragging a column's right edge sets its width. Widths live in CSS custom properties so React - * doesn't render mid-drag. Only the dragged column's final width is stored in Onyx. + * Web column resizing by dragging a column's right edge. Widths live in CSS custom properties so React doesn't render + * mid-drag, and the layout is saved to Onyx when the drag ends. */ +import {SCROLL_EXTENT_FLOOR_VARIABLE, TABLE_COLUMN_SCROLLER_SELECTOR} from '@components/Table/columnResize/columnWidthExpressions'; import getDraggedColumnWidth from '@components/Table/columnResize/getDraggedColumnWidth'; import useThemeStyles from '@hooks/useThemeStyles'; -import {setTableColumnWidth} from '@libs/actions/TableColumnWidths'; +import {setTableColumnWidths} from '@libs/actions/TableColumnWidths'; import CONST from '@src/CONST'; @@ -17,7 +18,7 @@ import {useEffect, useRef} from 'react'; import type {ColumnResizeController, ColumnResizeHandleDOMProps, UseColumnResizeParams} from './types'; import useLiveColumnWidths from './useLiveColumnWidths'; -import useResizeIndicator from './useResizeIndicator'; +import useResizeIndicator, {setGripHovered, trackHeadingHover} from './useResizeIndicator'; /** A DOM `div` style, which the React Native style system can't type */ function getHandleStyle(columnGap: number): React.CSSProperties { @@ -25,8 +26,9 @@ function getHandleStyle(columnGap: number): React.CSSProperties { position: 'absolute', top: 0, bottom: 0, - right: -(columnGap / 2 + CONST.TABLES.COLUMN_RESIZE.HANDLE_HIT_WIDTH / 2), - width: CONST.TABLES.COLUMN_RESIZE.HANDLE_HIT_WIDTH, + // Spans the gap to the next column, with the grip and the line flush against this column's edge, so they read as part of the column being resized. + right: -columnGap, + width: columnGap + CONST.TABLES.COLUMN_RESIZE.INDICATOR_WIDTH, // Otherwise a touch drag on the handle is taken over by the table's own horizontal scrolling. touchAction: 'none', }; @@ -42,7 +44,15 @@ type Drag = { startWidth: number; }; -function useColumnResize({columnResizingID, resizableColumnKeys, resolvedColumnWidths, dragMinWidths, columnGap}: UseColumnResizeParams): ColumnResizeController | undefined { +function useColumnResize({ + columnResizingID, + resizableColumnKeys, + getResizedColumnWidths, + resolvedColumnWidths, + dragMinWidths, + getStoredTableColumnWidths, + columnGap, +}: UseColumnResizeParams): ColumnResizeController | undefined { const styles = useThemeStyles(); const dragRef = useRef(null); const {scopeElementRef, setScopeElement, writeColumnWidth, readColumnWidth, clearLiveWidths} = useLiveColumnWidths({resolvedColumnWidths, dragRef}); @@ -57,16 +67,25 @@ function useColumnResize({columnResizingID, resizableColumnKeys, resolvedColumnW const endDrag = (drag: Drag) => { resetDrag(); hideIndicator(); + scopeElementRef.current?.style.removeProperty(SCROLL_EXTENT_FLOOR_VARIABLE); const width = readColumnWidth(drag.columnKey) ?? drag.startWidth; // Nothing gets stored, so no render follows to clear the live widths. - if (!columnResizingID || width === drag.startWidth) { + if (!columnResizingID || !getStoredTableColumnWidths || width === drag.startWidth) { clearLiveWidths(); return; } - setTableColumnWidth(columnResizingID, drag.columnKey, width); + const paintedColumnWidths = Object.fromEntries(Object.keys(resolvedColumnWidths).map((columnKey) => [columnKey, readColumnWidth(columnKey) ?? 0])); + setTableColumnWidths(columnResizingID, getStoredTableColumnWidths(paintedColumnWidths, drag.columnKey)); + }; + + // Releasing or losing capture doesn't reliably fire boundary events, so hit-test the heading, including the handle overhanging it. + const syncGripHover = (event: React.PointerEvent) => { + const isPointerOnHeading = !!event.currentTarget.parentElement?.contains(document.elementFromPoint(event.clientX, event.clientY)); + + setGripHovered(event.currentTarget, isPointerOnHeading); }; const handlePointerDown = (columnKey: string, event: React.PointerEvent) => { @@ -84,6 +103,12 @@ function useColumnResize({columnResizingID, resizableColumnKeys, resolvedColumnW revealIndicator(event.currentTarget); + // Shrinking a column while scrolled right would otherwise drag the pinned filter bar along with the columns. + const scroller = event.currentTarget.closest(TABLE_COLUMN_SCROLLER_SELECTOR); + if (scroller) { + scopeElementRef.current?.style.setProperty(SCROLL_EXTENT_FLOOR_VARIABLE, `${scroller.scrollLeft + scroller.clientWidth}px`); + } + dragRef.current = { columnKey, startClientX: event.clientX, @@ -101,7 +126,11 @@ function useColumnResize({columnResizingID, resizableColumnKeys, resolvedColumnW } // The line rides the handle, so it follows the clamped width, not the pointer. - writeColumnWidth(drag.columnKey, getDraggedColumnWidth(drag.startWidth, drag.startClientX, event.clientX, dragMinWidths?.[drag.columnKey])); + const width = getDraggedColumnWidth(drag.startWidth, drag.startClientX, event.clientX, dragMinWidths?.[drag.columnKey]); + + for (const [columnKey, resizedWidth] of Object.entries(getResizedColumnWidths(drag.columnKey, width))) { + writeColumnWidth(columnKey, resizedWidth); + } }; const handlePointerUp = (event: React.PointerEvent) => { @@ -116,10 +145,11 @@ function useColumnResize({columnResizingID, resizableColumnKeys, resolvedColumnW } endDrag(drag); + syncGripHover(event); }; /** Ends a drag whose pointer capture the browser reclaimed. Also fires after a normal pointerup, when it's a no-op. */ - const handleLostPointerCapture = () => { + const handleLostPointerCapture = (event: React.PointerEvent) => { const drag = dragRef.current; if (!drag) { @@ -127,6 +157,7 @@ function useColumnResize({columnResizingID, resizableColumnKeys, resolvedColumnW } endDrag(drag); + syncGripHover(event); }; // Unmounting mid-drag would otherwise leave the resize cursor on the document and a dangling drag. @@ -152,6 +183,7 @@ function useColumnResize({columnResizingID, resizableColumnKeys, resolvedColumnW return { style: {...getHandleStyle(columnGap), ...styles.cursorColResize}, + ref: trackHeadingHover, onPointerDown: (event) => handlePointerDown(columnKey, event), onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, diff --git a/src/components/Table/columnResize/useColumnResize/types.ts b/src/components/Table/columnResize/useColumnResize/types.ts index 59dcab67581a..7186af7e1c21 100644 --- a/src/components/Table/columnResize/useColumnResize/types.ts +++ b/src/components/Table/columnResize/useColumnResize/types.ts @@ -1,3 +1,5 @@ +import type {StoredTableColumnWidths} from '@src/types/onyx/TableColumnWidths'; + import type React from 'react'; type UseColumnResizeParams = { @@ -7,17 +9,23 @@ type UseColumnResizeParams = { /** Keys of the columns whose right edge the user can drag. */ resizableColumnKeys: string[]; + /** The dragged column's width and its absorbers' widths, painted mid-drag. */ + getResizedColumnWidths: (columnKey: string, width: number) => Record; + /** Each column's resolved width, which a drag starts from. A new object means React rendered new widths. */ resolvedColumnWidths: Record; /** The narrowest width a drag may take a column to, for columns tighter than the default drag bound. */ dragMinWidths?: Record; - /** Gap between columns, so handles can be centred in it. */ + /** Layout to save when dragging `columnKey` ends, from the painted widths. `undefined` when no column is resizable. */ + getStoredTableColumnWidths?: (columnWidths: Record, columnKey: string) => StoredTableColumnWidths; + + /** Gap between columns, which handles span. */ columnGap: number; }; -type ColumnResizeHandleDOMProps = React.HTMLAttributes; +type ColumnResizeHandleDOMProps = React.ComponentPropsWithRef<'div'>; type ColumnResizeController = { /** Element holding the width custom properties. The header and rows inherit them, so one write repaints all. */ diff --git a/src/components/Table/columnResize/useColumnResize/useResizeIndicator.ts b/src/components/Table/columnResize/useColumnResize/useResizeIndicator.ts index c8446a1041d3..4648e82fdd9e 100644 --- a/src/components/Table/columnResize/useColumnResize/useResizeIndicator.ts +++ b/src/components/Table/columnResize/useColumnResize/useResizeIndicator.ts @@ -1,5 +1,14 @@ -/** The line marking a dragged column edge, toggled through custom properties so a drag never re-renders the table. */ -import {RESIZE_INDICATOR_HEIGHT_VARIABLE, RESIZE_INDICATOR_OPACITY_VARIABLE, RESIZE_INDICATOR_TOP_VARIABLE, TABLE_ROW_SELECTOR} from '@components/Table/columnResize/columnWidthExpressions'; +/** The line marking a dragged column edge and the grip marking a hovered heading's edge, toggled through custom properties so neither re-renders the table. */ +import { + RESIZE_GRIP_HOVER_VARIABLE, + RESIZE_INDICATOR_DATA_ATTRIBUTE, + RESIZE_INDICATOR_HEIGHT_VARIABLE, + RESIZE_INDICATOR_OPACITY_VARIABLE, + RESIZE_INDICATOR_TOP_VARIABLE, + TABLE_ROW_SELECTOR, +} from '@components/Table/columnResize/columnWidthExpressions'; + +import CONST from '@src/CONST'; import type {RefObject} from 'react'; @@ -36,6 +45,39 @@ function drawIndicatorAtHandle(scopeElement: HTMLElement | null, handleElement: handleElement.style.setProperty(RESIZE_INDICATOR_TOP_VARIABLE, `${headerRowTop - handleRect.top}px`); handleElement.style.setProperty(RESIZE_INDICATOR_HEIGHT_VARIABLE, `${lowestRowBottom - headerRowTop}px`); handleElement.style.setProperty(RESIZE_INDICATOR_OPACITY_VARIABLE, INDICATOR_OPACITY.VISIBLE); + + // Starts clipped to the grip, so the grip reads as stretching into the line. + const gripInset = (handleRect.height * (1 - CONST.TABLES.COLUMN_RESIZE.GRIP_HEIGHT_RATIO)) / 2; + const gripTop = handleRect.top + gripInset - headerRowTop; + const gripBottom = lowestRowBottom - handleRect.bottom + gripInset; + handleElement.querySelector(`[${RESIZE_INDICATOR_DATA_ATTRIBUTE}]`)?.animate([{clipPath: `inset(${gripTop}px 0 ${gripBottom}px 0)`}, {clipPath: 'inset(0 0 0 0)'}], { + duration: CONST.TABLES.COLUMN_RESIZE.INDICATOR_MORPH_DURATION, + easing: 'ease-out', + }); +} + +function setGripHovered(handleElement: HTMLElement, isHovered: boolean) { + handleElement.style.setProperty(RESIZE_GRIP_HOVER_VARIABLE, isHovered ? INDICATOR_OPACITY.VISIBLE : INDICATOR_OPACITY.HIDDEN); +} + +/** Ref callback for a handle: shows its grip while the pointer is over its heading cell, the handle's parent. */ +function trackHeadingHover(handleElement: HTMLElement | null): (() => void) | undefined { + const headingElement = handleElement?.parentElement; + + if (!handleElement || !headingElement) { + return; + } + + const showGrip = () => setGripHovered(handleElement, true); + const hideGrip = () => setGripHovered(handleElement, false); + + headingElement.addEventListener('pointerenter', showGrip); + headingElement.addEventListener('pointerleave', hideGrip); + + return () => { + headingElement.removeEventListener('pointerenter', showGrip); + headingElement.removeEventListener('pointerleave', hideGrip); + }; } function useResizeIndicator(scopeElementRef: RefObject): ResizeIndicator { @@ -56,3 +98,4 @@ function useResizeIndicator(scopeElementRef: RefObject): Res } export default useResizeIndicator; +export {setGripHovered, trackHeadingHover}; diff --git a/src/components/Table/columnResize/useStoredColumnWidths/index.native.ts b/src/components/Table/columnResize/useStoredColumnWidths/index.native.ts index a8b30cf1ee6d..f9d8ee38cafd 100644 --- a/src/components/Table/columnResize/useStoredColumnWidths/index.native.ts +++ b/src/components/Table/columnResize/useStoredColumnWidths/index.native.ts @@ -1,8 +1,8 @@ -import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; +import type {StoredTableColumnWidths} from '@src/types/onyx/TableColumnWidths'; -/** Column resizing is web-only, so native never subscribes to stored widths. */ +/** Column resizing is web-only, so native never subscribes to a stored layout. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- the parameter exists only to match the web signature -function useStoredColumnWidths(columnResizingID: string): ColumnWidthOverrides | undefined { +function useStoredColumnWidths(columnResizingID: string): StoredTableColumnWidths | undefined { return undefined; } diff --git a/src/components/Table/columnResize/useStoredColumnWidths/index.ts b/src/components/Table/columnResize/useStoredColumnWidths/index.ts index 878a9f95a9b7..43f62d45f732 100644 --- a/src/components/Table/columnResize/useStoredColumnWidths/index.ts +++ b/src/components/Table/columnResize/useStoredColumnWidths/index.ts @@ -2,12 +2,12 @@ import useOnyx from '@hooks/useOnyx'; import ONYXKEYS from '@src/ONYXKEYS'; import {tableColumnWidthsSelector} from '@src/selectors/TableColumnWidths'; -import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; +import type {StoredTableColumnWidths} from '@src/types/onyx/TableColumnWidths'; -/** Widths the user dragged in tables sharing this `columnResizingID`. */ -function useStoredColumnWidths(columnResizingID: string): ColumnWidthOverrides | undefined { - const [columnWidthOverrides] = useOnyx(ONYXKEYS.TABLE_COLUMN_WIDTHS, {selector: tableColumnWidthsSelector(columnResizingID)}); - return columnWidthOverrides; +/** Layout the last drag saved for this `columnResizingID`. */ +function useStoredColumnWidths(columnResizingID: string): StoredTableColumnWidths | undefined { + const [storedColumnWidths] = useOnyx(ONYXKEYS.TABLE_COLUMN_WIDTHS, {selector: tableColumnWidthsSelector(columnResizingID)}); + return storedColumnWidths; } export default useStoredColumnWidths; diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx index ca994883f0c5..c68d07362daa 100644 --- a/src/components/Table/index.tsx +++ b/src/components/Table/index.tsx @@ -36,6 +36,7 @@ import composeTableListHeader from './composeTableListHeader'; import TableComponent from './Table'; import TableBody from './TableBody'; +import TableBulkActionBar from './TableBulkActionBar'; import TableContext from './TableContext'; import TableEmptyState from './TableEmptyStates/TableEmptyState'; import TableNoResultsState from './TableEmptyStates/TableNoResultsState'; @@ -58,6 +59,7 @@ import TableRow from './TableRow'; * - `Table.EmptyState` - Renders when the table has no rows * - `Table.NoResultsState` - Renders when the table has rows, but the user has filtered all of them out * - `Table.LoadingState` - Renders a loading indicator when the table is loading its data + * - `Table.BulkActionBar` - The floating bar of bulk actions for the current selection */ const Table = Object.assign(TableComponent, { /** The React context for accessing table state directly. */ @@ -86,6 +88,9 @@ const Table = Object.assign(TableComponent, { /** Renders a loading indicator when the table is loading its data */ LoadingState: TableLoadingState, + + /** The floating bar of bulk actions, shown in the wide layout while rows are selected */ + BulkActionBar: TableBulkActionBar, }); export default Table; diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index b541c2c20a8e..1be2f4bd7a19 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -9,6 +9,7 @@ import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import type {Dispatch, SetStateAction} from 'react'; +import {useIsFocused} from '@react-navigation/native'; import {useCallback, useEffect, useState} from 'react'; import type {MiddlewareHookResult} from './types'; @@ -87,6 +88,7 @@ export default function useSelection({ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const selectionUsesNarrowLayout = shouldEnableSelectionInNarrowPaneModal ? isSmallScreenWidth : shouldUseNarrowLayout; + const isFocused = useIsFocused(); // When a user long-presses a row on mobile, store the key of the row that will be selected if // the user confirms the selection @@ -125,6 +127,11 @@ export default function useSelection({ // Sync the selection mode with the screen size & selection state useEffect(() => { + // Selection mode is app wide, so a table in a background tab would turn off the mode a focused screen just enabled. + if (!isFocused) { + return; + } + const isMobileMissingSelectionMode = selectionUsesNarrowLayout && !isSelectionModeEnabled && selectedKeys.length; const isDesktopWithoutSelectableKeys = isSelectionModeEnabled && !selectableKeys.length && !selectionUsesNarrowLayout; const isSelectionModeEnabledWithoutSelectableKeys = isSelectionModeEnabled && !selectableKeys.length && !originalSelectableCount; @@ -137,7 +144,7 @@ export default function useSelection({ // setSelectionModeEnabled is omitted from the dependencies below because a caller is free to pass an inline // callback, and re-running this effect on every render would fight the selection mode it just set. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectionUsesNarrowLayout, isSelectionModeEnabled, selectedKeys.length, originalSelectableCount, selectableKeys.length]); + }, [isFocused, selectionUsesNarrowLayout, isSelectionModeEnabled, selectedKeys.length, originalSelectableCount, selectableKeys.length]); // When selection mode is turned off, clear the list of selected keys, so that re-enabling selection mode doesn't retain rows const wasSelectionModeEnabled = usePrevious(isSelectionModeEnabled); diff --git a/src/components/Table/useDynamicColumnWidths.ts b/src/components/Table/useDynamicColumnWidths.ts index 561480acdc5c..d06abf844598 100644 --- a/src/components/Table/useDynamicColumnWidths.ts +++ b/src/components/Table/useDynamicColumnWidths.ts @@ -8,15 +8,17 @@ import {fontScale} from '@styles/typography'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; +import type {StoredTableColumnWidths} from '@src/types/onyx/TableColumnWidths'; import type {StyleProp, ViewStyle} from 'react-native'; import type {DynamicColumnConstraints} from './calculateDynamicColumnWidths'; +import type {ResizableColumn} from './columnResize/resolveDraggedColumnWidths'; import type {TableColumn, TableData} from './types'; import calculateDynamicColumnWidths, {distributeEqualWidths} from './calculateDynamicColumnWidths'; -import getResizableColumnLayout from './columnResize/getResizableColumnLayout'; +import getFrozenColumnWidths from './columnResize/getFrozenColumnWidths'; +import getResizableColumnLayout, {getAvailableColumnsWidth} from './columnResize/getResizableColumnLayout'; const {MIN_FREE_TEXT_COLUMN_WIDTH} = CONST.TABLES.DYNAMIC_COLUMNS; @@ -42,8 +44,8 @@ type UseDynamicColumnWidthsParams Record; + /** Each column's resolved width, which a drag starts from. */ resolvedColumnWidths: Record; /** Narrowest width a drag may take a column to, for columns tighter than the default drag bound. */ dragMinWidths: Record; + + /** Layout to save when dragging `columnKey` ends, from the painted widths. `undefined` when no column is resizable. */ + getStoredTableColumnWidths?: (columnWidths: Record, columnKey: string) => StoredTableColumnWidths; }; /** @@ -151,7 +159,7 @@ function useDynamicColumnWidths): UseDynamicColumnWidthsResult { const styles = useThemeStyles(); const {values} = useLayoutSpacing(); @@ -161,6 +169,7 @@ function useDynamicColumnWidths ({[columnKey]: width}), resolvedColumnWidths: {}, dragMinWidths: {}, }; @@ -207,6 +216,7 @@ function useDynamicColumnWidths> = {}; for (const column of dynamicColumns) { const contentWidth = measureColumnContentWidth(column, data); @@ -227,9 +237,12 @@ function useDynamicColumnWidths ({ + key: column.key, + label: column.label, + declaredWidth: typeof column.width === 'number' ? column.width : undefined, + contentWidth: measuredWidths[column.key]?.contentWidth ?? 0, + minWidth: measuredWidths[column.key]?.minWidth ?? 0, + maxWidth: column.dynamicSizing?.maxWidth ?? Number.POSITIVE_INFINITY, + })); + + const buildResizableResult = (baseColumnWidths: Record, resizedColumnKeys: string[]): UseDynamicColumnWidthsResult => ({ + ...getResizableColumnLayout({columns: resizableColumns, resolvedColumnWidths: baseColumnWidths, resizedColumnKeys, tableWidth, rowChromeWidths}), + getStoredTableColumnWidths: (columnWidths, columnKey) => ({ + availableColumnsWidth, + columnWidths, + resizedColumnKeys: resizedColumnKeys.includes(columnKey) ? resizedColumnKeys : [...resizedColumnKeys, columnKey], + }), + }); + + // After the first drag, only room changes move the columns, so new content never shifts them. + if (isColumnResizingEnabled && storedTableColumnWidths) { + const frozenColumnWidths = getFrozenColumnWidths({columns: resizableColumns, storedTableColumnWidths, availableColumnsWidth}); + + return buildResizableResult(frozenColumnWidths, storedTableColumnWidths.resizedColumnKeys); + } + const {widths, shouldScrollHorizontally} = calculateDynamicColumnWidths(constraints, availableWidth); // Equal columns are what the static `1fr` tracks already do, but a drag needs px widths to start from. if (widths.length === 0 && !isColumnResizingEnabled) { @@ -271,13 +312,7 @@ function useDynamicColumnWidths, - 'onEndReached' | 'onEndReachedThreshold' | 'ListFooterComponent' + 'onEndReached' | 'onEndReachedThreshold' | 'ListFooterComponent' | 'shouldFooterRenderAsLastRow' > & { data: UnreportedExpenseTableRowData[]; selectedKeys: string[]; onRowSelectionChange: (selectedRowKeys: string[]) => void; }; -function AddExistingExpenseTable({data, selectedKeys, onRowSelectionChange, onEndReached, onEndReachedThreshold, ListFooterComponent}: AddExistingExpenseTableProps) { +function AddExistingExpenseTable({ + data, + selectedKeys, + onRowSelectionChange, + onEndReached, + onEndReachedThreshold, + ListFooterComponent, + shouldFooterRenderAsLastRow, +}: AddExistingExpenseTableProps) { const {translate, localeCompare} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); @@ -123,8 +131,7 @@ function AddExistingExpenseTable({data, selectedKeys, onRowSelectionChange, onEn shouldAlwaysEnableSelection // Expenses are confirmed in one batch, so searching or filtering must not drop what is already picked. shouldPreserveSelectionOnSearchAndFilter - // The skeleton appended while the next page loads continues the rows, so it draws the bottom corners. - shouldFooterRenderAsLastRow + shouldFooterRenderAsLastRow={shouldFooterRenderAsLastRow} selectedKeys={selectedKeys} onRowSelectionChange={onRowSelectionChange} initialSortColumn="date" diff --git a/src/components/Tables/AgentsTable/AgentsTableRow.tsx b/src/components/Tables/AgentsTable/AgentsTableRow.tsx index 4cbd4510aee0..eeff6f2cb709 100644 --- a/src/components/Tables/AgentsTable/AgentsTableRow.tsx +++ b/src/components/Tables/AgentsTable/AgentsTableRow.tsx @@ -1,9 +1,12 @@ import AccountAvatar from '@components/Avatar/connected/AccountAvatar'; +import PolicyAvatar from '@components/Avatar/connected/PolicyAvatar'; import Button from '@components/Button'; import Icon from '@components/Icon'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import type {TableRow} from '@components/Table'; import Table from '@components/Table'; import {getCellAccessibilityProps, shouldUseTableSemantics} from '@components/Table/tableAccessibility'; +import WorkspaceMemberRoleCell from '@components/Tables/WorkspaceMembersTable/WorkspaceMemberRoleCell'; import TextWithTooltip from '@components/TextWithTooltip'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -15,7 +18,6 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; -import React from 'react'; import {View} from 'react-native'; import type {AgentRowData} from '.'; @@ -26,22 +28,51 @@ type AgentsTableRowProps = { rowIndex: number; shouldUseNarrowTableLayout: boolean; + + /** Whether to show the role column */ + shouldShowRoleColumn?: boolean; + + /** Whether to show the owner column */ + shouldShowOwnerColumn?: boolean; }; -export default function AgentsTableRow({item, rowIndex, shouldUseNarrowTableLayout}: AgentsTableRowProps) { +function buildAgentSubtitle( + item: TableRow, + translate: LocalizedTranslate, + shouldUseNarrowTableLayout: boolean, + shouldShowRoleColumn?: boolean, + shouldShowOwnerColumn?: boolean, +) { + const role = item.role ? translate('workspace.common.roleName', item.role) : undefined; + const owner = item.ownerDisplayName; + const login = item.login; + const parts: string[] = []; + if (shouldUseNarrowTableLayout && shouldShowRoleColumn && role) { + parts.push(role); + } + if (shouldUseNarrowTableLayout && shouldShowOwnerColumn && owner) { + parts.push(`${translate('common.owner')}: ${owner}`); + } + parts.push(login); + return parts.join(' • '); +} + +export default function AgentsTableRow({item, rowIndex, shouldUseNarrowTableLayout, shouldShowRoleColumn = false, shouldShowOwnerColumn = false}: AgentsTableRowProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'ChatBubble']); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'ChatBubble', 'Globe']); const isTableSemanticsEnabled = shouldUseTableSemantics(shouldUseNarrowTableLayout); const avatarSize = shouldUseNarrowTableLayout ? CONST.AVATAR_SIZE.DEFAULT : CONST.AVATAR_SIZE.SMALL; const isPendingDeletion = item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const isPendingAddOrDelete = item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD || isPendingDeletion; - const areActionsDisabled = isPendingAddOrDelete || item.accountID <= 0 || !item.login; + const areActionsDisabled = !item.canManage || isPendingAddOrDelete || item.accountID <= 0 || !item.login; + const isEditable = item.canManage && !isPendingDeletion; const accessibilityLabel = [item.displayName, item.login].filter(Boolean).join(', '); const selectedButtonInnerStyle = item.selected ? styles.buttonDefaultHovered : undefined; + const agentSubtitle = buildAgentSubtitle(item, translate, shouldUseNarrowTableLayout, shouldShowRoleColumn, shouldShowOwnerColumn); return ( + {!shouldUseNarrowTableLayout && shouldShowRoleColumn && ( + + + + )} + + {!shouldUseNarrowTableLayout && shouldShowOwnerColumn && ( + + {item.ownerType === CONST.AGENTS.OWNER_TYPE.PERSON && !!item.ownerAccountID && ( + + )} + {item.ownerType === CONST.AGENTS.OWNER_TYPE.WORKSPACE && !!item.ownerPolicyID && ( + + )} + {item.ownerType === CONST.AGENTS.OWNER_TYPE.DOMAIN && ( + + )} + {!!item.ownerDisplayName && ( + + )} + + )} + diff --git a/src/components/Tables/AgentsTable/index.tsx b/src/components/Tables/AgentsTable/index.tsx index d23fa6926caf..9e6079c8ab0e 100644 --- a/src/components/Tables/AgentsTable/index.tsx +++ b/src/components/Tables/AgentsTable/index.tsx @@ -1,6 +1,8 @@ +import type {EmptyStateButton} from '@components/EmptyStateComponent/types'; import RenderHTML from '@components/RenderHTML'; import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData, TableHandle} from '@components/Table'; import Table, {composeTableListHeader} from '@components/Table'; +import compareOptionalValues from '@components/Table/compareOptionalValues'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -12,22 +14,40 @@ import tokenizedSearch from '@libs/tokenizedSearch'; import variables from '@styles/variables'; +import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {AgentOwnerType} from '@src/types/onyx/Agent'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {ListRenderItemInfo} from '@shopify/flash-list'; +import type {ValueOf} from 'type-fest'; import React from 'react'; import {View} from 'react-native'; import AgentsTableRow from './AgentsTableRow'; -type AgentsTableColumnKey = 'agent' | 'actions'; +type AgentsTableColumnKey = 'agent' | 'role' | 'owner' | 'actions'; type AgentRowData = TableData & { accountID: number; displayName: string; login: string; + role?: ValueOf; + + /** The accountID of the agent owner */ + ownerAccountID?: number; + + /** The display name of the agent owner */ + ownerDisplayName?: string; + + /** The type of the agent owner */ + ownerType?: AgentOwnerType; + + /** The policy ID of the agent owner, only set if ownerType=workspace */ + ownerPolicyID?: string; + + canManage: boolean; errors?: OnyxCommon.Errors; pendingAction?: OnyxCommon.PendingAction; action: () => void; @@ -40,6 +60,9 @@ type AgentsTableProps = { ref?: React.Ref> | undefined; agents: AgentRowData[]; + /** Buttons to display on empty view */ + emptyStateButtons?: EmptyStateButton[]; + /** Content rendered above the table header inside the scrollable list */ headerComponent?: React.ReactElement; @@ -49,10 +72,26 @@ type AgentsTableProps = { /** Keys of the currently selected rows */ selectedKeys: string[]; + /** Whether to show the role column */ + shouldShowRoleColumn?: boolean; + + /** Whether to show the owner column */ + shouldShowOwnerColumn?: boolean; + onRowSelectionChange: (selectedRowKeys: string[]) => void; }; -export default function AgentsTable({ref, agents, headerComponent, canSelectAgents, selectedKeys, onRowSelectionChange}: AgentsTableProps) { +export default function AgentsTable({ + ref, + agents, + emptyStateButtons, + headerComponent, + canSelectAgents, + selectedKeys, + shouldShowRoleColumn, + shouldShowOwnerColumn, + onRowSelectionChange, +}: AgentsTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); @@ -62,12 +101,34 @@ export default function AgentsTable({ref, agents, headerComponent, canSelectAgen const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; - const agentsTableColumns: Array> = [ + const agentsTableColumns: Array> = [ { key: 'agent', label: translate('agentsPage.title'), sortable: true, }, + ...(shouldShowRoleColumn + ? [ + { + key: 'role' as const, + label: translate('common.role'), + sortable: true, + styling: { + // editableCellHeader matches the padded role cell so the label and value share an edge. + containerStyles: [styles.editableCellHeader], + }, + }, + ] + : []), + ...(shouldShowOwnerColumn + ? [ + { + key: 'owner' as const, + label: translate('common.owner'), + sortable: true, + }, + ] + : []), { key: 'actions', label: '', @@ -78,7 +139,19 @@ export default function AgentsTable({ref, agents, headerComponent, canSelectAgen const compareTableItems: CompareItemsCallback = (item1, item2, activeSorting) => { const orderMultiplier = activeSorting.order === 'asc' ? 1 : -1; - return localeCompare(item1.displayName, item2.displayName) * orderMultiplier; + const displayNameComparison = localeCompare(item1.displayName, item2.displayName) * orderMultiplier; + + if (activeSorting.columnKey === 'role') { + const compareRoleNames = (role1: string, role2: string) => localeCompare(translate('workspace.common.roleName', role1), translate('workspace.common.roleName', role2)); + return compareOptionalValues(item1.role, item2.role, compareRoleNames, orderMultiplier, displayNameComparison); + } + + if (activeSorting.columnKey === 'owner') { + const compareOwnerDisplayNames = (ownerDisplayName1: string, ownerDisplayName2: string) => localeCompare(ownerDisplayName1, ownerDisplayName2); + return compareOptionalValues(item1.ownerDisplayName, item2.ownerDisplayName, compareOwnerDisplayNames, orderMultiplier, displayNameComparison); + } + + return displayNameComparison; }; const isTableItemInSearch: IsItemInSearchCallback = (item, searchValue) => { @@ -91,6 +164,8 @@ export default function AgentsTable({ref, agents, headerComponent, canSelectAgen item={item} rowIndex={index} shouldUseNarrowTableLayout={shouldUseNarrowTableLayout} + shouldShowRoleColumn={shouldShowRoleColumn} + shouldShowOwnerColumn={shouldShowOwnerColumn} /> ); @@ -133,6 +208,7 @@ export default function AgentsTable({ref, agents, headerComponent, canSelectAgen } + buttons={emptyStateButtons} /> diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx index 0dde14a96366..ad26cfad9c22 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx @@ -15,6 +15,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {unassignWorkspaceCompanyCard} from '@libs/actions/CompanyCards'; import navigateToCardTransactions from '@libs/CardNavigationUtils'; import {formatMaskedCardName} from '@libs/CardUtils'; +import escapeCsvField from '@libs/escapeCsvField'; import localFileDownload from '@libs/localFileDownload'; import CONST from '@src/CONST'; @@ -42,16 +43,6 @@ type WorkspaceCompanyCardsTableControlsProps = { isSelectionModeEnabled: boolean; }; -const CSV_FORMULA_PREFIX_REGEXP = /^(?:[\t\r\n]|\s*[=+\-@])/; - -function escapeCsvField(value: string): string { - const safeValue = CSV_FORMULA_PREFIX_REGEXP.test(value) ? `'${value}` : value; - if (safeValue.includes('"') || safeValue.includes(',') || safeValue.includes('\n') || safeValue.includes('\r')) { - return `"${safeValue.replaceAll('"', '""')}"`; - } - return safeValue; -} - function WorkspaceCompanyCardsTableControls({ policyID, domainOrWorkspaceAccountID, diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx index dd815f62fcfe..9dbfec57da4d 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx @@ -23,6 +23,7 @@ import {resetFailedWorkspaceCompanyCardUnassignment} from '@libs/actions/Company import {renameCompanyCardInline} from '@libs/actions/Policy/InlineEdit'; import {formatMaskedCardName, getCompanyCardCustomName, getDefaultCardName} from '@libs/CardUtils'; import {getConnectedIntegration} from '@libs/PolicyUtils'; +import showInlineEditError from '@libs/showInlineEditError'; import tokenizedSearch from '@libs/tokenizedSearch'; import {getCardExportAccountTitle, getExportAccountColumn, getPolicyCardExportSettings} from '@pages/workspace/companyCards/utils'; @@ -306,7 +307,7 @@ function WorkspaceCompanyCardsTable({ if (!bankName || cardID === undefined) { return; } - renameCompanyCardInline(domainOrWorkspaceAccountID, String(cardID), newName, bankName, customCardName, storedCardName); + showInlineEditError(renameCompanyCardInline(translate, domainOrWorkspaceAccountID, String(cardID), newName, bankName, customCardName, storedCardName)); }, onDismissError: () => resetFailedWorkspaceCompanyCardUnassignment(domainOrWorkspaceAccountID, bankName, assignedCard?.cardID), }; diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx index 47d9b941749a..b0872fbac169 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx @@ -95,11 +95,12 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay const status = getRateStatus(rate); const statusColors = getRateStatusColors(status, theme, isSelected); const dateLabelText = DistanceRequestUtils.getRateDateLabel({...rate, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, translate, dateFnsLocale); + const rateDisplayName = DistanceRequestUtils.getRateDisplayName(rate) ?? ''; const isAutoGeneratedRate = isGovernmentRateUnmodified(rate, unit); // Keep the icon aligned with the text height: the narrow layout uses the smaller supporting-label font, the wide layout uses the larger display font. const boltIconSize = shouldUseNarrowTableLayout ? variables.iconSizeExtraSmall : variables.iconSizeSmall; - const accessibilityLabel = [rate.name, statusLabels[status], formattedRate, dateLabelText, isAutoGeneratedRate ? translate('workspace.distanceRates.autoGeneratedRateTooltip') : ''] + const accessibilityLabel = [rateDisplayName, statusLabels[status], formattedRate, dateLabelText, isAutoGeneratedRate ? translate('workspace.distanceRates.autoGeneratedRateTooltip') : ''] .filter(Boolean) .join(', '); @@ -137,7 +138,7 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay @@ -195,7 +196,7 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay {...getCellAccessibilityProps(isTableSemanticsEnabled)} > diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx index 75a30fb9d701..5466a52bb3eb 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx @@ -6,6 +6,7 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getRateStatus} from '@libs/PolicyDistanceRatesUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; @@ -108,11 +109,11 @@ function WorkspaceDistanceRatesTable({ if (diff !== 0) { return diff * orderMultiplier; } - return localeCompare(a.rate.name ?? '', b.rate.name ?? '') * orderMultiplier; + return DistanceRequestUtils.compareRatesByDisplayName(a.rate, b.rate, localeCompare, orderMultiplier); } if (activeSorting.columnKey === 'name') { - return localeCompare(a.rate.name ?? '', b.rate.name ?? '') * orderMultiplier; + return DistanceRequestUtils.compareRatesByDisplayName(a.rate, b.rate, localeCompare, orderMultiplier); } if (activeSorting.columnKey === 'rate') { diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardLimitTypeCell.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardLimitTypeCell.tsx index f0a4ac0e6729..6a67145f599a 100644 --- a/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardLimitTypeCell.tsx +++ b/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardLimitTypeCell.tsx @@ -25,13 +25,13 @@ function WorkspaceExpensifyCardLimitTypeCell({limitType, card, policy, canEdit, const {translate} = useLocalize(); const currentLimitType = getDisplayedExpensifyCardLimitType(limitType, getDefaultExpensifyCardLimitType(policy)); const limitTypeLabel = translate(getTranslationKeyForLimitType(currentLimitType)); - const {popoverHeight} = useWorkspaceExpensifyCardLimitTypePickerPopover({card, policy}); + const {popoverHeight: preferredPopoverHeight} = useWorkspaceExpensifyCardLimitTypePickerPopover({card, policy}); - const {isEditing, anchorRef, isPopoverVisible, popoverPosition, isInverted, startEditing, cancelEditing, handleSave} = usePopoverEditState({ + const {isEditing, anchorRef, isPopoverVisible, popoverPosition, popoverHeight, anchorAlignment, shouldOpenAbove, startEditing, cancelEditing, handleSave} = usePopoverEditState({ canEdit, value: currentLimitType, onSave, - popoverHeight, + popoverHeight: preferredPopoverHeight, }); return ( @@ -48,7 +48,10 @@ function WorkspaceExpensifyCardLimitTypeCell({limitType, card, policy, canEdit, isVisible={isPopoverVisible} onClose={cancelEditing} anchorPosition={popoverPosition} - shouldMeasureAnchorPositionFromTop={!isInverted} + anchorAlignment={anchorAlignment} + popoverHeight={popoverHeight} + shouldMeasureAnchorPositionFromTop={!shouldOpenAbove} + shouldSwitchPositionIfOverflow={false} onSelected={(selectedLimitType) => { handleSave(selectedLimitType); }} diff --git a/src/components/Tables/WorkspaceListTable/index.tsx b/src/components/Tables/WorkspaceListTable/index.tsx index 4f1ae02ce830..c2daaa19bbcb 100644 --- a/src/components/Tables/WorkspaceListTable/index.tsx +++ b/src/components/Tables/WorkspaceListTable/index.tsx @@ -1,3 +1,4 @@ +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import type {CompareItemsCallback, FilterConfig, IsItemInFilterCallback, IsItemInSearchCallback, TableColumn, TableData, TableHandle} from '@components/Table'; import Table, {composeTableListHeader} from '@components/Table'; @@ -69,6 +70,7 @@ type WorkspaceListTableProps = { }; export default function WorkspaceListTable({ref, workspaces, headerComponent, onDeleteWorkspace, onArchiveWorkspace, pendingDeletePolicyID}: WorkspaceListTableProps) { + const {contentInsetAdjustmentBehavior} = useTabRootScrollProps(); const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const {isBetaEnabled} = usePermissions(); @@ -211,6 +213,7 @@ export default function WorkspaceListTable({ref, workspaces, headerComponent, on keyExtractor={(row, index) => `${row.policyID}-${index}`} filters={filterConfig} isItemInFilter={isItemInFilter} + contentInsetAdjustmentBehavior={contentInsetAdjustmentBehavior} > {tableHeaderComponent} diff --git a/src/components/Tables/WorkspaceMembersTable/WorkspaceMemberRoleCell.tsx b/src/components/Tables/WorkspaceMembersTable/WorkspaceMemberRoleCell.tsx index 08ccdd0ef3b7..6f6c1da1f34d 100644 --- a/src/components/Tables/WorkspaceMembersTable/WorkspaceMemberRoleCell.tsx +++ b/src/components/Tables/WorkspaceMembersTable/WorkspaceMemberRoleCell.tsx @@ -5,6 +5,7 @@ import WorkspaceMemberRolePickerModal, {useWorkspaceMemberRolePickerPopover} fro import useLocalize from '@hooks/useLocalize'; import {getAllowedRolesForMember} from '@libs/PolicyMemberRoleUtils'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import type CONST from '@src/CONST'; import type {Policy} from '@src/types/onyx'; @@ -16,7 +17,7 @@ import React from 'react'; type WorkspaceMemberRoleCellProps = { role: ValueOf | undefined; - policy: OnyxEntry; + policy?: OnyxEntry; memberLogin: string; canEdit?: boolean; onSave?: (role: ValueOf) => void; @@ -24,15 +25,15 @@ type WorkspaceMemberRoleCellProps = { function WorkspaceMemberRoleCell({role, policy, memberLogin, canEdit, onSave}: WorkspaceMemberRoleCellProps) { const {translate} = useLocalize(); - const roleLabel = translate('workspace.common.roleName', role); + const roleLabel = role ? translate('workspace.common.roleName', role) : undefined; const allowedRoles = getAllowedRolesForMember(policy, memberLogin); - const {popoverHeight} = useWorkspaceMemberRolePickerPopover({policy, selectedRole: role, allowedRoles}); + const {popoverHeight: preferredPopoverHeight} = useWorkspaceMemberRolePickerPopover({policy, selectedRole: role, allowedRoles}); - const {isEditing, anchorRef, isPopoverVisible, popoverPosition, isInverted, startEditing, cancelEditing, handleSave} = usePopoverEditState({ + const {isEditing, anchorRef, isPopoverVisible, popoverPosition, popoverHeight, anchorAlignment, shouldOpenAbove, startEditing, cancelEditing, handleSave} = usePopoverEditState({ canEdit, value: role, onSave, - popoverHeight, + popoverHeight: preferredPopoverHeight, }); return ( @@ -49,12 +50,21 @@ function WorkspaceMemberRoleCell({role, policy, memberLogin, canEdit, onSave}: W isVisible={isPopoverVisible} onClose={cancelEditing} anchorPosition={popoverPosition} - shouldMeasureAnchorPositionFromTop={!isInverted} + anchorAlignment={anchorAlignment} + popoverHeight={popoverHeight} + shouldMeasureAnchorPositionFromTop={!shouldOpenAbove} + shouldSwitchPositionIfOverflow={false} onSelected={handleSave} /> } > - {roleLabel} + + {roleLabel} + ); } diff --git a/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx b/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx index 540bc847c3ec..d0caf7e373bf 100644 --- a/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx +++ b/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx @@ -34,6 +34,11 @@ type WorkspaceMembersTableRowProps = { /** Whether the custom field 2 column is visible on web screens or not */ shouldShowCustomField2Column: boolean; + /** Whether the office location column is visible on web screens or not */ + shouldShowOfficeLocationColumn: boolean; + /** Whether the bank account column is visible on web screens or not */ + shouldShowBankAccountColumn: boolean; + /** Policy used to determine which roles can be assigned from the inline editor */ policy: OnyxEntry; }; @@ -43,6 +48,8 @@ export default function WorkspaceMembersTableRow({ rowIndex, shouldShowCustomField1Column, shouldShowCustomField2Column, + shouldShowOfficeLocationColumn, + shouldShowBankAccountColumn, shouldUseNarrowTableLayout, policy, }: WorkspaceMembersTableRowProps) { @@ -55,12 +62,15 @@ export default function WorkspaceMembersTableRow({ const avatarSize = shouldUseNarrowTableLayout ? CONST.AVATAR_SIZE.DEFAULT : CONST.AVATAR_SIZE.SMALL; const roleLabel = translate('workspace.common.roleName', item.role); - const accessibilityLabel = `${item.name}, ${item.email}, ${roleLabel}`; + const bankAccountLabel = + !shouldUseNarrowTableLayout && shouldShowBankAccountColumn && item.bankAccountLastFour ? `${translate('paymentMethodList.accountLastFour')} ${item.bankAccountLastFour}` : ''; + const accessibilityLabel = [item.name, item.email, bankAccountLabel, roleLabel].filter(Boolean).join(', '); const memberSubtitle = !shouldUseNarrowTableLayout ? item.email : `${roleLabel} • ${item.email}`; return ( - + + + + {!shouldUseNarrowTableLayout && shouldShowOfficeLocationColumn && ( + + {!!item.officeLocationName && ( + + )} + + )} + {!shouldUseNarrowTableLayout && shouldShowCustomField1Column && ( )} @@ -127,6 +161,23 @@ export default function WorkspaceMembersTableRow({ numberOfLines={1} text={item.employeePayrollID} style={[styles.lh16, styles.optionDisplayName, styles.pre]} + isSelectable + /> + )} + + )} + + {!shouldUseNarrowTableLayout && shouldShowBankAccountColumn && ( + + {!!item.bankAccountLastFour && ( + )} diff --git a/src/components/Tables/WorkspaceMembersTable/index.tsx b/src/components/Tables/WorkspaceMembersTable/index.tsx index 91ecfb28a192..3aa123b0685a 100644 --- a/src/components/Tables/WorkspaceMembersTable/index.tsx +++ b/src/components/Tables/WorkspaceMembersTable/index.tsx @@ -1,7 +1,9 @@ +import type {DropdownOption, WorkspaceMemberBulkActionType} from '@components/ButtonWithDropdownMenu/types'; import type {CompareItemsCallback, FilterConfig, IsItemInFilterCallback, IsItemInSearchCallback, TableColumn, TableData, TableHandle} from '@components/Table'; import Table, {composeTableListHeader} from '@components/Table'; import compareOptionalValues from '@components/Table/compareOptionalValues'; +import useCopySelectionEvent from '@hooks/useCopySelectionEvent'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -24,7 +26,7 @@ import React from 'react'; import WorkspaceMembersTableRow from './WorkspaceMembersTableRow'; -type WorkspaceMembersTableColumnKey = 'member' | 'role' | 'actions' | 'customField1' | 'customField2'; +type WorkspaceMembersTableColumnKey = 'member' | 'role' | 'actions' | 'officeLocation' | 'customField1' | 'customField2' | 'bankAccount'; type WorkspaceMemberRowData = TableData & { accountID: number; @@ -32,6 +34,8 @@ type WorkspaceMemberRowData = TableData & { role?: ValueOf; employeeUserID?: string; employeePayrollID?: string; + officeLocationName?: string; + bankAccountLastFour?: string; name: string; email: string; shouldShowEmployeeUserID: boolean; @@ -54,8 +58,13 @@ type WorkspaceMembersTableProps = { selectedKeys: string[]; shouldShowCustomField1Column: boolean; shouldShowCustomField2Column: boolean; + shouldShowOfficeLocationColumn: boolean; + shouldShowBankAccountColumn: boolean; onRowSelectionChange: (selectedRowKeys: string[]) => void; headerComponent?: React.ReactElement; + + /** The actions the selected members support, offered in the floating bar the wide layout shows while rows are selected. */ + bulkActionOptions?: Array>; }; const WORKSPACE_MEMBER_FILTER_VALUES = { @@ -77,13 +86,17 @@ export default function WorkspaceMembersTable({ selectedKeys, shouldShowCustomField1Column, shouldShowCustomField2Column, + shouldShowOfficeLocationColumn, + shouldShowBankAccountColumn, members, onRowSelectionChange, headerComponent, + bulkActionOptions, }: WorkspaceMembersTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); + useCopySelectionEvent(); const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; const workspaceMembersColumns: Array> = [ @@ -102,6 +115,18 @@ export default function WorkspaceMembersTable({ }, }, + ...(shouldShowOfficeLocationColumn + ? [ + { + sortable: true, + key: 'officeLocation' as const, + label: translate('workspace.people.officeLocation'), + dynamicSizing: { + getContentToMeasure: (item: WorkspaceMemberRowData) => (item.officeLocationName ? [{text: item.officeLocationName, fontSize: fontScale.text}] : []), + }, + }, + ] + : []), ...(shouldShowCustomField1Column ? [ { @@ -126,6 +151,19 @@ export default function WorkspaceMembersTable({ }, ] : []), + ...(shouldShowBankAccountColumn + ? [ + { + sortable: true, + key: 'bankAccount' as const, + label: translate('common.bankAccount'), + dynamicSizing: { + getContentToMeasure: (item: WorkspaceMemberRowData) => + item.bankAccountLastFour ? [{text: `${translate('paymentMethodList.accountLastFour')} ${item.bankAccountLastFour}`, fontSize: fontScale.text}] : [], + }, + }, + ] + : []), { key: 'role', label: translate('common.role'), @@ -163,6 +201,10 @@ export default function WorkspaceMembersTable({ return compareOptionalValues(item1.role, item2.role, compareRoleNames, orderMultiplier, memberNameComparison); } + if (activeSorting.columnKey === 'officeLocation') { + return compareOptionalValues(item1.officeLocationName, item2.officeLocationName, localeCompare, orderMultiplier, memberNameComparison); + } + if (activeSorting.columnKey === 'customField1') { return compareOptionalValues(item1.employeeUserID, item2.employeeUserID, localeCompare, orderMultiplier, memberNameComparison); } @@ -171,6 +213,10 @@ export default function WorkspaceMembersTable({ return compareOptionalValues(item1.employeePayrollID, item2.employeePayrollID, localeCompare, orderMultiplier, memberNameComparison); } + if (activeSorting.columnKey === 'bankAccount') { + return compareOptionalValues(item1.bankAccountLastFour, item2.bankAccountLastFour, localeCompare, orderMultiplier, memberNameComparison); + } + return 1; }; @@ -297,6 +343,8 @@ export default function WorkspaceMembersTable({ shouldUseNarrowTableLayout={shouldUseNarrowTableLayout} shouldShowCustomField1Column={shouldShowCustomField1Column} shouldShowCustomField2Column={shouldShowCustomField2Column} + shouldShowOfficeLocationColumn={shouldShowOfficeLocationColumn} + shouldShowBankAccountColumn={shouldShowBankAccountColumn} policy={policy} /> ); @@ -326,6 +374,7 @@ export default function WorkspaceMembersTable({ + {!!bulkActionOptions && }
); } diff --git a/src/components/Tables/WorkspaceVendorsTable/index.tsx b/src/components/Tables/WorkspaceVendorsTable/index.tsx index 7f3cd7444099..7b3e7e981e2e 100644 --- a/src/components/Tables/WorkspaceVendorsTable/index.tsx +++ b/src/components/Tables/WorkspaceVendorsTable/index.tsx @@ -1,3 +1,4 @@ +import Switch from '@components/Switch'; import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData} from '@components/Table'; import Table, {composeTableListHeader} from '@components/Table'; import TextWithTooltip from '@components/TextWithTooltip'; @@ -7,28 +8,46 @@ import useThemeStyles from '@hooks/useThemeStyles'; import tokenizedSearch from '@libs/tokenizedSearch'; +import variables from '@styles/variables'; + import CONST from '@src/CONST'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {ListRenderItemInfo} from '@shopify/flash-list'; import React from 'react'; import {View} from 'react-native'; -type WorkspaceVendorTableColumnKey = 'name'; +type WorkspaceVendorTableColumnKey = 'name' | 'enabled'; type WorkspaceVendorTableRowData = TableData & { name: string; + enabled: boolean; + isLocked: boolean; + errors?: OnyxCommon.Errors; + pendingAction?: OnyxCommon.PendingAction; + onToggleEnabled: (enabled: boolean) => void; + dismissError: () => void; }; type WorkspaceVendorsTableProps = { /** Vendor rows to render */ vendors: WorkspaceVendorTableRowData[]; + /** Whether row selection is enabled */ + selectionEnabled?: boolean; + + /** Currently selected vendor keys */ + selectedKeys?: string[]; + + /** Callback fired when row selection changes */ + onRowSelectionChange?: (selectedKeys: string[]) => void; + /** Page-level content rendered above the table header inside the scrollable list */ headerComponent?: React.ReactElement; }; -function WorkspaceVendorsTable({vendors, headerComponent}: WorkspaceVendorsTableProps) { +function WorkspaceVendorsTable({vendors, selectionEnabled = false, selectedKeys = [], onRowSelectionChange, headerComponent}: WorkspaceVendorsTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); @@ -38,10 +57,24 @@ function WorkspaceVendorsTable({vendors, headerComponent}: WorkspaceVendorsTable label: translate('common.name'), sortable: true, }, + { + key: 'enabled', + label: translate('common.enabled'), + sortable: true, + width: variables.tableSwitchColumnWidth, + styling: { + containerStyles: [styles.justifyContentEnd], + }, + }, ]; const compareItems: CompareItemsCallback = (item1, item2, activeSorting) => { const orderMultiplier = activeSorting.order === 'asc' ? 1 : -1; + if (activeSorting.columnKey === 'enabled') { + const enabled1 = item1.enabled ? 1 : 0; + const enabled2 = item2.enabled ? 1 : 0; + return (enabled1 - enabled2) * orderMultiplier; + } return localeCompare(item1.name, item2.name) * orderMultiplier; }; @@ -50,22 +83,41 @@ function WorkspaceVendorsTable({vendors, headerComponent}: WorkspaceVendorsTable return results.length > 0; }; - const renderVendorItem = ({item, index}: ListRenderItemInfo) => ( - - - - - - ); + const renderVendorItem = ({item, index}: ListRenderItemInfo) => { + const accessibilityLabel = [item.name, item.enabled ? translate('common.enabled') : translate('common.disabled')].filter(Boolean).join(', '); + + return ( + + + + + + + + + ); + }; const tableHeaderComponent = composeTableListHeader(headerComponent, ); @@ -79,6 +131,9 @@ function WorkspaceVendorsTable({vendors, headerComponent}: WorkspaceVendorsTable isItemInSearch={isItemInSearch} renderItem={renderVendorItem} keyExtractor={(item) => item.keyForList} + selectionEnabled={selectionEnabled} + selectedKeys={selectedKeys} + onRowSelectionChange={onRowSelectionChange} > {tableHeaderComponent} void; + + /** Height of the picker. Defaults to the full dropdown height. */ + popoverHeight?: number; } & Omit; function TagPickerModal({ @@ -63,6 +61,8 @@ function TagPickerModal({ onSelected, anchorAlignment = DEFAULT_ANCHOR_ALIGNMENT, shouldMeasureAnchorPositionFromTop = false, + shouldSwitchPositionIfOverflow = true, + popoverHeight = CONST.POPOVER_DROPDOWN_MAX_HEIGHT, }: TagPickerModalProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -71,6 +71,10 @@ function TagPickerModal({ const {isKeyboardActive} = useKeyboardState(); const anchorRef = useRef>(null); + const popoverDimensions = { + width: CONST.POPOVER_DROPDOWN_WIDTH, + height: popoverHeight, + }; const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); const tagListName = getTagList(policyTags, 0).name; @@ -95,7 +99,7 @@ function TagPickerModal({ anchorAlignment={anchorAlignment} innerContainerStyle={StyleUtils.getWidthStyle(popoverDimensions.width)} restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE} - shouldSwitchPositionIfOverflow + shouldSwitchPositionIfOverflow={shouldSwitchPositionIfOverflow} shouldEnableNewFocusManagement shouldMeasureAnchorPositionFromTop={shouldMeasureAnchorPositionFromTop} shouldSkipRemeasurement @@ -114,6 +118,7 @@ function TagPickerModal({ onSubmit={handleTagSelected} addBottomSafeAreaPadding={isSmallScreenWidth && !isKeyboardActive} shouldAutoFocusSearchInput + searchInputThreshold={CONST.INLINE_EDIT_PICKER_SEARCH_INPUT_THRESHOLD} /> diff --git a/src/components/TagPicker/index.tsx b/src/components/TagPicker/index.tsx index dbac0ff4a7e2..2a86a56a8c53 100644 --- a/src/components/TagPicker/index.tsx +++ b/src/components/TagPicker/index.tsx @@ -64,6 +64,9 @@ type TagPickerProps = { /** Whether the search input should auto-focus when the picker mounts. Only opted into by the inline-edit popover wrapper. */ shouldAutoFocusSearchInput?: boolean; + + /** Number of available tags at which the search input appears. Defaults to `CONST.STANDARD_LIST_ITEM_LIMIT`. */ + searchInputThreshold?: number; }; const getSelectedOptions = (selectedTag: string): SelectedTagOption[] => { @@ -93,6 +96,7 @@ function TagPicker({ addBottomSafeAreaPadding = false, shouldShowGLCode: shouldShowGLCodeProp, shouldAutoFocusSearchInput = false, + searchInputThreshold = CONST.STANDARD_LIST_ITEM_LIMIT, }: TagPickerProps) { const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); const [shouldShowGLCodeFromPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { @@ -159,6 +163,7 @@ function TagPicker({ localeCompare, translate, shouldShowGLCode, + searchInputThreshold, }); const sections = tagSections.map((section) => ({ ...section, @@ -189,7 +194,7 @@ function TagPicker({ ListItem={SingleSelectListItem} style={{sectionTitleStyles: styles.mt5}} textInputOptions={textInputOptions} - shouldShowTextInput={availableTagsCount >= CONST.STANDARD_LIST_ITEM_LIMIT} + shouldShowTextInput={availableTagsCount >= searchInputThreshold} shouldShowLoadingPlaceholder={isLoadingNewOptions} isLoadingNewOptions={isLoadingNewOptions} initiallyFocusedItemKey={selectedOptionKey} diff --git a/src/components/TaskHeaderActionButton.tsx b/src/components/TaskHeaderActionButton.tsx index 0109abb97e51..c9588952f614 100644 --- a/src/components/TaskHeaderActionButton.tsx +++ b/src/components/TaskHeaderActionButton.tsx @@ -15,7 +15,6 @@ import {canActionTask, completeTask, reopenTask} from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type * as OnyxTypes from '@src/types/onyx'; import {delegateEmailSelector} from '@selectors/Account'; import React from 'react'; @@ -24,21 +23,22 @@ import {View} from 'react-native'; import Button from './Button'; type TaskHeaderActionButtonProps = { - report: OnyxTypes.Report; + reportID: string; }; -function TaskHeaderActionButton({report}: TaskHeaderActionButtonProps) { +function TaskHeaderActionButton({reportID}: TaskHeaderActionButtonProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const parentReport = useParentReport(report.reportID); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const parentReport = useParentReport(reportID); const isParentReportArchived = useReportIsArchived(parentReport?.reportID); const hasOutstandingChildTask = useHasOutstandingChildTask(report); const parentReportAction = useParentReportAction(report); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const isTaskActionable = canActionTask(report, parentReportAction, currentUserPersonalDetails?.accountID, parentReport, isParentReportArchived); - if (!canWriteInReport(report)) { + if (!report || !canWriteInReport(report)) { return null; } @@ -49,7 +49,7 @@ function TaskHeaderActionButton({report}: TaskHeaderActionButtonProps) { isDisabled={!isTaskActionable} onPress={callFunctionIfActionIsAllowed(() => { // If we're already navigating to these task editing pages, early return not to mark as completed, otherwise we would have not found page. - if (isActiveTaskEditRoute(report.reportID)) { + if (isActiveTaskEditRoute(reportID)) { return; } if (isCompletedTaskReport(report)) { diff --git a/src/components/TextInput/BaseTextInput/implementation/index.native.tsx b/src/components/TextInput/BaseTextInput/implementation/index.native.tsx index 7a94946cb56c..43a074eac030 100644 --- a/src/components/TextInput/BaseTextInput/implementation/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/implementation/index.native.tsx @@ -112,7 +112,6 @@ function BaseTextInput({ const {hasError = false} = inputProps; // Disabling this line for safeness as nullish coalescing works only if the value is undefined or null - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const initialValue = value || defaultValue || ''; const initialActiveLabel = !!forceActiveLabel || initialValue.length > 0 || !!prefixCharacter || !!suffixCharacter; const isMultiline = multiline || autoGrowHeight; diff --git a/src/components/TextInput/BaseTextInput/implementation/index.tsx b/src/components/TextInput/BaseTextInput/implementation/index.tsx index ea06fd735f38..53eb2d453c4c 100644 --- a/src/components/TextInput/BaseTextInput/implementation/index.tsx +++ b/src/components/TextInput/BaseTextInput/implementation/index.tsx @@ -112,7 +112,6 @@ function BaseTextInput({ const expensifyIcons = useMemoizedLazyExpensifyIcons(['Eye', 'EyeDisabled']); // Disabling this line for safeness as nullish coalescing works only if value is undefined or null - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const initialValue = value || defaultValue || ''; const initialActiveLabel = !!forceActiveLabel || initialValue.length > 0 || !!prefixCharacter || !!suffixCharacter; diff --git a/src/components/TextWithTooltip/index.tsx b/src/components/TextWithTooltip/index.tsx index d591bdb3c16a..dee02f1f7d41 100644 --- a/src/components/TextWithTooltip/index.tsx +++ b/src/components/TextWithTooltip/index.tsx @@ -1,6 +1,8 @@ import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; + import React, {useState} from 'react'; import type TextWithTooltipProps from './types'; @@ -9,7 +11,7 @@ type LayoutChangeEvent = { target: HTMLElement; }; -function TextWithTooltip({testID, text, shouldShowTooltip, style, numberOfLines = 1, forwardedFSClass}: TextWithTooltipProps) { +function TextWithTooltip({testID, text, shouldShowTooltip, style, numberOfLines = 1, forwardedFSClass, isSelectable = false}: TextWithTooltipProps) { const [showTooltip, setShowTooltip] = useState(false); return ( @@ -21,6 +23,7 @@ function TextWithTooltip({testID, text, shouldShowTooltip, style, numberOfLines testID={testID} style={style} numberOfLines={numberOfLines} + selectable={isSelectable} onLayout={(e) => { const target = (e.nativeEvent as unknown as LayoutChangeEvent).target; if (!shouldShowTooltip) { @@ -33,6 +36,7 @@ function TextWithTooltip({testID, text, shouldShowTooltip, style, numberOfLines setShowTooltip(false); }} fsClass={forwardedFSClass} + dataSet={isSelectable ? COPYABLE_TEXT_DATA_SET : undefined} > {text} diff --git a/src/components/TextWithTooltip/types.ts b/src/components/TextWithTooltip/types.ts index 1bda032fb8da..365b9bf6cbd5 100644 --- a/src/components/TextWithTooltip/types.ts +++ b/src/components/TextWithTooltip/types.ts @@ -11,6 +11,9 @@ type TextWithTooltipProps = ForwardedFSClassProps & { numberOfLines?: number; testID?: string; + + /** Whether this value should be directly selectable inside pressable rows */ + isSelectable?: boolean; }; export default TextWithTooltipProps; diff --git a/src/components/ThemeStylesContextProvider/default.ts b/src/components/ThemeStylesContextProvider/default.ts index accfcbc8dcee..f31a1a99caf3 100644 --- a/src/components/ThemeStylesContextProvider/default.ts +++ b/src/components/ThemeStylesContextProvider/default.ts @@ -11,25 +11,26 @@ import type {ThemeStylesActionsContextType, ThemeStylesStateContextType} from '. let cachedState: ThemeStylesStateContextType | undefined; let cachedActions: ThemeStylesActionsContextType | undefined; -const defaultThemeStylesStateContextValue = new Proxy({} as ThemeStylesStateContextType, { - get(_, prop: keyof ThemeStylesStateContextType) { - if (!cachedState) { - cachedState = {styles: styles(defaultTheme)}; - } - return cachedState[prop]; +function getDefaultState(): ThemeStylesStateContextType { + if (!cachedState) { + cachedState = {styles: styles(defaultTheme)}; + } + return cachedState; +} + +const defaultThemeStylesStateContextValue: ThemeStylesStateContextType = { + get styles() { + return getDefaultState().styles; }, -}); +}; -const defaultThemeStylesActionsContextValue = new Proxy({} as ThemeStylesActionsContextType, { - get(_, prop: keyof ThemeStylesActionsContextType) { +const defaultThemeStylesActionsContextValue: ThemeStylesActionsContextType = { + get StyleUtils() { if (!cachedActions) { - if (!cachedState) { - cachedState = {styles: styles(defaultTheme)}; - } - cachedActions = {StyleUtils: createStyleUtils(defaultTheme, cachedState.styles)}; + cachedActions = {StyleUtils: createStyleUtils(defaultTheme, getDefaultState().styles)}; } - return cachedActions[prop]; + return cachedActions.StyleUtils; }, -}); +}; export {defaultThemeStylesStateContextValue, defaultThemeStylesActionsContextValue}; diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx index 18ba94d8279f..3f0bb956114f 100644 --- a/src/components/ThumbnailImage.tsx +++ b/src/components/ThumbnailImage.tsx @@ -69,6 +69,9 @@ type ThumbnailImageProps = { /** Low-resolution URI shown as a placeholder while the full image loads */ previewUri?: string; + + /** Identity of the picture for the native image view, see BaseImageProps */ + recyclingKey?: string; }; function ThumbnailImage({ @@ -92,6 +95,7 @@ function ThumbnailImage({ onLoad, resizeMode, previewUri, + recyclingKey, }: ThumbnailImageProps) { const icons = useMemoizedLazyExpensifyIcons(['Gallery', 'OfflineCloud']); const styles = useThemeStyles(); @@ -158,6 +162,7 @@ function ThumbnailImage({ onLoad={onLoad} resizeMode={resizeMode} previewUri={previewUri} + recyclingKey={recyclingKey} /> diff --git a/src/components/TimeModalPicker.tsx b/src/components/TimeModalPicker.tsx index 9c0078fafab2..c7ee63ce6f36 100644 --- a/src/components/TimeModalPicker.tsx +++ b/src/components/TimeModalPicker.tsx @@ -6,14 +6,15 @@ import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; import type {ComponentRef, ForwardedRef} from 'react'; +import type {View} from 'react-native'; import React, {useState} from 'react'; -import {View} from 'react-native'; import HeaderWithBackButton from './HeaderWithBackButton'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import Modal from './Modal'; import ScreenWrapper from './ScreenWrapper'; +import ScrollView from './ScrollView'; import TimePicker from './TimePicker/TimePicker'; type TimeModalPickerProps = { @@ -75,13 +76,16 @@ function TimeModalPicker({value, errorText, label, onInputChange = () => {}, ref title={label} onBackButtonPress={hidePickerModal} /> - + - +
diff --git a/src/components/TimePicker/TimePicker.tsx b/src/components/TimePicker/TimePicker.tsx index 22725c72cd83..44e8447d4e8c 100644 --- a/src/components/TimePicker/TimePicker.tsx +++ b/src/components/TimePicker/TimePicker.tsx @@ -2,6 +2,7 @@ import AmountTextInput from '@components/AmountTextInput'; import BigNumberPad from '@components/BigNumberPad'; import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; +import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; @@ -704,11 +705,14 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou } }; + // In landscape, the AM/PM buttons match the currency and Flip buttons on the amount page const renderedAmPmButtons = ( - + + ); + + // In landscape there isn't enough height to stack the inputs, number pad and Save button, so use the same layout as the + // amount page (NumberWithSymbolForm): inputs and AM/PM buttons next to the number pad, with the Save button pinned below. + if (isInLandscapeMode) { + return ( + + + + {renderedInputs} + {renderedAmPmButtons} + {isError && ( + - + )} + + {canUseTouchScreen && ( + + {numberPad()} + )} - + + {renderedSaveButton} + + ); + } + + return ( + + + {renderedInputs} {!canUseTouchScreen && renderedAmPmButtons} - {isError ? ( - - ) : ( - - )} + {renderedErrorMessage} {canUseTouchScreen && renderedAmPmButtons} {}, shou > {numberPad()} - + {renderedSaveButton} ); } diff --git a/src/components/TransactionItemRow/DataCells/CategoryCell.tsx b/src/components/TransactionItemRow/DataCells/CategoryCell.tsx index c65078fd3fd4..6dd58109daf3 100644 --- a/src/components/TransactionItemRow/DataCells/CategoryCell.tsx +++ b/src/components/TransactionItemRow/DataCells/CategoryCell.tsx @@ -10,6 +10,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; +import CONST from '@src/CONST'; + import React from 'react'; import type TransactionDataCellProps from './TransactionDataCellProps'; @@ -29,7 +31,7 @@ function CategoryCell({shouldUseNarrowLayout, shouldShowTooltip, transactionItem // For picker comparison: raw category name (empty if missing, matches IOURequestStepCategory) const categoryForComparison = isCategoryMissing(transactionItem?.category) ? '' : (transactionItem?.category ?? ''); - const {isEditing, anchorRef, isPopoverVisible, popoverPosition, isInverted, startEditing, cancelEditing, handleSave} = usePopoverEditState({ + const {isEditing, anchorRef, isPopoverVisible, popoverPosition, popoverHeight, anchorAlignment, shouldOpenAbove, startEditing, cancelEditing, handleSave} = usePopoverEditState({ canEdit, value: categoryForComparison, onSave, @@ -52,6 +54,7 @@ function CategoryCell({shouldUseNarrowLayout, shouldShowTooltip, transactionItem text={categoryForDisplay} numberOfLines={1} style={[styles.lineHeightLarge, styles.justifyContentCenter]} + isSelectable /> ); @@ -65,10 +68,14 @@ function CategoryCell({shouldUseNarrowLayout, shouldShowTooltip, transactionItem } diff --git a/src/components/TransactionItemRow/DataCells/CategoryGLCodeCell.tsx b/src/components/TransactionItemRow/DataCells/CategoryGLCodeCell.tsx new file mode 100644 index 000000000000..13eff2b53333 --- /dev/null +++ b/src/components/TransactionItemRow/DataCells/CategoryGLCodeCell.tsx @@ -0,0 +1,88 @@ +import CategoryPickerModal from '@components/CategoryPicker/CategoryPickerModal'; +import {EditableCell, usePopoverEditState} from '@components/EditableCell'; +import type {EditableProps} from '@components/EditableCell'; +import type {ListItem} from '@components/SelectionList/types'; +import TextWithTooltip from '@components/TextWithTooltip'; + +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getCategoryGLCode, hasAnyCategoryGLCode, isCategoryMissing} from '@libs/CategoryUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PolicyCategories} from '@src/types/onyx'; + +import React from 'react'; + +import type TransactionDataCellProps from './TransactionDataCellProps'; + +type CategoryGLCodeCellProps = TransactionDataCellProps & + EditableProps & { + policyID?: string; + policyCategories?: PolicyCategories; + }; + +/** + * A GL code belongs to the workspace category, not to the expense, so there is nothing on the expense to write. + * Editing the cell therefore reassigns the expense to the category that owns the picked GL code, which is what makes + * the category name update alongside it. The picker always lists each category with its GL code underneath, even when + * the workspace hides GL codes in the regular category picker, because the code is what the user is picking here. + * Codes shared by several categories stay distinguishable by name. When no category on the workspace has a GL code + * there is nothing to pick, so the cell stays read-only. + */ +function CategoryGLCodeCell({shouldShowTooltip, transactionItem, canEdit: canEditCategory, onSave, policyID, policyCategories}: CategoryGLCodeCellProps) { + const styles = useThemeStyles(); + const [categoriesLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_CATEGORIES_LOADING_STATE}${policyID}`); + + // On a lazy-loaded workspace `policyCategories` may hold only the expense's own category until the picker fetches + // the full list, so "no GL codes" is only trusted after that fetch. Otherwise the picker never mounts and never + // loads them. + const canEdit = !!canEditCategory && (!categoriesLoadingState?.hasOnceLoaded || hasAnyCategoryGLCode(policyCategories)); + + const categoryForComparison = isCategoryMissing(transactionItem?.category) ? '' : (transactionItem?.category ?? ''); + + const {isEditing, anchorRef, isPopoverVisible, popoverPosition, popoverHeight, anchorAlignment, shouldOpenAbove, startEditing, cancelEditing, handleSave} = usePopoverEditState({ + canEdit, + value: categoryForComparison, + onSave, + }); + + const handleCategorySelected = (item: ListItem) => { + handleSave(item.keyForList); + }; + + return ( + + } + > + + + ); +} + +export default CategoryGLCodeCell; diff --git a/src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx b/src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx index ebfbf5129ed6..9a5a9046a8f1 100644 --- a/src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx +++ b/src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx @@ -14,6 +14,7 @@ import {isChatThread} from '@libs/ReportUtils'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; import type Transaction from '@src/types/onyx/Transaction'; @@ -61,7 +62,18 @@ function ChatBubbleCell({transaction, containerStyles, isInSingleTransactionRepo return ( threadMessages.count > 0 && ( - + ); diff --git a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx index 495137a77e81..72201799a907 100644 --- a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx +++ b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx @@ -15,6 +15,7 @@ import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import type {Transaction} from '@src/types/onyx'; import type {ComponentRef} from 'react'; @@ -87,8 +88,10 @@ function ReceiptCell({ StyleUtils.getBorderRadiusStyle(variables.componentBorderRadiusSmall), styles.overflowHidden, backgroundStyles, + styles.userSelectNone, style, ]} + dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} onMouseEnter={handleMouseEnter} onMouseLeave={bind.onMouseLeave} > diff --git a/src/components/TransactionItemRow/DataCells/TagCell.tsx b/src/components/TransactionItemRow/DataCells/TagCell.tsx index 3ef5b0a0f8bf..a2a275631a1e 100644 --- a/src/components/TransactionItemRow/DataCells/TagCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TagCell.tsx @@ -36,7 +36,7 @@ function TagCell({canEdit, onSave, shouldUseNarrowLayout, shouldShowTooltip, tra const policyHasDependentTags = hasDependentTags(policy, policyTags); const shouldShowGLCode = !!policy?.showTagGLCodes && !!policy?.glCodes; - const {isEditing, anchorRef, isPopoverVisible, popoverPosition, isInverted, startEditing, cancelEditing, handleSave} = usePopoverEditState({ + const {isEditing, anchorRef, isPopoverVisible, popoverPosition, popoverHeight, anchorAlignment, shouldOpenAbove, startEditing, cancelEditing, handleSave} = usePopoverEditState({ canEdit, value: transactionItem?.tag ?? '', onSave, @@ -58,6 +58,7 @@ function TagCell({canEdit, onSave, shouldUseNarrowLayout, shouldShowTooltip, tra text={tagForDisplay} numberOfLines={1} style={[styles.lineHeightLarge, styles.justifyContentCenter]} + isSelectable /> ); @@ -77,7 +78,10 @@ function TagCell({canEdit, onSave, shouldUseNarrowLayout, shouldShowTooltip, tra isVisible={isPopoverVisible} onClose={cancelEditing} anchorPosition={popoverPosition} - shouldMeasureAnchorPositionFromTop={!isInverted} + anchorAlignment={anchorAlignment} + popoverHeight={popoverHeight} + shouldMeasureAnchorPositionFromTop={!shouldOpenAbove} + shouldSwitchPositionIfOverflow={false} onSelected={handleSave} /> } diff --git a/src/components/TransactionItemRow/DataCells/TagGLCodeCell.tsx b/src/components/TransactionItemRow/DataCells/TagGLCodeCell.tsx new file mode 100644 index 000000000000..1173cfb48076 --- /dev/null +++ b/src/components/TransactionItemRow/DataCells/TagGLCodeCell.tsx @@ -0,0 +1,86 @@ +import type {EditableProps} from '@components/EditableCell'; +import {EditableCell, usePopoverEditState} from '@components/EditableCell'; +import TagPickerModal from '@components/TagPicker/TagPickerModal'; +import TextWithTooltip from '@components/TextWithTooltip'; + +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getTagGLCode, hasAnyTagGLCode, hasDependentTags} from '@libs/PolicyUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, PolicyTagLists} from '@src/types/onyx'; + +import React from 'react'; + +import type TransactionDataCellProps from './TransactionDataCellProps'; + +type TagGLCodeCellProps = TransactionDataCellProps & + EditableProps & { + policyID?: string; + policy?: Policy; + policyTagLists?: PolicyTagLists; + }; + +/** + * Same shape as `CategoryGLCodeCell`: the code lives on the workspace tag, so editing picks the tag that owns the + * code rather than rewriting the code. Multi-level tags produce one code per level joined into a single string, so + * there is no single value to pick. `canEditTag` already refuses them, so the cell stays read-only there. Like the + * category cell, the picker always shows each tag's GL code, regardless of the workspace's tag GL code setting, and + * the cell stays read-only when no tag on the workspace has a GL code. + */ +function TagGLCodeCell({canEdit: canEditTag, onSave, shouldShowTooltip, transactionItem, policyID, policy: policyProp, policyTagLists}: TagGLCodeCellProps) { + const styles = useThemeStyles(); + const [tagsLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_TAGS_LOADING_STATE}${policyID}`); + + // On a lazy-loaded workspace `policyTagLists` may hold only the expense's own tag until the picker fetches the full + // list, so "no GL codes" is only trusted after that fetch. Otherwise the picker never mounts and never loads them. + const canEdit = !!canEditTag && (!tagsLoadingState?.hasOnceLoaded || hasAnyTagGLCode(policyTagLists)); + + const [livePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); + const policy = livePolicy ? {...policyProp, ...livePolicy} : policyProp; + + const policyHasDependentTags = hasDependentTags(policy, policyTags); + + const {isEditing, anchorRef, isPopoverVisible, popoverPosition, popoverHeight, anchorAlignment, shouldOpenAbove, startEditing, cancelEditing, handleSave} = usePopoverEditState({ + canEdit, + value: transactionItem?.tag ?? '', + onSave, + }); + + return ( + + } + > + + + ); +} + +export default TagGLCodeCell; diff --git a/src/components/TransactionItemRow/DataCells/TaxCell.tsx b/src/components/TransactionItemRow/DataCells/TaxCell.tsx index 36950112a47b..2133500677b8 100644 --- a/src/components/TransactionItemRow/DataCells/TaxCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TaxCell.tsx @@ -21,6 +21,7 @@ function TaxCell({transactionItem, shouldShowTooltip}: TransactionDataCellProps) shouldShowTooltip={shouldShowTooltip} text={convertToDisplayString(taxAmount, currency)} style={[styles.optionDisplayName, styles.lineHeightLarge, styles.pre, styles.justifyContentCenter, styles.textAlignRight]} + isSelectable /> ); } diff --git a/src/components/TransactionItemRow/DataCells/TotalCell.tsx b/src/components/TransactionItemRow/DataCells/TotalCell.tsx index cc0fbcdca871..278bf827c352 100644 --- a/src/components/TransactionItemRow/DataCells/TotalCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TotalCell.tsx @@ -12,6 +12,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {convertToBackendAmount, convertToFrontendAmountAsString, hasSpaceBetweenSymbolAndAmount} from '@libs/CurrencyUtils'; import {parseFloatAnyLocale, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {getTransactionDisplayAmount, isInvoiceReport, isSettled, shouldEnableNegative} from '@libs/ReportUtils'; +import showInlineEditError from '@libs/showInlineEditError'; import {getCurrency as getTransactionCurrency, isExpenseUnreported, isFailedScanAmountPlaceholder, isScanning} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; @@ -88,6 +89,7 @@ function TotalCell({shouldShowTooltip, transactionItem, canEdit, onSave, report, ? (value) => { const normalizedValue = getNormalizedValue(value, isNegative); if (normalizedValue === undefined) { + showInlineEditError(translate('iou.error.invalidAmount')); return; } onSave(normalizedValue); @@ -147,6 +149,7 @@ function TotalCell({shouldShowTooltip, transactionItem, canEdit, onSave, report, shouldShowTooltip={shouldShowTooltip} text={amountToDisplay} style={[styles.optionDisplayName, styles.justifyContentCenter, styles.flexShrink1, styles.textAlignRight]} + isSelectable /> ); diff --git a/src/components/TransactionItemRow/DataCells/TypeCell.tsx b/src/components/TransactionItemRow/DataCells/TypeCell.tsx index a2075a42ee8e..f1162f227e21 100644 --- a/src/components/TransactionItemRow/DataCells/TypeCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TypeCell.tsx @@ -102,6 +102,7 @@ function TypeCell({transactionItem, shouldUseNarrowLayout, shouldShowTooltip}: T shouldShowTooltip={shouldShowTooltip} text={translate(typeText)} style={[styles.mutedNormalTextLabel, styles.pre, styles.justifyContentCenter, styles.flexShrink0]} + isSelectable /> ) : ( diff --git a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx index 9438bd822f5d..54e5aa16097b 100644 --- a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx @@ -37,6 +37,7 @@ type TransactionItemRowNarrowProps = Pick< | 'onCheckboxPress' | 'shouldShowCheckbox' | 'style' + | 'dataSet' | 'isInSingleTransactionReport' | 'shouldShowRadioButton' | 'onRadioButtonPress' @@ -63,6 +64,7 @@ function TransactionItemRowNarrow({ onCheckboxPress = () => {}, shouldShowCheckbox = false, style, + dataSet, isInSingleTransactionReport = false, shouldShowRadioButton = false, onRadioButtonPress = () => {}, @@ -97,6 +99,7 @@ function TransactionItemRowNarrow({ <> diff --git a/src/components/TransactionItemRow/TransactionItemRowRBR.tsx b/src/components/TransactionItemRow/TransactionItemRowRBR.tsx index b909a733f690..d70afea367ff 100644 --- a/src/components/TransactionItemRow/TransactionItemRowRBR.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowRBR.tsx @@ -93,8 +93,9 @@ function TransactionItemRowRBRInner({transaction, violations, report, containerS return ( RBRMessages.length > 0 && ( {}, style, + dataSet, isReportItemChild = false, isActionLoading, isInSingleTransactionReport = false, @@ -176,6 +180,20 @@ function TransactionItemRowWide({ fullHeightMainRowStyle = hasValidationMessage ? styles.mnh13 : styles.tableRowHeight; } + const renderYesNoCell = (columnName: SearchColumnType, value: boolean) => ( + + + {value ? translate('common.yes') : translate('common.no')} + + + ); + const renderColumn = (column: SearchColumnType): React.ReactNode => { const shouldHideTaxValueByRequestType = isTimeRequest(transactionItem) || isPerDiemRequest(transactionItem); switch (column) { @@ -235,9 +253,17 @@ function TransactionItemRowWide({ return ( - + ); case CONST.SEARCH.TABLE_COLUMNS.DATE: @@ -330,34 +356,30 @@ function TransactionItemRowWide({ return ( - + ); case CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE: - return ( - - {getReimbursable(transactionItem) ? translate('common.yes') : translate('common.no')} - - ); + return renderYesNoCell(column, getReimbursable(transactionItem)); case CONST.SEARCH.TABLE_COLUMNS.BILLABLE: - return ( - - {transactionItem.billable ? translate('common.yes') : translate('common.no')} - - ); + return renderYesNoCell(column, !!transactionItem.billable); + case CONST.SEARCH.TABLE_COLUMNS.HELD: + return renderYesNoCell(column, isOnHold(transactionItem)); case CONST.SEARCH.TABLE_COLUMNS.ACTION: return ( {!!transactionItem.action && ( diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx index a0ecd20d6f4f..1a7237ce969d 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -57,6 +57,7 @@ function TransactionItemRow({ columns, onButtonPress = () => {}, style, + dataSet, isReportItemChild = false, isActionLoading, isInSingleTransactionReport = false, @@ -156,6 +157,7 @@ function TransactionItemRow({ onCheckboxPress, shouldShowCheckbox, style, + dataSet, isInSingleTransactionReport, shouldShowRadioButton, onRadioButtonPress, @@ -208,6 +210,7 @@ function TransactionItemRow({ columns, onButtonPress, style, + dataSet, isReportItemChild, isActionLoading, isInSingleTransactionReport, diff --git a/src/components/TransactionItemRow/types.ts b/src/components/TransactionItemRow/types.ts index 79da75aaf641..169db6102c95 100644 --- a/src/components/TransactionItemRow/types.ts +++ b/src/components/TransactionItemRow/types.ts @@ -85,6 +85,7 @@ type TransactionItemRowProps = { columns?: SearchColumnType[]; onButtonPress?: (event?: ModifiedMouseEvent) => void; style?: StyleProp; + dataSet?: Record; isReportItemChild?: boolean; isActionLoading?: boolean; isInSingleTransactionReport?: boolean; diff --git a/src/components/UnitPicker.tsx b/src/components/UnitPicker.tsx index c576e3cf2bc4..248ed09af25f 100644 --- a/src/components/UnitPicker.tsx +++ b/src/components/UnitPicker.tsx @@ -1,6 +1,6 @@ import useLocalize from '@hooks/useLocalize'; -import {getUnitTranslationKey} from '@libs/WorkspacesSettingsUtils'; +import {getUnitTranslationKey} from '@libs/WorkspaceDisplayUtils'; import CONST from '@src/CONST'; import type {Unit} from '@src/types/onyx/Policy'; diff --git a/src/components/UserPill.tsx b/src/components/UserPill.tsx index f86d04d29ac3..df54fa5ebe63 100644 --- a/src/components/UserPill.tsx +++ b/src/components/UserPill.tsx @@ -2,6 +2,7 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; @@ -22,9 +23,10 @@ type UserPillProps = { accountID?: number; email?: string; style?: StyleProp; + isSelectable?: boolean; }; -function UserPill({avatar, displayName, accountID, email, style}: UserPillProps) { +function UserPill({avatar, displayName, accountID, email, style, isSelectable = false}: UserPillProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {formatPhoneNumber} = useLocalize(); @@ -42,14 +44,21 @@ function UserPill({avatar, displayName, accountID, email, style}: UserPillProps) }} > - + + + {formattedDisplayName} diff --git a/src/components/UserPills.tsx b/src/components/UserPills.tsx index 4ee882a37633..c3faa654e815 100644 --- a/src/components/UserPills.tsx +++ b/src/components/UserPills.tsx @@ -24,6 +24,7 @@ type UserPillData = { type UserPillsProps = { users: UserPillData[]; maxVisible?: number; + isSelectable?: boolean; } & ( | {onShowAllPress?: undefined; showAllSentryLabel?: undefined} | { @@ -36,7 +37,7 @@ type UserPillsProps = { const DEFAULT_MAX_VISIBLE = 6; -function UserPills({users, maxVisible = DEFAULT_MAX_VISIBLE, onShowAllPress, showAllSentryLabel}: UserPillsProps) { +function UserPills({users, maxVisible = DEFAULT_MAX_VISIBLE, onShowAllPress, showAllSentryLabel, isSelectable = false}: UserPillsProps) { const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); @@ -62,6 +63,7 @@ function UserPills({users, maxVisible = DEFAULT_MAX_VISIBLE, onShowAllPress, sho displayName={user.displayName} accountID={user.accountID} email={user.email} + isSelectable={isSelectable} /> ); })} diff --git a/src/components/VacationDelegateForm.tsx b/src/components/VacationDelegateForm.tsx new file mode 100644 index 000000000000..524e76dd1c21 --- /dev/null +++ b/src/components/VacationDelegateForm.tsx @@ -0,0 +1,208 @@ +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'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import DateUtils from '@libs/DateUtils'; +import { + formatVacationDelegateClearDateTime, + getActiveVacationDelegate, + getVacationDelegateClearAfter, + getVacationDelegateClearDate, + getVacationDelegateClearDateTime, + getVacationDelegateLocalClearDateTime, + isVacationDelegateClearAfterTooSoon, + isVacationDelegateClearDatePassed, +} 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'; +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 OfflineWithFeedback from './OfflineWithFeedback'; +import Text from './Text'; +import TimeModalPicker from './TimeModalPicker'; +import VacationDelegateMenuItem from './VacationDelegateMenuItem'; + +type VacationDelegateFormProps = { + /** The saved vacation delegate */ + vacationDelegate?: BaseVacationDelegate; + + /** 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; + + /** + * 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; + + /** 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, + isLoading = false, +}: VacationDelegateFormProps) { + const styles = useThemeStyles(); + const {translate, dateFnsLocale, getLocalDateFromDatetime} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['CalendarSolid', 'Trashcan']); + const {timezone: currentUserTimezone} = useCurrentUserPersonalDetails(); + const timezone = timezoneProp ?? currentUserTimezone?.selected; + 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) : ''; + // 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; + const formattedClearDateTime = formatVacationDelegateClearDateTime( + getVacationDelegateLocalClearDateTime(clearDate, draftValues?.[INPUT_IDS.CLEAR_AFTER_TIME] ?? savedClearDateTime), + dateFnsLocale, + ); + + const validate = (values: FormOnyxValues): FormInputErrors => { + const formErrors: FormInputErrors = {}; + if (!values[INPUT_IDS.CLEAR_AFTER_DATE]) { + return formErrors; + } + + // 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))) { + formErrors[INPUT_IDS.CLEAR_AFTER_TIME] = translate('common.error.invalidTimeShouldBeFuture'); + } + return formErrors; + }; + + const submit = (values: FormOnyxValues) => { + if (!delegate) { + return; + } + + onSubmit(delegate, getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone)); + }; + + return ( + + {!!description && {description}} + + {/* The clear after date and time belong to the same pending save as the delegate, so they grey out with it */} + + + {/* Shows the calendar icon while empty and the clear button once a date is picked, so the delegate can go back to never clearing. + 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 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 && ( + + + + )} + + ); +} + +export default VacationDelegateForm; diff --git a/src/components/VacationDelegateMenuItem.tsx b/src/components/VacationDelegateMenuItem.tsx index ff3c58528440..f9a742fbf77e 100644 --- a/src/components/VacationDelegateMenuItem.tsx +++ b/src/components/VacationDelegateMenuItem.tsx @@ -1,25 +1,90 @@ +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 type {AvatarSource} from '@libs/UserAvatarUtils'; +import {formatVacationDelegateClearDateTime, getActiveVacationDelegate, getVacationDelegateClearDateTime} 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 {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; 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; + + /** Timezone the clear date is shown in. Defaults to the current user's, so a domain admin can pass the member's instead */ + timezone?: SelectedTimezone; + /** Errors related to setting the vacation delegate */ errors?: Errors; @@ -29,7 +94,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. @@ -38,17 +103,21 @@ type VacationDelegateSectionProps = { onPress: () => void; }; -function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCloseError, onPress}: VacationDelegateSectionProps) { +function VacationDelegateMenuItemPreset({vacationDelegate, label, timezone, errors, pendingAction, onCloseError, onPress}: VacationDelegateSectionProps) { const styles = useThemeStyles(); - const {translate, formatPhoneNumber} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); + const {translate, formatPhoneNumber, dateFnsLocale} = useLocalize(); + const {timezone: currentUserTimezone} = useCurrentUserPersonalDetails(); - const hasVacationDelegate = !!vacationDelegate?.delegate; - const vacationDelegatePersonalDetails = useVacationDelegatePersonalDetails(vacationDelegate?.delegate); + const rowLabel = label ?? translate('common.vacationDelegate'); + 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 delegateDescription = formatPhoneNumber(rawDelegateLogin); + const clearDate = formatVacationDelegateClearDateTime(getVacationDelegateClearDateTime(vacationDelegate?.clearAfter, timezone ?? currentUserTimezone?.selected), dateFnsLocale); + const untilText = clearDate ? translate('statusPage.vacationDelegate.until', clearDate) : ''; return ( {hasVacationDelegate ? ( - - - - - - - {delegateDisplayName} - {!!delegateDescription && {delegateDescription}} - - - - - - + + ) : ( )} @@ -90,4 +151,6 @@ function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCl ); } +const VacationDelegateMenuItem = Object.assign(VacationDelegateMenuItemPreset, {Row: VacationDelegateMenuItemRow}); + export default VacationDelegateMenuItem; diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index ee897df47c1e..13b797035ac9 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -413,7 +413,6 @@ function BaseValidateCodeForm({ style={[shouldShowSkipButton ? styles.mt3 : styles.mt4]} variant={CONST.BUTTON_VARIANT.SUCCESS} size={CONST.BUTTON_SIZE.LARGE} - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing isLoading={account?.isLoading || isLoading} sentryLabel={CONST.SENTRY_LABEL.VALIDATE_CODE.VERIFY} > diff --git a/src/components/ValuePicker/ValueSelectionList.tsx b/src/components/ValuePicker/ValueSelectionList.tsx index 88702cb5d928..8954d74afef1 100644 --- a/src/components/ValuePicker/ValueSelectionList.tsx +++ b/src/components/ValuePicker/ValueSelectionList.tsx @@ -2,8 +2,13 @@ import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; import useInitialSelection from '@hooks/useInitialSelection'; +import useLocalize from '@hooks/useLocalize'; +import useSearchResults from '@hooks/useSearchResults'; import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; +import tokenizedSearch from '@libs/tokenizedSearch'; + +import CONST from '@src/CONST'; import React, {useMemo} from 'react'; @@ -19,6 +24,7 @@ function ValueSelectionList({ alternateNumberOfSupportedLines, isVisible, }: ValueSelectionListProps) { + const {translate} = useLocalize(); const initialSelectedValue = useInitialSelection(selectedItem?.value ? selectedItem.value : undefined, isVisible === undefined ? {resetOnFocus: true} : {isVisible}); const options = useMemo(() => { @@ -38,14 +44,27 @@ function ValueSelectionList({ return orderedOptions.map((item) => ({...item, isSelected: item.value === selectedItem?.value})); }, [initialSelectedValue, items, selectedItem?.value]); + const [searchValue, setSearchValue, filteredOptions] = useSearchResults(options, (option, searchInput) => tokenizedSearch([option], searchInput, () => [option.text]).length > 0); + + // The search input is gated on the unfiltered list length so it doesn't disappear once a query narrows the results. + const isSearchable = options.length >= CONST.STANDARD_LIST_ITEM_LIMIT; + return ( onItemSelected?.(item)} + textInputOptions={{ + label: isSearchable ? translate('common.search') : undefined, + value: searchValue, + onChangeText: setSearchValue, + headerMessage: searchValue && filteredOptions.length === 0 ? translate('common.noResultsFound') : undefined, + }} initiallyFocusedItemKey={initialSelectedValue} shouldStopPropagation shouldShowTooltips={shouldShowTooltips} shouldScrollToFocusedIndexOnMount={false} + shouldUpdateFocusedIndex={isSearchable} + disableMaintainingScrollPosition={isSearchable} ListItem={SingleSelectListItem} addBottomSafeAreaPadding={addBottomSafeAreaPadding} disableKeyboardShortcuts={disableKeyboardShortcuts} diff --git a/src/components/ValuePicker/index.tsx b/src/components/ValuePicker/index.tsx index c7987cd2e611..9f86ec1d9f5a 100644 --- a/src/components/ValuePicker/index.tsx +++ b/src/components/ValuePicker/index.tsx @@ -56,7 +56,6 @@ function ValuePicker({ | GestureStateChangeEvent) => { + const progressBarInteraction = (event: PanGestureEvent) => { const progress = getProgress(event.x, sliderWidth); progressWidth.set(progress); scheduleOnRN(seekPosition, (progress * duration) / 100); @@ -53,22 +53,22 @@ function ProgressBar({duration, position, seekPosition, onSeekStart, onSeekEnd}: setSliderWidth(event.nativeEvent.layout.width); }; - const pan = Gesture.Pan() - .runOnJS(true) + const pan = usePanGesture({ + runOnJS: true, // Reduce gesture threshold so quick taps trigger onFinalize on iOS. - .minDistance(0) - .activateAfterLongPress(0) - .onBegin((event) => { + minDistance: 0, + activateAfterLongPress: 0, + onBegin: (event) => { setIsSliderPressed(true); checkIfVideoIsPlaying(onCheckIfVideoIsPlaying); onSeekStart?.(); pauseVideo(); progressBarInteraction(event); - }) - .onChange((event) => { + }, + onUpdate: (event) => { progressBarInteraction(event); - }) - .onFinalize(() => { + }, + onFinalize: () => { setIsSliderPressed(false); const shouldResumeAfterSeek = wasVideoPlayingOnCheck.get(); onSeekEnd?.(shouldResumeAfterSeek); @@ -76,7 +76,8 @@ function ProgressBar({duration, position, seekPosition, onSeekStart, onSeekEnd}: return; } playVideo(); - }); + }, + }); useEffect(() => { if (isSliderPressed) { diff --git a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx index 40ce107dcd4d..3e1d2ec12f23 100644 --- a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx +++ b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx @@ -12,11 +12,11 @@ import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; -import type {GestureStateChangeEvent, GestureUpdateEvent, PanGestureChangeEventPayload, PanGestureHandlerEventPayload} from 'react-native-gesture-handler'; +import type {PanGestureEvent} from 'react-native-gesture-handler'; import React, {memo, useCallback, useState} from 'react'; import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {GestureDetector, usePanGesture} from 'react-native-gesture-handler'; import Animated, {useAnimatedStyle, useDerivedValue} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -52,24 +52,25 @@ function VolumeButton({style, small = false}: VolumeButtonProps) { }, []); const changeVolumeOnPan = useCallback( - (event: GestureStateChangeEvent | GestureUpdateEvent) => { + (event: PanGestureEvent) => { const val = roundToTwoDecimalPlaces(1 - event.y / sliderHeight); volume.set(clamp(val, 0, 1)); }, [sliderHeight, volume], ); - const pan = Gesture.Pan() - .onBegin((event) => { + const pan = usePanGesture({ + onBegin: (event) => { scheduleOnRN(setIsSliderBeingUsed, true); changeVolumeOnPan(event); - }) - .onChange((event) => { + }, + onUpdate: (event) => { changeVolumeOnPan(event); - }) - .onFinalize(() => { + }, + onFinalize: () => { scheduleOnRN(setIsSliderBeingUsed, false); - }); + }, + }); const progressBarStyle = useAnimatedStyle(() => ({height: `${volume.get() * 100}%`})); diff --git a/src/components/VideoPlayer/utils.ts b/src/components/VideoPlayer/utils.ts index f3da3d20697f..e3f4980a317a 100644 --- a/src/components/VideoPlayer/utils.ts +++ b/src/components/VideoPlayer/utils.ts @@ -32,7 +32,7 @@ function addSkipTimeTagToURL(url: string, seconds: number) { } /** - * Builds the playable video source URL. Remote attachments get an auth token. + * Builds the playable video source URL. Attachments on Expensify servers get an auth token. * All URLs get the skip-time tag when supported. */ function buildVideoSourceURL(url: string, encryptedAuthToken: string) { @@ -40,7 +40,7 @@ function buildVideoSourceURL(url: string, encryptedAuthToken: string) { return url; } - const sourceWithAuth = url.includes('blob:') || url.includes('file:///') ? url : addEncryptedAuthTokenToURL(url, encryptedAuthToken); + const sourceWithAuth = addEncryptedAuthTokenToURL(url, encryptedAuthToken); return addSkipTimeTagToURL(sourceWithAuth, VIDEO_SKIP_TIME_SECONDS); } diff --git a/src/components/WalletStatementModal/index.native.tsx b/src/components/WalletStatementModal/index.native.tsx index c4e45e77b8e9..ca2653220936 100644 --- a/src/components/WalletStatementModal/index.native.tsx +++ b/src/components/WalletStatementModal/index.native.tsx @@ -10,7 +10,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {WebViewMessageEvent, WebViewNavigation} from 'react-native-webview'; import type {ValueOf} from 'type-fest'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useRef} from 'react'; import {StyleSheet, View} from 'react-native'; import {WebView} from 'react-native-webview'; @@ -28,7 +28,9 @@ function WalletStatementModal({statementPageURL}: WalletStatementProps) { const [session] = useOnyx(ONYXKEYS.SESSION); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const webViewRef = useRef(null); @@ -52,7 +54,16 @@ function WalletStatementModal({statementPageURL}: WalletStatementProps) { if (!webViewRef.current || !parsedData) { return; } - handleWalletStatementNavigation(conciergeReportID, introSelected, session?.accountID, isSelfTourViewed, conciergePersonalDetails, parsedData.type, parsedData.url); + handleWalletStatementNavigation( + conciergeReportID, + introSelected, + session?.accountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + parsedData.type, + parsedData.url, + ); }; return ( diff --git a/src/components/WalletStatementModal/index.tsx b/src/components/WalletStatementModal/index.tsx index 6785c328a7ba..8c8aead941df 100644 --- a/src/components/WalletStatementModal/index.tsx +++ b/src/components/WalletStatementModal/index.tsx @@ -7,7 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; @@ -23,7 +23,9 @@ function WalletStatementModal({statementPageURL}: WalletStatementProps) { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const navigateRef = useRef<(event: MessageEvent) => void>(null); @@ -34,9 +36,9 @@ function WalletStatementModal({statementPageURL}: WalletStatementProps) { (event: MessageEvent) => { const {data} = event; const {type, url} = data || {}; - handleWalletStatementNavigation(conciergeReportID, introSelected, session?.accountID, isSelfTourViewed, conciergePersonalDetails, type, url); + handleWalletStatementNavigation(conciergeReportID, introSelected, session?.accountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails, type, url); }, - [conciergeReportID, introSelected, session?.accountID, isSelfTourViewed, conciergePersonalDetails], + [conciergeReportID, introSelected, session?.accountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails], ); useEffect(() => { diff --git a/src/components/WalletStatementModal/walletNavigationUtils.ts b/src/components/WalletStatementModal/walletNavigationUtils.ts index 3a18ac17458e..4799f8a17a9a 100644 --- a/src/components/WalletStatementModal/walletNavigationUtils.ts +++ b/src/components/WalletStatementModal/walletNavigationUtils.ts @@ -19,6 +19,7 @@ function handleWalletStatementNavigation( introSelected: OnyxEntry, currentUserAccountID: number | undefined, isSelfTourViewed: boolean | undefined, + hasCompletedGuidedSetupFlow: boolean | undefined, conciergePersonalDetails: OnyxEntry, type?: string, url?: string, @@ -28,7 +29,14 @@ function handleWalletStatementNavigation( } if (type === CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE) { - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID: currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, isSelfTourViewed, conciergePersonalDetails}); + navigateToConciergeChat({ + conciergeReportID, + introSelected, + currentUserAccountID: currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + }); return; } diff --git a/src/components/WiseGlobalReimbursementsBanner.tsx b/src/components/WiseGlobalReimbursementsBanner.tsx new file mode 100644 index 000000000000..10410714aeaf --- /dev/null +++ b/src/components/WiseGlobalReimbursementsBanner.tsx @@ -0,0 +1,63 @@ +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import BillingBanner from '@pages/settings/Subscription/CardSection/BillingBanner/BillingBanner'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {View} from 'react-native'; + +import Button from './Button'; + +type WiseGlobalReimbursementsBannerProps = { + /** Opens the global reimbursements intro page */ + onGetStartedPress: () => void; + + /** Hides the banner for good */ + onDismissPress: () => void; + + /** Sentry labels of the "Get started" and close buttons */ + sentryLabels: {CTA: string; DISMISS: string}; +}; + +/** The "Pay people worldwide" banner promoting Wise global reimbursements. Callers decide when it shows and what its buttons do */ +function WiseGlobalReimbursementsBanner({onGetStartedPress, onDismissPress, sentryLabels}: WiseGlobalReimbursementsBannerProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); + const illustrations = useMemoizedLazyIllustrations(['EarthWithCoins']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']); + + const shouldDisplayButtonInSeparateLine = shouldUseNarrowLayout && !isInLandscapeMode; + + return ( + + + + } + /> + ); +} + +export default WiseGlobalReimbursementsBanner; diff --git a/src/components/WorkspaceExpensifyCardLimitTypePickerModal.tsx b/src/components/WorkspaceExpensifyCardLimitTypePickerModal.tsx index b4693083d15d..f4544ba72049 100644 --- a/src/components/WorkspaceExpensifyCardLimitTypePickerModal.tsx +++ b/src/components/WorkspaceExpensifyCardLimitTypePickerModal.tsx @@ -58,6 +58,9 @@ type WorkspaceExpensifyCardLimitTypePickerModalProps = { selectedLimitType?: CardLimitType; onSelected?: (limitType: CardLimitType) => void; + + /** Height of the picker. Defaults to the height its limit types need. */ + popoverHeight?: number; } & Omit; /** @@ -95,6 +98,8 @@ function WorkspaceExpensifyCardLimitTypePickerModal({ onSelected, anchorAlignment = DEFAULT_ANCHOR_ALIGNMENT, shouldMeasureAnchorPositionFromTop = false, + shouldSwitchPositionIfOverflow = true, + popoverHeight, }: WorkspaceExpensifyCardLimitTypePickerModalProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -104,7 +109,8 @@ function WorkspaceExpensifyCardLimitTypePickerModal({ const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lock']); const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const anchorRef = useRef(null); - const {popoverHeight} = useWorkspaceExpensifyCardLimitTypePickerPopover({card, policy}); + const {popoverHeight: contentHeight} = useWorkspaceExpensifyCardLimitTypePickerPopover({card, policy}); + const resolvedHeight = popoverHeight ?? contentHeight; const areApprovalsConfigured = getApprovalWorkflow(policy) !== CONST.POLICY.APPROVAL_MODE.OPTIONAL; const canReadWorkflows = canMemberRead(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.WORKFLOWS); @@ -174,19 +180,19 @@ function WorkspaceExpensifyCardLimitTypePickerModal({ anchorPosition={anchorPosition} popoverDimensions={{ width: CONST.POPOVER_DROPDOWN_WIDTH, - height: popoverHeight, + height: resolvedHeight, }} anchorAlignment={anchorAlignment} innerContainerStyle={StyleUtils.getWidthStyle(CONST.POPOVER_DROPDOWN_WIDTH)} restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE} - shouldSwitchPositionIfOverflow + shouldSwitchPositionIfOverflow={shouldSwitchPositionIfOverflow} shouldEnableNewFocusManagement shouldMeasureAnchorPositionFromTop={shouldMeasureAnchorPositionFromTop} shouldSkipRemeasurement shouldDisplayBelowModals enableEdgeToEdgeBottomSafeAreaPadding > - + } + {...tabRootScreenWrapperProps} > diff --git a/src/components/WorkspaceMemberRoleList.tsx b/src/components/WorkspaceMemberRoleList.tsx index 3a11f6c1af61..91d3d8e86772 100644 --- a/src/components/WorkspaceMemberRoleList.tsx +++ b/src/components/WorkspaceMemberRoleList.tsx @@ -3,7 +3,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import {canMemberAssignRole} from '@libs/PolicyUtils'; +import {getSelectableRoles} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import type {Route} from '@src/ROUTES'; @@ -16,8 +16,9 @@ import React from 'react'; import {View} from 'react-native'; import type {LocalizedTranslate} from './LocaleContextProvider'; -import type {ListItem} from './SelectionList/types'; +import type {ConfirmButtonOptions, ListItem} from './SelectionList/types'; +import FormHelpMessage from './FormHelpMessage'; import HeaderWithBackButton from './HeaderWithBackButton'; import SelectionList from './SelectionList'; import SingleSelectListItem from './SelectionList/ListItem/SingleSelectListItem'; @@ -38,6 +39,15 @@ type WorkspaceMemberRoleListProps = { /** When provided, restricts the selectable roles to this set (e.g. an Authorized Payer may only be an Admin or Payments Admin) */ allowedRoles?: Array>; + + /** When provided, the list confirms the pick with a button instead of applying it as soon as a row is pressed */ + confirmButtonOptions?: ConfirmButtonOptions; + + /** Shown above the confirm button, for a confirmation pressed without a role picked */ + errorMessage?: string; + + /** Bump to have the error read out again, since a repeated confirmation leaves the message itself unchanged */ + errorAnnouncementKey?: number; }; /** @@ -103,10 +113,22 @@ function getAssignableWorkspaceMemberRoleItems( }, ]; - return workspaceRoles.filter((item) => canMemberAssignRole(policy, currentUserLogin, item.value) && (!allowedRoles || allowedRoles.includes(item.value))); + const selectableRoles = getSelectableRoles(policy, currentUserLogin, allowedRoles); + + return workspaceRoles.filter((item) => selectableRoles.includes(item.value)); } -function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLoading = false, onSelectRole = () => {}, allowedRoles = undefined}: WorkspaceMemberRoleListProps) { +function WorkspaceMemberRoleList({ + role, + policy, + navigateBackTo = undefined, + isLoading = false, + onSelectRole = () => {}, + allowedRoles = undefined, + confirmButtonOptions = undefined, + errorMessage = '', + errorAnnouncementKey = 0, +}: WorkspaceMemberRoleListProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); @@ -125,10 +147,23 @@ function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLo data={availableRoleItems} ListItem={SingleSelectListItem} onSelectRow={onSelectRole} + confirmButtonOptions={confirmButtonOptions} shouldSingleExecuteRowSelect initiallyFocusedItemKey={availableRoleItems.find((item) => item.isSelected)?.keyForList} addBottomSafeAreaPadding - /> + > + {!!errorMessage && ( + + {/* Remounting is what makes the alert speak again. The list is not inside a `Form`, + so `FormHelpMessage` has no submit count of its own to re-announce from. */} + + + )} + )} diff --git a/src/components/WorkspaceMemberRolePickerModal.tsx b/src/components/WorkspaceMemberRolePickerModal.tsx index 563c8fe3ff34..6c2c7b87df72 100644 --- a/src/components/WorkspaceMemberRolePickerModal.tsx +++ b/src/components/WorkspaceMemberRolePickerModal.tsx @@ -44,6 +44,9 @@ type WorkspaceMemberRolePickerModalProps = { allowedRoles?: Array>; onSelected?: (role: ValueOf) => void; + + /** Height of the picker. Defaults to the height its roles need. */ + popoverHeight?: number; } & Omit; /** @@ -80,12 +83,15 @@ function WorkspaceMemberRolePickerModal({ onSelected, anchorAlignment = DEFAULT_ANCHOR_ALIGNMENT, shouldMeasureAnchorPositionFromTop = false, + shouldSwitchPositionIfOverflow = true, + popoverHeight, }: WorkspaceMemberRolePickerModalProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const anchorRef = useRef(null); - const {availableRoleItems, popoverHeight} = useWorkspaceMemberRolePickerPopover({policy, selectedRole, allowedRoles}); + const {availableRoleItems, popoverHeight: contentHeight} = useWorkspaceMemberRolePickerPopover({policy, selectedRole, allowedRoles}); + const resolvedHeight = popoverHeight ?? contentHeight; const handleRoleSelected = (item: ListItemType) => { onSelected?.(item.value); @@ -100,19 +106,19 @@ function WorkspaceMemberRolePickerModal({ anchorPosition={anchorPosition} popoverDimensions={{ width: CONST.POPOVER_DROPDOWN_WIDTH, - height: popoverHeight, + height: resolvedHeight, }} anchorAlignment={anchorAlignment} innerContainerStyle={StyleUtils.getWidthStyle(CONST.POPOVER_DROPDOWN_WIDTH)} restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE} - shouldSwitchPositionIfOverflow + shouldSwitchPositionIfOverflow={shouldSwitchPositionIfOverflow} shouldEnableNewFocusManagement shouldMeasureAnchorPositionFromTop={shouldMeasureAnchorPositionFromTop} shouldSkipRemeasurement shouldDisplayBelowModals enableEdgeToEdgeBottomSafeAreaPadding > - + >({Wra ); } -export default function >(WrappedComponent: ComponentType): ComponentType { +export default function >(WrappedComponent: ComponentType): FunctionComponent { function WithNavigationFallback(props: TProps) { return ( ) { - const [isLoadingApp = true, isLoadingAppMetadata] = useOnyx(ONYXKEYS.IS_LOADING_APP); - // Suppresses the promo for supportal/copilot sessions and fails closed while SESSION/ACCOUNT load, so eligibility already waits for ACCOUNT before scheduling the promo - const shouldSuppressPromotionalUI = useShouldSuppressPromotionalUI(); - const [dismissedProductTraining, dismissedProductTrainingMetadata] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); - const [tryNewDot, tryNewDotMetadata] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {selector: tryNewDotOnyxSelector}); - const [onboarding, onboardingMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING); - - const isWaitingForProtectedRoutes = useRef(false); - - const hasBeenAddedToNudgeMigration = tryNewDot?.hasBeenAddedToNudgeMigration ?? false; - const isMigrationModalDismissed = isProductTrainingElementDismissed(CONST.MIGRATED_USER_WELCOME_MODAL, dismissedProductTraining); - const isAIPromoModalDismissed = isProductTrainingElementDismissed(CONST.AI_FEATURES_PROMO_MODAL, dismissedProductTraining); - const isMigrationModalPending = hasBeenAddedToNudgeMigration && !isMigrationModalDismissed; - const hasCompletedOnboarding = hasCompletedGuidedSetupFlowSelector(onboarding); - - useEffect(() => { - if (!isAIPromoModalDismissed) { - return; - } - hasRedirectedToAIFeaturesPromoModal = false; - }, [isAIPromoModalDismissed]); - - useEffect(() => { - if (!isMigrationModalPending) { - return; - } - observedActiveMigrationModalThisSession = true; - }, [isMigrationModalPending]); - - useEffect(() => { - if (hasCompletedOnboarding !== false) { - return; - } - observedActiveOnboardingThisSession = true; - }, [hasCompletedOnboarding]); - - const isAllOnyxLoaded = !isLoadingOnyxValue(isLoadingAppMetadata, dismissedProductTrainingMetadata, tryNewDotMetadata, onboardingMetadata); - - const isEligible = - isAllOnyxLoaded && - !!session?.authToken && - !isLoadingApp && - !shouldSuppressPromotionalUI && - !hasRedirectedToAIFeaturesPromoModal && - !isAIPromoModalDismissed && - !isMigrationModalPending && - !observedActiveMigrationModalThisSession && - !observedActiveOnboardingThisSession; - - useEffect(() => { - if (!isEligible || isWaitingForProtectedRoutes.current) { - return; - } - isWaitingForProtectedRoutes.current = true; - // Defer until any in-flight navigation transition (splash → home, etc.) has fully settled - const handle = TransitionTracker.runAfterTransitions({ - callback: () => { - Navigation.waitForProtectedRoutes().then(() => { - isWaitingForProtectedRoutes.current = false; - if (hasRedirectedToAIFeaturesPromoModal || observedActiveMigrationModalThisSession || observedActiveOnboardingThisSession || isAIPromoModalDismissed) { - return; - } - const lastRoute = navigationRef.getRootState?.()?.routes.at(-1)?.name; - if (lastRoute === NAVIGATORS.SHARE_MODAL_NAVIGATOR || lastRoute === SCREENS.NOT_FOUND) { - return; - } - Log.info('[useAIFeaturesPromoModal] Navigating to AI features promo modal'); - hasRedirectedToAIFeaturesPromoModal = true; - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AI_FEATURES_PROMO.path, Navigation.getActiveRoute() || ROUTES.HOME)); - }); - }, - waitForUpcomingTransition: true, - }); - - return () => { - handle.cancel(); - isWaitingForProtectedRoutes.current = false; - }; - }, [isEligible, isAIPromoModalDismissed]); -} - -export default useAIFeaturesPromoModal; diff --git a/src/hooks/useAccountIDToNameMap.ts b/src/hooks/useAccountIDToNameMap.ts index 0c21722a88ba..8b38c26468a3 100644 --- a/src/hooks/useAccountIDToNameMap.ts +++ b/src/hooks/useAccountIDToNameMap.ts @@ -7,10 +7,24 @@ import {useAllPersonalDetails} from './usePersonalDetails'; /** Mapping from accountID to user name (login or displayName). */ type AccountIDToNameMap = Record; -/** Build the accountID -> name map from PERSONAL_DETAILS_LIST. */ -function buildAccountIDToNameMap(personalDetailsList: PersonalDetailsList | undefined): AccountIDToNameMap { +const MENTION_ACCOUNT_ID_REGEX = /]*\saccountID="?(\d+)"?/gi; + +/** Unique accountIDs referenced by tags in the given HTML string */ +function getMentionedAccountIDs(htmlString: string | undefined): number[] { + if (!htmlString?.includes(' Number(match[1])))]; +} + +/** + * Build the accountID -> name map from PERSONAL_DETAILS_LIST. + * When accountIDs is passed, only those accounts are looked up, so callers rendering many items don't walk the whole list. + */ +function buildAccountIDToNameMap(personalDetailsList: PersonalDetailsList | undefined, accountIDs?: number[]): AccountIDToNameMap { const map: AccountIDToNameMap = {}; - for (const personalDetails of Object.values(personalDetailsList ?? {})) { + const personalDetailsToMap = accountIDs ? accountIDs.map((accountID) => personalDetailsList?.[accountID]) : Object.values(personalDetailsList ?? {}); + for (const personalDetails of personalDetailsToMap) { if (!personalDetails) { continue; } @@ -25,4 +39,11 @@ function useAccountIDToNameMap(): AccountIDToNameMap { return useMemo(() => buildAccountIDToNameMap(personalDetailsList), [personalDetailsList]); } +/** Same as useAccountIDToNameMap, but only maps the users mentioned in the given HTML string, for components rendered many times in a list. */ +function useMentionedAccountIDToNameMap(htmlString: string | undefined): AccountIDToNameMap { + const [personalDetailsList] = useAllPersonalDetails(); + return useMemo(() => buildAccountIDToNameMap(personalDetailsList, getMentionedAccountIDs(htmlString)), [personalDetailsList, htmlString]); +} + export default useAccountIDToNameMap; +export {buildAccountIDToNameMap, useMentionedAccountIDToNameMap}; diff --git a/src/hooks/useAdvancedSearchFilters.ts b/src/hooks/useAdvancedSearchFilters.ts index 54ff2aa0531a..c2d5f532854d 100644 --- a/src/hooks/useAdvancedSearchFilters.ts +++ b/src/hooks/useAdvancedSearchFilters.ts @@ -42,6 +42,8 @@ const typeFiltersKeys = { CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE, CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE, CONST.SEARCH.SYNTAX_FILTER_KEYS.BILLABLE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.DECLINED, + CONST.SEARCH.SYNTAX_FILTER_KEYS.HELD, CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, CONST.SEARCH.SYNTAX_FILTER_KEYS.BANK_ACCOUNT, CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED, @@ -60,6 +62,7 @@ const typeFiltersKeys = { [ CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED, + CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID, @@ -89,6 +92,7 @@ const typeFiltersKeys = { CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY, CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_STATUS, + CONST.SEARCH.SYNTAX_FILTER_KEYS.DECLINED, CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE, CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID, diff --git a/src/hooks/useAgents.ts b/src/hooks/useAgents.ts new file mode 100644 index 000000000000..ea5fdb91b77a --- /dev/null +++ b/src/hooks/useAgents.ts @@ -0,0 +1,194 @@ +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import type {TableHandle} from '@components/Table'; +import type {AgentRowData, AgentsTableColumnKey} from '@components/Tables/AgentsTable'; + +import {clearAgentDeleteError, clearAgentError, clearAgentUpdateError, deleteAgent, openAgentsPage} from '@libs/actions/Agent'; +import {getRuleBotEnforcedPolicy} from '@libs/AgentRulesUtils'; +import {getLatestError} from '@libs/ErrorUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {groupByPolicyAccountIDPoliciesSelector} from '@src/selectors/Policy'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; + +import {Str} from 'expensify-common'; +import {useEffect, useRef, useState} from 'react'; + +import useChatWithAgent from './useChatWithAgent'; +import useCleanupSelectedOptions from './useCleanupSelectedOptions'; +import useConfirmModal from './useConfirmModal'; +import useLocalize from './useLocalize'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import usePrevious from './usePrevious'; +import useRuleBotGuardModal from './useRuleBotGuardModal'; +import useSearchBackPress from './useSearchBackPress'; +import useSwitchToDelegator from './useSwitchToDelegator'; + +function handleErrorClose(pendingAction: PendingAction | null | undefined, accountID: number): void { + if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { + clearAgentError(accountID); + } else if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + clearAgentDeleteError(accountID); + } else { + clearAgentUpdateError(accountID); + } +} + +/** + * Hook that derives the current user's manageable agent rows from Onyx and exposes selection and bulk-delete helpers for the Agents table. + */ +function useAgents() { + useEffect(() => { + openAgentsPage(); + }, []); + + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const [allAgents] = useOnyx(ONYXKEYS.COLLECTION.AGENT); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const personalDetailsList = usePersonalDetails(); + const chatWithAgent = useChatWithAgent(); + const switchToDelegator = useSwitchToDelegator(); + const {showConfirmModal} = useConfirmModal(); + const showRuleBotGuardModal = useRuleBotGuardModal(); + const [selectedAgents, setSelectedAgents] = useState([]); + const policiesByAccountID = groupByPolicyAccountIDPoliciesSelector(allPolicies); + + const agents: AgentRowData[] = Object.entries(allAgents ?? {}).flatMap(([key, agent]) => { + if (!agent) { + return []; + } + const accountID = Number(key.slice(ONYXKEYS.COLLECTION.AGENT.length)); + const details = personalDetailsList?.[accountID]; + if (!details) { + return []; + } + const pendingAction = agent.pendingAction; + const isPendingDeletion = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + + if (!isOffline && isPendingDeletion) { + return []; + } + + let ownerDisplayName: string | undefined; + let ownerPolicyID: string | undefined; + if (agent.ownerType === CONST.AGENTS.OWNER_TYPE.PERSON) { + const ownerDetails = agent.ownerAccountID ? personalDetailsList?.[agent.ownerAccountID] : undefined; + ownerDisplayName = ownerDetails?.displayName ?? ownerDetails?.login; + } else if (agent.ownerType === CONST.AGENTS.OWNER_TYPE.WORKSPACE) { + const policy = agent.ownerAccountID ? policiesByAccountID.get(agent.ownerAccountID) : undefined; + ownerDisplayName = policy?.name; + ownerPolicyID = policy?.id; + } else if (agent.ownerType === CONST.AGENTS.OWNER_TYPE.DOMAIN) { + const ownerDetails = agent.ownerAccountID ? personalDetailsList?.[agent.ownerAccountID] : undefined; + ownerDisplayName = ownerDetails?.login ? Str.extractEmailDomain(ownerDetails.login) : undefined; + } + + const mergedErrors = { + ...getLatestError(agent.errors ?? undefined), + ...getLatestError(agent.nameErrors ?? undefined), + ...getLatestError(agent.promptErrors ?? undefined), + ...getLatestError(agent.avatarErrors ?? undefined), + }; + const rowErrors = getLatestError(mergedErrors); + + return [ + { + keyForList: String(accountID), + accountID, + displayName: details.displayName ?? details.login ?? '', + login: details.login ?? '', + ownerAccountID: agent.ownerAccountID, + ownerDisplayName, + ownerType: agent.ownerType, + ownerPolicyID, + pendingAction, + errors: Object.keys(rowErrors).length > 0 ? rowErrors : undefined, + disabled: isPendingDeletion, + canManage: true, + action: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT.getRoute(accountID))), + onChatPress: () => chatWithAgent(accountID), + onCopilotPress: () => switchToDelegator(details.login ?? ''), + dismissError: () => handleErrorClose(pendingAction, accountID), + }, + ]; + }); + + const tableRef = useRef>(null); + const agentKeys = agents.map((agent) => agent.keyForList); + const prevAgentKeys = usePrevious(agentKeys); + + // Highlight (and scroll to) a newly created agent's row once it appears in the table, mirroring + // the same pattern used for newly-invited workspace members (see WorkspaceMembersPage). Not + // gated on useIsFocused: on wide layouts this page is the central pane of a split navigator and + // stays visible (but unfocused per react-navigation) while the new agent's DM opens in the RHP. + useEffect(() => { + const newAgentKeys = agentKeys.filter((key) => !prevAgentKeys.includes(key)); + if (!newAgentKeys.length) { + return; + } + + const tableAgents = tableRef.current?.getProcessedData() ?? []; + const newAgentIndex = tableAgents.findIndex((agent) => newAgentKeys.includes(agent.keyForList)); + if (newAgentIndex !== -1) { + tableRef.current?.scrollToIndex({ + index: newAgentIndex, + animated: false, + viewPosition: 0.5, + }); + } + tableRef.current?.highlightItems(newAgentKeys); + }, [agentKeys, prevAgentKeys]); + + const agentsByAccountID = new Map(agents.map((agent) => [agent.keyForList, agent])); + const selectedAgentKeys = selectedAgents.filter((accountIDString) => { + const agent = agentsByAccountID.get(accountIDString); + return !!agent && agent.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + }); + + const clearSelectedAgents = () => setSelectedAgents((prevSelectedAgents) => (prevSelectedAgents.length > 0 ? [] : prevSelectedAgents)); + useCleanupSelectedOptions(clearSelectedAgents); + useSearchBackPress({ + onClearSelection: clearSelectedAgents, + onNavigationCallBack: () => Navigation.goBack(), + }); + + const removeSelectedAgents = () => { + for (const accountIDString of selectedAgentKeys) { + const accountID = Number(accountIDString); + const agentLogin = personalDetailsList?.[accountID]?.login; + deleteAgent(accountID, agentLogin, allPolicies, false); + } + clearSelectedAgents(); + }; + const askForConfirmationToDelete = async () => { + const ruleBotEnforcedPolicy = selectedAgentKeys.map((accountIDString) => getRuleBotEnforcedPolicy(Number(accountIDString), allPolicies)).find(Boolean); + if (ruleBotEnforcedPolicy) { + showRuleBotGuardModal('deleteAgent', ruleBotEnforcedPolicy.id); + return; + } + const result = await showConfirmModal({ + title: translate('agentsPage.deleteAgentsTitle', {count: selectedAgentKeys.length}), + prompt: translate('agentsPage.deleteAgentsMessage', {count: selectedAgentKeys.length}), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + shouldHandleNavigationBack: false, + }); + + if (result.action !== ModalActions.CONFIRM) { + return; + } + + removeSelectedAgents(); + }; + + return {agents, selectedAgentKeys, setSelectedAgents, clearSelectedAgents, askForConfirmationToDelete, tableRef}; +} + +export default useAgents; diff --git a/src/hooks/useAlertModals.ts b/src/hooks/useAlertModals.ts new file mode 100644 index 000000000000..8390e657f0b4 --- /dev/null +++ b/src/hooks/useAlertModals.ts @@ -0,0 +1,30 @@ +import useDecisionModal from './useDecisionModal'; +import useLocalize from './useLocalize'; + +/** + * Provides the one-button "You appear to be offline" and "Download failed" alerts shown through the global decision modal. + */ +function useAlertModals() { + const {translate} = useLocalize(); + const {showDecisionModal} = useDecisionModal(); + + const showOfflineModal = () => { + showDecisionModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.offlinePrompt'), + secondOptionText: translate('common.buttonConfirm'), + }); + }; + + const showDownloadErrorModal = () => { + showDecisionModal({ + title: translate('common.downloadFailedTitle'), + prompt: translate('common.downloadFailedDescription'), + secondOptionText: translate('common.buttonConfirm'), + }); + }; + + return {showOfflineModal, showDownloadErrorModal}; +} + +export default useAlertModals; diff --git a/src/hooks/useArrowKeyFocusManager.ts b/src/hooks/useArrowKeyFocusManager.ts index ce414d7a5831..cc5dc846b085 100644 --- a/src/hooks/useArrowKeyFocusManager.ts +++ b/src/hooks/useArrowKeyFocusManager.ts @@ -49,6 +49,7 @@ type UseArrowKeyFocusManager = [number, (index: number, shouldScrollHint?: boole * @param [config.itemsPerRow] – The number of items per row. If provided, the arrow keys will move focus horizontally as well as vertically * @param [config.disableCyclicTraversal] – Whether to disable cyclic traversal of the list. If true, the arrow keys will have no effect when the first or last item is focused * @param [config.allowHorizontalArrowKeys] – Whether to enable the right/left keys + * @param [config.allowNegativeIndexes] – Whether ArrowUp from the first enabled item moves focus to -1 (for example, back to a search input), even when cyclic traversal is on * @param [config.isFocused] Whether navigation is focused */ export default function useArrowKeyFocusManager({ @@ -114,7 +115,8 @@ export default function useArrowKeyFocusManager({ if (maxIndex < 0 || !isFocused) { return; } - const nextIndex = disableCyclicTraversal ? -1 : maxIndex; + // With negative indexes allowed, ArrowUp from the first item moves to -1 even when ArrowDown still wraps + const nextIndex = disableCyclicTraversal || allowNegativeIndexes ? -1 : maxIndex; setHasKeyBeenPressed?.(); setFocusedIndexInternal((actualIndex) => { const currentFocusedIndex = actualIndex > 0 ? actualIndex - (itemsPerRow ?? 1) : nextIndex; @@ -123,12 +125,12 @@ export default function useArrowKeyFocusManager({ while (disabledIndexes.includes(newFocusedIndex)) { newFocusedIndex -= itemsPerRow ?? 1; if (newFocusedIndex < 0) { - if (disableCyclicTraversal) { - if (!allowNegativeIndexes) { - return actualIndex; - } + if (allowNegativeIndexes) { break; } + if (disableCyclicTraversal) { + return actualIndex; + } newFocusedIndex = maxIndex; } if (newFocusedIndex === currentFocusedIndex) { diff --git a/src/hooks/useAssignCard.ts b/src/hooks/useAssignCard.ts index deb5234d1d85..efaeb370bf0a 100644 --- a/src/hooks/useAssignCard.ts +++ b/src/hooks/useAssignCard.ts @@ -1,5 +1,5 @@ import {importPlaidAccounts} from '@libs/actions/Plaid'; -import {getCompanyCardFeed, getCompanyFeeds, getDefaultCardName, getPlaidCountry, getPlaidInstitutionId, isCustomFeed, isSelectedFeedExpired} from '@libs/CardUtils'; +import {getCompanyCardFeed, getCompanyFeeds, getDefaultCardName, getPlaidCountry, getPlaidInstitutionId, isCSVUploadFeed, isCustomFeed, isSelectedFeedExpired} from '@libs/CardUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getDomainNameForPolicy, isDeletedPolicyEmployee} from '@libs/PolicyUtils'; @@ -147,6 +147,14 @@ function useInitialAssignCardStep({policyID, selectedFeed}: UseInitialAssignCard encryptedCardNumber: cardID, }; + // We assume admins only put the transactions they want in a CSV file, so there's no need to limit by date. + // Import all of them by default. + // Direct feeds keep today as the default because the bank can return months of history. + if (isCSVUploadFeed(selectedFeed)) { + cardToAssign.dateOption = CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING; + cardToAssign.startDate = ''; + } + // Refetch plaid card list if (!isFeedExpired && plaidAccessToken && !hasImportedPlaidAccounts.current) { const country = feedData?.country ?? ''; diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index c79150251df5..b11e4c97a1fb 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -19,6 +19,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import {useCallback, useMemo} from 'react'; import useDelegateAccountID from './useDelegateAccountID'; +import useIsAnonymousUser from './useIsAnonymousUser'; import useOnboardingWorkspaceCreationState from './useOnboardingWorkspaceCreationState'; import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; @@ -62,6 +63,7 @@ function useAutoCreateTrackWorkspace() { const [selfDMReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`); const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const isAnonymousUser = useIsAnonymousUser(); const {isBetaEnabled} = usePermissions(); const delegateAccountID = useDelegateAccountID(); @@ -143,6 +145,7 @@ function useAutoCreateTrackWorkspace() { isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), conciergeChatReportID, reportNameValuePairs, + isAnonymousUser, newPolicyID, mergedAccountConciergeReportID, false, @@ -172,6 +175,7 @@ function useAutoCreateTrackWorkspace() { isBetaEnabled, conciergeChatReportID, reportNameValuePairs, + isAnonymousUser, mergedAccountConciergeReportID, conciergeChat, selfDMReport, diff --git a/src/hooks/useAutocompleteSuggestions.ts b/src/hooks/useAutocompleteSuggestions.ts index 2bd13293d02d..c249c7bd1369 100644 --- a/src/hooks/useAutocompleteSuggestions.ts +++ b/src/hooks/useAutocompleteSuggestions.ts @@ -152,7 +152,7 @@ function useAutocompleteSuggestions({ if (!autocomplete && ranges.length > 0) { const lastRange = ranges.at(ranges.length - 1); - if (lastRange && CONTINUATION_DETECTION_SEARCH_FILTER_KEYS.includes(lastRange.key)) { + if (lastRange && CONTINUATION_DETECTION_SEARCH_FILTER_KEYS.some((key) => key === lastRange.key)) { const afterLastRange = autocompleteQueryValue.substring(lastRange.start + lastRange.length); const continuationMatch = afterLastRange.match(/^\s+(\w+)/); @@ -546,7 +546,9 @@ function useAutocompleteSuggestions({ })); } case CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE: - case CONST.SEARCH.SYNTAX_FILTER_KEYS.BILLABLE: { + case CONST.SEARCH.SYNTAX_FILTER_KEYS.BILLABLE: + case CONST.SEARCH.SYNTAX_FILTER_KEYS.DECLINED: + case CONST.SEARCH.SYNTAX_FILTER_KEYS.HELD: { const filteredValues = BOOLEAN_VALUES.filter((value) => value.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.has(value)).sort(); return filteredValues.map((value) => ({ @@ -571,10 +573,10 @@ function useAutocompleteSuggestions({ new Set( policyIdRanges .map((range) => { - const baseKey = getSubstitutionMapKey(range.key, range.value); + const baseKey = getSubstitutionMapKey(CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, range.value); const index = keyValueCount.get(baseKey) ?? 0; keyValueCount.set(baseKey, index + 1); - const fullKey = getSubstitutionMapKeyWithIndex(range.key, range.value, index); + const fullKey = getSubstitutionMapKeyWithIndex(CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, range.value, index); return autocompleteSubstitutions[fullKey] ?? (index === 0 ? autocompleteSubstitutions[baseKey] : undefined); }) .filter(Boolean), @@ -652,6 +654,7 @@ function useAutocompleteSuggestions({ mapKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO, })); } + case CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL: case CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE: case CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED: case CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED: @@ -663,7 +666,7 @@ function useAutocompleteSuggestions({ .filter((datePreset) => datePreset.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.has(datePreset.toLowerCase())) .sort() .slice(0, 10); - return filteredDatePresets.map((datePreset) => ({filterKey: autocompleteKey, text: datePreset})); + return filteredDatePresets.map((datePreset) => ({filterKey: getUserFriendlyKey(autocompleteKey), text: datePreset})); } default: { return []; diff --git a/src/hooks/useBlurOnKeyboardHide/index.android.ts b/src/hooks/useBlurOnKeyboardHide/index.android.ts index d83979265e9a..7623dd55493f 100644 --- a/src/hooks/useBlurOnKeyboardHide/index.android.ts +++ b/src/hooks/useBlurOnKeyboardHide/index.android.ts @@ -1,9 +1,13 @@ import {useEffect} from 'react'; -import {AppState} from 'react-native'; +import {AppState, Dimensions} from 'react-native'; import {KeyboardEvents} from 'react-native-keyboard-controller'; import type UseBlurOnKeyboardHide from './type'; +// Android tears the keyboard down without an animation when the device rotates and brings it back on its own. +// An instant hide waits this long for it before it is treated as a user dismissal. +const INSTANT_HIDE_BLUR_DELAY_MS = 300; + /** * Android hides the soft keyboard on a back press/gesture without clearing focus from the native EditText, * so JS never receives onBlur and the input stays focused. Blur it explicitly once the keyboard is gone. @@ -11,16 +15,39 @@ import type UseBlurOnKeyboardHide from './type'; const useBlurOnKeyboardHide: UseBlurOnKeyboardHide = (ref) => { useEffect(() => { let shouldSkipNextHide = false; + let pendingBlurTimeoutID: ReturnType | undefined; + let lastWindowWidth = Dimensions.get('window').width; + let lastWindowWidthChangeTime = 0; + + const cancelPendingBlur = () => { + clearTimeout(pendingBlurTimeoutID); + pendingBlurTimeoutID = undefined; + }; const appStateSubscription = AppState.addEventListener('change', (state) => { if (state === 'active') { return; } shouldSkipNextHide = true; + cancelPendingBlur(); + }); + + // Only width changes count, because the soft keyboard itself changes the window height on Android. + const dimensionsSubscription = Dimensions.addEventListener('change', ({window}) => { + if (window.width === lastWindowWidth) { + return; + } + lastWindowWidth = window.width; + lastWindowWidthChangeTime = Date.now(); + cancelPendingBlur(); }); + // keyboardDidShow only fires once the show animation ends, which can be after the pending blur fires. + const willShowSubscription = KeyboardEvents.addListener('keyboardWillShow', cancelPendingBlur); + const showSubscription = KeyboardEvents.addListener('keyboardDidShow', () => { shouldSkipNextHide = false; + cancelPendingBlur(); }); const hideSubscription = KeyboardEvents.addListener('keyboardDidHide', (event) => { @@ -36,11 +63,29 @@ const useBlurOnKeyboardHide: UseBlurOnKeyboardHide = (ref) => { if (shouldSkip) { return; } - ref.current?.blur(); + + if (event.duration !== 0) { + ref.current?.blur(); + return; + } + + // An instant hide is most likely a rotation, which must keep the input focused. The width change may + // arrive on either side of the hide, so the one above skips the blur and this one cancels it. + if (Date.now() - lastWindowWidthChangeTime < INSTANT_HIDE_BLUR_DELAY_MS) { + return; + } + cancelPendingBlur(); + pendingBlurTimeoutID = setTimeout(() => { + pendingBlurTimeoutID = undefined; + ref.current?.blur(); + }, INSTANT_HIDE_BLUR_DELAY_MS); }); return () => { + cancelPendingBlur(); appStateSubscription.remove(); + dimensionsSubscription.remove(); + willShowSubscription.remove(); showSubscription.remove(); hideSubscription.remove(); }; diff --git a/src/hooks/useCompanyCards.ts b/src/hooks/useCompanyCards.ts index 7bc08dbfd222..536177e1fa0d 100644 --- a/src/hooks/useCompanyCards.ts +++ b/src/hooks/useCompanyCards.ts @@ -63,7 +63,6 @@ function resolveCardListEntry(card: Card, cardListEntries: Array<[string, string const {cardName, encryptedCardNumber, lastFourPAN} = card; // Using || instead of ?? because an empty-string lastFourPAN should fall through to cardName - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const panSuffix = lastFourPAN || cardName; const isLinkedByEncrypted = encryptedCardNumber && cardListEntries.some(([, entryEncryptedCardNumber]) => entryEncryptedCardNumber === encryptedCardNumber); @@ -161,7 +160,6 @@ function buildCompanyCardEntries( function useCompanyCards({policyID, feedName: feedNameProp}: UseCompanyCardsProps): UseCompanyCardsResult { // If an empty string is passed, we need to use an invalid key to avoid fetching the whole collection. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const policyIDKey = policyID || CONST.DEFAULT_MISSING_ID; const [lastSelectedFeed, lastSelectedFeedMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyIDKey}`); diff --git a/src/hooks/useCompleteOnboarding.ts b/src/hooks/useCompleteOnboarding.ts index 0d432a3b4f92..bb8943332ccc 100644 --- a/src/hooks/useCompleteOnboarding.ts +++ b/src/hooks/useCompleteOnboarding.ts @@ -21,6 +21,7 @@ import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useDelegateAccountID from './useDelegateAccountID'; import useHasActiveAdminPolicies from './useHasActiveAdminPolicies'; import useHasOwnedPaidPolicy from './useHasOwnedPaidPolicy'; +import useIsAnonymousUser from './useIsAnonymousUser'; import useLastWorkspaceNumber from './useLastWorkspaceNumber'; import useLocalize from './useLocalize'; import useOnboardingMessages from './useOnboardingMessages'; @@ -59,6 +60,7 @@ function useCompleteOnboarding() { const [adminsChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${onboardingAdminsChatReportID}`); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const isAnonymousUser = useIsAnonymousUser(); const [isLoading, setIsLoading] = useState(false); const groupPolicy = Object.values(allPolicies ?? {}).find((policy) => isGroupPolicy(policy) && isPolicyAdmin(policy, currentUserPersonalDetails.email)); @@ -143,6 +145,7 @@ function useCompleteOnboarding() { isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), conciergeReportID, reportNameValuePairs, + isAnonymousUser, policyID, adminsChatReportID, (currentUserPersonalDetails.email ?? '').includes('+'), diff --git a/src/hooks/useContinueJoinWorkspaceOnboarding.ts b/src/hooks/useContinueJoinWorkspaceOnboarding.ts new file mode 100644 index 000000000000..5b799064ef3c --- /dev/null +++ b/src/hooks/useContinueJoinWorkspaceOnboarding.ts @@ -0,0 +1,131 @@ +import Log from '@libs/Log'; +import {getEmailDomain} from '@libs/LoginUtils'; +import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import type {LinkToOptions} from '@libs/Navigation/helpers/linkTo/types'; +import Navigation from '@libs/Navigation/Navigation'; +import {getJoinWorkspaceMessage} from '@libs/OnboardingUtils'; +import {isCurrentUserValidated} from '@libs/UserUtils'; + +import {completeOnboarding} from '@userActions/Report'; +import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@userActions/Welcome'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {DisplayNameForm} from '@src/types/form/DisplayNameForm'; +import type {IntroSelected, LoginList, Onboarding, Report, ReportNameValuePairs, Session} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {useRef} from 'react'; + +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useOnboardingMessages from './useOnboardingMessages'; +import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; +import useResponsiveLayout from './useResponsiveLayout'; + +type UseContinueJoinWorkspaceOnboardingParams = { + session: OnyxEntry; + loginList: OnyxEntry; + onboardingValues: OnyxEntry; + onboardingPersonalDetails: OnyxEntry; + introSelected: OnyxEntry; + conciergeReportID: OnyxEntry; + conciergeChat: OnyxEntry; + reportNameValuePairs: OnyxCollection; + delegateAccountID: number | undefined; +}; + +/** + * Returns a function that ends the Join Workspace onboarding flow from a step that has nothing further to show. + * + * Callers own the shared Onyx subscriptions; this hook only reads the policy and admins chat used by completion. + * + * The name step is the last step of this flow. When a name is already saved, onboarding completes with it exactly as + * pressing Continue on the name step would; otherwise the name step opens to collect one. + */ +function useContinueJoinWorkspaceOnboarding({ + session, + loginList, + onboardingValues, + onboardingPersonalDetails, + introSelected, + conciergeReportID, + conciergeChat, + reportNameValuePairs, + delegateAccountID, +}: UseContinueJoinWorkspaceOnboardingParams) { + // We need to use isSmallScreenWidth, see navigateAfterOnboarding function comment + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const {isBetaEnabled} = usePermissions(); + const {joinWorkspaceMessages} = useOnboardingMessages(); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID); + const [onboardingPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${onboardingPolicyID}`); + const [onboardingAdminsChatReportID] = useOnyx(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID); + const isCompletingOnboarding = useRef(false); + const isSelfTourViewed = hasSeenTourSelector(onboardingValues); + + const workEmail = session?.email ?? ''; + const isAnonymousUser = session?.authTokenType === CONST.AUTH_TOKEN_TYPES.ANONYMOUS; + const isValidated = isCurrentUserValidated(loginList, workEmail); + const firstName = onboardingPersonalDetails?.firstName?.trim(); + const lastName = onboardingPersonalDetails?.lastName?.trim() ?? ''; + + // After a public email is merged into a work email, onboarding ends in the work email's Concierge chat. + const mergedAccountConciergeReportID = !onboardingValues?.shouldRedirectToClassicAfterMerge && onboardingValues?.shouldValidate ? conciergeReportID : undefined; + + const completeOnboardingWithSavedName = async (savedFirstName: string) => { + if (isCompletingOnboarding.current) { + return; + } + isCompletingOnboarding.current = true; + + try { + await completeOnboarding({ + engagementChoice: CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE, + onboardingMessage: getJoinWorkspaceMessage({joinWorkspaceMessages, onboardingPolicyID, onboardingPolicy, workEmail, isValidated}), + firstName: savedFirstName, + lastName, + adminsChatReportID: onboardingAdminsChatReportID, + introSelected, + isSelfTourViewed, + conciergeChat, + companyDomain: getEmailDomain(workEmail), + workEmail, + currentUserAccountID, + delegateAccountID, + }); + + setOnboardingAdminsChatReportID(); + setOnboardingPolicyID(); + + navigateAfterOnboardingWithMicrotaskQueue( + isSmallScreenWidth, + isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), + conciergeReportID, + reportNameValuePairs, + isAnonymousUser, + onboardingPolicy ? onboardingPolicyID : undefined, + mergedAccountConciergeReportID, + false, + ); + } catch (error) { + Log.warn('[useContinueJoinWorkspaceOnboarding] Error completing onboarding with the saved name', {error}); + isCompletingOnboarding.current = false; + } + }; + + return (navigationOptions?: LinkToOptions) => { + if (!firstName) { + Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), navigationOptions); + return; + } + completeOnboardingWithSavedName(firstName); + }; +} + +export default useContinueJoinWorkspaceOnboarding; diff --git a/src/hooks/useCopySelectionEvent.ts b/src/hooks/useCopySelectionEvent.ts new file mode 100644 index 000000000000..68945915fbd4 --- /dev/null +++ b/src/hooks/useCopySelectionEvent.ts @@ -0,0 +1,56 @@ +import getClipboardText from '@libs/Clipboard/getClipboardText'; +import SelectionScraper from '@libs/SelectionScraper'; + +import {useEffect} from 'react'; + +function hasSelectionWithinEditableTarget(target: EventTarget | null) { + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + return target.selectionStart !== null && target.selectionEnd !== null && target.selectionStart !== target.selectionEnd; + } + + if (!(target instanceof HTMLElement) || !target.isContentEditable) { + return false; + } + + const selection = window.getSelection(); + return !!selection && (target.contains(selection.anchorNode) || target.contains(selection.focusNode)); +} + +function copySelectionToClipboard(event: ClipboardEvent) { + if (event.defaultPrevented || !event.clipboardData || hasSelectionWithinEditableTarget(event.target)) { + return; + } + + const selection = SelectionScraper.getCurrentSelection(); + if (!selection) { + return; + } + + const clipboardText = getClipboardText(selection); + event.preventDefault(); + event.clipboardData.clearData(); + event.clipboardData.setData('text/html', selection); + event.clipboardData.setData('text/plain', clipboardText); +} + +let copySelectionEventConsumerCount = 0; + +export default function useCopySelectionEvent() { + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + + if (copySelectionEventConsumerCount === 0) { + document.addEventListener('copy', copySelectionToClipboard); + } + copySelectionEventConsumerCount += 1; + + return () => { + copySelectionEventConsumerCount -= 1; + if (copySelectionEventConsumerCount === 0) { + document.removeEventListener('copy', copySelectionToClipboard); + } + }; + }, []); +} diff --git a/src/hooks/useCopyableTextRowPress/index.native.ts b/src/hooks/useCopyableTextRowPress/index.native.ts new file mode 100644 index 000000000000..0ed3d818b772 --- /dev/null +++ b/src/hooks/useCopyableTextRowPress/index.native.ts @@ -0,0 +1,40 @@ +type MarkCopyableTextMouseDownOptions = { + shouldSuppressNextPress?: boolean; +}; + +type HandleCopyableTextRowPressOptions = { + shouldCheck?: boolean; +}; + +type HandleCopyableTextRowPressInOptions = { + shouldEnableTextSelection?: boolean; + shouldBlockSelection?: boolean; +}; + +const isPressStartOnCopyableText = () => false; + +function useCopyableTextRowPress() { + // Native does not expose browser selection APIs, so copyable text never suppresses row interactions here. + const markMouseDownOnCopyableText: (target: EventTarget | null | undefined, shouldCheck?: boolean, options?: MarkCopyableTextMouseDownOptions) => boolean = () => false; + const markTouchStartOnCopyableText: (event: unknown, shouldCheck?: boolean) => boolean = () => false; + const shouldSuppressCopyableTextRowPress: (shouldCheck?: boolean) => boolean = () => false; + const shouldSuppressCopyableTextRowLongPress: (shouldCheck?: boolean) => boolean = () => false; + const shouldSuppressCopyableTextRowFocus = () => false; + const handleCopyableTextRowPress: (onPress: () => void, options?: HandleCopyableTextRowPressOptions) => void = (onPress) => onPress(); + const handleCopyableTextRowPressIn: (options?: HandleCopyableTextRowPressInOptions) => void = () => {}; + + return { + didTouchStartOnCopyableText: false, + handleCopyableTextRowPress, + handleCopyableTextRowPressIn, + isPressStartOnCopyableText, + markMouseDownOnCopyableText, + markTouchStartOnCopyableText, + shouldSuppressCopyableTextRowFocus, + shouldSuppressCopyableTextRowLongPress, + shouldSuppressCopyableTextRowPress, + }; +} + +export {isPressStartOnCopyableText}; +export default useCopyableTextRowPress; diff --git a/src/hooks/useCopyableTextRowPress/index.ts b/src/hooks/useCopyableTextRowPress/index.ts new file mode 100644 index 000000000000..d42d6a3762e8 --- /dev/null +++ b/src/hooks/useCopyableTextRowPress/index.ts @@ -0,0 +1,266 @@ +import ControlSelection from '@libs/ControlSelection'; + +import CONST from '@src/CONST'; + +import {useRef, useState} from 'react'; + +const COPYABLE_TEXT_SELECTOR = `[data-${CONST.COPYABLE_TEXT_ELEMENT}=true]`; + +type MarkCopyableTextMouseDownOptions = { + shouldSuppressNextPress?: boolean; +}; + +type HandleCopyableTextRowPressOptions = { + shouldCheck?: boolean; +}; + +type HandleCopyableTextRowPressInOptions = { + shouldEnableTextSelection?: boolean; + shouldBlockSelection?: boolean; +}; + +type UseCopyableTextRowPressOptions = { + shouldTrackTouchStart?: boolean; +}; + +type PressStartOnCopyableTextCache = { + clientX: number | undefined; + clientY: number | undefined; + result: boolean; + target: EventTarget | null; +}; + +function getCopyableTextElement(target: EventTarget | Node | null | undefined): HTMLElement | null { + if (typeof HTMLElement === 'undefined') { + return null; + } + + try { + if (target instanceof HTMLElement) { + return target.closest(COPYABLE_TEXT_SELECTOR); + } + + if (typeof Node !== 'undefined' && target instanceof Node) { + return target.parentElement?.closest(COPYABLE_TEXT_SELECTOR) ?? null; + } + } catch { + return null; + } + + return null; +} + +function isCopyableTextTarget(target: EventTarget | null | undefined): boolean { + return !!getCopyableTextElement(target); +} + +function getTouchPosition(touches: unknown): {clientX: number; clientY: number} | null { + if (typeof touches !== 'object' || touches === null || !('0' in touches)) { + return null; + } + + const touch = touches[0]; + if (typeof touch !== 'object' || touch === null || !('clientX' in touch) || !('clientY' in touch) || typeof touch.clientX !== 'number' || typeof touch.clientY !== 'number') { + return null; + } + + return {clientX: touch.clientX, clientY: touch.clientY}; +} + +function getPressStartPosition(event: unknown): {clientX: number; clientY: number} | null { + if (typeof event !== 'object' || event === null) { + return null; + } + + if ('clientX' in event && 'clientY' in event && typeof event.clientX === 'number' && typeof event.clientY === 'number') { + return {clientX: event.clientX, clientY: event.clientY}; + } + + if ('touches' in event) { + const touchPosition = getTouchPosition(event.touches); + if (touchPosition) { + return touchPosition; + } + } + + if (!('nativeEvent' in event) || typeof event.nativeEvent !== 'object' || event.nativeEvent === null) { + return null; + } + + const {nativeEvent} = event; + if ('clientX' in nativeEvent && 'clientY' in nativeEvent && typeof nativeEvent.clientX === 'number' && typeof nativeEvent.clientY === 'number') { + return {clientX: nativeEvent.clientX, clientY: nativeEvent.clientY}; + } + + if ('touches' in nativeEvent) { + return getTouchPosition(nativeEvent.touches); + } + + return null; +} + +function getPressStartTarget(event: unknown): EventTarget | null { + if (typeof EventTarget === 'undefined' || typeof event !== 'object' || event === null || !('target' in event) || !(event.target instanceof EventTarget)) { + return null; + } + + return event.target; +} + +function isPressStartOnCopyableText(event: unknown): boolean { + const position = getPressStartPosition(event); + const copyableElement = getCopyableTextElement(getPressStartTarget(event)); + if (!position || !copyableElement || typeof document === 'undefined' || typeof NodeFilter === 'undefined') { + return false; + } + + const walker = document.createTreeWalker(copyableElement, NodeFilter.SHOW_TEXT); + let textNode = walker.nextNode(); + + while (textNode) { + if (textNode.textContent?.trim()) { + const range = document.createRange(); + range.selectNodeContents(textNode); + + const isInsideText = Array.from(range.getClientRects()).some( + (rect) => position.clientX >= rect.left && position.clientX <= rect.right && position.clientY >= rect.top && position.clientY <= rect.bottom, + ); + if (isInsideText) { + return true; + } + } + textNode = walker.nextNode(); + } + + return false; +} + +// Row press handlers use this after mouseup/click to suppress navigation only for the drag-select gesture that started on copyable text. +function shouldSuppressCopyableTextPress(didMouseDownStartOnCopyableText: boolean): boolean { + if (!didMouseDownStartOnCopyableText) { + return false; + } + + if (typeof window === 'undefined' || typeof window.getSelection !== 'function') { + return false; + } + + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || selection.toString().length === 0) { + return false; + } + + const anchorCopyableElement = getCopyableTextElement(selection.anchorNode); + const focusCopyableElement = getCopyableTextElement(selection.focusNode); + return !!anchorCopyableElement || !!focusCopyableElement; +} + +function useCopyableTextRowPress({shouldTrackTouchStart = false}: UseCopyableTextRowPressOptions = {}) { + const wasMouseDownOnCopyableTextRef = useRef(false); + const wasTouchStartOnCopyableTextRef = useRef(false); + const didTouchStartOnCopyableTextRef = useRef(false); + const shouldSuppressNextPressRef = useRef(false); + const pressStartOnCopyableTextCacheRef = useRef(null); + const [didTouchStartOnCopyableText, setDidTouchStartOnCopyableText] = useState(false); + + const getCachedIsPressStartOnCopyableText = (event: unknown, shouldCheck = true): boolean => { + if (!shouldCheck) { + return false; + } + + const position = getPressStartPosition(event); + const target = getPressStartTarget(event); + const cachedPressStart = pressStartOnCopyableTextCacheRef.current; + + if (cachedPressStart && cachedPressStart.target === target && cachedPressStart.clientX === position?.clientX && cachedPressStart.clientY === position?.clientY) { + return cachedPressStart.result; + } + + const result = isPressStartOnCopyableText(event); + pressStartOnCopyableTextCacheRef.current = { + clientX: position?.clientX, + clientY: position?.clientY, + result, + target, + }; + return result; + }; + + const markMouseDownOnCopyableText = (target: EventTarget | null | undefined, shouldCheck = true, {shouldSuppressNextPress = false}: MarkCopyableTextMouseDownOptions = {}): boolean => { + pressStartOnCopyableTextCacheRef.current = null; + const isCopyableTarget = shouldCheck && isCopyableTextTarget(target); + wasMouseDownOnCopyableTextRef.current = isCopyableTarget; + didTouchStartOnCopyableTextRef.current = false; + shouldSuppressNextPressRef.current = isCopyableTarget && shouldSuppressNextPress; + if (shouldTrackTouchStart) { + setDidTouchStartOnCopyableText(false); + } + return isCopyableTarget; + }; + + const markTouchStartOnCopyableText = (event: unknown, shouldCheck = true): boolean => { + pressStartOnCopyableTextCacheRef.current = null; + const isCopyableTarget = shouldCheck && isCopyableTextTarget(getPressStartTarget(event)); + wasMouseDownOnCopyableTextRef.current = false; + wasTouchStartOnCopyableTextRef.current = isCopyableTarget; + didTouchStartOnCopyableTextRef.current = isCopyableTarget; + shouldSuppressNextPressRef.current = false; + if (shouldTrackTouchStart) { + setDidTouchStartOnCopyableText(isCopyableTarget); + } + return isCopyableTarget; + }; + + // RN Web press-in events omit client coordinates, so reuse the original touch hit-test before blocking selection. + const handleCopyableTextRowPressIn = ({shouldEnableTextSelection = false, shouldBlockSelection = false}: HandleCopyableTextRowPressInOptions = {}) => { + if (shouldEnableTextSelection && didTouchStartOnCopyableTextRef.current) { + return; + } + + if (shouldBlockSelection) { + ControlSelection.block(); + } + }; + + const shouldSuppressCopyableTextRowPress = (shouldCheck = true): boolean => { + const didPressStartOnCopyableText = wasMouseDownOnCopyableTextRef.current || wasTouchStartOnCopyableTextRef.current; + const shouldSuppressPress = shouldCheck && (shouldSuppressNextPressRef.current || shouldSuppressCopyableTextPress(didPressStartOnCopyableText)); + wasMouseDownOnCopyableTextRef.current = false; + wasTouchStartOnCopyableTextRef.current = false; + shouldSuppressNextPressRef.current = false; + pressStartOnCopyableTextCacheRef.current = null; + return shouldSuppressPress; + }; + + const shouldSuppressCopyableTextRowLongPress = (shouldCheck = true): boolean => { + const shouldSuppressLongPress = shouldCheck && wasTouchStartOnCopyableTextRef.current; + shouldSuppressNextPressRef.current = shouldSuppressLongPress; + return shouldSuppressLongPress; + }; + + const handleCopyableTextRowPress = (onPress: () => void, {shouldCheck = true}: HandleCopyableTextRowPressOptions = {}) => { + if (shouldSuppressCopyableTextRowPress(shouldCheck)) { + return; + } + + onPress(); + }; + + // Pointer focus from copyable text should not make virtualized lists scroll the row into view. + const shouldSuppressCopyableTextRowFocus = () => wasMouseDownOnCopyableTextRef.current; + + return { + didTouchStartOnCopyableText, + handleCopyableTextRowPress, + handleCopyableTextRowPressIn, + isPressStartOnCopyableText: getCachedIsPressStartOnCopyableText, + markMouseDownOnCopyableText, + markTouchStartOnCopyableText, + shouldSuppressCopyableTextRowFocus, + shouldSuppressCopyableTextRowLongPress, + shouldSuppressCopyableTextRowPress, + }; +} + +export {isPressStartOnCopyableText}; +export default useCopyableTextRowPress; diff --git a/src/hooks/useCreateReport.tsx b/src/hooks/useCreateReport.tsx index a67a94f32316..a3124b1c6dd3 100644 --- a/src/hooks/useCreateReport.tsx +++ b/src/hooks/useCreateReport.tsx @@ -1,7 +1,8 @@ +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; -import {getDefaultChatEnabledPolicy, isGroupPolicy} from '@libs/PolicyUtils'; +import {canCreateReportOnPolicy, getDefaultChatEnabledPolicySelection, isGroupPolicy} from '@libs/PolicyUtils'; import {generateReportID} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; @@ -11,20 +12,21 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useCallback} from 'react'; +// eslint-disable-next-line no-restricted-imports -- Need original useOnyx to avoid reading partial Search snapshot policy data. +import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx'; import useCreateEmptyReportConfirmation from './useCreateEmptyReportConfirmation'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useOnyx from './useOnyx'; +import usePreferredPolicy from './usePreferredPolicy'; import useShouldShowEmptyReportConfirmation from './useShouldShowEmptyReportConfirmation'; type UseCreateReportParams = { - /** Callback that creates the report and navigates after creation */ - onCreateReport: (shouldDismissEmptyReportsConfirmation?: boolean) => void; - /** Group paid policies with expense chat enabled */ - groupPoliciesWithChatEnabled: readonly never[] | Array>; + /** Callback that creates the report on the resolved workspace and navigates after creation */ + onCreateReport: (policy: OnyxEntry, shouldDismissEmptyReportsConfirmation?: boolean) => void; /** Optional custom navigation to the workspace selector */ onNavigateToWorkspaceSelection?: () => void; /** Whether the empty-report confirmation modal should push a history entry so browser-back dismisses it (default: true) */ @@ -46,33 +48,37 @@ type UseCreateReportResult = { * * Decision flow: * 1. Navigate to upgrade path if user has no valid group policies at all - * 2. Navigate to workspace selector if default is personal AND there are at least 2 non-personal workspaces, or if the chosen default is billing-restricted and alternatives exist + * 2. Navigate to workspace selector if default is personal AND there are at least 2 non-personal workspaces, or if the chosen default is billing-restricted and alternatives exist. + * When the domain security group locks the user to an eligible preferred workspace, that workspace is the default and the selector is never offered. * 3. Show empty report confirmation or create directly if workspace is valid * 4. Navigate to restricted action if billing restricts the workspace */ export default function useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled, onNavigateToWorkspaceSelection, shouldHandleNavigationBack = true, shouldSkipEmptyReportConfirmation = false, }: UseCreateReportParams): UseCreateReportResult { const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); - const [, policiesLoadStatus] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + // Read live: under SearchScopeProvider (Reports empty view) useOnyx would serve stale snapshot policies. + const [activePolicy] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); + const {accountID, login} = useCurrentUserPersonalDetails(); + const defaultChatEnabledPolicySelector = (policies: OnyxCollection) => getDefaultChatEnabledPolicySelection(policies, login, activePolicyID); + const [defaultChatEnabledPolicySelection, policiesLoadStatus] = useOnyxWithoutSnapshots(ONYXKEYS.COLLECTION.POLICY, {selector: defaultChatEnabledPolicySelector}); + const [defaultChatEnabledPolicyByKey] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(defaultChatEnabledPolicySelection?.defaultChatEnabledPolicyID)}`); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); - const {accountID} = useCurrentUserPersonalDetails(); + const {isRestrictedToPreferredPolicy, preferredPolicyID, isLoadingPreferredPolicy} = usePreferredPolicy(); + const [preferredPolicy, preferredPolicyLoadStatus] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(preferredPolicyID)}`); - // Gate visibility and routing on policy hydration. Without this, during Onyx cold-start - // groupPoliciesWithChatEnabled.length === 0 would be true even for users who actually have - // workspaces, sending them to MONEY_REQUEST_UPGRADE as if they had none. - const arePoliciesLoaded = !isLoadingOnyxValue(policiesLoadStatus); + // Wait for policies and security group to load, or a cold start reads as "no workspaces" / "not restricted". + const arePoliciesLoaded = !isLoadingOnyxValue(policiesLoadStatus, preferredPolicyLoadStatus) && !isLoadingPreferredPolicy; const isVisible = arePoliciesLoaded; - const shouldNavigateToUpgradePath = groupPoliciesWithChatEnabled.length === 0; + const shouldNavigateToUpgradePath = !defaultChatEnabledPolicySelection?.hasChatEnabledPolicies; - const defaultChatEnabledPolicy = getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled as Array>, activePolicy); + const isLockedToPreferredPolicy = isRestrictedToPreferredPolicy && canCreateReportOnPolicy(preferredPolicy, login); + const defaultChatEnabledPolicy = isLockedToPreferredPolicy ? preferredPolicy : defaultChatEnabledPolicyByKey; const defaultChatEnabledPolicyID = defaultChatEnabledPolicy?.id; const shouldShowEmptyReportConfirmation = useShouldShowEmptyReportConfirmation(defaultChatEnabledPolicyID, shouldSkipEmptyReportConfirmation); @@ -80,7 +86,7 @@ export default function useCreateReport({ const {openCreateReportConfirmation} = useCreateEmptyReportConfirmation({ policyID: defaultChatEnabledPolicyID, policyName: defaultChatEnabledPolicy?.name ?? '', - onConfirm: onCreateReport, + onConfirm: (shouldDismissEmptyReportsConfirmation: boolean) => onCreateReport(defaultChatEnabledPolicy, shouldDismissEmptyReportsConfirmation), shouldHandleNavigationBack, }); @@ -115,11 +121,12 @@ export default function useCreateReport({ // the default is billing-restricted and alternatives exist, so the user isn't dead-ended // on the restricted-action page. const isDefaultPersonal = !activePolicy || activePolicy.type === CONST.POLICY.TYPE.PERSONAL || !isGroupPolicy(activePolicy); - const hasMultipleNonPersonalWorkspaces = groupPoliciesWithChatEnabled.length > 1; + const hasMultipleNonPersonalWorkspaces = !!defaultChatEnabledPolicySelection?.hasMultipleChatEnabledPolicies; const isDefaultBillingRestricted = !!workspaceIDForReportCreation && shouldRestrictUserBillableActions(defaultChatEnabledPolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID); + const shouldOfferAlternatives = !isLockedToPreferredPolicy && hasMultipleNonPersonalWorkspaces && (isDefaultPersonal || isDefaultBillingRestricted); - if (!workspaceIDForReportCreation || (isDefaultPersonal && hasMultipleNonPersonalWorkspaces) || (isDefaultBillingRestricted && hasMultipleNonPersonalWorkspaces)) { + if (!workspaceIDForReportCreation || shouldOfferAlternatives) { if (onNavigateToWorkspaceSelection) { onNavigateToWorkspaceSelection(); } else { @@ -133,7 +140,7 @@ export default function useCreateReport({ if (shouldShowEmptyReportConfirmation) { openCreateReportConfirmation(); } else { - onCreateReport(false); + onCreateReport(defaultChatEnabledPolicy, false); } return; } @@ -150,7 +157,8 @@ export default function useCreateReport({ userBillingGracePeriodEnds, amountOwed, accountID, - groupPoliciesWithChatEnabled.length, + isLockedToPreferredPolicy, + defaultChatEnabledPolicySelection?.hasMultipleChatEnabledPolicies, onNavigateToWorkspaceSelection, shouldShowEmptyReportConfirmation, openCreateReportConfirmation, diff --git a/src/hooks/useDynamicBackPath.ts b/src/hooks/useDynamicBackPath.ts index 048bfa212ac1..9e535f8b43f0 100644 --- a/src/hooks/useDynamicBackPath.ts +++ b/src/hooks/useDynamicBackPath.ts @@ -1,5 +1,4 @@ -import findAllMatchingDynamicSuffixes from '@libs/Navigation/helpers/dynamicRoutesUtils/findAllMatchingDynamicSuffixes'; -import getPathWithoutDynamicSuffix from '@libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix'; +import stripDynamicRouteSuffixFromPath from '@libs/Navigation/helpers/dynamicRoutesUtils/stripDynamicRouteSuffixFromPath'; import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; import type {State} from '@libs/Navigation/types'; @@ -34,13 +33,7 @@ function useDynamicBackPath(dynamicRouteSuffix: DynamicRouteSuffix, isEnabled = return ROUTES.HOME; } - const pathWithoutLeadingSlash = path.replaceAll(/^\/+/g, ''); - const match = findAllMatchingDynamicSuffixes(pathWithoutLeadingSlash).find((m) => m.pattern === dynamicRouteSuffix); - if (match) { - return getPathWithoutDynamicSuffix(match.pathUsedForMatching, match.actualSuffix, match.pattern); - } - - return pathWithoutLeadingSlash as Route; + return stripDynamicRouteSuffixFromPath(path, dynamicRouteSuffix); } export default useDynamicBackPath; diff --git a/src/hooks/useExpenseEducationalModal.ts b/src/hooks/useExpenseEducationalModal.ts new file mode 100644 index 000000000000..5803b3464b9a --- /dev/null +++ b/src/hooks/useExpenseEducationalModal.ts @@ -0,0 +1,27 @@ +import variables from '@styles/variables'; + +import type {ReactNode} from 'react'; + +import useFeatureTrainingModal from './useFeatureTrainingModal'; +import useThemeStyles from './useThemeStyles'; + +/** Shows an expense educational modal and returns the global stack's dismissal promise. */ +function useExpenseEducationalModal() { + const styles = useThemeStyles(); + const {showFeatureTrainingModal} = useFeatureTrainingModal(); + + const showModal = (children: ReactNode) => { + return showFeatureTrainingModal({ + width: variables.holdEducationModalWidth, + modalInnerContainerStyle: styles.pt0, + shouldCloseOnConfirm: false, + shouldUseScrollView: true, + shouldHandleNavigationBack: true, + children, + }); + }; + + return showModal; +} + +export default useExpenseEducationalModal; diff --git a/src/hooks/useExpensifyCardStatementPDFDownloadModal.ts b/src/hooks/useExpensifyCardStatementPDFDownloadModal.ts new file mode 100644 index 000000000000..cfbb727770e5 --- /dev/null +++ b/src/hooks/useExpensifyCardStatementPDFDownloadModal.ts @@ -0,0 +1,28 @@ +import type {ExpensifyCardStatementPDFDownloadModalWrapperProps} from '@components/Modal/Global/ExpensifyCardStatementPDFDownloadModalWrapper'; +import ExpensifyCardStatementPDFDownloadModalWrapper from '@components/Modal/Global/ExpensifyCardStatementPDFDownloadModalWrapper'; +import type {ModalProps} from '@components/Modal/Global/ModalContext'; +import {useModal} from '@components/Modal/Global/ModalContext'; + +type ExpensifyCardStatementPDFDownloadModalOptions = Omit; + +// A fixed id lets a second show call update the open modal in place, which is how the statementKey reaches it +// once GetExpensifyCardStatementPDF returns. +const EXPENSIFY_CARD_STATEMENT_PDF_DOWNLOAD_MODAL_ID = 'expensifyCardStatementPDFDownloadModal'; + +const useExpensifyCardStatementPDFDownloadModal = () => { + const context = useModal(); + + const showExpensifyCardStatementPDFDownloadModal = (options: ExpensifyCardStatementPDFDownloadModalOptions) => { + return context.showModal({ + component: ExpensifyCardStatementPDFDownloadModalWrapper, + id: EXPENSIFY_CARD_STATEMENT_PDF_DOWNLOAD_MODAL_ID, + props: options, + }); + }; + + const closeExpensifyCardStatementPDFDownloadModal = () => context.closeModalByID(EXPENSIFY_CARD_STATEMENT_PDF_DOWNLOAD_MODAL_ID); + + return {showExpensifyCardStatementPDFDownloadModal, closeExpensifyCardStatementPDFDownloadModal}; +}; + +export default useExpensifyCardStatementPDFDownloadModal; diff --git a/src/hooks/useExportActions.ts b/src/hooks/useExportActions.ts index 70efa432033f..ea4cf7dc8e98 100644 --- a/src/hooks/useExportActions.ts +++ b/src/hooks/useExportActions.ts @@ -21,6 +21,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import useAlertModals from './useAlertModals'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useDecisionModal from './useDecisionModal'; import useExportAgainModal from './useExportAgainModal'; @@ -87,6 +88,7 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa const isExported = isExportedUtils(reportActions, moneyRequestReport); const {showDecisionModal} = useDecisionModal(); + const {showOfflineModal, showDownloadErrorModal} = useAlertModals(); const {triggerExportOrConfirm} = useExportAgainModal(moneyRequestReport?.reportID, moneyRequestReport?.policyID); const {clearSelectedTransactions} = useSearchSelectionActions(); @@ -106,27 +108,12 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa 'RilletSquare', 'DualEntrySquare', 'CampfireSquare', + 'ZohoBooksSquare', 'BusinessCentralSquare', 'GustoSquare', 'ArrowRight', ]); - const showOfflineModal = () => { - showDecisionModal({ - title: translate('common.youAppearToBeOffline'), - prompt: translate('common.offlinePrompt'), - secondOptionText: translate('common.buttonConfirm'), - }); - }; - - const showDownloadErrorModal = () => { - showDecisionModal({ - title: translate('common.downloadFailedTitle'), - prompt: translate('common.downloadFailedDescription'), - secondOptionText: translate('common.buttonConfirm'), - }); - }; - const showEmptyReportDownloadErrorModal = () => { showDecisionModal({ title: translate('common.downloadFailedTitle'), diff --git a/src/hooks/useFeatureTrainingModal.ts b/src/hooks/useFeatureTrainingModal.ts new file mode 100644 index 000000000000..71d8c7e85998 --- /dev/null +++ b/src/hooks/useFeatureTrainingModal.ts @@ -0,0 +1,25 @@ +import FeatureTrainingModalWrapper from '@components/Modal/Global/FeatureTrainingModalWrapper'; +import type {ModalProps} from '@components/Modal/Global/ModalContext'; +import {useModal} from '@components/Modal/Global/ModalContext'; + +type FeatureTrainingModalOptions = Omit, keyof ModalProps> & { + id?: string; +}; + +const useFeatureTrainingModal = () => { + const context = useModal(); + + const showFeatureTrainingModal = ({id, ...options}: FeatureTrainingModalOptions) => { + return context.showModal({ + component: FeatureTrainingModalWrapper, + id, + props: options, + }); + }; + + const closeFeatureTrainingModal = (id: string) => context.closeModalByID(id); + + return {showFeatureTrainingModal, closeFeatureTrainingModal}; +}; + +export default useFeatureTrainingModal; diff --git a/src/hooks/useFetchRoute.ts b/src/hooks/useFetchRoute.ts index 34ca17be44fc..339a1d340302 100644 --- a/src/hooks/useFetchRoute.ts +++ b/src/hooks/useFetchRoute.ts @@ -25,7 +25,8 @@ export default function useFetchRoute( ) { const {isOffline} = useNetwork(); const hasRouteError = !!transaction?.errorFields?.route; - const hasRoute = hasRouteTransactionUtils(transaction); + const isReusedRoute = !!transaction?.isReusedRoute; + const hasRoute = hasRouteTransactionUtils(transaction, isReusedRoute); const isRouteAbsentWithoutErrors = !hasRoute && !hasRouteError; const isLoadingRoute = transaction?.comment?.isLoading ?? false; const validatedWaypoints = getValidWaypoints(waypoints); @@ -41,8 +42,7 @@ export default function useFetchRoute( const isCommuterExclusionPreviewStale = !!homeAndOfficeExclusionPolicyID && hasRoute && !hasRouteError && !DistanceRequestUtils.hasCommuterExclusionPreviewForPolicy(transaction, policy); const shouldFetchRoute = isMapDistanceRequest && - !transaction?.isReusedRoute && // A draft seeded from a reused route already carries the server-computed distance, so routing must not run again. - (isRouteAbsentWithoutErrors || haveValidatedWaypointsChanged || isCommuterExclusionPreviewStale) && + (isReusedRoute ? isCommuterExclusionPreviewStale : isRouteAbsentWithoutErrors || haveValidatedWaypointsChanged || isCommuterExclusionPreviewStale) && !isLoadingRoute && Object.keys(validatedWaypoints).length > 1; diff --git a/src/hooks/useFilteredOptions.ts b/src/hooks/useFilteredOptions.ts index 007a35a052fb..6ee22dad1f45 100644 --- a/src/hooks/useFilteredOptions.ts +++ b/src/hooks/useFilteredOptions.ts @@ -39,6 +39,8 @@ type UseFilteredOptionsConfig = { * an option per contact on open. Leave false for contact pickers (default: false). */ deferContactsUntilSearch?: boolean; + /** The user's default workspace. Its expense chat is always kept by the recent-reports pre-filter */ + activePolicyID?: string; }; type UseFilteredOptionsResult = { @@ -90,7 +92,7 @@ type UseFilteredOptionsResult = { * /> */ function useFilteredOptions(config: UseFilteredOptionsConfig): UseFilteredOptionsResult { - const {maxRecentReports = 500, enabled = true, includeP2P, batchSize = 100, isSearching = false, deferContactsUntilSearch = false} = config; + const {maxRecentReports = 500, enabled = true, includeP2P, batchSize = 100, isSearching = false, deferContactsUntilSearch = false, activePolicyID} = config; const [reportsLimit, setReportsLimit] = useState(maxRecentReports); @@ -104,7 +106,7 @@ function useFilteredOptions(config: UseFilteredOptionsConfig): UseFilteredOption const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); // Option building is locale-dependent, so a consumer that stays mounted through a language switch recomputes. - const {preferredLocale, dateFnsLocale} = useLocalize(); + const {preferredLocale, dateFnsLocale, formatPhoneNumber} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); // Sorted report actions from the RAM_ONLY_SORTED_REPORT_ACTIONS derived value; a new reference on @@ -146,6 +148,8 @@ function useFilteredOptions(config: UseFilteredOptionsConfig): UseFilteredOption deferContactsUntilSearch, locale: preferredLocale, pendingDeleteMemberAccountIDsByReportID, + formatPhoneNumber, + activePolicyID, }, rules, undefined, @@ -177,6 +181,8 @@ function useFilteredOptions(config: UseFilteredOptionsConfig): UseFilteredOption pendingDeleteMemberAccountIDsByReportID, dateFnsLocale, convertToDisplayString, + formatPhoneNumber, + activePolicyID, ], ); diff --git a/src/hooks/useFindLastAccessedReport.ts b/src/hooks/useFindLastAccessedReport.ts index c371cb4d6ac0..cb5e767acbfb 100644 --- a/src/hooks/useFindLastAccessedReport.ts +++ b/src/hooks/useFindLastAccessedReport.ts @@ -4,6 +4,7 @@ import {findLastAccessedReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import useIsAnonymousUser from './useIsAnonymousUser'; import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; @@ -20,9 +21,11 @@ function useFindLastAccessedReport({openOnAdminRoom, enabled = true}: UseFindLas const ignoreDomainRooms = !isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS); const shouldPreferAdminRoom = openOnAdminRoom ?? shouldOpenOnAdminRoom(); const [guideAccountIDs] = useOnyx(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS); + const isAnonymousUser = useIsAnonymousUser(); const [reportNameValuePairs, reportNameValuePairsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [lastAccessedReportID, reportsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { - selector: (reports) => (enabled ? findLastAccessedReport(ignoreDomainRooms, guideAccountIDs, shouldPreferAdminRoom, undefined, reportNameValuePairs, reports)?.reportID : undefined), + selector: (reports) => + enabled ? findLastAccessedReport(ignoreDomainRooms, guideAccountIDs, isAnonymousUser, shouldPreferAdminRoom, undefined, reportNameValuePairs, reports)?.reportID : undefined, }); return {lastAccessedReportID, reportsMetadata, reportNameValuePairsMetadata}; } diff --git a/src/hooks/useFocusAfterNav/type.ts b/src/hooks/useFocusAfterNav/type.ts index c5c22ced2d63..a04125326dcb 100644 --- a/src/hooks/useFocusAfterNav/type.ts +++ b/src/hooks/useFocusAfterNav/type.ts @@ -2,6 +2,6 @@ import type {AnimatedTextInputRef} from '@components/RNTextInput'; import type {RefObject} from 'react'; -type UseFocusAfterNav = (ref: RefObject, shouldDelayFocus: boolean) => boolean; +type UseFocusAfterNav = (ref: RefObject | null>, shouldDelayFocus: boolean) => boolean; export default UseFocusAfterNav; diff --git a/src/hooks/useHasTabBeenShown.ts b/src/hooks/useHasTabBeenShown.ts new file mode 100644 index 000000000000..f4ee1cf3de54 --- /dev/null +++ b/src/hooks/useHasTabBeenShown.ts @@ -0,0 +1,37 @@ +import {getTabNavigatorState} from '@libs/Navigation/helpers/tabNavigatorUtils'; + +import type NAVIGATORS from '@src/NAVIGATORS'; + +import type {NavigationState} from '@react-navigation/native'; +import type {ValueOf} from 'type-fest'; + +import {useState} from 'react'; + +import useRootNavigationState from './useRootNavigationState'; + +type TabNavigatorName = ValueOf; + +/** Whether the tab is shown or under a screen drawn over it, rather than a background tab of the tab navigator. */ +function isTabShown(rootState: NavigationState | undefined, tabName: TabNavigatorName) { + if (rootState?.routes.some((route) => route.name === tabName)) { + return true; + } + const tabState = getTabNavigatorState(rootState); + return !tabState || tabState.routes.at(tabState.index ?? 0)?.name === tabName; +} + +/** + * Whether the tab has been shown since the screen mounted. Native tab bars mount every tab at startup, so a tab root + * screen uses it to hold back the requests and writes it makes on mount until the user first opens the tab, as it does + * when it mounts with the tab. + */ +function useHasTabBeenShown(tabName: TabNavigatorName) { + const isShown = useRootNavigationState((rootState) => isTabShown(rootState, tabName)); + const [hasBeenShown, setHasBeenShown] = useState(isShown); + if (isShown && !hasBeenShown) { + setHasBeenShown(true); + } + return hasBeenShown; +} + +export default useHasTabBeenShown; diff --git a/src/hooks/useHoldMenuSubmit.ts b/src/hooks/useHoldMenuSubmit.ts index b21e8da14ec1..a4f92c8ffcca 100644 --- a/src/hooks/useHoldMenuSubmit.ts +++ b/src/hooks/useHoldMenuSubmit.ts @@ -26,6 +26,7 @@ import usePayChatReportActions from './usePayChatReportActions'; import usePermissions from './usePermissions'; import {usePersonalDetail} from './usePersonalDetails'; import usePolicy from './usePolicy'; +import useReportTransactions from './useReportTransactions'; type ActionHandledType = DeepValueOf; @@ -40,6 +41,7 @@ type UseHoldMenuSubmitParams = { }; function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, paymentType, methodID, onClose, onConfirm}: UseHoldMenuSubmitParams) { + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); @@ -47,6 +49,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment const activePolicy = usePolicy(activePolicyID); const policy = usePolicy(moneyRequestReport?.policyID); const chatReportPolicy = usePolicy(chatReport?.policyID); + const moneyRequestReportTransactions = useReportTransactions(moneyRequestReport?.reportID); const getChatReportActions = usePayChatReportActions(chatReport, undefined); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); @@ -106,11 +109,13 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment }); } else if (currentChatReport && paymentType) { payMoneyRequest({ + bankAccountList, isASAPSubmitBetaEnabled, getCurrencyDecimals, paymentType, chatReport: currentChatReport, iouReport: currentMoneyRequestReport, + iouReportTransactions: moneyRequestReportTransactions, introSelected, currentUserAccountID: currentUserDetails.accountID, currentUserLogin: currentUserDetails.login ?? '', diff --git a/src/hooks/useHoldRejectActions.ts b/src/hooks/useHoldRejectActions.ts index 879e708a64ca..c26fb6d00e84 100644 --- a/src/hooks/useHoldRejectActions.ts +++ b/src/hooks/useHoldRejectActions.ts @@ -1,6 +1,5 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import type {SecondaryActionEntry} from '@components/MoneyReportHeaderActions/types'; -import type {RejectModalAction} from '@components/MoneyReportHeaderEducationalModals'; import {useMoneyReportTransactionThread} from '@components/MoneyReportTransactionThreadContext'; import {changeMoneyRequestHoldStatus} from '@libs/actions/IOU/Hold'; @@ -16,6 +15,8 @@ import type {ValueOf} from 'type-fest'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import type {RejectModalAction} from './useHoldRejectEducationalModal'; + import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useDelegateAccountID from './useDelegateAccountID'; import useGetIOUReportFromReportAction from './useGetIOUReportFromReportAction'; diff --git a/src/hooks/useHoldRejectEducationalModal.tsx b/src/hooks/useHoldRejectEducationalModal.tsx new file mode 100644 index 000000000000..3f72a00eff9a --- /dev/null +++ b/src/hooks/useHoldRejectEducationalModal.tsx @@ -0,0 +1,31 @@ +import HoldOrRejectEducationalModalContent from '@components/HoldOrRejectEducationalModalContent'; + +import type CONST from '@src/CONST'; + +import type {ValueOf} from 'type-fest'; + +import React from 'react'; + +import useExpenseEducationalModal from './useExpenseEducationalModal'; + +/** The action the user picked before the hold/reject educational modal was shown, which decides what runs once they dismiss it */ +type RejectModalAction = ValueOf< + | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD + | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT + | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK + | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_REPORT +>; + +/** Opens the hold/reject educational modal on the global modal stack. */ +const useHoldRejectEducationalModal = () => { + const showModal = useExpenseEducationalModal(); + + const showHoldOrRejectEducationalModal = () => { + return showModal(); + }; + + return {showHoldOrRejectEducationalModal}; +}; + +export default useHoldRejectEducationalModal; +export type {RejectModalAction}; diff --git a/src/hooks/useHoldSubmitterEducationalModal.tsx b/src/hooks/useHoldSubmitterEducationalModal.tsx new file mode 100644 index 000000000000..21d11386a3d3 --- /dev/null +++ b/src/hooks/useHoldSubmitterEducationalModal.tsx @@ -0,0 +1,23 @@ +import HoldSubmitterEducationalModalContent from '@components/HoldSubmitterEducationalModalContent'; + +import React from 'react'; + +import useExpenseEducationalModal from './useExpenseEducationalModal'; + +type HoldSubmitterEducationalModalOptions = { + /** Whether the expense is from a DM (direct message) report */ + isDM?: boolean; +}; + +/** Opens the hold submitter educational modal on the global modal stack. */ +const useHoldSubmitterEducationalModal = () => { + const showModal = useExpenseEducationalModal(); + + const showHoldSubmitterEducationalModal = (options?: HoldSubmitterEducationalModalOptions) => { + return showModal(); + }; + + return {showHoldSubmitterEducationalModal}; +}; + +export default useHoldSubmitterEducationalModal; diff --git a/src/hooks/useHtmlPaste/index.ts b/src/hooks/useHtmlPaste/index.ts index 770ebdf84a4e..ac0f09988b9f 100644 --- a/src/hooks/useHtmlPaste/index.ts +++ b/src/hooks/useHtmlPaste/index.ts @@ -1,4 +1,6 @@ +import {buildAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; import useOnyx from '@hooks/useOnyx'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import {buildReportIDToNameMap} from '@hooks/useReportIDToNameMap'; import {isStandaloneURL, toMarkdownLink} from '@libs/MarkdownLinkHelpers'; @@ -7,6 +9,8 @@ import Parser from '@libs/Parser'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {Str} from 'expensify-common'; +import {DomUtils, parseDocument} from 'htmlparser2'; import {useCallback, useEffect, useRef} from 'react'; import type UseHtmlPaste from './types'; @@ -45,7 +49,82 @@ const insertAtCaret = (target: HTMLElement, insertedText: string, maxLength: num } }; +/** + * Checks whether an image contains Slack's metadata identifying it as an emoji. + * + * @param image The image element to inspect. + * @returns `true` when Slack's emoji metadata is present; otherwise, `false`. + */ +function isEmojiImage(image: HTMLImageElement): boolean { + const dataset = image.dataset; + + return dataset.stringifyEmoji !== undefined || dataset.stringifyType === 'emoji'; +} + +/** + * Returns the text that should replace an image identified by Slack's emoji metadata. + * + * @param image Image element being pasted. + * @returns The shortcode or legacy alt text, or an empty string when the image should remain unchanged. + */ +function getEmojiReplacementText(image: HTMLImageElement): string { + if (!isEmojiImage(image)) { + return ''; + } + + // Slack's data-* metadata identifies the emoji and supplies its shortcode, with alt text as the legacy fallback. + const shortcode = image.dataset.stringifyEmoji; + return shortcode?.length ? shortcode : image.alt; +} + +/** + * Replaces recognized emoji image tags while preserving every other character from the original clipboard HTML. + * + * @param html Original clipboard HTML. + * @param images Images parsed from the clipboard HTML. + * @param replacements Replacement text keyed by its corresponding parsed image. + * @returns Clipboard HTML with only recognized emoji image tags replaced. + */ +function replaceEmojiImagesInHTML(html: string, images: HTMLImageElement[], replacements: Map): string { + if (replacements.size === 0) { + return html; + } + + const sourceDocument = parseDocument(html, {withStartIndices: true, withEndIndices: true}); + const sourceImages = DomUtils.getElementsByTagName('img', sourceDocument, true); + if (sourceImages.length !== images.length) { + return html; + } + + const sourceReplacements: Array<{startIndex: number; endIndex: number; text: string}> = []; + for (const [index, image] of images.entries()) { + const replacement = replacements.get(image); + if (!replacement) { + continue; + } + + const sourceImage = sourceImages.at(index); + if (sourceImage?.startIndex === null || sourceImage?.startIndex === undefined || sourceImage.endIndex === null) { + return html; + } + + sourceReplacements.push({ + startIndex: sourceImage.startIndex, + endIndex: sourceImage.endIndex, + text: Str.htmlEncode(replacement), + }); + } + + let htmlWithEmojiReplacements = html; + for (const replacement of sourceReplacements.toSorted((first, second) => second.startIndex - first.startIndex)) { + htmlWithEmojiReplacements = htmlWithEmojiReplacements.slice(0, replacement.startIndex) + replacement.text + htmlWithEmojiReplacements.slice(replacement.endIndex + 1); + } + + return htmlWithEmojiReplacements; +} + const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, isActive = false, maxLength = CONST.MAX_COMMENT_LENGTH + 1) => { + const [personalDetailsList] = useAllPersonalDetails(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); /** @@ -55,13 +134,17 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, isActive const paste = useCallback( (text: string) => { try { - const textInputHTMLElement = textInputRef.current as HTMLElement; - if (textInputHTMLElement?.hasAttribute('contenteditable')) { + const textInputHTMLElement = textInputRef.current; + if (!(textInputHTMLElement instanceof HTMLElement)) { + return; + } + if (textInputHTMLElement.hasAttribute('contenteditable')) { insertAtCaret(textInputHTMLElement, text, maxLength); + } else if (textInputHTMLElement instanceof HTMLInputElement || textInputHTMLElement instanceof HTMLTextAreaElement) { + const availableLength = maxLength - (textInputHTMLElement.value?.length ?? 0); + textInputHTMLElement.setRangeText(text.slice(0, availableLength)); } else { - const htmlInput = textInputRef.current as unknown as HTMLInputElement; - const availableLength = maxLength - (htmlInput.value?.length ?? 0); - htmlInput.setRangeText(text.slice(0, availableLength)); + return; } requestAnimationFrame(() => { @@ -103,9 +186,9 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, isActive const handlePastedHTML = useCallback( (html: string) => { // Built here rather than in render so it costs nothing until a paste actually happens. - paste(Parser.htmlToMarkdown(html, {reportIDToName: buildReportIDToNameMap(reports)})); + paste(Parser.htmlToMarkdown(html, {reportIDToName: buildReportIDToNameMap(reports), accountIDToName: buildAccountIDToNameMap(personalDetailsList)})); }, - [paste, reports], + [paste, reports, personalDetailsList], ); /** @@ -114,7 +197,6 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, isActive */ const handlePastePlainText = useCallback( (event: ClipboardEvent) => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const clipboardText = event.clipboardData?.getData('text/plain') || event.clipboardData?.getData('text/uri-list'); if (!clipboardText) { return; @@ -157,24 +239,29 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, isActive if (event.clipboardData?.types?.includes(TEXT_HTML)) { const pastedHTML = event.clipboardData.getData(TEXT_HTML); - const domparser = new DOMParser(); - const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images; - - // Exclude parsing img tags in the HTML, as fetching the image via fetch triggers a connect-src Content-Security-Policy error. - if (embeddedImages.length > 0 && embeddedImages[0].src) { - // If HTML has emoji, then treat this as plain text. - if (embeddedImages[0].dataset?.stringifyType === 'emoji') { - handlePastePlainText(event); - return; - } - } - // If HTML starts with

, it means that the text was copied from the markdown input from the native app - // and was saved to clipboard with additional styling, so we need to treat this as plain text to avoid adding unnecessary characters. + // Native markdown input adds this wrapper and styling when copied, so preserve its original plain text. if (pastedHTML.startsWith('

')) { handlePastePlainText(event); return; } - handlePastedHTML(pastedHTML); + + const domparser = new DOMParser(); + const htmlDocument = domparser.parseFromString(pastedHTML, TEXT_HTML); + const embeddedImages = Array.from(htmlDocument.images); + const emojiImageReplacements = new Map(); + + // Collect emoji replacements before parsing HTML so they do not become inaccessible markdown image URLs. + for (const image of embeddedImages) { + const emojiText = getEmojiReplacementText(image); + + if (!emojiText) { + continue; + } + + emojiImageReplacements.set(image, emojiText); + } + + handlePastedHTML(replaceEmojiImagesInHTML(pastedHTML, embeddedImages, emojiImageReplacements)); return; } handlePastePlainText(event); diff --git a/src/hooks/useHtmlPaste/types.ts b/src/hooks/useHtmlPaste/types.ts index 8add6a319001..72548e306788 100644 --- a/src/hooks/useHtmlPaste/types.ts +++ b/src/hooks/useHtmlPaste/types.ts @@ -2,7 +2,7 @@ import type {ComponentRef, RefObject} from 'react'; import type {TextInput} from 'react-native'; type UseHtmlPaste = ( - textInputRef: RefObject<(HTMLTextAreaElement & ComponentRef) | ComponentRef | null>, + textInputRef: RefObject<(HTMLElement & Pick) | ComponentRef | null>, preHtmlPasteCallback?: (event: ClipboardEvent) => boolean, isActive?: boolean, maxLength?: number, // Maximum length of the text input value after pasting diff --git a/src/hooks/useImageLoadStall.ts b/src/hooks/useImageLoadStall.ts new file mode 100644 index 000000000000..88975333b8b7 --- /dev/null +++ b/src/hooks/useImageLoadStall.ts @@ -0,0 +1,70 @@ +import CONST from '@src/CONST'; + +import {useEffect, useRef, useState} from 'react'; + +/** + * Reports an image load whose transport went silent: silence after the first activity is a stall + * (IMAGE_LOAD_STALL_TIMEOUT); no activity at all gets the ceiling (IMAGE_LOAD_CEILING_TIMEOUT), as + * platforms without a progress API are indistinguishable from dead ones. reportActivity is stable + * and moves the timer without re-rendering the caller. + * + * The hook keeps all state in refs and creates its functions once, with a lazy state initializer, + * instead of using useCallback, which React Compiler forbids here. The returned identity must never + * change, because callers pass it straight to the Image onProgress prop. + */ +function useImageLoadStall(isWatchActive: boolean, onStalled: () => void) { + const onStalledRef = useRef(onStalled); + useEffect(() => { + onStalledRef.current = onStalled; + }); + + const timerRef = useRef | null>(null); + const hasActivityRef = useRef(false); + + // A lazy initializer runs once, on the first render, and React never re-runs it: the pair keeps + // a stable identity for the lifetime of the component without useCallback. + const [helpers] = useState(() => { + const armTimer = () => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + } + + const timeout = hasActivityRef.current ? CONST.TIMING.IMAGE_LOAD_STALL_TIMEOUT : CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT; + timerRef.current = setTimeout(() => { + timerRef.current = null; + onStalledRef.current(); + }, timeout); + }; + + const reportActivity = () => { + if (timerRef.current === null) { + return; + } + hasActivityRef.current = true; + armTimer(); + }; + + return {armTimer, reportActivity}; + }); + + const {armTimer, reportActivity} = helpers; + + useEffect(() => { + if (!isWatchActive) { + return; + } + hasActivityRef.current = false; + armTimer(); + return () => { + if (timerRef.current === null) { + return; + } + clearTimeout(timerRef.current); + timerRef.current = null; + }; + }, [isWatchActive, armTimer]); + + return reportActivity; +} + +export default useImageLoadStall; diff --git a/src/hooks/useInboxTabIndicatorStatus.ts b/src/hooks/useInboxTabIndicatorStatus.ts new file mode 100644 index 000000000000..0420652be03a --- /dev/null +++ b/src/hooks/useInboxTabIndicatorStatus.ts @@ -0,0 +1,26 @@ +import CONST from '@src/CONST'; + +import {useChatTabBrickRoad} from './useSidebarOrderedReports'; +import useTheme from './useTheme'; + +type InboxTabIndicatorStatusResult = { + /** The chat tab's brick road status, or undefined when no chat needs attention. */ + status: ReturnType; + + /** The indicator dot color: success for an info status, danger for anything else, none without a status. */ + indicatorColor: string | undefined; +}; + +function useInboxTabIndicatorStatus(): InboxTabIndicatorStatusResult { + const theme = useTheme(); + const status = useChatTabBrickRoad(); + + let indicatorColor: string | undefined; + if (status) { + indicatorColor = status === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO ? theme.iconSuccessFill : theme.danger; + } + + return {status, indicatorColor}; +} + +export default useInboxTabIndicatorStatus; diff --git a/src/hooks/useIsPastDate.ts b/src/hooks/useIsPastDate.ts new file mode 100644 index 000000000000..74009b8eb9ee --- /dev/null +++ b/src/hooks/useIsPastDate.ts @@ -0,0 +1,41 @@ +import CONST from '@src/CONST'; + +import {isPast} from 'date-fns'; +import {useEffect, useState} from 'react'; + +/** + * Returns whether `date` has passed, and re-renders exactly once when it does. + * A plain `isPast(date)` in render is memoized by React Compiler on `date`, so it would never flip while `date` stays the same. + */ +function useIsPastDate(date: string | undefined): boolean { + const [passedDate, setPassedDate] = useState(); + const hasPassed = !!date && (passedDate === date || isPast(date)); + + useEffect(() => { + if (!date || hasPassed) { + return; + } + const targetTime = new Date(date).getTime(); + if (Number.isNaN(targetTime)) { + return; + } + let timeoutID: ReturnType | undefined; + + // setTimeout overflows above CONST.LIMIT_TIMEOUT, so wait in LIMIT_TIMEOUT steps until the target is in range (same as UserStatusHandler) + const scheduleTimeout = () => { + const remainingTime = targetTime - Date.now(); + if (remainingTime > CONST.LIMIT_TIMEOUT) { + timeoutID = setTimeout(scheduleTimeout, CONST.LIMIT_TIMEOUT); + return; + } + timeoutID = setTimeout(() => setPassedDate(date), Math.max(remainingTime, 0)); + }; + scheduleTimeout(); + + return () => clearTimeout(timeoutID); + }, [date, hasPassed]); + + return hasPassed; +} + +export default useIsPastDate; diff --git a/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts b/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts new file mode 100644 index 000000000000..ba0c059aafa2 --- /dev/null +++ b/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts @@ -0,0 +1,19 @@ +import usePermissions from '@hooks/usePermissions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import getTabWithoutBarItem from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; + +/** + * When the native tab bar has no Account item, the Account tab is drawn over the other tabs + * as a full screen with the bar hidden, and it needs its own way back. + */ +function useIsSettingsDrawnOverTabs() { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBetaEnabled} = usePermissions(); + return shouldUseNarrowLayout && getTabWithoutBarItem(isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE)) === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR; +} + +export default useIsSettingsDrawnOverTabs; diff --git a/src/hooks/useIsSettingsDrawnOverTabs/index.ts b/src/hooks/useIsSettingsDrawnOverTabs/index.ts new file mode 100644 index 000000000000..7229ecc8f572 --- /dev/null +++ b/src/hooks/useIsSettingsDrawnOverTabs/index.ts @@ -0,0 +1,6 @@ +/** On web the Account tab stays a tab root screen above the JS tab bar, whether or not the bar shows its item. */ +function useIsSettingsDrawnOverTabs() { + return false; +} + +export default useIsSettingsDrawnOverTabs; diff --git a/src/hooks/useLandscapeOnBlurProxy/index.android.ts b/src/hooks/useLandscapeOnBlurProxy/index.android.ts index 796d41fd2e42..026de30d4073 100644 --- a/src/hooks/useLandscapeOnBlurProxy/index.android.ts +++ b/src/hooks/useLandscapeOnBlurProxy/index.android.ts @@ -1,26 +1,86 @@ import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import useKeyboardState from '@hooks/useKeyboardState'; -import usePrevious from '@hooks/usePrevious'; + +import {useEffect, useRef} from 'react'; +import {Dimensions, TextInput} from 'react-native'; +import {KeyboardEvents} from 'react-native-keyboard-controller'; import type {UseLandscapeOnBlurProxy} from './types'; -// During a portrait → landscape rotation and sometimes when the keyboard is -// opening in the landscape mode, the input briefly ends up behind the keyboard/header -// while KeyboardAvoidingView catches up, and native blurs it as a result. When that blur -// fires we re-focus the input after a short delay — long enough for KAV to reposition so -// the input is on-screen again, otherwise the re-focus gets clobbered by the same issue. +// On rotation, KeyboardAvoidingView padding can briefly collapse an input's ancestor to zero size, which makes Android +// clear its focus. We re-focus after a short delay, once the layout has settled and the input has a size again. const ROTATION_REFOCUS_DELAY_MS = 100; +// A blur counts as caused by a rotation when the window width changed this recently before it. The blur can also +// reach JS a few dozen milliseconds before the width change does, which the re-focus delay leaves room for. +const ROTATION_BLUR_WINDOW_MS = 500; + const useLandscapeOnBlurProxy: UseLandscapeOnBlurProxy = (inputRef, onBlur) => { const isInLandscapeMode = useIsInLandscapeMode(); - const prevIsInLandscapeMode = usePrevious(isInLandscapeMode); const {isKeyboardAnimatingRef, isKeyboardActive} = useKeyboardState(); + // The orientation is tracked outside of render, because the blur of a rotation lands before (or right around) the + // re-render that would report the new orientation, so values read during render still describe the old one. + const lastWindowWidthChangeTimeRef = useRef(0); + const pendingRefocusTimeoutIDRef = useRef | undefined>(undefined); + + useEffect(() => { + let lastWindowWidth = Dimensions.get('window').width; + + // Only width changes count, because the soft keyboard itself changes the window height on Android. + const subscription = Dimensions.addEventListener('change', ({window}) => { + if (window.width === lastWindowWidth) { + return; + } + lastWindowWidth = window.width; + lastWindowWidthChangeTimeRef.current = Date.now(); + }); + + // keyboardWillHide is only emitted for animated hides. It is needed because Keyboard.dismiss() blurs the input + // as the hide starts, and keyboardDidHide only arrives once the animation ends, after the re-focus delay. + const keyboardWillHideSubscription = KeyboardEvents.addListener('keyboardWillHide', (event) => { + if (event.duration === 0) { + return; + } + lastWindowWidthChangeTimeRef.current = 0; + }); + + // Covers a rotation landing mid-hide-animation, so the Composer's blur on keyboardDidHide isn't taken as a rotation. + const keyboardHideSubscription = KeyboardEvents.addListener('keyboardDidHide', (event) => { + if (event.height !== 0 || event.duration === 0) { + return; + } + lastWindowWidthChangeTimeRef.current = 0; + }); + + return () => { + subscription.remove(); + keyboardWillHideSubscription.remove(); + keyboardHideSubscription.remove(); + clearTimeout(pendingRefocusTimeoutIDRef.current); + }; + }, []); + return (e) => { const isKeyboardOpening = isKeyboardAnimatingRef.current && isKeyboardActive; - if ((prevIsInLandscapeMode !== isInLandscapeMode || isKeyboardOpening) && isInLandscapeMode) { - setTimeout(() => inputRef.current?.focus?.(), ROTATION_REFOCUS_DELAY_MS); - } + const blurTime = Date.now(); + + clearTimeout(pendingRefocusTimeoutIDRef.current); + pendingRefocusTimeoutIDRef.current = setTimeout(() => { + pendingRefocusTimeoutIDRef.current = undefined; + const isRotationBlur = lastWindowWidthChangeTimeRef.current > blurTime - ROTATION_BLUR_WINDOW_MS; + if (!isRotationBlur && !(isKeyboardOpening && isInLandscapeMode)) { + return; + } + + // Focus that moved to another input on purpose (e.g. the user tapped it) must not be taken back. + if (TextInput.State.currentlyFocusedInput()) { + return; + } + + inputRef.current?.focus?.(); + }, ROTATION_REFOCUS_DELAY_MS); + onBlur?.(e); }; }; diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index f822645d75d7..0612c1576a7d 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -304,6 +304,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, submitReport({ getCurrencyDecimals, expenseReport: moneyRequestReport, + expenseReportTransactions: transactions, policy, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', diff --git a/src/hooks/useListKeyboardNav.ts b/src/hooks/useListKeyboardNav.ts index fa670c5a318a..8a10d13d24b2 100644 --- a/src/hooks/useListKeyboardNav.ts +++ b/src/hooks/useListKeyboardNav.ts @@ -1,3 +1,5 @@ +import isHTMLElement from '@libs/isHTMLElement'; + import type {ComponentRef} from 'react'; import type React from 'react'; import type {View} from 'react-native'; @@ -7,12 +9,6 @@ import {useEffect, useLayoutEffect, useRef, useState} from 'react'; import useArrowKeyFocusManager from './useArrowKeyFocusManager'; -type FocusableContainer = { - addEventListener: HTMLElement['addEventListener']; - removeEventListener: HTMLElement['removeEventListener']; - contains: HTMLElement['contains']; -}; - type UseListKeyboardNavConfig | HTMLElement> = { isActive: boolean; itemKeys: string[]; @@ -50,15 +46,15 @@ function useListKeyboardNav | HTMLElement>({ if (!isActive) { return; } - const container = containerRef.current as FocusableContainer | null; - if (!container?.addEventListener) { + const container = containerRef.current; + if (!isHTMLElement(container)) { return; } const handleFocusIn = (event: FocusEvent) => { setHasBeenFocused(true); setHasFocus(true); - const target = event.target as HTMLElement | null; - if (target?.id) { + const target = event.target; + if (isHTMLElement(target) && target.id) { const index = itemKeysRef.current.indexOf(target.id); if (index >= 0) { setFocusedIndex(index); diff --git a/src/hooks/useMergeResultConfirmationConfig.tsx b/src/hooks/useMergeResultConfirmationConfig.tsx new file mode 100644 index 000000000000..dead33c9c04a --- /dev/null +++ b/src/hooks/useMergeResultConfirmationConfig.tsx @@ -0,0 +1,155 @@ +import ConfirmationPage from '@components/ConfirmationPage'; +/** + * Builds the ConfirmationPage config (heading, description, CTA, buttons, illustration) for each merge-account result. + */ +import LottieAnimations from '@components/LottieAnimations'; +import type DotLottieAnimation from '@components/LottieAnimations/types'; +import RenderHTML from '@components/RenderHTML'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; + +import {shouldHideOldAppRedirect} from '@libs/TryNewDotUtils'; + +import {closeReactNativeApp} from '@userActions/HybridApp'; +import {openOldDotLink} from '@userActions/Link'; + +import CONFIG from '@src/CONFIG'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {isTrackingSelector} from '@src/selectors/GPSDraftDetails'; +import type IconAsset from '@src/types/utils/IconAsset'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import type {StyleProp, ViewStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; + +import {emailSelector} from '@selectors/Session'; +import React from 'react'; +import {View} from 'react-native'; + +import useEnvironment from './useEnvironment'; +import {useMemoizedLazyIllustrations} from './useLazyAsset'; +import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; +import useScreenBoundDynamicRoute from './useScreenBoundDynamicRoute'; +import useThemeStyles from './useThemeStyles'; + +type MergeResultConfirmationConfig = { + heading?: string; + descriptionComponent?: React.ReactNode; + ctaComponent?: React.ReactNode; + onButtonPress?: () => void; + illustration?: DotLottieAnimation | IconAsset; + illustrationStyle?: StyleProp; + secondaryButtonText?: string; + onSecondaryButtonPress?: () => void; + shouldShowSecondaryButton?: boolean; +}; + +function DescriptionHTML({html}: {html: string}) { + const styles = useThemeStyles(); + + return ( + + + + ); +} + +function useMergeResultConfirmationConfig({result, login}: {result: ValueOf; login: string}): MergeResultConfirmationConfig { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [userEmailOrPhone] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); + const [tryNewDot, tryNewDotMetadata] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT); + const [isTrackingGPS = false] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS, {selector: isTrackingSelector}); + const {environmentURL} = useEnvironment(); + const buildDynamicRoute = useScreenBoundDynamicRoute(); + const lazyIllustrations = useMemoizedLazyIllustrations(['RunningTurtle', 'LockClosedOrange']); + const isLoadingTryNewDot = isLoadingOnyxValue(tryNewDotMetadata); + const isClassicRedirectBlocked = shouldHideOldAppRedirect(tryNewDot, isLoadingTryNewDot, CONFIG.IS_HYBRID_APP); + + const defaultResult = { + illustration: lazyIllustrations.LockClosedOrange, + onButtonPress: () => {}, + }; + + const results: Record, MergeResultConfirmationConfig> = { + [CONST.MERGE_ACCOUNT_RESULTS.SUCCESS]: { + heading: translate('mergeAccountsPage.mergeSuccess.accountsMerged'), + descriptionComponent: , + illustration: LottieAnimations.Fireworks, + illustrationStyle: {width: 150, height: 150}, + }, + [CONST.MERGE_ACCOUNT_RESULTS.ERR_NO_EXIST]: { + descriptionComponent: ( + + ), + }, + [CONST.MERGE_ACCOUNT_RESULTS.ERR_2FA]: { + descriptionComponent: , + ctaComponent: ( + + {translate('mergeAccountsPage.mergeFailure2FA.learnMore')} + + ), + }, + [CONST.MERGE_ACCOUNT_RESULTS.ERR_SMART_SCANNER]: { + descriptionComponent: , + }, + [CONST.MERGE_ACCOUNT_RESULTS.ERR_SAML_DOMAIN_CONTROL]: { + descriptionComponent: , + }, + [CONST.MERGE_ACCOUNT_RESULTS.ERR_SAML_NOT_SUPPORTED]: { + heading: translate('mergeAccountsPage.mergePendingSAML.weAreWorkingOnIt'), + descriptionComponent: {translate('mergeAccountsPage.mergePendingSAML.limitedSupport')}, + ctaComponent: ( + + + + ), + secondaryButtonText: translate('mergeAccountsPage.mergePendingSAML.goToExpensifyClassic'), + onSecondaryButtonPress: () => { + if (CONFIG.IS_HYBRID_APP) { + closeReactNativeApp({shouldSetNVP: true, isTrackingGPS}); + return; + } + openOldDotLink(CONST.OLDDOT_URLS.INBOX, false); + }, + shouldShowSecondaryButton: !isClassicRedirectBlocked, + illustration: lazyIllustrations.RunningTurtle, + illustrationStyle: {width: 132, height: 150}, + }, + [CONST.MERGE_ACCOUNT_RESULTS.ERR_SAML_PRIMARY_LOGIN]: { + descriptionComponent: , + }, + [CONST.MERGE_ACCOUNT_RESULTS.ERR_ACCOUNT_LOCKED]: { + descriptionComponent: , + }, + [CONST.MERGE_ACCOUNT_RESULTS.ERR_INVOICING]: { + descriptionComponent: , + }, + [CONST.MERGE_ACCOUNT_RESULTS.TOO_MANY_ATTEMPTS]: { + heading: translate('mergeAccountsPage.mergeFailureTooManyAttempts.heading'), + descriptionComponent: ( + {translate('mergeAccountsPage.mergeFailureTooManyAttempts.description')} + ), + }, + [CONST.MERGE_ACCOUNT_RESULTS.ACCOUNT_UNVALIDATED]: { + descriptionComponent: ( + {translate('mergeAccountsPage.mergeFailureUnvalidatedAccount.description')} + ), + }, + [CONST.MERGE_ACCOUNT_RESULTS.ERR_MERGE_SELF]: { + descriptionComponent: ( + {translate('mergeAccountsPage.mergeFailureSelfMerge.description')} + ), + }, + }; + + return results[result] || defaultResult; +} + +export default useMergeResultConfirmationConfig; diff --git a/src/hooks/useMergeTransactions.ts b/src/hooks/useMergeTransactions.ts index 51d4230c0875..50c1b2d00af5 100644 --- a/src/hooks/useMergeTransactions.ts +++ b/src/hooks/useMergeTransactions.ts @@ -56,7 +56,7 @@ function getTransaction( return transaction; } - return currentSearchResults?.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? onyxTransaction; + return currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? onyxTransaction; } function useMergeTransactions({mergeTransaction}: UseMergeTransactionsProps): UseMergeTransactionsReturn { diff --git a/src/components/MoneyReportHeaderEducationalModals.tsx b/src/hooks/useMoneyReportHeaderEducationalModals.ts similarity index 60% rename from src/components/MoneyReportHeaderEducationalModals.tsx rename to src/hooks/useMoneyReportHeaderEducationalModals.ts index 6ed21d25d464..d2b024fd4941 100644 --- a/src/components/MoneyReportHeaderEducationalModals.tsx +++ b/src/hooks/useMoneyReportHeaderEducationalModals.ts @@ -1,7 +1,4 @@ -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDelegateAccountID from '@hooks/useDelegateAccountID'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; +import {useMoneyReportTransactionThread} from '@components/MoneyReportTransactionThreadContext'; import {changeMoneyRequestHoldStatus} from '@libs/actions/IOU/Hold'; import {setNameValuePair} from '@libs/actions/User'; @@ -15,38 +12,20 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Ref} from 'react'; -import type {ValueOf} from 'type-fest'; - import {shouldFailAllRequestsSelector} from '@selectors/Network'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import React, {useImperativeHandle, useState} from 'react'; - -import HoldOrRejectEducationalModal from './HoldOrRejectEducationalModal'; -import HoldSubmitterEducationalModal from './HoldSubmitterEducationalModal'; -import {useMoneyReportTransactionThread} from './MoneyReportTransactionThreadContext'; - -type RejectModalAction = ValueOf< - | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD - | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT - | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK - | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_REPORT ->; - -type MoneyReportHeaderEducationalModalsHandle = { - openHoldEducational: () => void; - openRejectModal: (action: RejectModalAction) => void; -}; -type MoneyReportHeaderEducationalModalsProps = { - reportID: string | undefined; - ref: Ref; -}; +import type {RejectModalAction} from './useHoldRejectEducationalModal'; -function MoneyReportHeaderEducationalModals({reportID, ref}: MoneyReportHeaderEducationalModalsProps) { - const [isHoldEducationalModalVisible, setIsHoldEducationalModalVisible] = useState(false); - const [rejectModalAction, setRejectModalAction] = useState(null); +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useDelegateAccountID from './useDelegateAccountID'; +import useHoldRejectEducationalModal from './useHoldRejectEducationalModal'; +import useHoldSubmitterEducationalModal from './useHoldSubmitterEducationalModal'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +/** Opens the hold/reject educational modals for the money report header and runs the follow-up action once the user dismisses them */ +function useMoneyReportHeaderEducationalModals(reportID: string | undefined) { const {isOffline} = useNetwork(); const [shouldFailAllRequests] = useOnyx(ONYXKEYS.NETWORK, {selector: shouldFailAllRequestsSelector}); @@ -62,13 +41,7 @@ function MoneyReportHeaderEducationalModals({reportID, ref}: MoneyReportHeaderEd const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - useImperativeHandle(ref, () => ({ - openHoldEducational: () => setIsHoldEducationalModalVisible(true), - openRejectModal: (action: RejectModalAction) => setRejectModalAction(action), - })); - const dismissModalAndUpdateUseHold = () => { - setIsHoldEducationalModalVisible(false); setNameValuePair(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, true, false, !shouldFailAllRequests); if (requestParentReportAction) { changeMoneyRequestHoldStatus( @@ -86,7 +59,7 @@ function MoneyReportHeaderEducationalModals({reportID, ref}: MoneyReportHeaderEd } }; - const dismissRejectModalBasedOnAction = () => { + const dismissRejectModalBasedOnAction = (rejectModalAction: RejectModalAction) => { if (rejectModalAction === CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD) { dismissRejectUseExplanation(); if (requestParentReportAction) { @@ -123,27 +96,15 @@ function MoneyReportHeaderEducationalModals({reportID, ref}: MoneyReportHeaderEd rejectMoneyRequestReason(requestParentReportAction); } } - setRejectModalAction(null); }; - return ( - <> - {!!rejectModalAction && ( - - )} - {!!isHoldEducationalModalVisible && ( - - )} - - ); + const {showHoldOrRejectEducationalModal} = useHoldRejectEducationalModal(); + const {showHoldSubmitterEducationalModal} = useHoldSubmitterEducationalModal(); + + return { + openHoldEducational: () => showHoldSubmitterEducationalModal({isDM: isDM(chatReport)}).finally(dismissModalAndUpdateUseHold), + openRejectModal: (action: RejectModalAction) => showHoldOrRejectEducationalModal().finally(() => dismissRejectModalBasedOnAction(action)), + }; } -export type {RejectModalAction, MoneyReportHeaderEducationalModalsHandle}; -export default MoneyReportHeaderEducationalModals; +export default useMoneyReportHeaderEducationalModals; diff --git a/src/hooks/useMoneyRequestParticipantsPolicyTags.ts b/src/hooks/useMoneyRequestParticipantsPolicyTags.ts index 6b5be83e423c..94fd7c880bd0 100644 --- a/src/hooks/useMoneyRequestParticipantsPolicyTags.ts +++ b/src/hooks/useMoneyRequestParticipantsPolicyTags.ts @@ -17,6 +17,7 @@ import type {Locale as DateFnsLocale} from 'date-fns'; import type {OnyxEntry} from 'react-native-onyx'; import {useCurrencyListActions} from './useCurrencyList'; +import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; import useParticipantsPolicyTags from './useParticipantsPolicyTags'; @@ -51,6 +52,7 @@ function useMoneyRequestParticipantsPolicyTags({ dateFnsLocale, }: UseMoneyRequestParticipantsPolicyTagsParams): UseMoneyRequestParticipantsPolicyTagsResult { const {convertToDisplayString} = useCurrencyListActions(); + const {formatPhoneNumber} = useLocalize(); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const participants = getMoneyRequestParticipantOptions({ currentUserAccountID, @@ -65,6 +67,7 @@ function useMoneyRequestParticipantsPolicyTags({ translate, convertToDisplayString, dateFnsLocale, + formatPhoneNumber, }); const participantsPolicyTags = useParticipantsPolicyTags(participants); diff --git a/src/hooks/useOnboardingFlow.ts b/src/hooks/useOnboardingFlow.ts index 4d7fc8ef9ad6..c4e615c404b3 100644 --- a/src/hooks/useOnboardingFlow.ts +++ b/src/hooks/useOnboardingFlow.ts @@ -113,7 +113,6 @@ function useOnboardingFlowRouter() { } const isMigratedUser = hasBeenAddedToNudgeMigration ?? false; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const isInvitedOrGroupMember = (hasNonPersonalPolicy || wasInvitedToNewDot) ?? false; if (isMigratedUser || isInvitedOrGroupMember || shouldSuppressPromotionalUI) { return; diff --git a/src/hooks/useOpenConciergeAnywhere/index.native.tsx b/src/hooks/useOpenConciergeAnywhere/index.native.tsx index 89ed50dcb6cf..4072d3ccce86 100644 --- a/src/hooks/useOpenConciergeAnywhere/index.native.tsx +++ b/src/hooks/useOpenConciergeAnywhere/index.native.tsx @@ -7,7 +7,7 @@ import {navigateToConciergeChat} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; /** * Returns a callback that navigates to the Concierge chat on native (opens the side panel on web instead), @@ -17,12 +17,14 @@ function useOpenConciergeAnywhere() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); // eslint-disable-next-line @typescript-eslint/no-unused-vars const openConciergeAnywhere = (_options?: {forceConcierge?: boolean; reportID?: string}) => { - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails}); }; return {openConciergeAnywhere, isInSidePanel: false}; diff --git a/src/hooks/useOpenRecipientPicker.ts b/src/hooks/useOpenRecipientPicker.ts new file mode 100644 index 000000000000..d8da2f21a139 --- /dev/null +++ b/src/hooks/useOpenRecipientPicker.ts @@ -0,0 +1,19 @@ +import {openRecipientPicker} from '@userActions/Report'; + +import {useEffect} from 'react'; + +import useNetwork from './useNetwork'; + +/** Loads policy expense chats for workspace pickers, retrying on reconnect since offline reads are dropped. */ +function useOpenRecipientPicker(shouldSkip = false) { + const {isOffline} = useNetwork(); + + useEffect(() => { + if (shouldSkip || isOffline) { + return; + } + openRecipientPicker(); + }, [shouldSkip, isOffline]); +} + +export default useOpenRecipientPicker; diff --git a/src/hooks/useOpenReport.ts b/src/hooks/useOpenReport.ts index d95deb14d649..cb30795c5191 100644 --- a/src/hooks/useOpenReport.ts +++ b/src/hooks/useOpenReport.ts @@ -6,10 +6,11 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useDelegateAccountID from './useDelegateAccountID'; import useOnyx from './useOnyx'; /** Params this hook fills in itself */ -type SharedOpenReportParams = 'introSelected' | 'conciergeChat' | 'currentUserAccountID' | 'isSelfTourViewed' | 'hasCompletedGuidedSetupFlow'; +type SharedOpenReportParams = 'introSelected' | 'conciergeChat' | 'currentUserAccountID' | 'isSelfTourViewed' | 'hasCompletedGuidedSetupFlow' | 'delegateAccountID'; type UseOpenReportParams = Omit; @@ -20,6 +21,7 @@ function useOpenReport() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const delegateAccountID = useDelegateAccountID(); return (params: UseOpenReportParams) => { openReport({ @@ -27,6 +29,7 @@ function useOpenReport() { introSelected, conciergeChat, currentUserAccountID, + delegateAccountID, isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, }); diff --git a/src/hooks/useOptimisticNextStep.ts b/src/hooks/useOptimisticNextStep.ts index c77d7c192f10..ffdf7c718d3a 100644 --- a/src/hooks/useOptimisticNextStep.ts +++ b/src/hooks/useOptimisticNextStep.ts @@ -15,6 +15,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import {loginSelector} from '@selectors/PersonalDetails'; +import {pendingExpenseActionSelector} from '@selectors/ReportMetaData'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useOnyx from './useOnyx'; @@ -31,6 +32,7 @@ function useOptimisticNextStep(reportID: string | undefined) { const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [ownerLogin] = usePersonalDetail(moneyRequestReport?.ownerAccountID, loginSelector); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); + const [pendingExpenseActionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {selector: pendingExpenseActionSelector}); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); @@ -88,6 +90,7 @@ function useOptimisticNextStep(reportID: string | undefined) { currentUserAccountID: accountID, transactionViolations: allTransactionViolations, isReportArchived: isArchivedReport, + reportMetadata: pendingExpenseActionMetadata, }); const hasDEWApproveFailed = gbrResult?.reason === CONST.REQUIRES_ATTENTION_REASONS.HAS_DEW_APPROVE_FAILED; if (shouldShowDynamicExternalWorkflowApproveErrorNextStep(gbrResult?.reportAction, hasDEWApproveFailed)) { diff --git a/src/hooks/useOtherFeedsForFeedSelector.tsx b/src/hooks/useOtherFeedsForFeedSelector.tsx index 77296a08b224..10516a5d2a31 100644 --- a/src/hooks/useOtherFeedsForFeedSelector.tsx +++ b/src/hooks/useOtherFeedsForFeedSelector.tsx @@ -27,6 +27,8 @@ import useThemeStyles from './useThemeStyles'; type CardFeedListItem = ListItem & { /** Combined feed key */ value: CompanyCardFeedWithDomainID; + /** Key the feed has in the active policy's feed list (`${feed}${separator}${fundID}`), set for feeds from other workspaces. Undefined when the fundID is missing. */ + selectionValue?: CompanyCardFeedWithDomainID; /** Card feed value */ feed: CompanyCardFeedWithNumber; fundID?: number; @@ -62,14 +64,16 @@ function useOtherFeedsForFeedSelector(policyID: string): CardFeedListItem[] { if (feed?.linkedPolicyIDs?.filter(Boolean).some((linkedPolicyID) => linkedPolicyID.toUpperCase() === upperPolicyID)) { continue; } + const feedName = feed.feed as CompanyCardFeedWithNumber; + const fundID = Number(feed.fundID); + + // The key this feed gets in the active policy's available list (`${feed}${separator}${fundID}`). Without a valid fundID there is no such key. + const feedValueForActivePolicy = fundID ? getCardFeedWithDomainID(feedName, fundID) : undefined; + // Skip feeds already present in the active policy's available list to avoid duplicate rows across the two lists. - // `companyCardFeeds` is keyed at runtime by the domain-ID-suffixed feed value (`${feed}${separator}${fundID}`), - // so a membership check on that value avoids an unsafe assertion to the narrower `CompanyCardFeedWithNumber` key type. - const feedValueForActivePolicy = getCardFeedWithDomainID(feed.feed, Number(feed.fundID)); - if (companyCardFeeds && feedValueForActivePolicy in companyCardFeeds) { + if (feedValueForActivePolicy && companyCardFeeds && feedValueForActivePolicy in companyCardFeeds) { continue; } - const feedName = feed.feed; const plaidUrl = getPlaidInstitutionIconUrl(feedName); // Use the same fundID-aware domain lookup as the visibility logic (`getVisibleCompanyCardFeedsForSelector`) // so the domain email resolves even when the domains collection isn't keyed by fundID. @@ -82,8 +86,9 @@ function useOtherFeedsForFeedSelector(policyID: string): CardFeedListItem[] { otherPolicyFeeds.push({ value: feed.id as CompanyCardFeedWithDomainID, - feed: feedName as CompanyCardFeedWithNumber, - fundID: Number(feed.fundID), + selectionValue: feedValueForActivePolicy, + feed: feedName, + fundID, country: feed?.country, alternateText: domainName ?? linkedPolicy?.name ?? firstLinkedPolicyID, text: getCustomOrFormattedFeedName(translate, feedName, feed.name), diff --git a/src/hooks/useParentReportAction.ts b/src/hooks/useParentReportAction.ts index 5a183d00bab5..e3b1052f0e8b 100644 --- a/src/hooks/useParentReportAction.ts +++ b/src/hooks/useParentReportAction.ts @@ -1,19 +1,32 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportActions} from '@src/types/onyx'; +import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; +import type {ReportActionWithoutThreadReplyCounters} from '@selectors/ReportAction'; import type {OnyxEntry} from 'react-native-onyx'; -import {getParentReportActionSelector} from '@selectors/ReportAction'; +import {getParentReportActionSelector, getParentReportActionWithoutThreadRepliesSelector} from '@selectors/ReportAction'; import {useCallback} from 'react'; import useOnyx from './useOnyx'; -function useParentReportAction(report: OnyxEntry) { +type UseParentReportActionOptions = { + shouldIgnoreThreadReplies?: boolean; +}; + +/** The only report fields the hook reads, so callers can pass a narrowed report projection. */ +type ParentReportFields = Pick; + +function useParentReportAction(report: OnyxEntry): OnyxEntry; +function useParentReportAction(report: OnyxEntry, options: UseParentReportActionOptions): ReportActionWithoutThreadReplyCounters | undefined; +function useParentReportAction(report: OnyxEntry, {shouldIgnoreThreadReplies = false}: UseParentReportActionOptions = {}) { const getParentReportAction = useCallback( - (parentReportActions: OnyxEntry) => getParentReportActionSelector(parentReportActions, report?.parentReportActionID), - [report?.parentReportActionID], + (parentReportActions: OnyxEntry) => + shouldIgnoreThreadReplies + ? getParentReportActionWithoutThreadRepliesSelector(parentReportActions, report?.parentReportActionID) + : getParentReportActionSelector(parentReportActions, report?.parentReportActionID), + [report?.parentReportActionID, shouldIgnoreThreadReplies], ); const parentReportID = getNonEmptyStringOnyxID(report?.parentReportID); diff --git a/src/hooks/useParticipantSubmission.ts b/src/hooks/useParticipantSubmission.ts index de23e3431a9d..ae493d6a0d66 100644 --- a/src/hooks/useParticipantSubmission.ts +++ b/src/hooks/useParticipantSubmission.ts @@ -342,7 +342,6 @@ function useParticipantSubmission({ if (isInvoice) { selectedReportID.current = firstParticipantReportID && isInvoiceRoomWithID(firstParticipantReportID) ? firstParticipantReportID : generateReportID(); } else { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing selectedReportID.current = firstParticipantReportID || generateReportID(); } }; diff --git a/src/hooks/usePaymentContext.tsx b/src/hooks/usePaymentContext.tsx index 7875d3496bec..b6e66e28ba5d 100644 --- a/src/hooks/usePaymentContext.tsx +++ b/src/hooks/usePaymentContext.tsx @@ -4,7 +4,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {delegateEmailSelector} from '@src/selectors/Account'; import {hasSeenTourSelector} from '@src/selectors/Onboarding'; -import type {BillingGraceEndPeriod, IntroSelected, Policy, Report} from '@src/types/onyx'; +import type {BankAccountList, BillingGraceEndPeriod, IntroSelected, Policy, Report} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -36,6 +36,7 @@ type PaymentContextValue = { defaultWorkspaceName: string; delegateEmail: string | undefined; delegateAccountID: number | undefined; + bankAccountList: OnyxEntry; }; type ReportPaymentContextValue = PaymentContextValue & { @@ -68,6 +69,7 @@ function usePaymentContextValues(): PaymentContextValue { const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const delegateAccountID = useDelegateAccountID(); const activePolicy = usePolicy(activePolicyID); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const defaultWorkspaceName = generateDefaultWorkspaceName(email ?? '', displayName, lastWorkspaceNumber, translate); @@ -89,6 +91,7 @@ function usePaymentContextValues(): PaymentContextValue { defaultWorkspaceName, delegateEmail, delegateAccountID, + bankAccountList, }; } diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index be09beaa39f9..7cc5f96cd51b 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -2,12 +2,13 @@ import {useActivePolicyContext} from '@components/ActivePolicyProvider'; import {useSession} from '@components/OnyxListItemProvider'; import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; -import {canSubmitPerDiemExpenseFromWorkspace, isGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; +import {canSubmitPerDiemExpenseFromWorkspace, getPolicyRole, isGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -21,14 +22,15 @@ function checkForUserPendingDelete(login: string, policy: OnyxEntry) { return isPolicyMemberWithoutPendingDelete(login, policy); } -function isPolicyMemberByRole(policy: OnyxEntry) { - return !!policy?.role && Object.values(CONST.POLICY.ROLE).includes(policy.role); +function isPolicyMemberByRole(policy: OnyxEntry, login: string) { + const role = getPolicyRole(policy, login); + return !!role && (Object.values(CONST.POLICY.ROLE) as string[]).includes(role); } function isPolicyValidForMovingExpenses(policy: OnyxEntry, login: string, isPerDiemRequest?: boolean, isTimeRequest?: boolean) { return ( checkForUserPendingDelete(login, policy) && - isPolicyMemberByRole(policy) && + isPolicyMemberByRole(policy, login) && isGroupPolicy(policy) && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && // Teachers Unite doesn't support reimbursement, so it can never be a destination for moving/reporting an expense. @@ -89,6 +91,11 @@ type PolicyForMovingExpenses = { policyForMovingExpenses: OnyxEntry; shouldSelectPolicy: boolean; shouldNavigateToUpgradePath: boolean; + /** + * False while policies are still loading from Onyx. Callers that act on the result (create report, move + * transactions) must no-op until true, as with `useCreateReport`. Picker-only callers can ignore it. + */ + arePoliciesLoaded: boolean; }; function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: boolean, expensePolicyID?: string, isUnreportedManagedCardTransaction?: boolean): PolicyForMovingExpenses { @@ -100,7 +107,7 @@ function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: // Contextual selector — captures login/flags from closure. // Returns only IDs + flags (stable output) to prevent re-renders when unrelated policies change. const policyQualificationSelector = (policies: OnyxCollection) => getPolicyQualificationResult(policies, login, isPerDiemRequest, isTimeRequest, expensePolicyID); - const [qualificationResult] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + const [qualificationResult, policiesLoadStatus] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { selector: policyQualificationSelector, }); @@ -110,40 +117,44 @@ function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: const resolvedPolicyID = validExpensePolicyID ?? singlePolicyID; const [resolvedPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${resolvedPolicyID}`); - // User has no eligible policy - if (!resolvedPolicyID) { - return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: false, shouldNavigateToUpgradePath: true}; - } + // Gate the upgrade path on policy hydration. Without this, during Onyx cold-start the collection reads + // empty and we'd report that a member of a group workspace has none, sending them to MONEY_REQUEST_UPGRADE. + // `useCreateReport` guards the same way. + const arePoliciesLoaded = !isLoadingOnyxValue(policiesLoadStatus); // If this is an employee's card transaction that we manage, then we should report it to their default policy // which we don't know. Sending an empty `policyID` instructs the backend to auto-select the preferred policy. + // This never depends on a locally resolved policy, so it has to be answered before the upgrade path below. if (isUnreportedManagedCardTransaction) { - return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false}; + return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; + } + + if (!resolvedPolicyID) { + if (isPolicyValidForMovingExpenses(activePolicy, login, isPerDiemRequest, isTimeRequest)) { + return {policyForMovingExpensesID: activePolicyID, policyForMovingExpenses: activePolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; + } + + return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: false, shouldNavigateToUpgradePath: arePoliciesLoaded, arePoliciesLoaded}; } // If an expense policy ID is provided and valid, prefer it over the active policy if (validExpensePolicyID) { - return {policyForMovingExpensesID: validExpensePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false}; + return {policyForMovingExpensesID: validExpensePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; } - if ( - activePolicy && - !isTeachersUnitePolicyID(activePolicy.id) && - (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(activePolicy)) && - (!isTimeRequest || isTimeTrackingEnabled(activePolicy)) - ) { - return {policyForMovingExpensesID: activePolicyID, policyForMovingExpenses: activePolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false}; + if (isPolicyValidForMovingExpenses(activePolicy, login, isPerDiemRequest, isTimeRequest)) { + return {policyForMovingExpensesID: activePolicyID, policyForMovingExpenses: activePolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; } if (singlePolicyID && !isMemberOfMoreThanOnePolicy) { - return {policyForMovingExpensesID: singlePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false}; + return {policyForMovingExpensesID: singlePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; } if (isMemberOfMoreThanOnePolicy) { - return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: true, shouldNavigateToUpgradePath: false}; + return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: true, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; } - return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: false, shouldNavigateToUpgradePath: true}; + return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: false, shouldNavigateToUpgradePath: true, arePoliciesLoaded}; } export default usePolicyForMovingExpenses; diff --git a/src/hooks/usePolicyIndicatorChecks.ts b/src/hooks/usePolicyIndicatorChecks.ts index ed4ff9fb379f..1b9061a254cb 100644 --- a/src/hooks/usePolicyIndicatorChecks.ts +++ b/src/hooks/usePolicyIndicatorChecks.ts @@ -1,5 +1,4 @@ import {isConnectionInProgress} from '@libs/actions/connections'; -import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; import {getDomainsWithErrors, hasPendingDomainAdminRequestsToReview} from '@libs/DomainUtils'; import {isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; import { @@ -8,6 +7,7 @@ import { shouldShowCustomUnitsError, shouldShowEmployeeListError, shouldShowPolicyError, + shouldShowQBOReimbursableExportDestinationAccountError, shouldShowSyncError, } from '@libs/PolicyUtils'; import {getApprovalWorkflowRulesForPolicy, hasApprovalWorkflowWithNonMemberApprover} from '@libs/WorkflowUtils'; diff --git a/src/hooks/usePreferredPolicy.ts b/src/hooks/usePreferredPolicy.ts index f748b97b1324..94b3eff4b0fd 100644 --- a/src/hooks/usePreferredPolicy.ts +++ b/src/hooks/usePreferredPolicy.ts @@ -8,13 +8,16 @@ type UsePreferredPolicyResult = { /** Whether the user is restricted from creating policies */ isRestrictedPolicyCreation: boolean; + + /** Whether the security group backing these flags is still loading, in which case `isRestrictedToPreferredPolicy` is not yet meaningful */ + isLoadingPreferredPolicy: boolean; }; /** * Hook to get the preferred policy settings from the user's domain security group */ function usePreferredPolicy(): UsePreferredPolicyResult { - const {securityGroup} = useUserSecurityGroup(); + const {securityGroup, isLoadingSecurityGroup} = useUserSecurityGroup(); // Only restrict if both enableRestrictedPrimaryPolicy is true AND we have a valid policy ID const restrictedPolicyID = securityGroup?.restrictedPrimaryPolicyID; @@ -25,6 +28,7 @@ function usePreferredPolicy(): UsePreferredPolicyResult { isRestrictedToPreferredPolicy: isRestrictionEnabled && hasValidPolicyID, preferredPolicyID: restrictedPolicyID, isRestrictedPolicyCreation: securityGroup?.enableRestrictedPolicyCreation === true, + isLoadingPreferredPolicy: isLoadingSecurityGroup, }; } diff --git a/src/hooks/usePrimaryContactMethod.ts b/src/hooks/usePrimaryContactMethod.ts index 47135eb7b7be..caf99e1cff42 100644 --- a/src/hooks/usePrimaryContactMethod.ts +++ b/src/hooks/usePrimaryContactMethod.ts @@ -8,7 +8,6 @@ function usePrimaryContactMethod(): string { const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); // primaryLogin is sometimes stored as an empty string rather than being absent, so treat it as missing and fall back to the session email. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return account?.primaryLogin || sessionEmail || ''; } diff --git a/src/hooks/useProactiveAppReview.ts b/src/hooks/useProactiveAppReview.ts index 7d66d83c64d7..e1b026b998f0 100644 --- a/src/hooks/useProactiveAppReview.ts +++ b/src/hooks/useProactiveAppReview.ts @@ -1,6 +1,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type ProactiveAppReview from '@src/types/onyx/AppReview'; +import {accountIDSelector} from '@selectors/Session'; import {useState} from 'react'; import useOnyx from './useOnyx'; @@ -16,11 +17,26 @@ type UseProactiveAppReviewReturn = { proactiveAppReview: ProactiveAppReview | null | undefined; }; +/** + * Returns the newest valid prompt timestamp out of the values we know about, or undefined if none are usable. + */ +function getMostRecentPromptTime(...timestamps: Array): number | undefined { + const times = timestamps.filter((timestamp): timestamp is string => !!timestamp).map((timestamp) => new Date(timestamp).getTime()); + const validTimes = times.filter((time) => !Number.isNaN(time)); + return validTimes.length > 0 ? Math.max(...validTimes) : undefined; +} + /** * Hook to check if the proactive app review modal should be shown */ function useProactiveAppReview(): UseProactiveAppReviewReturn { const [proactiveAppReview] = useOnyx(ONYXKEYS.NVP_APP_REVIEW); + // Device-local prompt record. The server owns nvp_appReview and does not refresh its lastPrompt on + // every response, so we keep our own copy of when we prompted in a key nothing but this client writes. + const [localPromptRecord] = useOnyx(ONYXKEYS.APP_REVIEW_LAST_PROMPT); + const [sessionAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); + // The record survives sign-out, so it only counts for the account that wrote it. + const localLastPrompt = !!sessionAccountID && localPromptRecord?.accountID === sessionAccountID ? localPromptRecord.lastPrompt : undefined; const shouldSuppressPromotionalUI = useShouldSuppressPromotionalUI(); // Capture once so render stays pure (Date.now is impure). Fine for a 30-day cool-down gate. @@ -36,11 +52,14 @@ function useProactiveAppReview(): UseProactiveAppReviewReturn { } else if (proactiveAppReview?.response === 'positive') { // Don't show again after user gave a positive response shouldShowModal = false; - } else if (proactiveAppReview?.lastPrompt) { - // Don't show again within 30 days of the last prompt - const lastPromptTime = new Date(proactiveAppReview.lastPrompt).getTime(); - const msSinceLastPrompt = timeAtMount - lastPromptTime; - if (msSinceLastPrompt < THIRTY_DAYS_IN_MS) { + } else { + const lastPromptTime = getMostRecentPromptTime(proactiveAppReview?.lastPrompt, localLastPrompt); + if (proactiveAppReview?.response && lastPromptTime === undefined) { + // The prompt was answered but we have no timestamp to schedule the next one from. + // Never fall through to "show" here, or the answer re-shows the modal forever. + shouldShowModal = false; + } else if (lastPromptTime !== undefined && timeAtMount - lastPromptTime < THIRTY_DAYS_IN_MS) { + // Don't show again within 30 days of the last prompt shouldShowModal = false; } } diff --git a/src/hooks/useReopenAfterReturn.ts b/src/hooks/useReopenAfterReturn.ts new file mode 100644 index 000000000000..8777b9de80c6 --- /dev/null +++ b/src/hooks/useReopenAfterReturn.ts @@ -0,0 +1,72 @@ +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import {useEffect, useRef} from 'react'; + +/** Idle until `setIsDeparting(true)` (departing), away once the screen blurs, returned when it is focused again, then idle once `onReturn` ran or the return was dropped. */ +type ReturnStep = 'idle' | 'departing' | 'away' | 'returned'; + +type UseReopenAfterReturnParams = { + /** Runs once the screen is focused again and the transition back to it has finished */ + onReturn: () => void; + + /** Drops the pending return, e.g. the user came back without what `onReturn` needs */ + shouldCancel?: boolean; + + /** While false, the return stays pending until this flips or the screen is left again */ + canReopen?: boolean; +}; + +/** + * Runs `onReturn` when the user comes back to this screen after leaving it through `setIsDeparting(true)`. Scoped to that + * one departure: leaving the screen again after coming back drops it, so a later unrelated focus never triggers it. + */ +function useReopenAfterReturn({onReturn, shouldCancel = false, canReopen = true}: UseReopenAfterReturnParams) { + const isFocused = useIsFocused(); + const stepRef = useRef('idle'); + + useEffect(() => { + if (isFocused) { + return; + } + if (stepRef.current === 'departing') { + stepRef.current = 'away'; + } else if (stepRef.current === 'returned') { + stepRef.current = 'idle'; + } + }, [isFocused]); + + useFocusEffect(() => { + if (stepRef.current === 'away') { + stepRef.current = 'returned'; + } + if (stepRef.current !== 'returned') { + return; + } + if (shouldCancel) { + stepRef.current = 'idle'; + return; + } + if (!canReopen) { + return; + } + + const handle = TransitionTracker.runAfterTransitions({ + callback: () => { + stepRef.current = 'idle'; + onReturn(); + }, + waitForUpcomingTransition: 'navigation', + }); + return () => handle.cancel(); + }); + + const setIsDeparting = (isDeparting: boolean) => { + stepRef.current = isDeparting ? 'departing' : 'idle'; + }; + + return {setIsDeparting}; +} + +export default useReopenAfterReturn; +export type {UseReopenAfterReturnParams}; diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 09bf411c31b8..c8d05b3521d5 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -113,7 +113,7 @@ type UseReportActionsScrollResult = { scrollToActionBadgeTarget: () => void; - /** Completes a live-tail scroll-to-bottom once the list has laid out; call on every list layout */ + /** Completes a live-tail scroll-to-bottom once the list has laid out; call on every list layout and content size change */ flushPendingScrollToBottom: () => void; /** Whether the list should be pinned to the visual top (transaction thread / money request) */ @@ -171,6 +171,7 @@ function useReportActionsScroll({ const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`); const [reportActionPages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${getNonEmptyStringOnyxID(reportID)}`); + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); const prevIsLoadingInitialReportActions = usePrevious(reportLoadingState?.isLoadingInitialReportActions); const [actionIdToHighlight, setActionIdToHighlight] = useState(''); @@ -216,21 +217,41 @@ function useReportActionsScroll({ ...(shouldFocusToTopOnMount ? {autoscrollToBottomThreshold: shouldAutoscrollToBottom ? CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD : 0, animateAutoScrollToBottom: false} : {}), }; - const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, isActionBadgeAboveViewport, trackVerticalScrolling, onViewableItemsChanged, updatePillVisibility} = - useReportUnreadMessageScrollTracking({ - reportID, - currentVerticalScrollingOffsetRef: scrollOffsetRef, - onUnreadActionVisible: completeSkippedMarkAsRead, - hasNewerActions, - unreadMarkerReportActionIndex, - isInverted: true, - shouldDisablePillTracking, - onTrackScrolling: (event: NativeSyntheticEvent) => { - scrollOffsetRef.current = event.nativeEvent.contentOffset.y; - }, - actionBadgeTargetIndex, - shouldBeAlignedToTop, - }); + const { + isFloatingMessageCounterVisible, + setIsFloatingMessageCounterVisible, + isActionBadgeAboveViewport, + setIsActionBadgeAboveViewport, + trackVerticalScrolling, + onViewableItemsChanged, + updatePillVisibility, + } = useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: scrollOffsetRef, + onUnreadActionVisible: completeSkippedMarkAsRead, + hasNewerActions, + unreadMarkerReportActionIndex, + isInverted: true, + // The list is hidden behind the expanded composer, so scrolling it must not bring the Latest messages pill back + shouldDisablePillTracking: shouldDisablePillTracking || isComposerFullSize, + onTrackScrolling: (event: NativeSyntheticEvent) => { + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }, + actionBadgeTargetIndex, + shouldBeAlignedToTop, + }); + + const prevIsComposerFullSize = usePrevious(isComposerFullSize); + // Scroll events skipped pill tracking while expanded, so sync the Latest messages pill with the current offset + const syncPillAfterComposerCollapse = useEffectEvent(() => { + if (!prevIsComposerFullSize || isComposerFullSize || shouldDisablePillTracking) { + return; + } + updatePillVisibility(); + }); + useEffect(() => { + syncPillAfterComposerCollapse(); + }, [isComposerFullSize]); const {isScrollToBottomEnabled, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom} = useReportActionsNewActionLiveTail({ conciergeChat, @@ -399,6 +420,11 @@ function useReportActionsScroll({ if (actionBadgeTargetIndex < 0) { return; } + // The expanded composer covers the list, so the target never becomes viewable to hide the pill + if (isComposerFullSize) { + setIsActionBadgeAboveViewport(false); + setIsFloatingMessageCounterVisible(false); + } // `animated` is explicit because native defaults to an instant jump, which would teleport the list and lose the user's place. reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {animated: true, viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }; diff --git a/src/hooks/useReportsParentHierarchy.ts b/src/hooks/useReportsParentHierarchy.ts index 43dd826bd057..9b2291b4ef0a 100644 --- a/src/hooks/useReportsParentHierarchy.ts +++ b/src/hooks/useReportsParentHierarchy.ts @@ -1,20 +1,20 @@ import type {ReportHierarchyInfo} from '@userActions/ClearReportActionErrors'; import ONYXKEYS from '@src/ONYXKEYS'; -import {reportsParentHierarchySelector} from '@src/selectors/Report'; +import {reportParentChainSelector} from '@src/selectors/Report'; import type {OnyxCollection} from 'react-native-onyx'; import useOnyx from './useOnyx'; /** - * Subscribes to the parent hierarchy links (`parentReportID`/`parentReportActionID`) of every report so callers - * can resolve arbitrary ancestor chains (e.g. `clearAllRelatedReportActionErrors`) without subscribing to full - * report objects. Since these links are effectively immutable, the projected result only changes when reports - * are added or removed, so regular report updates don't re-render the subscriber. + * Subscribes to the parent hierarchy links (`parentReportID`/`parentReportActionID`) of the given report and its + * ancestors so callers can walk the ancestor chain (e.g. `clearAllRelatedReportActionErrors`) without subscribing to + * full report objects. The selector only looks up the reports on that chain, and since these links are effectively + * immutable, the result only changes when a report on the chain is added or removed. */ -function useReportsParentHierarchy(): OnyxCollection { - const [reportsParentHierarchy] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: reportsParentHierarchySelector}); +function useReportsParentHierarchy(reportID: string | undefined): OnyxCollection { + const [reportsParentHierarchy] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: reportParentChainSelector(reportID)}); return reportsParentHierarchy; } diff --git a/src/hooks/useSaveReportField.ts b/src/hooks/useSaveReportField.ts new file mode 100644 index 000000000000..cba5052bed1a --- /dev/null +++ b/src/hooks/useSaveReportField.ts @@ -0,0 +1,81 @@ +/** + * Builds the callback that persists a report field value. + * + * Both the report field editor page and the inline report field inputs in the report view save through this, so the + * violation, beta and recently-used handling only has to exist in one place. + */ + +import {useSession} from '@components/OnyxListItemProvider'; + +import {updateReportField, updateReportName} from '@libs/actions/Report'; +import {getReportFieldKey, hasViolations as hasViolationsReportUtils, isReportFieldDisabled, isReportFieldOfTypeTitle} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, PolicyReportField, Report} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {isTrackIntentUserSelector} from '@selectors/Onboarding'; + +import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; +import useReportTransactions from './useReportTransactions'; +import useReportTransactionViolations from './useReportTransactionViolations'; + +function useSaveReportField(report: OnyxEntry, policy: OnyxEntry) { + const session = useSession(); + const {isBetaEnabled} = usePermissions(); + const [recentlyUsedReportFields] = useOnyx(ONYXKEYS.RECENTLY_USED_REPORT_FIELDS); + // Both of these are scoped to this report so a transaction or violation change in an unrelated report does not re-render every consumer. + const reportTransactions = useReportTransactions(report?.reportID); + const [transactionViolations] = useReportTransactionViolations(reportTransactions); + const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + + return (reportField: PolicyReportField, value: string) => { + if (!report) { + return; + } + + // Only a text field can be cleared, the same as on OldDot. A report always has a name, so the title can never be + // empty, and the other field types have no way to enter an empty value. + if (value === '' && (isReportFieldOfTypeTitle(reportField) || reportField.type !== CONST.REPORT_FIELD_TYPES.TEXT)) { + return; + } + + if (isReportFieldOfTypeTitle(reportField)) { + updateReportName(report.reportID, value, report.reportName ?? ''); + return; + } + + const fieldKey = getReportFieldKey(reportField.fieldID); + const hasViolations = hasViolationsReportUtils( + report.reportID, + transactionViolations, + session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + session?.email ?? '', + undefined, + reportTransactions, + ); + const isFieldDisabled = isReportFieldDisabled(report, reportField, policy, rules); + const hasOtherViolations = Object.entries(report.fieldList ?? {}).some(([key, field]) => key !== fieldKey && field.value === '' && !isFieldDisabled); + + updateReportField({ + report: {...report, reportID: report.reportID}, + reportField: {...reportField, value}, + previousReportField: reportField, + policy, + isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), + accountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + email: session?.email ?? '', + hasViolationsParam: hasViolations, + recentlyUsedReportFields, + shouldFixViolations: hasOtherViolations, + isTrackIntentUser, + rules, + }); + }; +} + +export default useSaveReportField; diff --git a/src/hooks/useScreenBoundDynamicRoute.ts b/src/hooks/useScreenBoundDynamicRoute.ts index 0f647a42e451..bec85fe7c26f 100644 --- a/src/hooks/useScreenBoundDynamicRoute.ts +++ b/src/hooks/useScreenBoundDynamicRoute.ts @@ -39,7 +39,6 @@ function useScreenBoundDynamicRoute(): (dynamicRouteSuffixWithParams: string) => return navigationRef.addListener('state', sync); }); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const basePath = focusedBasePath || route?.path; return (dynamicRouteSuffixWithParams: string) => createDynamicRoute(dynamicRouteSuffixWithParams, basePath); diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 3e4cdabb9dbf..a145090197bf 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -7,7 +7,7 @@ import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useOpenSearchReportSubmitToPopover} from '@components/ReportSubmitToPopoverAnchor'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import {getSearchGroupCountByKey} from '@components/Search/selectionBuilders'; -import type {BulkPaySelectionData, PaymentData, QueryFilterKey, SearchColumnType, SearchFilterKey, SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; +import type {BulkPaySelectionData, PaymentData, QueryFilterKey, SearchColumnType, SearchGroupBy, SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; import {getAccountingIntegrationDisplayName, getExportLabelForConnection, isIntuitEnterpriseSuiteConnection} from '@libs/AccountingUtils'; import {getExpensifyCardStatementPDF} from '@libs/actions/CompanyCards'; @@ -48,13 +48,13 @@ import {canResolveTransactionCard} from '@libs/CardUtils'; import {getConnectionCompanyID} from '@libs/CopyPolicySettingsUtils'; import deferModalPresentationAfterPopoverDismiss from '@libs/deferModalPresentationAfterPopoverDismiss'; import {getExpensifyCardStatementParamsFromFeed, getExpensifyCardStatementSelection} from '@libs/ExpensifyCardStatementUtils'; -import type {ExpensifyCardStatementParams} from '@libs/ExpensifyCardStatementUtils'; import Log from '@libs/Log'; import {getTransactionsAndReportsFromSearch} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import Parser from '@libs/Parser'; import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; +import {canAccessPolicyBankAccount} from '@libs/PolicyPaymentUtils'; import {getConnectedIntegration, isAdminOfCardEnabledPolicy, isSubmitPolicy} from '@libs/PolicyUtils'; import {getReportAccountingExportActions, isMergeActionForSelectedTransactions} from '@libs/ReportSecondaryActionUtils'; import { @@ -138,17 +138,23 @@ import type {ValueOf} from 'type-fest'; /* eslint-disable react-hooks/refs -- Refs in this hook are used inside callbacks that capture stable references; the lint rule flags false positives for these patterns */ import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useCallback, useEffect, useMemo, useRef} from 'react'; + +import type {RejectModalAction} from './useHoldRejectEducationalModal'; import useAllTransactions from './useAllTransactions'; import useBulkPayOptions from './useBulkPayOptions'; import useConfirmModal from './useConfirmModal'; import {useCurrencyListActions} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useDecisionModal from './useDecisionModal'; import useDefaultExpensePolicy from './useDefaultExpensePolicy'; import useDelegateAccountID from './useDelegateAccountID'; import useDeleteTransactions from './useDeleteTransactions'; import useDuplicateTransactionsAndViolations from './useDuplicateTransactionsAndViolations'; +import useExpensifyCardStatementPDFDownloadModal from './useExpensifyCardStatementPDFDownloadModal'; +import useHoldRejectEducationalModal from './useHoldRejectEducationalModal'; +import useHoldSubmitterEducationalModal from './useHoldSubmitterEducationalModal'; import useIsVendorColumnAvailable from './useIsVendorColumnAvailable'; import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useLoadSearchCardData from './useLoadSearchCardData'; @@ -452,11 +458,18 @@ function getAllMatchingReportQuery(queryJSON: SearchQueryJSON, excludedTransacti return buildSearchQueryJSON(buildSearchQueryString({...queryJSON, flatFilters})); } -const MERCHANT_GROUP_EXACT_MATCH_FILTER_KEYS = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); - -function getGroupExportExactMatchFilterKeys(groupBy: SearchQueryJSON['groupBy']): ReadonlySet | undefined { - return groupBy === CONST.SEARCH.GROUP_BY.MERCHANT ? MERCHANT_GROUP_EXACT_MATCH_FILTER_KEYS : undefined; -} +/** + * The groupings whose selected groups `getSelectedGroupFilterEntry` can turn into a query filter, which a template export needs. + * Keep this in sync with that function. + */ +const TEMPLATE_EXPORTABLE_GROUP_BYS = new Set([ + CONST.SEARCH.GROUP_BY.FROM, + CONST.SEARCH.GROUP_BY.CARD, + CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID, + CONST.SEARCH.GROUP_BY.CATEGORY, + CONST.SEARCH.GROUP_BY.MERCHANT, + CONST.SEARCH.GROUP_BY.TAG, +]); type ShouldShowBulkDuplicateParams = { selectedTransactionsKeys: string[]; @@ -670,20 +683,47 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }, [currentSearchResults]); const searchResults = currentSearchResults?.data ? currentSearchResults : lastNonEmptySearchResultsRef.current; - const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); - const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); - const [isExpensifyCardStatementPDFModalVisible, setIsExpensifyCardStatementPDFModalVisible] = useState(false); - const [expensifyCardStatementPDFParams, setExpensifyCardStatementPDFParams] = useState(undefined); - const [isExpensifyCardStatementMultiFeedAlertVisible, setIsExpensifyCardStatementMultiFeedAlertVisible] = useState(false); const {showConfirmModal} = useConfirmModal(); + const {showDecisionModal} = useDecisionModal(); + + const showOfflineModal = useCallback(() => { + showDecisionModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.offlinePrompt'), + secondOptionText: translate('common.buttonConfirm'), + }); + }, [showDecisionModal, translate]); + + const showDownloadErrorModal = useCallback(() => { + showDecisionModal({ + title: translate('common.downloadFailedTitle'), + prompt: translate('common.downloadFailedDescription'), + secondOptionText: translate('common.buttonConfirm'), + }); + }, [showDecisionModal, translate]); + + const showEmptyReportsDownloadErrorModal = useCallback( + (count: number) => { + showDecisionModal({ + title: translate('common.downloadFailedTitle'), + prompt: translate('common.downloadFailedEmptyReportDescription', {count}), + secondOptionText: translate('common.buttonConfirm'), + }); + }, + [showDecisionModal, translate], + ); + + const showExpensifyCardStatementMultiFeedAlert = useCallback(() => { + showDecisionModal({ + title: translate('search.expensifyCardStatementPDF.title'), + prompt: translate('search.expensifyCardStatementPDF.oneFeedAtATime'), + secondOptionText: translate('common.buttonConfirm'), + }); + }, [showDecisionModal, translate]); + const openSearchReportSubmitToPopover = useOpenSearchReportSubmitToPopover(); const {showReportPDFDownloadModal} = useReportPDFDownloadModal(); - const [isHoldEducationalModalVisible, setIsHoldEducationalModalVisible] = useState(false); - const [rejectModalAction, setRejectModalAction] = useState | null>(null); - - const [emptyReportsCount, setEmptyReportsCount] = useState(0); + const {showExpensifyCardStatementPDFDownloadModal, closeExpensifyCardStatementPDFDownloadModal} = useExpensifyCardStatementPDFDownloadModal(); const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION); const [dismissedHoldUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION); @@ -725,6 +765,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { 'RilletSquare', 'DualEntrySquare', 'CampfireSquare', + 'ZohoBooksSquare', 'BusinessCentralSquare', 'GustoSquare', 'Pencil', @@ -916,6 +957,32 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const shouldCalculateTotalsOnRefresh = useSearchShouldCalculateTotals(currentSearchKey, true); const isExpenseType = queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE; const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); + + const dismissModalAndUpdateUseHold = useCallback(() => { + setNameValuePair(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, true, false, !isOffline); + if (hash && selectedTransactionsKeys.length > 0) { + navigateToSearchRHP(ROUTES.TRANSACTION_HOLD_REASON_SEARCH, ROUTES.TRANSACTION_HOLD_REASON_RHP); + } + }, [hash, selectedTransactionsKeys.length, isOffline]); + + const dismissRejectModalBasedOnAction = useCallback( + (rejectModalAction: RejectModalAction) => { + if (rejectModalAction === CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD) { + dismissRejectUseExplanation(); + if (hash && selectedTransactionsKeys.length > 0) { + navigateToSearchRHP(ROUTES.TRANSACTION_HOLD_REASON_SEARCH, ROUTES.TRANSACTION_HOLD_REASON_RHP); + } + } else { + dismissRejectUseExplanation(); + Navigation.navigate(ROUTES.SEARCH_REJECT_REASON_RHP); + } + }, + [hash, selectedTransactionsKeys.length], + ); + + const {showHoldOrRejectEducationalModal} = useHoldRejectEducationalModal(); + const {showHoldSubmitterEducationalModal} = useHoldSubmitterEducationalModal(); + // Use currentSearchResults, not the lastNonEmpty fallback: the export scope must reflect the query on screen now, // so an empty current result yields no selection rather than one derived from the previous query's settlements. const expensifyCardStatementSelection = useMemo( @@ -935,7 +1002,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const exportExpensifyCardStatementPDF = useCallback(() => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } @@ -945,7 +1012,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } if (selection.hasMultipleFeeds) { - setIsExpensifyCardStatementMultiFeedAlertVisible(true); + showExpensifyCardStatementMultiFeedAlert(); return; } @@ -963,13 +1030,21 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { if (requestID !== expensifyCardStatementRequestIDRef.current) { return; } - setIsExpensifyCardStatementPDFModalVisible(false); - setExpensifyCardStatementPDFParams(undefined); - setIsDownloadErrorModalVisible(true); + closeExpensifyCardStatementPDFDownloadModal(); + showDownloadErrorModal(); }; - setExpensifyCardStatementPDFParams(statementParams); - setIsExpensifyCardStatementPDFModalVisible(true); + showExpensifyCardStatementPDFDownloadModal({statementParams}).then(() => { + // The modal is gone, so retire this request: a response that lands later must not reopen it, since a + // show call with the same id after close pushes a new modal instead of updating the old one. + if (requestID === expensifyCardStatementRequestIDRef.current) { + expensifyCardStatementRequestIDRef.current++; + } + + // The promise resolves once the modal has finished hiding, so the selection clears after the flow is + // done, like the other bulk actions. + clearSelectedTransactions(); + }); getExpensifyCardStatementPDF(statementParams.policyID, statementParams.feedCountry, entryIDs) ?.then((response) => { const statementKey = response?.statementKey; @@ -986,10 +1061,18 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { if (requestID !== expensifyCardStatementRequestIDRef.current) { return; } - setExpensifyCardStatementPDFParams((currentParams) => (currentParams ? {...currentParams, statementKey} : currentParams)); + showExpensifyCardStatementPDFDownloadModal({statementParams: {...statementParams, statementKey}}); }) .catch(showStatementError); - }, [isOffline]); + }, [ + isOffline, + showOfflineModal, + showExpensifyCardStatementMultiFeedAlert, + showDownloadErrorModal, + showExpensifyCardStatementPDFDownloadModal, + closeExpensifyCardStatementPDFDownloadModal, + clearSelectedTransactions, + ]); const firstTransactionID = selectedTransactionsKeys.at(0); const firstTransaction = (firstTransactionID ? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${firstTransactionID}`] : undefined) ?? @@ -1037,18 +1120,17 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const beginExportWithTemplate = useCallback( (templateName: string, templateType: string, policyID: string | undefined, exportName: string) => { if (hasOnlyEmptyReports) { - setEmptyReportsCount(emptyReports.length); - setIsDownloadErrorModalVisible(true); + showEmptyReportsDownloadErrorModal(emptyReports.length); return; } if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } if (areAllMatchingItemsSelected) { const allMatchingQueryJSON = queryJSON ? getUngroupedTemplateExportQuery(queryJSON) : undefined; if (queryJSON && !allMatchingQueryJSON) { - setIsDownloadErrorModalVisible(true); + showDownloadErrorModal(); return; } queueExportSearchWithTemplate( @@ -1069,7 +1151,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {isGroupExport, transactionIDList} = getGroupExportScope(queryJSON, selectedTransactions); const groupExportQueryJSON = isGroupExport && queryJSON ? getTemplateGroupExportQuery(queryJSON, selectedTransactions, currentSearchResults?.data) : undefined; if (isGroupExport && !groupExportQueryJSON) { - setIsDownloadErrorModalVisible(true); + showDownloadErrorModal(); return; } queueExportSearchWithTemplate( @@ -1078,9 +1160,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { templateType, // searchKey changes what the backend query matches (e.g. reconciliation includes Expensify Card cash back), // so the export must send it exactly as search() does or the exported set differs from the viewed set. - jsonQuery: groupExportQueryJSON - ? serializeQueryJSONForBackend({...groupExportQueryJSON, searchKey: currentSearchKey}, getGroupExportExactMatchFilterKeys(queryJSON?.groupBy)) - : '{}', + jsonQuery: groupExportQueryJSON ? serializeQueryJSONForBackend({...groupExportQueryJSON, searchKey: currentSearchKey}) : '{}', reportIDList: isGroupExport ? [] : selectedTransactionReportIDs, transactionIDList, policyID, @@ -1099,6 +1179,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { emptyReports.length, selectedTransactions, isOffline, + showOfflineModal, + showDownloadErrorModal, + showEmptyReportsDownloadErrorModal, areAllMatchingItemsSelected, currentSearchResults?.data, currentSearchKey, @@ -1112,7 +1195,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const policyIDsWithVBBA = useMemo(() => { const result = []; for (const policy of Object.values(policies ?? {})) { - if (!policy?.achAccount?.bankAccountID) { + // Bulk pay funds from the workspace bank account when no account was picked in the menu, so a workspace only + // counts here if the workspace account is actually shared with the current user. Anyone else has to open the + // report and pick an account of their own. That includes a payer the account was never shared with. + if (!policy || !canAccessPolicyBankAccount(policy, bankAccountList)) { continue; } @@ -1120,13 +1206,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } return result; - }, [policies]); + }, [policies, bankAccountList]); const exportSearchData = searchResults?.data; const exportSearchType = searchResults?.search.type ?? queryJSON?.type; const getCSVExportParameters = useCallback( - (isBasicExport: boolean, queryJSONToExport: SearchQueryJSON | undefined, exactMatchFilterKeys?: ReadonlySet) => { + (isBasicExport: boolean, queryJSONToExport: SearchQueryJSON | undefined) => { const groupBy = getValidGroupBy(queryJSON?.groupBy); let columnsToExport: SearchColumnType[]; let groupColumnsToExport: SearchColumnType[] = []; @@ -1186,7 +1272,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { searchKey: currentSearchKey, } : undefined; - const jsonQuery = queryToExport ? serializeQueryJSONForBackend(queryToExport, exactMatchFilterKeys) : (JSON.stringify(queryJSONToExport) ?? ''); + const jsonQuery = queryToExport ? serializeQueryJSONForBackend(queryToExport) : (JSON.stringify(queryJSONToExport) ?? ''); return { jsonQuery, @@ -1200,12 +1286,11 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const handleCSVExport = useCallback( async (isBasicExport: boolean) => { if (hasOnlyEmptyReports) { - setEmptyReportsCount(emptyReports.length); - setIsDownloadErrorModalVisible(true); + showEmptyReportsDownloadErrorModal(emptyReports.length); return; } if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } @@ -1219,7 +1304,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const allMatchingExportData = isExpenseType && queryJSON ? getAllMatchingExportQueryAndExclusions(queryJSON, excludedTransactions, currentSearchResults?.data) : undefined; const allMatchingReportExportQuery = isExpenseReportType && queryJSON ? getAllMatchingReportQuery(queryJSON, excludedTransactions) : undefined; if ((isExpenseType && !allMatchingExportData) || (isExpenseReportType && !allMatchingReportExportQuery)) { - setIsDownloadErrorModalVisible(true); + showDownloadErrorModal(); return; } const reportIDList = selectedReports?.map((report) => report?.reportID).filter((reportID) => reportID !== undefined) ?? []; @@ -1244,8 +1329,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { let didFail = false; const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs; const queryJSONToExport = isGroupExport && queryJSON ? addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data) : queryJSON; - const exactMatchFilterKeys = isGroupExport ? getGroupExportExactMatchFilterKeys(queryJSON?.groupBy) : undefined; - const exportParameters = getCSVExportParameters(isBasicExport, queryJSONToExport, exactMatchFilterKeys); + const exportParameters = getCSVExportParameters(isBasicExport, queryJSONToExport); await exportSearchItemsToCSV( { jsonQuery: exportParameters.jsonQuery, @@ -1258,8 +1342,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }, () => { didFail = true; - setEmptyReportsCount(0); - setIsDownloadErrorModalVisible(true); + showDownloadErrorModal(); }, translate, allReportsTransactionsAndViolations, @@ -1272,6 +1355,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { hasOnlyEmptyReports, emptyReports.length, isOffline, + showOfflineModal, + showDownloadErrorModal, + showEmptyReportsDownloadErrorModal, areAllMatchingItemsSelected, queryJSON, selectedReports, @@ -1297,7 +1383,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const handleApproveWithDEWCheck = useCallback(async () => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } @@ -1376,6 +1462,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } }, [ isOffline, + showOfflineModal, isDelegateAccessRestricted, showDelegateNoAccessModal, selectedReports, @@ -1567,7 +1654,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { if (isOffline) { // On iOS, presenting the offline modal while the payment popover is still dismissing freezes the app, // so defer it until the popover transition completes (matching the delegate/locked-account guards). - deferModalPresentationAfterPopoverDismiss(() => setIsOfflineModalVisible(true)); + deferModalPresentationAfterPopoverDismiss(() => showOfflineModal()); return; } @@ -1713,6 +1800,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } const isItemInvoice = isInvoiceReport(iouReport); + const iouReportTransactions = Object.values(allReportsTransactionsAndViolations?.[item.reportID]?.transactions ?? {}); const fallbackChatReportID = item.chatReportID ?? iouReport.chatReportID ?? iouReport.parentReportID; const fallbackPolicyID = iouReport.policyID ?? item.policyID; const {chatReport, isFallbackChatReport} = getChatReportWithFallback( @@ -1774,11 +1862,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const payChatReportID = shouldUseB2BInvoiceReport ? existingB2BInvoiceReport.reportID : chatReport.reportID; payInvoice({ + bankAccountList, isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), getCurrencyDecimals, paymentMethodType: paymentItem.paymentType as PaymentMethodType, chatReport, invoiceReport: iouReport, + invoiceReportTransactions: iouReportTransactions, introSelected, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', @@ -1803,11 +1893,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } payMoneyRequest({ + bankAccountList, isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), getCurrencyDecimals, paymentType: paymentItem.paymentType as PaymentMethodType, chatReport, iouReport, + iouReportTransactions, introSelected, currentUserAccountID: accountID, currentUserLogin: currentUserLogin ?? '', @@ -1844,6 +1936,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }, [ hash, + showOfflineModal, areAllMatchingItemsSelected, queryJSON, excludedTransactions, @@ -1864,8 +1957,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { personalPolicyID, lastPaymentMethods, allTransactions, + allReportsTransactionsAndViolations, policyIDsWithVBBA, clearSelectedTransactions, + bankAccountList, accountID, email, currentUserLogin, @@ -2111,14 +2206,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const isReportsTab = isExpenseReportType; const includesGroupExport = isGroupedSearch && Object.entries(selectedTransactions).some(([key, selectedTransaction]) => isGroupSelection(key, selectedTransaction)); - // Group selections are sent as query filters, not report or transaction IDs, so template exports are usually unavailable. - // Reconciliation - All Expenses is supported for card-grouped searches because selected card groups become a `cardID:` - // filter, which template reports can use to scope card spend. - const includesCardGroupExport = includesGroupExport && groupBy === CONST.SEARCH.GROUP_BY.CARD; - const customTemplatesToShow = includesCardGroupExport ? [] : availableCustomTemplates; - const defaultTemplatesToShow = includesCardGroupExport - ? availableDefaultTemplates.filter((template) => template.templateName === CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES) - : availableDefaultTemplates; + // A template export sends a group selection as a filter on the ungrouped query (see `getTemplateGroupExportQuery`), alongside any individually picked expenses. + // Groupings with no such filter (the time-based ones) would fail that export, so they keep only "Current view". + const canExportGroupWithTemplates = !includesGroupExport || (!!groupBy && TEMPLATE_EXPORTABLE_GROUP_BYS.has(groupBy)); const canReportBeExported = (report: (typeof selectedReports)[0], exportOption: ValueOf) => { if (!report.reportID) { @@ -2355,7 +2445,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } clearSelectedTransactions(); @@ -2406,7 +2496,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { addSeparatorBefore: true, }); - if (!allSelectedAreDeleted && (!includesGroupExport || includesCardGroupExport)) { + if (!allSelectedAreDeleted && canExportGroupWithTemplates) { // Builds a single export sub-menu item for a template. `isDefaultTemplate` picks the icon and `addSeparatorBefore` draws the divider at the top of each group. const buildExportOption = (template: ExportTemplate, isDefaultTemplate: boolean, addSeparatorBefore: boolean): ExportMenuItem => { // The basic export is a plain CSV download, so it uses its own handler rather than the template export flow @@ -2430,10 +2520,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }; // Add each group's templates separately so the icon and the group-boundary divider come from the group itself, not from an index into a combined list. - for (const [index, template] of customTemplatesToShow.entries()) { + for (const [index, template] of availableCustomTemplates.entries()) { exportOptions.push(buildExportOption(template, false, index === 0)); } - for (const [index, template] of defaultTemplatesToShow.entries()) { + for (const [index, template] of availableDefaultTemplates.entries()) { exportOptions.push(buildExportOption(template, true, index === 0)); } } else if (!isGroupedSearch) { @@ -2535,7 +2625,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { onSelected: () => { // A queued all-matching move would send a stale query, so ask the user to reconnect like export does if (areAllMatchingItemsSelected && isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } Navigation.navigate(ROUTES.MOVE_TRANSACTIONS_SEARCH_RHP.getRoute()); @@ -2567,7 +2657,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCloseModalOnSelect: true, onSelected: async () => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } // In "Select all" mode the matching reports aren't enumerated on the client (results are paged), @@ -2576,7 +2666,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { if (areAllMatchingItemsSelected) { const allMatchingReportQuery = queryJSON ? getAllMatchingReportQuery(queryJSON, excludedTransactions) : undefined; if (!allMatchingReportQuery) { - setIsDownloadErrorModalVisible(true); + showDownloadErrorModal(); return; } const serializedQuery = serializeQueryJSONForBackend({...allMatchingReportQuery, searchKey: currentSearchKey}); @@ -2729,7 +2819,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } @@ -2741,7 +2831,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { if (dismissedRejectUseExplanation) { Navigation.navigate(ROUTES.SEARCH_REJECT_REASON_RHP); } else { - setRejectModalAction(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT); + showHoldOrRejectEducationalModal().finally(() => dismissRejectModalBasedOnAction(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT)); } }, }); @@ -2789,7 +2879,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } @@ -2995,6 +3085,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { selectedMergeReports, currentUserPersonalDetails.accountID, rules, + getLoginByAccountID(selectedMergeReports.at(0)?.ownerAccountID, personalDetails), allReportNameValuePairs, policies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedMergeReports.at(0)?.policyID}`], ) @@ -3029,7 +3120,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } exportReceiptsToZip({reportIDs: selectedReportIDs}); @@ -3059,7 +3150,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } exportReceiptsToZip({transactionIDs}); @@ -3094,7 +3185,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } @@ -3109,9 +3200,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { if (isDismissed) { navigateToSearchRHP(ROUTES.TRANSACTION_HOLD_REASON_SEARCH, ROUTES.TRANSACTION_HOLD_REASON_RHP); } else if (shouldShowHoldEducationalModal) { - setIsHoldEducationalModalVisible(true); + showHoldSubmitterEducationalModal({isDM: areAllTransactionsFromDMReports}).finally(dismissModalAndUpdateUseHold); } else { - setRejectModalAction(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD); + showHoldOrRejectEducationalModal().finally(() => dismissRejectModalBasedOnAction(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD)); } }, }); @@ -3159,26 +3250,14 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { if (selectedTransactionsKeys.length < 3 && searchResults?.search.type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && searchResults?.data) { const {transactions: searchedTransactions, reports, policies: transactionPolicies} = getTransactionsAndReportsFromSearch(searchResults, selectedTransactionsKeys); - if (isMergeActionForSelectedTransactions(searchedTransactions, reports, transactionPolicies, rules, accountID)) { + if (isMergeActionForSelectedTransactions(searchedTransactions, reports, transactionPolicies, rules, personalDetails, accountID)) { const transactionID = searchedTransactions.at(0)?.transactionID; if (transactionID) { options.push({ text: translate('common.merge'), icon: expensifyIcons.ArrowCollapse, value: CONST.SEARCH.BULK_ACTION_TYPES.MERGE, - onSelected: () => - setupMergeTransactionDataAndNavigate( - transactionID, - searchedTransactions, - localeCompare, - getCurrencyDecimals, - reports, - false, - true, - undefined, - accountID, - personalDetails, - ), + onSelected: () => setupMergeTransactionDataAndNavigate(transactionID, searchedTransactions, localeCompare, getCurrencyDecimals, reports, false, true, undefined), }); } } @@ -3190,7 +3269,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const firstTransactionKey = selectedTransactionsKeys.at(0); const firstTransactionMeta = firstTransactionKey ? selectedTransactions[firstTransactionKey] : undefined; - const canShowDeleteAction = shouldShowDeleteOption(selectedTransactions, currentSearchResults?.data, accountID, rules, cardList, selectedReports, queryJSON?.type); + const canShowDeleteAction = shouldShowDeleteOption(selectedTransactions, currentSearchResults?.data, accountID, rules, personalDetails, cardList, selectedReports, queryJSON?.type); const isSplittable = !!firstTransactionMeta?.canSplit; const isAlreadySplit = !!firstTransactionMeta?.hasBeenSplit; @@ -3290,6 +3369,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return buildResult(options); }, [ selectedTransactionsKeys, + showOfflineModal, + showDownloadErrorModal, cardList, hash, selectedTransactions, @@ -3339,6 +3420,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { areAllTransactionsFromSubmitter, areAllTransactionsFromDMReports, dismissedHoldUseExplanation, + showHoldOrRejectEducationalModal, + showHoldSubmitterEducationalModal, + dismissRejectModalBasedOnAction, + dismissModalAndUpdateUseHold, localeCompare, firstTransaction, isDuplicateOptionVisible, @@ -3398,47 +3483,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { dropdownButtonsOptions.length > 0 && dropdownButtonsOptions.every((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT && !option.subMenuItems); const bulkActionsMenuHeaderText = isShowingExportOptionsDirectly ? translate('common.export') : undefined; - const handleOfflineModalClose = useCallback(() => { - setIsOfflineModalVisible(false); - }, [setIsOfflineModalVisible]); - - const handleDownloadErrorModalClose = useCallback(() => { - setIsDownloadErrorModalVisible(false); - }, [setIsDownloadErrorModalVisible]); - - const handleExpensifyCardStatementPDFModalHide = useCallback(() => { - setExpensifyCardStatementPDFParams(undefined); - // Clear the selection when the statement modal closes (after download or failure), like the other bulk - // actions clear once their flow is done. Done on hide, not on trigger, because the modal renders inside - // the selection-gated bulk-action bar and would unmount if the selection cleared while it was open. - clearSelectedTransactions(); - }, [clearSelectedTransactions]); - - const handleExpensifyCardStatementMultiFeedAlertClose = useCallback(() => { - setIsExpensifyCardStatementMultiFeedAlertVisible(false); - }, []); - - const dismissModalAndUpdateUseHold = useCallback(() => { - setIsHoldEducationalModalVisible(false); - setNameValuePair(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, true, false, !isOffline); - if (hash && selectedTransactionsKeys.length > 0) { - navigateToSearchRHP(ROUTES.TRANSACTION_HOLD_REASON_SEARCH, ROUTES.TRANSACTION_HOLD_REASON_RHP); - } - }, [hash, selectedTransactionsKeys.length, isOffline]); - - const dismissRejectModalBasedOnAction = useCallback(() => { - if (rejectModalAction === CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD) { - dismissRejectUseExplanation(); - if (hash && selectedTransactionsKeys.length > 0) { - navigateToSearchRHP(ROUTES.TRANSACTION_HOLD_REASON_SEARCH, ROUTES.TRANSACTION_HOLD_REASON_RHP); - } - } else { - dismissRejectUseExplanation(); - Navigation.navigate(ROUTES.SEARCH_REJECT_REASON_RHP); - } - setRejectModalAction(null); - }, [rejectModalAction, hash, selectedTransactionsKeys.length]); - return { headerButtonsOptions, dropdownButtonsOptions, @@ -3448,22 +3492,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { selectedReportIDs, businessBankAccountOptions, confirmPayment: stableOnBulkPaySelected, - isOfflineModalVisible, - isDownloadErrorModalVisible, - isHoldEducationalModalVisible, - areAllTransactionsFromDMReports, - rejectModalAction, - emptyReportsCount, - handleOfflineModalClose, - handleDownloadErrorModalClose, - isExpensifyCardStatementPDFModalVisible, - setIsExpensifyCardStatementPDFModalVisible, - expensifyCardStatementPDFParams, - handleExpensifyCardStatementPDFModalHide, - isExpensifyCardStatementMultiFeedAlertVisible, - handleExpensifyCardStatementMultiFeedAlertClose, - dismissModalAndUpdateUseHold, - dismissRejectModalBasedOnAction, isDuplicateOptionVisible, setDuplicateHandler, isDuplicateReportOptionVisible, diff --git a/src/hooks/useSearchSelector/base.ts b/src/hooks/useSearchSelector/base.ts index fcc0e311dcdb..8b3989a2db3f 100644 --- a/src/hooks/useSearchSelector/base.ts +++ b/src/hooks/useSearchSelector/base.ts @@ -158,7 +158,7 @@ const CONTEXTS_APPLYING_GET_VALID_OPTIONS_CONFIG: ReadonlySet { return ( - (option1.accountID && option1.accountID === option2.accountID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison + (option1.accountID && option1.accountID === option2.accountID) || (option1.reportID && option1.reportID !== '-1' && option1.reportID === option2.reportID) || (option1.login && option1.login === option2.login) ); @@ -234,6 +234,7 @@ function useSearchSelectorBase({ isSearching: isSearchingOptions, includeP2P, batchSize: maxResultsPerPage, + activePolicyID: appliesGetValidOptionsConfig ? getValidOptionsConfig.activePolicyID : undefined, }); const areOptionsInitialized = !isLoadingOptions; const defaultOptions = filteredOptions ?? emptyOptionList; @@ -363,7 +364,7 @@ function useSearchSelectorBase({ getReportByID, ...appliedGetValidOptionsConfig, }, - translate, + {translate, formatPhoneNumber}, rules, ); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SHARE_DESTINATION: @@ -409,7 +410,7 @@ function useSearchSelectorBase({ getReportByID, ...appliedGetValidOptionsConfig, }, - translate, + {translate, formatPhoneNumber}, rules, ); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_ATTENDEES: @@ -452,7 +453,7 @@ function useSearchSelectorBase({ getReportByID, ...appliedGetValidOptionsConfig, }, - translate, + {translate, formatPhoneNumber}, rules, ); default: diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index bd88e40df03f..00edffd223b2 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -466,6 +466,7 @@ function useSelectedTransactionsActions({ clearSelectedTransactions(true); } : () => beginExportWithTemplate(template.templateName, template.type, selectedTransactionIDs, template.name, template.policyID), + shouldCallAfterModalHide: true, addSeparatorBefore, }; }; @@ -511,7 +512,8 @@ function useSelectedTransactionsActions({ return canMoveExpense; }); - const canMergeTransaction = selectedTransactionsList.length < 3 && report && policy && isMergeActionForSelectedTransactions(selectedTransactionsList, [report], [policy], rules); + const canMergeTransaction = + selectedTransactionsList.length < 3 && report && policy && isMergeActionForSelectedTransactions(selectedTransactionsList, [report], [policy], rules, personalDetails); if (canMergeTransaction) { const transactionID = selectedTransactionsList.at(0)?.transactionID; if (transactionID) { @@ -529,8 +531,6 @@ function useSelectedTransactionsActions({ false, isOnSearch, selectedTransactionsList.length > 1 ? [policy, policy] : undefined, - currentUserAccountID, - personalDetails, ), }); } @@ -621,7 +621,11 @@ function useSelectedTransactionsActions({ // Some reported expenses have no IOU action, and the API still deletes them by transactionID, so the report-level delete rules decide. // Passing no report actions is safe: they only decide ownership on unreported and IOU reports, which then fail closed, while expense reports check the submitter. if (!action) { - return !!report && typeof session?.accountID === 'number' && canDeleteMoneyRequestReport(report, [transaction], [], session.accountID, rules, policy, cardList); + return ( + !!report && + typeof session?.accountID === 'number' && + canDeleteMoneyRequestReport(report, [transaction], [], session.accountID, rules, getLoginByAccountID(report?.ownerAccountID, personalDetails), policy, cardList) + ); } if (isDeletedAction(action)) { diff --git a/src/hooks/useSelectionListSearch.ts b/src/hooks/useSelectionListSearch.ts index 48af29342be2..d536e9de4316 100644 --- a/src/hooks/useSelectionListSearch.ts +++ b/src/hooks/useSelectionListSearch.ts @@ -1,9 +1,12 @@ import type {ListItem} from '@components/SelectionList/types'; +import moveInitialSelectionToTop, {shouldMoveInitialSelectionToTop} from '@libs/SelectionListOrderUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; import CONST from '@src/CONST'; +import {useState} from 'react'; + import useLocalize from './useLocalize'; import useSearchResults from './useSearchResults'; @@ -13,13 +16,25 @@ import useSearchResults from './useSearchResults'; */ function useSelectionListSearch(data: T[], noResultsMessage?: string) { const {translate} = useLocalize(); - const [searchValue, setSearchValue, filteredData] = useSearchResults(data, (item, searchInput) => tokenizedSearch([item], searchInput, () => [item.text ?? '', item.value]).length > 0); + // Snapshot the selected items on the first render that has data (handles async-loaded lists), then keep it frozen + // so staged selections don't move the pin. + const [initialSelectedValues, setInitialSelectedValues] = useState(undefined); + if (initialSelectedValues === undefined && data.length > 0) { + setInitialSelectedValues(data.filter((item) => item.isSelected).map((item) => item.value)); + } + // The values actually pinned to the top, so callers share one source of truth instead of re-deriving the pin. + const pinnedValues = shouldMoveInitialSelectionToTop(data.length) ? (initialSelectedValues ?? []) : []; + const orderedData = moveInitialSelectionToTop(data, pinnedValues); + const [searchValue, setSearchValue, filteredData] = useSearchResults( + orderedData, + (item, searchInput) => tokenizedSearch([item], searchInput, () => [item.text ?? '', item.value]).length > 0, + ); const textInputOptions = { label: data.length >= CONST.STANDARD_LIST_ITEM_LIMIT ? translate('common.search') : undefined, value: searchValue, onChangeText: setSearchValue, headerMessage: noResultsMessage && searchValue && filteredData.length === 0 ? noResultsMessage : undefined, }; - return {filteredData, textInputOptions}; + return {filteredData, textInputOptions, pinnedValues}; } export default useSelectionListSearch; diff --git a/src/hooks/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts index 60aaa6b22843..8603b9c17ee5 100644 --- a/src/hooks/useSelectionModePayment.ts +++ b/src/hooks/useSelectionModePayment.ts @@ -92,6 +92,7 @@ function useSelectionModePayment({ const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [ownerLogin] = usePersonalDetail(moneyRequestReport?.ownerAccountID, loginSelector); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); @@ -188,11 +189,13 @@ function useSelectionModePayment({ if (isInvoiceReport) { payInvoice({ + bankAccountList, isASAPSubmitBetaEnabled, getCurrencyDecimals, paymentMethodType: type, chatReport, invoiceReport: moneyRequestReport, + invoiceReportTransactions: transactions, introSelected, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', @@ -212,11 +215,13 @@ function useSelectionModePayment({ }); } else { payMoneyRequest({ + bankAccountList, getCurrencyDecimals, isASAPSubmitBetaEnabled, paymentType: type, chatReport, iouReport: moneyRequestReport, + iouReportTransactions: transactions, introSelected, currentUserAccountID: accountID, currentUserLogin: currentUserLogin ?? '', diff --git a/src/hooks/useShareActions.ts b/src/hooks/useShareActions.ts new file mode 100644 index 000000000000..1781f7c85b8f --- /dev/null +++ b/src/hooks/useShareActions.ts @@ -0,0 +1,91 @@ +/** + * Builds the Share option of the report More menu and the options under it. + */ +import type {SecondaryActionEntry} from '@components/MoneyReportHeaderActions/types'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; + +import Clipboard from '@libs/Clipboard'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import {canShareReport} from '@libs/ReportUtils'; +import addTrailingForwardSlash from '@libs/UrlUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useEnvironment from './useEnvironment'; +import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; +import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; + +type UseShareActionsParams = { + reportID: string | undefined; + policy?: OnyxEntry; +}; + +type UseShareActionsReturn = Pick, SecondaryActionEntry>, typeof CONST.REPORT.SECONDARY_ACTIONS.SHARE>; + +function useShareActions({reportID, policy}: UseShareActionsParams): UseShareActionsReturn { + const {translate} = useLocalize(); + const {environmentURL} = useEnvironment(); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Mail', 'QrCode', 'Cash', 'Copy', 'ArrowRight'] as const); + + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); + + const shareSubMenuItems: PopoverMenuItem[] = [ + { + text: translate('common.shareCode'), + icon: expensifyIcons.QrCode, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.SHARE_CODE, + onSelected: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_CODE.getRoute(true))), + }, + { + text: translate(`referralProgram.${CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND}.header`), + icon: expensifyIcons.Cash, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.REFER_FRIEND, + onSelected: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REFERRAL_DETAILS.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND))), + }, + { + text: translate('qrCodes.copy'), + icon: expensifyIcons.Copy, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.COPY_URL, + onSelected: () => { + if (!moneyRequestReport?.reportID) { + return; + } + Clipboard.setString(`${addTrailingForwardSlash(environmentURL)}${ROUTES.REPORT_WITH_ID.getRoute(moneyRequestReport.reportID)}`); + }, + }, + ]; + + if (canShareReport(moneyRequestReport, policy, currentUserAccountID)) { + shareSubMenuItems.unshift({ + text: translate('common.shareReport'), + icon: expensifyIcons.Mail, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.SHARE_REPORT, + onSelected: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_REPORT.path)), + }); + } + + return { + [CONST.REPORT.SECONDARY_ACTIONS.SHARE]: { + value: CONST.REPORT.SECONDARY_ACTIONS.SHARE, + text: translate('common.share'), + backButtonText: translate('common.share'), + icon: expensifyIcons.Mail, + rightIcon: expensifyIcons.ArrowRight, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.SHARE, + subMenuItems: shareSubMenuItems, + }, + }; +} + +export default useShareActions; diff --git a/src/hooks/useShowNotFoundPageInIOUStep.ts b/src/hooks/useShowNotFoundPageInIOUStep.ts index 98d81e567fcc..daa8b64caf0d 100644 --- a/src/hooks/useShowNotFoundPageInIOUStep.ts +++ b/src/hooks/useShowNotFoundPageInIOUStep.ts @@ -42,7 +42,6 @@ const useShowNotFoundPageInIOUStep = (action: IOUAction, iouType: IOUType, repor const [reportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportActionsReportID}`, { selector: (reportActions: OnyxEntry): OnyxEntry => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return reportActions?.[`${report?.parentReportActionID || reportActionID}`]; }, }); diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index 80dd9483a600..8612b4f409cf 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -8,6 +8,7 @@ import {getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; +import arraysEqual from '@src/utils/arraysEqual'; import type {ValueOf} from 'type-fest'; @@ -23,6 +24,7 @@ import usePermissions from './usePermissions'; import usePrevious from './usePrevious'; import useReportAttributes from './useReportAttributes'; import useResponsiveLayout from './useResponsiveLayout'; +import useStableArrayReference from './useStableArrayReference'; type SidebarOrderedReportsContextProviderProps = { children: React.ReactNode; @@ -30,8 +32,8 @@ type SidebarOrderedReportsContextProviderProps = { }; type SidebarOrderedReportsStateContextValue = { - /** The reports rendered in the LHN for the active Inbox tab (a filtered subset of orderedReportIDs). */ - filteredReports: OnyxTypes.Report[]; + /** The report IDs rendered in the LHN for the active Inbox tab (a filtered subset of orderedReportIDs). Keeps its reference while the list is unchanged. */ + filteredReportIDs: string[]; /** All ordered LHN report IDs, unfiltered by the active Inbox tab. Used for total counts (e.g. focus-mode switch) and brick road. */ orderedReportIDs: string[]; currentReportID: string | undefined; @@ -60,7 +62,7 @@ type ReportsToDisplayInLHN = Record< >; const SidebarOrderedReportsStateContext = createContext({ - filteredReports: [], + filteredReportIDs: [], orderedReportIDs: [], currentReportID: '', chatTabBrickRoad: undefined, @@ -72,6 +74,9 @@ const SidebarOrderedReportsStateContext = createContext(undefined); + const SidebarOrderedReportsActionsContext = createContext({ clearLHNCache: () => {}, setActiveTab: () => {}, @@ -342,7 +347,7 @@ function SidebarOrderedReportsContextProvider({ // The reports for the active tab, plus the sticky report opened from it (kept visible even after it's read). const stickyReportID = stickyReport?.reportID; const stickyReportTab = stickyReport?.tab; - const filteredReportIDs = useMemo(() => { + const computedFilteredReportIDs = useMemo(() => { const baseFilteredReportIDs = SidebarUtils.filterReportsForInboxTab(orderedReportIDs, reportsToDisplayInLHN, activeTab); if (activeTab === CONST.INBOX_TAB.ALL || !stickyReportID || stickyReportTab !== activeTab || baseFilteredReportIDs.includes(stickyReportID)) { return baseFilteredReportIDs; @@ -350,11 +355,15 @@ function SidebarOrderedReportsContextProvider({ if (!orderedReportIDs.includes(stickyReportID)) { // While opening the report, reading it can briefly drop it from the LHN set entirely (before // navigation marks it as the focused report). Keep it at the top so the list doesn't flash empty. + if (!chatReports?.[`${ONYXKEYS.COLLECTION.REPORT}${stickyReportID}`]) { + return baseFilteredReportIDs; + } return [stickyReportID, ...baseFilteredReportIDs]; } const baseSet = new Set(baseFilteredReportIDs); return orderedReportIDs.filter((reportID) => baseSet.has(reportID) || reportID === stickyReportID); - }, [orderedReportIDs, reportsToDisplayInLHN, activeTab, stickyReportTab, stickyReportID]); + }, [orderedReportIDs, reportsToDisplayInLHN, activeTab, stickyReportTab, stickyReportID, chatReports]); + const filteredReportIDs = useStableArrayReference(computedFilteredReportIDs); // Derived from the full "All" set (not the currently filtered view). const {counts: inboxTabCounts, hasStaleUnreadReport} = useMemo(() => SidebarUtils.getInboxTabSummary(orderedReportIDs, reportsToDisplayInLHN), [orderedReportIDs, reportsToDisplayInLHN]); @@ -370,19 +379,6 @@ function SidebarOrderedReportsContextProvider({ [], ); - // Get the actual reports based on the filtered IDs - const getOrderedReports = useCallback( - (reportIDs: string[]): OnyxTypes.Report[] => { - if (!chatReports) { - return []; - } - return reportIDs.map((reportID) => chatReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]).filter(Boolean) as OnyxTypes.Report[]; - }, - [chatReports], - ); - - const filteredReports = useMemo(() => getOrderedReports(filteredReportIDs), [getOrderedReports, filteredReportIDs]); - const clearLHNCache = useCallback(() => { Log.info('[useSidebarOrderedReports] Clearing sidebar cache manually via debug modal'); setCurrentReportsToDisplay({}); @@ -431,9 +427,8 @@ function SidebarOrderedReportsContextProvider({ ) { const updatedReportIDs = getOrderedReportIDs(); const updatedFilteredIDs = SidebarUtils.filterReportsForInboxTab(updatedReportIDs, reportsToDisplayInLHN, activeTab); - const updatedReports = getOrderedReports(updatedFilteredIDs); return { - filteredReports: updatedReports, + filteredReportIDs: arraysEqual(updatedFilteredIDs, filteredReportIDs) ? filteredReportIDs : updatedFilteredIDs, orderedReportIDs: updatedReportIDs, currentReportID: derivedCurrentReportID, chatTabBrickRoad: getChatTabBrickRoad(updatedReportIDs, reportAttributes), @@ -444,7 +439,7 @@ function SidebarOrderedReportsContextProvider({ } return { - filteredReports, + filteredReportIDs, orderedReportIDs, currentReportID: derivedCurrentReportID, chatTabBrickRoad: getChatTabBrickRoad(orderedReportIDs, reportAttributes), @@ -458,8 +453,6 @@ function SidebarOrderedReportsContextProvider({ filteredReportIDs, derivedCurrentReportID, shouldUseNarrowLayout, - getOrderedReports, - filteredReports, reportAttributes, activeTab, inboxTabCounts, @@ -474,7 +467,9 @@ function SidebarOrderedReportsContextProvider({ return ( - {children} + + {children} + ); } @@ -483,6 +478,10 @@ function useSidebarOrderedReportsState() { return useContext(SidebarOrderedReportsStateContext); } +function useChatTabBrickRoad() { + return useContext(SidebarChatTabBrickRoadContext); +} + function useSidebarOrderedReportsActions() { return useContext(SidebarOrderedReportsActionsContext); } @@ -495,5 +494,5 @@ function useSidebarOrderedReports() { return useMemo(() => ({...state, ...actions}), [state, actions]); } -export {SidebarOrderedReportsContextProvider, useSidebarOrderedReports, useSidebarOrderedReportsState, useSidebarOrderedReportsActions}; +export {SidebarOrderedReportsContextProvider, useChatTabBrickRoad, useSidebarOrderedReports, useSidebarOrderedReportsState, useSidebarOrderedReportsActions}; export type {ReportsToDisplayInLHN}; diff --git a/src/hooks/useTackInputFocus/index.ts b/src/hooks/useTackInputFocus/index.ts index 2b758484782a..90460be73058 100644 --- a/src/hooks/useTackInputFocus/index.ts +++ b/src/hooks/useTackInputFocus/index.ts @@ -1,6 +1,8 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useSidePanelState from '@hooks/useSidePanelState'; +import isHTMLElement from '@libs/isHTMLElement'; + import CONST from '@src/CONST'; import {useCallback, useEffect} from 'react'; @@ -14,7 +16,10 @@ export default function useTackInputFocus(enable = false): boolean { const handleFocusIn = useCallback( (event: FocusEvent) => { - const targetElement = event.target as HTMLElement; + const targetElement = event.target; + if (!isHTMLElement(targetElement)) { + return; + } if (targetElement.tagName === CONST.ELEMENT_NAME.INPUT || targetElement.tagName === CONST.ELEMENT_NAME.TEXTAREA) { setIsInputFocus(true); } @@ -24,7 +29,10 @@ export default function useTackInputFocus(enable = false): boolean { const handleFocusOut = useCallback( (event: FocusEvent) => { - const targetElement = event.target as HTMLElement; + const targetElement = event.target; + if (!isHTMLElement(targetElement)) { + return; + } if (targetElement.tagName === CONST.ELEMENT_NAME.INPUT || targetElement.tagName === CONST.ELEMENT_NAME.TEXTAREA) { setIsInputFocus(false); } diff --git a/src/hooks/useTextWithMiddleEllipsis.ts b/src/hooks/useTextWithMiddleEllipsis.ts index 5a5984ed7066..d9431a3e6f38 100644 --- a/src/hooks/useTextWithMiddleEllipsis.ts +++ b/src/hooks/useTextWithMiddleEllipsis.ts @@ -1,3 +1,5 @@ +import isHTMLElement from '@libs/isHTMLElement'; + import type {ComponentRef, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text as RNText} from 'react-native'; @@ -85,13 +87,13 @@ function useTextWithMiddleEllipsis(props: TruncateProps): string { useEffect(() => { const element = ref.current; - if (!element) { + if (!isHTMLElement(element)) { return; } const measure = () => { - const style = window.getComputedStyle(element as unknown as Element); - const rect = (element as unknown as Element).getBoundingClientRect(); + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); setFontStyle({ fontWeight: style.fontWeight, fontStyle: style.fontStyle, diff --git a/src/hooks/useThumbnailDimensions.ts b/src/hooks/useThumbnailDimensions.ts index 9af9669b211e..8535acd815d8 100644 --- a/src/hooks/useThumbnailDimensions.ts +++ b/src/hooks/useThumbnailDimensions.ts @@ -5,6 +5,7 @@ import type {DimensionValue} from 'react-native/Libraries/StyleSheet/StyleSheetT import {useMemo} from 'react'; +import useIsInSidePanel from './useIsInSidePanel'; import useResponsiveLayout from './useResponsiveLayout'; type ThumbnailDimensions = { @@ -17,7 +18,9 @@ type ThumbnailDimensions = { export default function useThumbnailDimensions(width: number, height: number): ThumbnailDimensions { const {shouldUseNarrowLayout} = useResponsiveLayout(); - const fixedDimension = shouldUseNarrowLayout ? CONST.THUMBNAIL_IMAGE.SMALL_SCREEN.SIZE : CONST.THUMBNAIL_IMAGE.WIDE_SCREEN.SIZE; + const isInSidePanel = useIsInSidePanel(); + // The side panel is as narrow as a phone screen even on wide layouts, so it needs the small thumbnail size to avoid clipping + const fixedDimension = shouldUseNarrowLayout || isInSidePanel ? CONST.THUMBNAIL_IMAGE.SMALL_SCREEN.SIZE : CONST.THUMBNAIL_IMAGE.WIDE_SCREEN.SIZE; const thumbnailDimensionsStyles = useMemo(() => { if (!width || !height) { return {width: fixedDimension, aspectRatio: CONST.THUMBNAIL_IMAGE.NAN_ASPECT_RATIO}; diff --git a/src/hooks/useTodoSearchResults.ts b/src/hooks/useTodoSearchResults.ts index 289f29b880ce..a121dbd3b97f 100644 --- a/src/hooks/useTodoSearchResults.ts +++ b/src/hooks/useTodoSearchResults.ts @@ -7,9 +7,11 @@ import {getTodoReportsForSearchKey} from '@libs/TodosUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList, Policy, Report, ReportActions, ReportNameValuePairs, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import {useState} from 'react'; // We need direct access to useOnyx from react-native-onyx to avoid reading search snapshots instead of live to-do data // eslint-disable-next-line no-restricted-imports import {useOnyx} from 'react-native-onyx'; @@ -25,6 +27,8 @@ type TodoMetadata = { total: number; /** Currency of the first report, used as reference currency */ currency: string | undefined; + /** The server's report count, minus the reports that have left the list since its response */ + reportCount?: number; }; /** @@ -61,6 +65,41 @@ function computeMetadata(reports: Report[], transactionsByReportID: Record; + +/** Counted reports this device has listed, per to-do list */ +type SeenReportIDsBySearchKey = Partial>; + +type TodoReportCountParams = { + /** The server's figures for the search, from its snapshot */ + serverSearch: SearchResults['search'] | undefined; + + /** The to-do list as search data */ + liveData: TodoSearchResultsData; + + /** Whether every collection that decides which reports are to-dos has loaded */ + isLoaded: boolean; + + /** Counted reports this device has listed for the search */ + seenReportIDs: ReportIDSet; +}; + +/** + * The server's report count, minus the counted reports this device listed that have since left the list. Reports it + * never listed, such as one with a receipt still scanning, never count as left. + */ +function getTodoReportCount({serverSearch, liveData, isLoaded, seenReportIDs}: TodoReportCountParams): number | undefined { + if (serverSearch?.reportCount === undefined) { + return undefined; + } + + // Until everything has loaded, reports that still match are missing from the list, so none counts as left. + const countedReportIDs = isLoaded ? Object.keys(serverSearch.countedReportIDs ?? {}) : []; + const departedReportCount = countedReportIDs.filter((reportID) => seenReportIDs[reportID] && !liveData[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]).length; + return Math.max(serverSearch.reportCount - departedReportCount, 0); +} + /** * Builds a SearchResults-compatible data object from the given reports and related data. * This allows the search UI to use live Onyx data instead of snapshot data when viewing to-do results. @@ -136,21 +175,27 @@ function buildSearchResultsData( /** * Builds live SearchResults-shaped data and metadata for a single to-do search (submit/approve/pay/export). + * Its report count is the server's from `serverSearch`, minus the reports that have left the list since. * Returns `undefined` (and skips all classification work) when `searchKey` is not provided, so the app-wide * SearchResultsProvider only does work while a to-do search is actually being viewed. */ -function useTodoSearchResults(searchKey: SearchKey | undefined, footerTotal: SearchFooterTotal | undefined): {data: TodoSearchResultsData; metadata: TodoMetadata} | undefined { - const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); +function useTodoSearchResults( + searchKey: SearchKey | undefined, + footerTotal: SearchFooterTotal | undefined, + serverSearch: SearchResults['search'] | undefined, +): {data: TodoSearchResultsData; metadata: TodoMetadata} | undefined { + const [allReports, reportsResult] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [allPolicies, policiesResult] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [allReportNameValuePairs, reportNameValuePairsResult] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [allTransactions, transactionsMetadata] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); - const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); - const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA); - const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - const [session] = useOnyx(ONYXKEYS.SESSION); - const [personalDetailsList] = useAllPersonalDetailsWithoutSnapshots(); + const [allReportActions, reportActionsResult] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const [allReportMetadata, reportMetadataResult] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA); + const [bankAccountList, bankAccountListResult] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [session, sessionResult] = useOnyx(ONYXKEYS.SESSION); + const [personalDetailsList, personalDetailsResult] = useAllPersonalDetailsWithoutSnapshots(); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [rules, rulesResult] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [seenReportIDsBySearchKey, setSeenReportIDsBySearchKey] = useState({}); if (!searchKey) { return undefined; @@ -158,6 +203,20 @@ function useTodoSearchResults(searchKey: SearchKey | undefined, footerTotal: Sea const userAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; const login = personalDetailsList?.[userAccountID]?.login ?? session?.email ?? ''; + const areTransactionsLoaded = transactionsMetadata.status === 'loaded'; + // Every input of getTodoReportsForSearchKey must be listed here, or a report still loading counts as left. + const isLoaded = !isLoadingOnyxValue( + reportsResult, + policiesResult, + reportNameValuePairsResult, + transactionsMetadata, + reportActionsResult, + reportMetadataResult, + bankAccountListResult, + sessionResult, + personalDetailsResult, + rulesResult, + ); const {reports, transactionsByReportID} = getTodoReportsForSearchKey(searchKey, { allReports, @@ -170,14 +229,25 @@ function useTodoSearchResults(searchKey: SearchKey | undefined, footerTotal: Sea bankAccountList, currentUserAccountID: userAccountID, login, - areTransactionsLoaded: transactionsMetadata.status === 'loaded', + areTransactionsLoaded, rules, }); - const metadata = computeMetadata(reports, transactionsByReportID, footerTotal); const data = buildSearchResultsData(reports, transactionsByReportID, allPolicies, allReportActions, allReportNameValuePairs, personalDetailsList, allTransactionViolations); + const {count, total, currency} = computeMetadata(reports, transactionsByReportID, footerTotal); + + // A counted report can only leave the list after this device has listed it, so remember the ones it has, per to-do list. + const seenReportIDs = seenReportIDsBySearchKey[searchKey] ?? {}; + const newlySeenReportIDs = isLoaded + ? Object.keys(serverSearch?.countedReportIDs ?? {}).filter((reportID) => !seenReportIDs[reportID] && !!data[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]) + : []; + if (newlySeenReportIDs.length > 0) { + setSeenReportIDsBySearchKey({...seenReportIDsBySearchKey, [searchKey]: {...seenReportIDs, ...Object.fromEntries(newlySeenReportIDs.map((reportID) => [reportID, true]))}}); + } + const reportCount = getTodoReportCount({serverSearch, liveData: data, isLoaded, seenReportIDs}); - return {data, metadata}; + return {data, metadata: {count, total, currency, reportCount}}; } export default useTodoSearchResults; +export {getTodoReportCount}; diff --git a/src/hooks/useTransactionInlineEdit.ts b/src/hooks/useTransactionInlineEdit.ts index 42230641d9f2..6d6a1f7702f4 100644 --- a/src/hooks/useTransactionInlineEdit.ts +++ b/src/hooks/useTransactionInlineEdit.ts @@ -14,6 +14,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; import {isTrackExpenseReportNew} from '@libs/ReportUtils'; +import showInlineEditError from '@libs/showInlineEditError'; import {isDistanceRequest, isExpenseUnreported, isPerDiemRequest} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; @@ -36,6 +37,7 @@ import {useCurrencyListActions} from './useCurrencyList'; import useDelegateAccountID from './useDelegateAccountID'; import useDistanceRateOriginalPolicy from './useDistanceRateOriginalPolicy'; import {useLiveDuplicateTransactionsAndViolations} from './useDuplicateTransactionsAndViolations'; +import useLocalize from './useLocalize'; import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; @@ -85,6 +87,7 @@ type UseTransactionInlineEditReturn = { }; function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: UseTransactionInlineEditParams): UseTransactionInlineEditReturn { + const {translate} = useLocalize(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); const delegateAccountID = useDelegateAccountID(); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); @@ -237,11 +240,11 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use }; const onEditMerchant = (newMerchant: string) => { - editTransactionMerchantInline(getEditParams(), newMerchant); + showInlineEditError(editTransactionMerchantInline(translate, getEditParams(), newMerchant)); }; const onEditDescription = (newDescription: string) => { - editTransactionDescriptionInline(getEditParams(), newDescription); + showInlineEditError(editTransactionDescriptionInline(translate, getEditParams(), newDescription)); }; const onEditCategory = (newCategory: string) => { @@ -249,7 +252,7 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use }; const onEditAmount = (newAmount: number) => { - editTransactionAmountInline(getEditParams(), newAmount); + showInlineEditError(editTransactionAmountInline(translate, getEditParams(), newAmount)); }; const onEditTag = (newTag: string) => { diff --git a/src/hooks/useTravelBillingFundID.ts b/src/hooks/useTravelBillingFundID.ts new file mode 100644 index 000000000000..e9dcbc392155 --- /dev/null +++ b/src/hooks/useTravelBillingFundID.ts @@ -0,0 +1,28 @@ +import {getCardSettings} from '@libs/CardUtils'; +import {getTravelBillingCardSettingsKey} from '@libs/TravelBillingUtils'; + +import CONST from '@src/CONST'; + +import useDefaultFundID from './useDefaultFundID'; +import useOnyx from './useOnyx'; +import useWorkspaceAccountID from './useWorkspaceAccountID'; + +/** + * Hook to get the fundID that holds Consolidated Travel Billing for a given policyID. + * It returns the default fundID unless only the workspace account holds the TRAVEL_US program. + */ +function useTravelBillingFundID(policyID: string | undefined) { + const defaultFundID = useDefaultFundID(policyID); + const workspaceAccountID = useWorkspaceAccountID(policyID); + const [defaultFundCardSettings] = useOnyx(getTravelBillingCardSettingsKey(defaultFundID)); + const [workspaceCardSettings] = useOnyx(getTravelBillingCardSettingsKey(workspaceAccountID)); + + // Setup falls back to the workspace account when the domain already has a card program. + if (!getCardSettings(defaultFundCardSettings, CONST.TRAVEL.PROGRAM_TRAVEL_US) && getCardSettings(workspaceCardSettings, CONST.TRAVEL.PROGRAM_TRAVEL_US)) { + return workspaceAccountID; + } + + return defaultFundID; +} + +export default useTravelBillingFundID; diff --git a/src/hooks/useWindowDimensions/index.ts b/src/hooks/useWindowDimensions/index.ts index cc4df2625693..4d6ec150660d 100644 --- a/src/hooks/useWindowDimensions/index.ts +++ b/src/hooks/useWindowDimensions/index.ts @@ -4,6 +4,7 @@ import {FullScreenActionsContext, FullScreenStateContext} from '@components/Vide import useDebouncedState from '@hooks/useDebouncedState'; import {isMobile as isMobileBrowser, isMobileWebKit} from '@libs/Browser'; +import isHTMLElement from '@libs/isHTMLElement'; import variables from '@styles/variables'; @@ -61,8 +62,8 @@ export default function (useCachedViewportHeight = false): WindowDimensions { const [, cachedViewportHeight, setCachedViewportHeight] = useDebouncedState(windowHeight, CONST.TIMING.RESIZE_DEBOUNCE_TIME); const handleFocusIn = useRef((event: FocusEvent) => { - const targetElement = event.target as HTMLElement; - if (tagNamesOpenKeyboard.includes(targetElement.tagName)) { + const targetElement = event.target; + if (isHTMLElement(targetElement) && tagNamesOpenKeyboard.includes(targetElement.tagName)) { setCachedViewportHeight(cachedViewportHeightWithKeyboardRef.current); } }); @@ -80,8 +81,8 @@ export default function (useCachedViewportHeight = false): WindowDimensions { }, [isCachedViewportHeight]); const handleFocusOut = useRef((event: FocusEvent) => { - const targetElement = event.target as HTMLElement; - if (tagNamesOpenKeyboard.includes(targetElement.tagName)) { + const targetElement = event.target; + if (isHTMLElement(targetElement) && tagNamesOpenKeyboard.includes(targetElement.tagName)) { setCachedViewportHeight(initialViewportHeight); } }); diff --git a/src/hooks/useWorkspaceAgents.ts b/src/hooks/useWorkspaceAgents.ts new file mode 100644 index 000000000000..a62326007db6 --- /dev/null +++ b/src/hooks/useWorkspaceAgents.ts @@ -0,0 +1,71 @@ +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import useAgents from './useAgents'; +import useNetwork from './useNetwork'; +import useWorkspaceAccountID from './useWorkspaceAccountID'; +import useWorkspaceMembers from './useWorkspaceMembers'; + +/** + * Hook that returns the agent rows for a workspace: agents the user can manage joined with their workspace role, plus read-only rows for custom-agent members the user cannot manage. + */ +function useWorkspaceAgents(policy: OnyxEntry) { + const {isOffline} = useNetwork(); + const policyID = policy?.id; + const workspaceAccountID = useWorkspaceAccountID(policyID); + const workspaceMembers = useWorkspaceMembers(policy); + const policyRoles = Object.values(CONST.POLICY.ROLE); + + const {agents, ...rest} = useAgents(); + const workspaceAgents = agents.flatMap((agent) => { + const agentMembership = workspaceMembers.find((member) => member.accountID === agent.accountID); + const isAgentOwnedByWorkspace = agent.ownerAccountID === workspaceAccountID; + const isAgentMemberOfWorkspace = !!agentMembership; + if (!isAgentOwnedByWorkspace && !isAgentMemberOfWorkspace) { + return []; + } + // Attach the workspace role to the agent row. The agent object is local to this flatMap and not shared + // eslint-disable-next-line no-param-reassign + agent.role = policyRoles.find((policyRole) => policyRole === agentMembership?.policyEmployee.role); + return [agent]; + }); + + // The useAgents hook only includes agents that the user can manage + // We need to include other agents in the workspace too + const manageableAgentAccountIDs = new Set(workspaceAgents.map(({accountID}) => accountID)); + for (const workspaceMember of workspaceMembers) { + const {policyEmployee, accountID, details} = workspaceMember; + if (!details.isCustomAgent) { + continue; + } + if (manageableAgentAccountIDs.has(accountID)) { + continue; + } + const pendingAction = policyEmployee.pendingAction; + const isPendingDeletion = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + if (!isOffline && isPendingDeletion) { + continue; + } + workspaceAgents.push({ + keyForList: String(accountID), + accountID, + displayName: details.displayName ?? details.login ?? '', + login: details.login ?? '', + role: policyRoles.find((policyRole) => policyRole === policyEmployee.role), + pendingAction, + disabled: true, + isSelectionDisabled: true, + canManage: false, + action: () => {}, + onChatPress: () => {}, + onCopilotPress: () => {}, + dismissError: () => {}, + }); + } + + return {agents: workspaceAgents, ...rest}; +} + +export default useWorkspaceAgents; diff --git a/src/hooks/useWorkspaceMembers.ts b/src/hooks/useWorkspaceMembers.ts new file mode 100644 index 000000000000..e82e4951cbca --- /dev/null +++ b/src/hooks/useWorkspaceMembers.ts @@ -0,0 +1,84 @@ +import {openWorkspaceMembersPage} from '@libs/actions/Policy/Member'; +import {arePaymentsEnabled, shouldFilterExpensifyTeam, isDeletedPolicyEmployee, isExpensifyTeam, isPolicyAdmin, canMemberRead, getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; +import {generateAccountID} from '@libs/UserUtils'; + +import CONST from '@src/CONST'; +import type {PersonalDetails, Policy, PolicyEmployee} from '@src/types/onyx'; +import {isEmptyValueObject} from '@src/types/utils/EmptyObject'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {useEffectEvent, useEffect} from 'react'; + +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useLocalize from './useLocalize'; +import useNetwork from './useNetwork'; +import {usePersonalDetailsByLogins} from './usePersonalDetailByLogin'; +import {useAllPersonalDetails} from './usePersonalDetails'; + +/** + * Hook that returns a workspace's members joined with personal details, filling in fallback identities when details haven't loaded and filtering out Expensify team members on Expensify-owned policies. + */ +function useWorkspaceMembers(policy: OnyxEntry) { + const policyID = policy?.id; + const policyOwner = policy?.owner; + + const {formatPhoneNumber} = useLocalize(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUserLogin = currentUserPersonalDetails.login; + const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); + const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails, true); + const shouldFilter = shouldFilterExpensifyTeam(policyOwner, currentUserLogin); + const [personalDetails] = useAllPersonalDetails(); + + const getWorkspaceMembers = () => { + if (!policyID) { + return; + } + if (!canMemberRead(policy, currentUserPersonalDetails.login ?? '', CONST.POLICY.POLICY_FEATURE.MEMBERS)) { + return; + } + const clientMemberEmails = Object.keys(getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails)); + openWorkspaceMembersPage(policyID, clientMemberEmails); + }; + const {isOffline} = useNetwork({onReconnect: getWorkspaceMembers}); + const getWorkspaceMembersEvent = useEffectEvent(() => getWorkspaceMembers()); + const shouldShowMemberBankAccounts = isPolicyAdmin(policy) && arePaymentsEnabled(policy); + + // The bank accounts are only returned while the workspace shows them, so load the members again when that changes. + useEffect(() => getWorkspaceMembersEvent(), [shouldShowMemberBankAccounts]); + + const result: Array<{email: string; policyEmployee: PolicyEmployee; accountID: number; details: PersonalDetails}> = []; + for (const [email, policyEmployee] of Object.entries(policy?.employeeList ?? {})) { + // Inviting a secondary login leaves an empty employeeList entry: the backend nulls that key, then + // successData merges {pendingAction: null} back onto it. Skip it so it doesn't render as a second + // member. A real member whose personal details haven't loaded still has a role and stays visible. + if (isEmptyValueObject(policyEmployee) || isDeletedPolicyEmployee(policyEmployee, isOffline)) { + continue; + } + + // The accountID normally comes from the personal-details join. When a member's personal details + // haven't loaded (e.g. the backend under-returns them), that join is empty, so we fall back to a + // generated accountID. This keeps the rendered count in sync with employeeList and matches OldDot, + // which shows every member rather than silently dropping the ones without loaded details. + const accountID = policyMemberEmailsToAccountIDs[email] ? Number(policyMemberEmailsToAccountIDs[email]) : generateAccountID(email); + + // Render a fallback identity (email as display name) when personal details are missing so the member + // is still shown instead of being dropped from the list. + const fallbackDetails: PersonalDetails = {accountID, login: email, displayName: formatPhoneNumber(email)}; + const details = personalDetails?.[accountID] ?? fallbackDetails; + + // If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails + // We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they + // see random people added to their policy, but guides having access to the policies help set them up. + if (shouldFilter && isExpensifyTeam(details?.login ?? details?.displayName)) { + continue; + } + + result.push({email, policyEmployee, accountID, details}); + } + + return result; +} + +export default useWorkspaceMembers; diff --git a/src/languages/TranslationTypes.ts b/src/languages/TranslationTypes.ts index 2ff1b97b0a59..22b6b4eaacc7 100644 --- a/src/languages/TranslationTypes.ts +++ b/src/languages/TranslationTypes.ts @@ -73,7 +73,8 @@ type PersonalRulesModifiedFields = PolicyRulesModifiedFields & { reportName?: string; }; -type ConnectionName = ValueOf; +// Exact Online only has a waitlist until its connector is released. +type ConnectionName = Exclude, typeof CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE>; type AllConnectionName = ConnectionName; type PolicyConnectionSyncStage = ValueOf; type SageIntacctMappingName = ValueOf; diff --git a/src/languages/de.ts b/src/languages/de.ts index 9443b7ef5276..ee4fa2474f4d 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -307,7 +307,6 @@ const translations: TranslationDeepObject = { change: 'Ändern', category: 'Kategorie', vendor: 'Anbieter', - supplier: 'Lieferant', report: 'Bericht', billable: 'Abrechenbar', nonBillable: 'Nicht abrechenbar', @@ -532,6 +531,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'Das Diagramm konnte nicht geladen werden. Bitte aktualisieren Sie die Seite und versuchen Sie es erneut.', apiKey: 'API-Schlüssel', exportsTo: 'Exportiert nach', + shareReport: 'Bericht teilen', + reportShared: 'Bericht geteilt', }, socials: { podcast: 'Folgen Sie uns auf Podcast', @@ -981,6 +982,7 @@ const translations: TranslationDeepObject = { addShippingAddress: {title: 'Wir benötigen deine Versandadresse', subtitle: 'Geben Sie eine Adresse an, um Ihre Expensify Karte zu erhalten.', cta: 'Hinzufügen'}, addPaymentCard: {title: 'Fügen Sie eine Zahlungskarte hinzu, um Expensify weiter zu nutzen', subtitle: 'Konto > Abonnement', cta: 'Hinzufügen'}, addBankAccount: {title: 'Fügen Sie ein Bankkonto hinzu, um eine Erstattung zu erhalten'}, + addDepositAccount: {title: 'Fügen Sie ein Bankkonto hinzu, um Zahlungen zu erhalten'}, activateCard: {title: 'Aktivieren Sie Ihre Expensify Karte', subtitle: 'Validieren Sie Ihre Karte und beginnen Sie mit dem Ausgeben.', cta: 'Aktivieren'}, confirmDigitalWalletAddition: { subtitle: 'Expensify Karte', @@ -1027,6 +1029,7 @@ const translations: TranslationDeepObject = { cta: 'Verwalten', }, emailDeliveryFailure: {title: 'Wir können Ihnen keine E-Mail-Benachrichtigungen senden', subtitle: 'Konto'}, + upgradeNetSuiteConnection: {title: 'Aktualisieren Sie Ihre NetSuite-Verbindung'}, }, forYouSection: { submit: ({count}: {count: number}) => ({ @@ -1174,7 +1177,18 @@ const translations: TranslationDeepObject = { viewOnSpend: 'In Ausgaben anzeigen', emptyState: {title: 'Nichts anzuzeigen', subtitle: 'Versuchen Sie, Ihre Kriterien oben anzupassen'}, noExpensesState: {title: 'Sehen Sie, wohin Ihr Geld fließt', subtitle: 'Sobald Sie Ausgaben haben, finden Sie Ausgabentrends, Top-Händler und mehr.'}, - compare: {label: 'Vergleichen', previousPeriod: 'Vorheriger Zeitraum', average: 'Durchschnitt'}, + compare: { + label: 'Vergleichen', + previousPeriod: 'Vorheriger Zeitraum', + average: 'Durchschnitt', + dayLabel: (day?: number) => (day === undefined ? 'Tag' : `Tag ${day}`), + weekLabel: (week?: number) => (week === undefined ? 'Woche' : `Woche ${week}`), + monthLabel: (month?: number) => (month === undefined ? 'Monat' : `Monat ${month}`), + quarterLabel: (quarter?: number) => (quarter === undefined ? 'Quartal' : `Quartal ${quarter}`), + yearLabel: (year?: number) => (year === undefined ? 'Jahr' : `Jahr ${year}`), + increase: (percentage: string) => `Um ${percentage} gestiegen`, + decrease: (percentage: string) => `Um ${percentage} gesunken`, + }, }, allSettingsScreen: { subscription: 'Abonnement', @@ -2184,6 +2198,18 @@ const translations: TranslationDeepObject = { } return `Warten, bis die Zahlung abgeschlossen ist${formattedETA}.`; }, + [CONST.NEXT_STEP.MESSAGE_KEY.WAITING_FOR_CANCELLED_REIMBURSEMENT]: ( + _actor: string, + _actorType: ValueOf, + eta?: string, + etaType?: ValueOf, + ) => { + let formattedETA = ''; + if (eta) { + formattedETA = etaType === CONST.NEXT_STEP.ETA_TYPE.DATE_TIME ? ` bis ${eta}` : ` ${eta}`; + } + return `Warten, bis die stornierte Zahlung vollständig verarbeitet ist${formattedETA}.`; + }, [CONST.NEXT_STEP.MESSAGE_KEY.SUBMITTING_TO_SELF]: ( _actor: string, _actorType: ValueOf, @@ -2825,6 +2851,13 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'Dieses Konto erfordert Ihre Aufmerksamkeit', unlock: 'Entsperren', }, + wiseGlobalReimbursementsIntro: { + title: 'Globale Rückerstattungen einrichten?', + subtitle: 'Zahlen Sie Mitglieder weltweit in ihrer lokalen Währung.', + getStarted: "Los geht's", + headerTitle: 'Globale Erstattungen', + }, + wiseGlobalReimbursementsBanner: {title: 'Weltweit bezahlen', subtitle: 'Aktivieren Sie globale Erstattungen auf Ihrem USD-Konto.'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Karte zu ${walletName} hinzufügen`, @@ -3954,6 +3987,11 @@ ${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`, + until: (date: string) => `Bis ${date}`, + removeDelegate: 'Urlaubsvertretung entfernen', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -4046,6 +4084,10 @@ ${amount} für ${merchant} – ${date}`, unlockAlreadyRequestedTitle: 'Anfrage bereits eingereicht', unlockAlreadyRequestedDescription: 'Ihre Anfrage zur Entsperrung dieses Bankkontos wurde bereits gesendet. Concierge meldet sich bei Ihnen, falls noch etwas benötigt wird.', }, + listField: { + editItem: ({name}: {name: string}) => `${name} bearbeiten`, + removeItem: ({name}: {name: string}) => `${name} entfernen`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Beispiel: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4057,7 +4099,13 @@ ${amount} für ${merchant} – ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Ungültiges Format. Beispiel: ${example}` : 'Ungültiges Format'), invalidOption: 'Wähle eine der verfügbaren Optionen', outOfRange: ({min, max}: {min: number; max: number}) => `Gib einen Wert zwischen ${min} und ${max} ein`, + tooFewItems: ({min}: {min: number}) => `Füge mindestens ${min} hinzu`, + tooManyItems: ({max}: {max: number}) => `Füge höchstens ${max} hinzu`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `${item} hinzufügen`, + removeItemTitle: ({item}: {item: string}) => `${item} entfernen?`, + removeItemPrompt: ({name}: {name: string}) => `Möchtest du ${name} wirklich entfernen?`, }, addPersonalBankAccount: { swiftBicFormatError: 'Der SWIFT/BIC muss 8 oder 11 Zeichen lang sein, mit 6 Buchstaben gefolgt von 2 oder 5 Buchstaben oder Ziffern.', @@ -4098,6 +4146,7 @@ ${amount} für ${merchant} – ${date}`, formLabel: 'PDF anzeigen', }, attachmentNotFound: 'Anhang nicht gefunden', + loadTimedOut: 'Dieses Bild lädt zu lange.', retry: 'Wiederholen', }, messages: { @@ -5102,7 +5151,10 @@ ${amount} für ${merchant} – ${date}`, }, requestEmptyList: { title: 'Noch keine Pauschalbeträge', - subtitle: 'In diesem Workspace sind keine Pauschalen hinterlegt, bitten Sie Ihre Admin, welche hinzuzufügen.', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} hat keine Pauschalspesensätze. Bitten Sie eine Adminperson, einige hinzuzufügen.` + : 'Dieser Workspace hat keine Pauschalspesensätze. Bitten Sie eine Adminperson, einige hinzuzufügen.', adminSubtitle: 'Fügen Sie Pauschalen hinzu, um Ihre Ausgaben zu organisieren.', }, importPerDiemRates: 'Tagespauschalen importieren', @@ -5110,6 +5162,7 @@ ${amount} für ${merchant} – ${date}`, editPerDiemRates: 'Pauschalvergütungssätze bearbeiten', editDestinationSubtitle: (destination: string) => `Wenn Sie dieses Ziel aktualisieren, wird es für alle ${destination}-Tagespauschalen-Teilbeträge geändert.`, editCurrencySubtitle: (destination: string) => `Wenn Sie diese Währung aktualisieren, wird sie für alle ${destination}-Tagesgeld-Teilbeträge geändert.`, + missingPerDiemUnit: 'Per Diem ist in diesem Workspace nicht korrekt eingerichtet. Bitte wenden Sie sich für Hilfe an Concierge.', }, qbd: { exportOutOfPocketExpensesDescription: 'Legen Sie fest, wie aus eigener Tasche bezahlte Ausgaben nach QuickBooks Desktop exportiert werden.', @@ -5443,8 +5496,8 @@ ${amount} für ${merchant} – ${date}`, }, noAccountsFound: 'Keine Konten gefunden', noAccountsFoundDescription: 'Bitte fügen Sie das Konto in Xero hinzu und synchronisieren Sie die Verbindung erneut', - noSuppliersFound: 'Keine Lieferanten gefunden', - noSuppliersFoundDescription: 'Bitte fügen Sie den Lieferanten in Xero hinzu und synchronisieren Sie die Verbindung erneut.', + noVendorsFound: 'Keine Anbieter gefunden', + noVendorsFoundDescription: 'Bitte fügen Sie Lieferanten in Xero hinzu und synchronisieren Sie die Verbindung erneut', accountingMethods: { label: 'Wann exportieren', description: 'Wähle aus, wann die Ausgaben exportiert werden sollen:', @@ -6652,6 +6705,8 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU distanceRates: { title: 'Kilometersätze', subtitle: 'Sätze Tarife fest, aktualisiere sie und setze sie durch.', + commuterExclusionsOnTitle: 'Pendlerfahrten-Ausschlüsse sind aktiviert', + disableLockedByCommuterExclusionsPrompt: 'Um Entfernungsraten zu deaktivieren, stellen Sie zuerst „Arbeitswege ausschließen“ auf „Arbeitswege nicht ausschließen“.', }, perDiem: { title: 'Tagegeld', @@ -7008,6 +7063,11 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU emptySubtitle: 'Anbieter werden hier angezeigt, nachdem Ihre Buchhaltungssynchronisierung abgeschlossen ist.', findVendor: 'Lieferanten finden', managedInAccountingSoftware: 'Lieferanten werden verwaltet in Ihrem', + enableVendor: 'Lieferanten aktivieren', + enableVendors: 'Anbieter aktivieren', + disableVendor: 'Lieferanten deaktivieren', + disableVendors: 'Lieferanten deaktivieren', + updateFailureMessage: 'Beim Aktualisieren des Lieferanten ist ein Fehler aufgetreten, bitte versuchen Sie es erneut.', }, tags: { tagName: 'Tag-Name', @@ -7051,6 +7111,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU tagRules: 'Tag-Regeln', approverDescription: 'Genehmiger', importTags: 'Tags importieren', + appendCustomTagList: 'Benutzerdefinierte Tag-Liste anhängen', importTagsSupportingText: 'Kategorisiere deine Ausgaben mit einem oder mehreren Tags.', configureMultiLevelTags: 'Konfiguriere deine Liste von Tags für mehrstufiges Tagging.', importMultiLevelTagsSupportingText: `Hier ist eine Vorschau deiner Tags. Wenn alles gut aussieht, klicke unten, um sie zu importieren.`, @@ -7318,6 +7379,11 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, 'Das Mitglied arbeitet von zu Hause aus oder reist zwischen verschiedenen Standorten ohne festes Büro, daher gelten die Pendelregelungen nicht.', futureOnlyNote: 'Änderungen gelten nur für zukünftige Kilometerberechnungen. Bestehende Kilometerabrechnungen werden nicht neu berechnet.', }, + officeLocation: 'Bürostandort', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Bürostandort ändern', + changeWorkArrangement: 'Arbeitsvereinbarung ändern', + changeRole: 'Rolle ändern', }, card: { getStartedIssuing: 'Beginne, indem du deine erste virtuelle oder physische Karte ausstellst.', @@ -7430,6 +7496,12 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Bei einer in Expensify Classic eingerichteten Verbindung ist ein Fehler aufgetreten. [Wechsle zu Expensify Classic, um dieses Problem zu beheben.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Wechsle zu Expensify Classic, um deine Einstellungen zu verwalten.', + exactOnline: { + comingSoon: 'Demnächst verfügbar', + joinWaitlist: 'Warteliste beitreten', + onWaitlist: 'Sie stehen auf der Warteliste', + waitlistDescription: 'Concierge sendet Ihnen eine Nachricht, sobald diese Integration bereit ist, und erklärt Ihnen, wie Sie die Verbindung herstellen.', + }, setup: 'Verbinden', connectToSandbox: 'Mit Sandbox verbinden', lastSync: (relativeDate: string) => `Zuletzt synchronisiert ${relativeDate}`, @@ -7712,6 +7784,9 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, businessCentral: 'Dynamics 365 Business Central', qboConnectionExpiring: ({date}: {date: string}) => `Ihre QuickBooks Online-Verbindung läuft am ${date} ab.`, qboConnectionExpired: ({date}: {date: string}) => `Ihre QuickBooks Online-Verbindung ist am ${date} abgelaufen.`, + netSuiteOAuthMigrationRequired: 'Aktualisieren Sie Ihre NetSuite-Verbindung bis Februar 2027 auf OAuth 2.0.', + netSuiteOAuthMigrationRequiredUrgent: 'Ihre NetSuite-Verbindung muss umgehend auf OAuth 2.0 aktualisiert werden.', + netSuiteOAuthUpgradeNow: 'Jetzt upgraden', }, export: { notReadyHeading: 'Nicht bereit zum Export', @@ -7890,6 +7965,21 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, addressContext: 'Für die Aktivierung von Expensify Travel ist eine Workspace-Adresse erforderlich. Bitte gib eine Adresse ein, die deinem Unternehmen zugeordnet ist.', policy: 'Spesenrichtlinie', }, + officeLocations: { + title: 'Bürostandorte', + subtitle: 'Fügen Sie Bürostandorte hinzu, die Sie Mitgliedern für Dinge wie die Entfernungserfassung zuweisen können.', + addOfficeLocation: 'Bürostandort hinzufügen', + editOfficeLocation: 'Standort des Büros bearbeiten', + locationName: 'Standortname', + locationNameHint: 'Wählen Sie einen Standort-Spitznamen, wenn Sie mehrere Bürostandorte hinzufügen', + defaultName: ({officeNumber}: {officeNumber: number}) => `Bürostandort ${officeNumber}`, + setAsPrimary: 'Als primär festlegen', + primary: 'Primär', + deleteOfficeLocation: 'Bürostandort löschen?', + deleteOfficeLocationConfirmation: 'Möchten Sie diesen Bürostandort wirklich löschen? Mitglieder werden dem Hauptbüro neu zugewiesen.', + existingOfficeLocationError: 'Ein Bürostandort mit diesem Namen existiert bereits', + setCompanyAddressAsPrimaryConfirmation: 'Unternehmensadresse als Hauptbüro festlegen?', + }, bankAccount: { continueWithSetup: 'Einrichtung fortsetzen', youAreAlmostDone: @@ -8430,6 +8520,8 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `Füge ${featureName} hinzu, um diese Funktion freizuschalten.`, enableFeatureSubtitle: (featureName: string, moreFeaturesLink?: string) => `Gehe zu [weitere Funktionen](${moreFeaturesLink}) und aktiviere ${featureName}, um diese Funktion freizuschalten.`, + preventPayoutNonReimbursableReportsTitle: 'Verhindern, dass nicht erstattungsfähige Berichte als bezahlt markiert werden', + preventPayoutNonReimbursableReportsSubtitle: 'Verhindern, dass Admins nicht erstattungsfähige Berichte als bezahlt markieren.', }, agentsPromoBanner: { title: 'Sie sehen die benötigte Regel nicht? Fügen Sie eine:n Agent:in hinzu', @@ -8483,7 +8575,6 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und ifAnyExpenseMatches: 'Wenn eine Ausgabe übereinstimmt:', thenApplyFollowingDefaults: 'Wenden Sie dann die folgenden Standardwerte an:', vendorUnavailable: 'Anbieter nicht verfügbar', - supplierUnavailable: 'Lieferant nicht verfügbar', confirmErrorCategory: 'Bitte wählen Sie eine Kategorie aus', confirmErrorCategoryTax: 'Bitte wählen Sie einen Steuersatz aus', confirmErrorCondition: 'Bitte geben Sie einen Händler ein', @@ -8722,6 +8813,9 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc describeRuleForConcierge: 'Beschreiben Sie Ihre Regel und Concierge erstellt sie', gotIt: 'Verstanden', createRule: 'Regel erstellen', + fallbackBanner: + 'Dies ist keine Standardregel, daher haben wir sie als Agentenregel erstellt. Agentenregeln greifen, nachdem sich eine Ausgabe oder ein Bericht geändert hat, zum Beispiel indem sie die Ausgabe zurückhalten oder den Bericht ablehnen.', + fallbackBannerWithExplanation: (explanation: string) => `Dies ist keine Standardregel, daher haben wir sie als Agentenregel formuliert. ${explanation}`, }, tabs: { general: 'Allgemein', @@ -8784,16 +8878,21 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc newRule: { title: 'Neue Regel', subtitle: 'Was möchten Sie tun?', + startWithTemplate: 'Mit einer Vorlage beginnen', restrictCardSpend: 'Kartenausgaben beschränken', restrictCardSpendDescription: 'Ausgaben am Verkaufsort blockieren oder begrenzen', applyExpenseDefaults: 'Standardspesen übernehmen', - applyExpenseDefaultsDescription: 'Felder aktualisieren, ohne dass die einreichende Person etwas tun muss', + applyExpenseDefaultsDescription: 'Felder automatisch aktualisieren', flagForReview: 'Zur Überprüfung markieren', - flagForReviewDescription: 'Genehmigende benachrichtigen, wenn Ausgaben Kategorienlimits überschreiten', + flagForReviewDescription: 'Benachrichtigen, wenn Ihre Bedingungen erfüllt sind', requireFields: 'Feldanforderungen festlegen', - requireFieldsDescription: 'Bestimmte Spesenfelder vorschreiben oder auf ihre Pflicht verzichten.', + requireFieldsShort: 'Felder vorschreiben', + requireFieldsDescription: 'Belege, Kategorien usw.', createAgentRule: 'Agentenregel beschreiben', createAgentRuleDescription: 'Erstellen Sie flexible Regeln, die ausgeführt werden, wenn Sie sie benötigen.', + describeItBelow: 'Oder beschreiben Sie sie einfach unten', + describeRulePlaceholder: 'Alle Ausgaben über 2.000 $ blockieren', + selectTemplateOrDescribe: 'Wählen Sie eine Vorlage aus oder beschreiben Sie eine Regel, um fortzufahren', describe: 'Beschreiben', describeRule: 'Beschreibe deine Regel in einfachen Worten und Concierge erstellt sie für dich', describeRuleInputLabel: 'Beschreibe die Regel', @@ -8801,6 +8900,15 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc unsupported: 'Diese Regel wird noch nicht unterstützt – wir arbeiten daran. Bitte versuche vorerst eine andere Regel.', unintelligible: 'Formuliere deine Regel etwa so: Wenn Ausgaben über 100 $ liegen, ist ein Beleg erforderlich.', multipleRules: 'Bitte eine Regel nach der anderen – beschreibe eine einzelne Regel, z. B.: Einzelhandelskäufe auf allen Karten blockieren.', + categoryHasRule: 'Für diese Kategorie gibt es bereits eine Regel. Bearbeite die bestehende Regel oder beschreibe eine Regel für eine andere Kategorie.', + }, + impossible: { + noTrigger: 'Regeln können nicht von diesem Ereignis aus gestartet werden.', + noCapability: 'Regeln können das nicht.', + missingData: 'Expensify hat diese Information nicht.', + retroactive: 'Regeln gelten nur für neue Aktivitäten.', + historyLimit: 'Regeln können nicht so weit zurückblicken.', + tryThisInstead: 'Versuchen Sie es stattdessen damit', }, }, expenseDefaultsTable: { @@ -8997,7 +9105,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc dontSeeYourATS: `Sie sehen Ihr ATS hier nicht? Fragen Sie Concierge, und wir können es hinzufügen.`, importSettings: 'Import-Einstellungen', defaultApprover: 'Standardgenehmiger', - approverFields: {recruiter: 'Personalvermittler', recruitingCoordinator: 'Recruiting-Koordinator'}, + approverFields: {recruiter: 'Personalvermittler', recruitingCoordinator: 'Recruiting-Koordinator', hiringManager: 'Einstellungsmanager'}, filters: { description: (providerName: string) => `Wählen Sie aus, welche Mitglieder aus ${providerName} importiert werden. Sie können nach Jobphasen, Tags und Büros auswählen.`, stages: { @@ -9018,7 +9126,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc toggleTitle: 'Büros', allSelected: 'Alle Büros', }, - enableJobStagesOrTags: 'Aktivieren Sie Jobphasen oder Tags, um fortzufahren', + enableJobStagesOrTags: 'Wählen Sie mindestens eine Jobphase oder ein Tag aus, um fortzufahren', }, subtitle: 'Verknüpfen Sie Recruiting-Tools und halten Sie Kandidatengenehmigungen synchron.', syncResults: { @@ -9031,8 +9139,12 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc finalApprover: `Endgültige genehmigende Person`, finalApproverOptional: 'Endgültige Freigabe (optional)', approvalModeDescription: (providerName: string) => `Legen Sie die genehmigende Person für neue Mitglieder fest, die von ${providerName} nach Expensify importiert werden.`, - approverFieldDescription: (providerName: string) => - `Wählen Sie die erste approver:ent für Ihre Kandidat:innen: entweder ihre:n Recruiter:in oder ihre:n Koordinator:in, die bzw. der in ${providerName} zugewiesen ist.`, + approverFieldDescription: (providerName: string, approverFields: string[]) => { + const fieldNames = approverFields.map((approverField) => approverField.toLowerCase()); + const lastFieldName = fieldNames.at(-1) ?? ''; + const fieldList = fieldNames.length > 2 ? `ihre*n ${fieldNames.slice(0, -1).join(', ')} oder ${lastFieldName}` : `entweder ihre ${fieldNames.join('oder deren')}`; + return `Wählen Sie die erste genehmigende Person für Ihre Kandidat:innen: ${fieldList}, zugewiesen in ${providerName}.`; + }, approvalModeDescriptions: { basic: 'Wählen Sie eine einzelne genehmigende Person', advanced: `Die Personalvermittlerin/der Personalvermittler oder die Koordinatorin/der Koordinator der Kandidatin/des Kandidaten wird zur Genehmigerin/zum Genehmiger ihrer Spesen`, @@ -9173,6 +9285,9 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, }, }, + zohoBooks: { + organization: 'Zoho Books-Organisation', + }, businessCentral: { businessCentralSetup: 'Dynamics 365 Business Central-Einrichtung', prerequisitesTitle: 'Bevor Sie eine Verbindung herstellen …', @@ -9233,6 +9348,51 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc noBankAccountsFoundDescription: 'Bitte fügen Sie Bankkonten in Dynamics 365 Business Central hinzu und synchronisieren Sie die Verbindung erneut', noPaymentMethodsFound: 'Keine Zahlungsformen gefunden', noPaymentMethodsFoundDescription: 'Bitte fügen Sie Zahlungsformen in Dynamics 365 Business Central hinzu und synchronisieren Sie die Verbindung erneut', + advanced: { + autoSyncDescription: 'Dynamics 365 Business Central und Expensify automatisch jeden Tag synchronisieren. Berichte werden in Echtzeit synchronisiert.', + accountingMethods: { + label: 'Exportmethode', + description: 'Wählen Sie, wann Ausgaben exportiert werden sollen.', + values: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Periodenabgrenzung', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Bargeld', + }, + alternateText: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Auslagen aus eigener Tasche werden nach endgültiger Genehmigung exportiert', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Auslagen aus eigener Tasche werden beim Bezahlen exportiert', + }, + }, + postingMode: { + label: 'Buchungsmodus', + description: 'Wählen Sie, ob Einträge automatisch gebucht werden oder zuerst manuell geprüft werden müssen.', + note: 'Hinweis: Expensify bucht jedes offene Buch.-Blatt im Fibu Buch.-Blattnamen, den Sie auf der Seite „Expensify Setup“ in Business Central ausgewählt haben, nicht nur die von Expensify erstellten. Verwenden Sie einen Buch.-Blattnamen nur für Expensify.', + values: { + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_AND_POST]: { + label: 'Erstellen und buchen', + description: 'Einträge beim Export automatisch im Hauptbuch buchen', + }, + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_ONLY]: { + label: 'Nur erstellen', + description: 'Einträge nach dem Export manuell in Dynamics 365 Business Central buchen', + }, + }, + }, + autoCreateEntities: 'Mitarbeitende/Lieferanten automatisch erstellen', + exportToNextOpenPeriod: 'In die nächste offene Periode exportieren', + exportToNextOpenPeriodDescription: + 'Wenn ein Bericht auf eine geschlossene Periode in Dynamics 365 Business Central datiert ist, wird er in die nächste offene Periode exportiert.', + syncReimbursedReports: 'Erstattete Berichte synchronisieren', + syncReimbursedReportsDescription: 'Wenn ein Bericht per ACH bezahlt wird, wird in diesem Konto eine allgemeine Journalzeile erzeugt.', + reimbursementBankAccount: { + label: 'Zahlungskonto', + description: 'Wählen Sie aus, von wo Sie Rechnungen bezahlen möchten, und wir erstellen die Zahlung in Dynamics 365 Business Central.', + }, + syncExpensifyCardSettlements: 'Expensify Karte-Abrechnungen synchronisieren', + settlementsBankAccount: { + label: 'Verrechnungskonto der Expensify Karte', + description: 'Wählen Sie Ihr Verrechnungskonto aus, und wir erstellen die Zahlung in Dynamics 365 Business Central.', + }, + }, accountsDescription: 'Ihre Dynamics 365 Business Central-Konten werden als Kategorien importiert.', dimensionsImportAsTags: 'Alle Dimensionen aus Dynamics 365 Business Central werden als Tags importiert', customers: 'Kunden', @@ -9717,6 +9877,20 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `„${prohibitedExpense}“ zu verbotenen Ausgaben hinzugefügt`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `„${prohibitedExpense}“ aus verbotenen Ausgaben entfernt`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `hat den Bürostandort „${name}“ (${address}) hinzugefügt`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `hat den Bürostandort „${oldName}“ in „${newName}“ umbenannt`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `hat die Adresse des Bürostandorts „${name}“ in „${newAddress}“ geändert (zuvor „${oldAddress}“)`, + setAsPrimary: ({name}: {name: string}) => `Legen Sie den Bürositz „${name}“ als primär fest`, + setCompanyAddressAsPrimary: 'Firmenadresse als primär festlegen', + removed: ({name}: {name: string}) => `hat den Bürostandort „${name}“ entfernt`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `hat den Bürostandort von ${displayName} in ${officeLocation} geändert (zuvor ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `„${name}“`, + companyAddress: 'die Firmenadresse', + primaryOffice: 'das Hauptbüro', + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `Standard-Arbeitsmodell auf ${arrangement} festlegen`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -9850,6 +10024,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc addCustomUnitRateWithAmountAndEndDate: (rateName: string, rateValue: string, endDate: string) => `„${rateName}“-Satz von ${rateValue} hinzugefügt, gültig bis ${endDate}`, addCustomUnitRateWithAmountAndDates: (rateName: string, rateValue: string, startDate: string, endDate: string) => `hat den Tarif „${rateName}“ mit ${rateValue} hinzugefügt, gültig vom ${startDate} bis ${endDate}`, + addCustomUnitRateTaxReclaimableSuffix: (taxReclaimableAmount: string, taxRateName: string) => `, mit ${taxReclaimableAmount} erstattungsfähiger Steuer unter „${taxRateName}“`, updatedCustomUnitRateDateRange: (rateName: string, newDateRange: string, oldDateRange: string) => `hat den Entfernungs­tarif „${rateName}“ aktualisiert, sodass er für ${newDateRange} gilt (zuvor ${oldDateRange})`, customUnitRateDateRangeStartToEnd: (startDate: string, endDate: string) => `${startDate} - ${endDate}`, @@ -9940,6 +10115,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc `hat die Arbeitsregelung von ${displayName} auf ${newArrangement} geändert (zuvor ${oldArrangement})`, updatedDefaultWorkArrangement: ({newArrangement, oldArrangement}: {newArrangement: string; oldArrangement: string}) => `hat die standardmäßige Arbeitsregelung in ${newArrangement} geändert (zuvor ${oldArrangement})`, + preventPayoutNonReimbursableReports: (oldValue: string, newValue: string) => + `aktualisierte „Verhindern, dass nicht erstattungsfähige Berichte als bezahlt markiert werden“ auf „${newValue === 'true' ? 'Aktiviert' : 'Deaktiviert'}“ (zuvor „${oldValue === 'true' ? 'Aktiviert' : 'Deaktiviert'}“)`, }, roomMembersPage: { memberNotFound: 'Mitglied nicht gefunden.', @@ -9994,6 +10171,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc fallbackTitle: 'Support-Ticket', resolved: 'Dieses Support-Ticket ist gelöst.', surveyPrompt: 'Wie war Ihre Erfahrung mit dem Support?', + great: 'Großartig', + poor: 'Schlecht', reopenTicket: 'Ticket erneut öffnen', }, statementPage: { @@ -10139,6 +10318,12 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, customDay: 'Benutzerdefinierter Tag', }, + describeSearch: { + title: 'Beschreiben Sie Ihre Suche', + description: 'Beschreiben Sie in einfachem Englisch, welche Ausgaben Sie sehen möchten, und wir wenden automatisch die richtigen Filter an.', + inputLabel: 'Beschreiben Sie Ihre Suche', + buttonText: 'Anwenden', + }, status: 'Status', keyword: 'Schlüsselwort', keywords: 'Schlüsselwörter', @@ -10174,6 +10359,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc past: 'Vergangenes', submitted: 'Eingereicht', approved: 'Genehmigt', + anyApproval: 'Jede Genehmigung', firstApprover: 'Erste:r Genehmiger:in', firstApproved: 'Zuerst genehmigt', paidBy: 'Bezahlt von', @@ -10182,6 +10368,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc posted: 'Gebucht', withdrawn: 'Zurückgezogen', billable: 'Abrechenbar', + rejected: 'Abgelehnt', + held: 'Zurückgestellt', reimbursable: 'Erstattungsfähig', purchaseCurrency: 'Kaufwährung', sortOrder: { @@ -10216,6 +10404,11 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Export', }, filterType: {label: 'Filtertyp', has: {positive: 'hat', negative: 'hat nicht'}, is: {positive: 'ist', negative: 'ist nicht'}}, + merchant: { + matchType: 'Übereinstimmungstyp', + contains: (merchant?: string) => (merchant ? `Enthält "${merchant}"` : 'Enthält'), + equalTo: (merchant?: string) => (merchant ? `Gleich "${merchant}"` : 'Gleich'), + }, created: 'Erstellt', createdDate: 'Erstellungsdatum', transactionStatus: { @@ -10395,6 +10588,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc type: { changeField: (oldValue: string | undefined, newValue: string, fieldName: string) => `${fieldName} auf „${newValue}“ geändert (zuvor „${oldValue}“)`, changeFieldEmpty: (newValue: string, fieldName: string) => `setze ${fieldName} auf „${newValue}“`, + changeFieldRemoved: (oldValue: string, fieldName: string) => `${fieldName} entfernt (zuvor „${oldValue}“)`, + changeFieldCleared: (fieldName: string) => `${fieldName} entfernt`, changeReportPolicy: (toPolicyName: string, fromPolicyName?: string) => { if (!toPolicyName) { return `hat den Arbeitsbereich${fromPolicyName ? `(zuvor ${fromPolicyName})` : ''} geändert`; @@ -10878,7 +11073,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc duplicatedTransaction: 'Möglicherweise dupliziert', fieldRequired: 'Berichtsfelder sind erforderlich', futureDate: 'Zukünftiges Datum nicht erlaubt', - inactiveVendor: (isSupplier = false) => (isSupplier ? 'Lieferant nicht mehr gültig' : 'Anbieter nicht mehr gültig'), + inactiveVendor: 'Anbieter nicht mehr gültig', invoiceMarkup: (invoiceMarkup: number) => `Um ${invoiceMarkup}% erhöht`, maxAge: (maxAge: number) => `Datum ist älter als ${maxAge} Tage`, missingCategory: 'Fehlende Kategorie', @@ -11197,6 +11392,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, systemMessage: { mergedWithCashTransaction: 'hat eine Quittung mit dieser Transaktion abgeglichen', + mergedExpenseDeleted: 'hat Ausgaben zusammengeführt und eine in Gelöscht verschoben', }, subscription: { authenticatePaymentCard: 'Zahlungskarte authentifizieren', @@ -11438,6 +11634,9 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc refundedOn: ({date}: {date: string}) => `Erstattet am ${date}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${amount} erstattet am ${date}`, }, + billedTo: 'Abgerechnet an', + viewReceipt: 'Beleg anzeigen', + viewInvoice: 'Rechnung anzeigen', }, subscriptionSettings: { title: 'Abonnementeinstellungen', @@ -11995,22 +12194,6 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, addPaymentCardButton: 'Zahlungskarte hinzufügen', }, monthPickerPage: {month: 'Monat', selectMonth: 'Bitte wählen Sie einen Monat aus'}, - aiFeaturesPromoModal: { - subtitle: 'Neu bei Concierge AI', - confirmText: 'Los geht’s!', - spendAnalysis: { - title: 'Interaktive Ausgabenanalyse', - description: `Concierge zeigt monatliche Ausgabenanalysen an und ermöglicht es Ihnen, die Details hinter jeder Zahl genauer zu betrachten. Mehr erfahren.`, - }, - expenseAssistant: { - title: 'Lernen Sie Ihre neue Spesenassistenz kennen', - description: `Chatten Sie mit Concierge, um Ausgaben direkt in der App oder per E-Mail oder SMS zu erstellen und zu aktualisieren. Mehr erfahren.`, - }, - customAgents: { - title: 'Erstellen Sie Ihre eigenen Agenten', - description: `Erstellen Sie benutzerdefinierte Agenten, die Ausgaben anhand Ihrer Regeln prüfen, genehmigen und weiterleiten. Mehr erfahren.`, - }, - }, emailIssuePage: { title: 'E-Mail-Problem', intro: (login: string) => `Unser E-Mail-Anbieter hat das Senden an ${login} aufgrund von Zustellproblemen pausiert. Um dieses Problem zu beheben:`, @@ -12052,5 +12235,6 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, mobileRenewPrompt: 'Besuchen Sie Expensify in Ihrem Webbrowser, um vorzeitig zu verlängern.', mobileClaimPrompt: 'Besuchen Sie Expensify in Ihrem Webbrowser, um Ihren Verlängerungsrabatt zu erhalten.', }, + bulkActionBar: {label: 'Sammelaktionen', clearSelection: 'Auswahl aufheben', loadingSelection: 'Auswahl wird geladen'}, }; export default translations; diff --git a/src/languages/el.ts b/src/languages/el.ts index 45c848e2985e..d3692fe65794 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -522,7 +522,6 @@ const translations: TranslationDeepObject = { tryAgain: 'Δοκιμάστε ξανά', noResultsFoundSubtitle: 'Δεν βρέθηκαν αποτελέσματα. Δοκιμάστε να προσαρμόσετε τα φίλτρα ή το ερώτημα αναζήτησης σας', submitViaPDF: 'Υποβολή μέσω PDF', - supplier: 'Προμηθευτής', securityCode: 'Κωδικός ασφαλείας', exportsTo: 'Εξάγει σε', expand: 'Ανάπτυξη', @@ -532,6 +531,8 @@ const translations: TranslationDeepObject = { other: 'Λήψη αποδείξεων', }), commuter: 'επιβάτης καθημερινών μετακινήσεων', + shareReport: 'Κοινοποιήστε την αναφορά', + reportShared: 'Η αναφορά κοινοποιήθηκε', }, socials: { podcast: 'Ακολουθήστε μας στο Podcast', @@ -1026,6 +1027,9 @@ const translations: TranslationDeepObject = { addBankAccount: { title: 'Προσθέστε έναν τραπεζικό λογαριασμό για να λάβετε αποζημίωση', }, + addDepositAccount: { + title: 'Προσθέστε έναν τραπεζικό λογαριασμό για να λαμβάνετε πληρωμές', + }, activateCard: { title: 'Ενεργοποιήστε την Κάρτα Expensify', subtitle: 'Επικυρώστε την κάρτα σας και ξεκινήστε να ξοδεύετε.', @@ -1066,6 +1070,7 @@ const translations: TranslationDeepObject = { cta: 'Διαχείριση', }, emailDeliveryFailure: {title: 'Δεν μπορούμε να σας στέλνουμε ειδοποιήσεις μέσω email', subtitle: 'Λογαρια​σμός'}, + upgradeNetSuiteConnection: {title: 'Αναβαθμίστε τη σύνδεσή σας με το NetSuite'}, }, freeTrialSection: { title: ({count}: {count: number}) => ({ @@ -1219,7 +1224,18 @@ const translations: TranslationDeepObject = { viewOnSpend: 'Προβολή στο Spend', emptyState: {title: 'Τίποτα προς εμφάνιση', subtitle: 'Δοκιμάστε να προσαρμόσετε τα κριτήριά σας παραπάνω'}, noExpensesState: {title: 'Δείτε πού πηγαίνουν τα χρήματά σας', subtitle: 'Μόλις έχετε δαπάνες, θα βλέπετε τάσεις δαπανών, κορυφαίους εμπόρους και άλλα.'}, - compare: {label: 'Σύγκριση', previousPeriod: 'Προηγούμενη περίοδος', average: 'Μέσος όρος'}, + compare: { + label: 'Σύγκριση', + previousPeriod: 'Προηγούμενη περίοδος', + average: 'Μέσος όρος', + dayLabel: (day?: number) => (day === undefined ? 'Ημέρα' : `Ημέρα ${day}`), + weekLabel: (week?: number) => (week === undefined ? 'Εβδομάδα' : `Εβδομάδα ${week}`), + monthLabel: (month?: number) => (month === undefined ? 'Μήνας' : `Μήνας ${month}`), + quarterLabel: (quarter?: number) => (quarter === undefined ? 'Τρίμηνο' : `Τρίμηνο ${quarter}`), + yearLabel: (year?: number) => (year === undefined ? 'Έτος' : `Έτος ${year}`), + increase: (percentage: string) => `Αύξηση ${percentage}`, + decrease: (percentage: string) => `Μείωση ${percentage}`, + }, }, allSettingsScreen: { subscription: 'Συνδρομή', @@ -2229,6 +2245,18 @@ const translations: TranslationDeepObject = { } return `Αναμονή για την ολοκλήρωση της πληρωμής${formattedETA}.`; }, + [CONST.NEXT_STEP.MESSAGE_KEY.WAITING_FOR_CANCELLED_REIMBURSEMENT]: ( + _actor: string, + _actorType: ValueOf, + eta?: string, + etaType?: ValueOf, + ) => { + let formattedETA = ''; + if (eta) { + formattedETA = etaType === CONST.NEXT_STEP.ETA_TYPE.DATE_TIME ? ` έως ${eta}` : ` ${eta}`; + } + return `Αναμονή για την ολοκλήρωση της επεξεργασίας της ακυρωμένης πληρωμής${formattedETA}.`; + }, [CONST.NEXT_STEP.MESSAGE_KEY.SUBMITTING_TO_SELF]: ( _actor: string, _actorType: ValueOf, @@ -2879,6 +2907,13 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'Αυτός ο λογαριασμός χρειάζεται προσοχή', unlock: 'Ξεκλείδωμα', }, + wiseGlobalReimbursementsIntro: { + title: 'Να ρυθμιστούν οι παγκόσμιες αποζημιώσεις;', + subtitle: 'Πληρώστε μέλη παγκοσμίως στο τοπικό τους νόμισμα.', + getStarted: 'Ξεκινήστε', + headerTitle: 'Παγκόσμιες αποζημιώσεις', + }, + wiseGlobalReimbursementsBanner: {title: 'Πληρώστε ανθρώπους παγκοσμίως', subtitle: 'Ενεργοποιήστε τις παγκόσμιες αποζημιώσεις στον λογαριασμό USD σας.'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Προσθήκη κάρτας στο ${walletName}`, @@ -4001,6 +4036,11 @@ ${amount} για ${merchant} - ${date}`, `Ο/Η ${delegate} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν. Θέλετε να τον/την προσκαλέσετε τώρα σε αυτούς που διαχειρίζεστε ως διαχειριστής;`, youAreAMemberOf: 'Είστε μέλος αυτών των χώρων εργασίας:', youAreAnAdminOf: 'Είστε διαχειριστής αυτών των χώρων εργασίας:', + chooseDelegate: 'Επιλέξτε εκπρόσωπο', + clearAfterRecommended: 'Εκκαθάριση μετά από (συνιστάται)', + willClearOn: (date: string) => `Ο εκπρόσωπος διακοπών θα καταργηθεί στις ${date}`, + until: (date: string) => `Έως ${date}`, + removeDelegate: 'Κατάργηση εκπροσώπου διακοπών', }, }, stepCounter: (step, total, text) => { @@ -4094,6 +4134,10 @@ ${amount} για ${merchant} - ${date}`, unlockAlreadyRequestedDescription: 'Το αίτημά σας για ξεκλείδωμα αυτού του τραπεζικού λογαριασμού έχει ήδη αποσταλεί. Το Concierge θα επικοινωνήσει μαζί σας αν χρειαστεί οτιδήποτε άλλο.', }, + listField: { + editItem: ({name}: {name: string}) => `Επεξεργασία ${name}`, + removeItem: ({name}: {name: string}) => `Αφαίρεση ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Παράδειγμα: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4105,7 +4149,13 @@ ${amount} για ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Μη έγκυρη μορφή. Παράδειγμα: ${example}` : 'Μη έγκυρη μορφή'), invalidOption: 'Επιλέξτε μία από τις διαθέσιμες επιλογές', outOfRange: ({min, max}: {min: number; max: number}) => `Εισαγάγετε μια τιμή μεταξύ ${min} και ${max}`, + tooFewItems: ({min}: {min: number}) => `Προσθέστε τουλάχιστον ${min}`, + tooManyItems: ({max}: {max: number}) => `Προσθέστε το πολύ ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `Προσθήκη ${item}`, + removeItemTitle: ({item}: {item: string}) => `Αφαίρεση ${item};`, + removeItemPrompt: ({name}: {name: string}) => `Θέλετε σίγουρα να αφαιρέσετε ${name};`, }, addPersonalBankAccount: { swiftBicFormatError: 'Το SWIFT/BIC πρέπει να έχει μήκος 8 ή 11 χαρακτήρες, με 6 γράμματα ακολουθούμενα από 2 ή 5 γράμματα ή αριθμούς.', @@ -4146,6 +4196,7 @@ ${amount} για ${merchant} - ${date}`, formLabel: 'Προβολή PDF', }, attachmentNotFound: 'Το συνημμένο δεν βρέθηκε', + loadTimedOut: 'Η εικόνα αργεί πολύ να φορτώσει.', retry: 'Προσπαθήστε ξανά', }, messages: { @@ -5178,9 +5229,13 @@ ${amount} για ${merchant} - ${date}`, editCurrencySubtitle: (destination: string) => `Η ενημέρωση αυτού του νομίσματος θα το αλλάξει για όλες τις υποτιμές ημερήσιας αποζημίωσης ${destination}.`, requestEmptyList: { title: 'Δεν υπάρχουν ακόμη ημερήσια επιδόματα', - subtitle: 'Αυτός ο χώρος εργασίας δεν έχει ημερήσια επιδόματα· ζητήστε από τον διαχειριστή σας να προσθέσει κάποια.', + subtitle: (workspaceName?: string) => + workspaceName + ? `Το ${workspaceName} δεν έχει ημερήσιες αποζημιώσεις. Ζητήστε από έναν διαχειριστή να προσθέσει μερικές.` + : 'Αυτός ο χώρος εργασίας δεν έχει ημερήσιες αποζημιώσεις. Ζητήστε από έναν διαχειριστή να προσθέσει μερικές.', adminSubtitle: 'Προσθέστε ημερήσια επιδόματα για να οργανώσετε τις δαπάνες σας.', }, + missingPerDiemUnit: 'Το ημερήσιο επίδομα δεν έχει ρυθμιστεί σωστά σε αυτόν τον χώρο εργασίας. Παρακαλούμε επικοινωνήστε με το Concierge για βοήθεια.', }, qbd: { exportOutOfPocketExpensesDescription: 'Ορίστε πώς θα εξαχθούν οι εκτός τσέπης δαπάνες στο QuickBooks Desktop.', @@ -5526,8 +5581,8 @@ ${amount} για ${merchant} - ${date}`, [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Οι εκτός τσέπης δαπάνες θα εξαχθούν όταν εξοφληθούν', }, }, - noSuppliersFound: 'Δεν βρέθηκαν προμηθευτές', - noSuppliersFoundDescription: 'Παρακαλούμε προσθέστε τον προμηθευτή στο Xero και συγχρονίστε ξανά τη σύνδεση.', + noVendorsFound: 'Δεν βρέθηκαν προμηθευτές', + noVendorsFoundDescription: 'Παρακαλούμε προσθέστε προμηθευτές στο Xero και συγχρονίστε ξανά τη σύνδεση', }, sageIntacct: { preferredExporter: 'Προτιμώμενος εξαγωγέας', @@ -6760,6 +6815,9 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ distanceRates: { title: 'Τιμές χιλιομετρικής αποζημίωσης', subtitle: 'Προσθέστε, ενημερώστε και εφαρμόστε τιμές.', + commuterExclusionsOnTitle: 'Οι εξαιρέσεις μετακίνησης είναι ενεργές', + disableLockedByCommuterExclusionsPrompt: + 'Για να απενεργοποιήσετε τις χρεώσεις απόστασης, πρώτα ορίστε την επιλογή «Εξαίρεση μετακινήσεων από και προς την εργασία» σε «Να μην εξαιρούνται οι μετακινήσεις από και προς την εργασία».', }, perDiem: { title: 'Ημερήσια αποζημίωση', @@ -7172,6 +7230,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ tagRules: 'Κανόνες ετικετών', approverDescription: 'Έγκριση', importTags: 'Εισαγωγή ετικετών', + appendCustomTagList: 'Προσάρτηση προσαρμοσμένης λίστας ετικετών', importTagsSupportingText: 'Κωδικοποιήστε τα έξοδά σας με έναν τύπο ετικέτας ή με πολλούς.', configureMultiLevelTags: 'Ρυθμίστε τη λίστα με τις ετικέτες σας για πολυεπίπεδη επισήμανση.', importMultiLevelTagsSupportingText: `Δείτε εδώ μια προεπισκόπηση των ετικετών σας. Αν όλα φαίνονται σωστά, κάντε κλικ παρακάτω για να τις εισαγάγετε.`, @@ -7448,6 +7507,11 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ optionNoRegularWorkspaceHelp: 'Το μέλος εργάζεται από το σπίτι ή ταξιδεύει μεταξύ τοποθεσιών χωρίς τακτικό γραφείο, επομένως οι κανόνες μετακίνησης δεν ισχύουν.', futureOnlyNote: 'Οι αλλαγές ισχύουν μόνο για μελλοντικούς υπολογισμούς χιλιομέτρων. Οι υπάρχουσες δαπάνες χιλιομέτρων δεν επανυπολογίζονται.', }, + officeLocation: 'Τοποθεσία γραφείου', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Αλλαγή τοποθεσίας γραφείου', + changeWorkArrangement: 'Αλλαγή ρύθμισης εργασίας', + changeRole: 'Αλλαγή ρόλου', }, card: { getStartedIssuing: 'Ξεκινήστε εκδίδοντας την πρώτη σας εικονική ή φυσική κάρτα.', @@ -7560,6 +7624,12 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Προέκυψε σφάλμα με μια σύνδεση που έχει ρυθμιστεί στο Expensify Classic. [Μεταβείτε στο Expensify Classic για να διορθώσετε αυτό το ζήτημα.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Μεταβείτε στο Expensify Classic για να διαχειριστείτε τις ρυθμίσεις σας.', + exactOnline: { + comingSoon: 'Έρχεται σύντομα', + joinWaitlist: 'Εγγραφείτε στη λίστα αναμονής', + onWaitlist: 'Είστε σε λίστα αναμονής', + waitlistDescription: 'Ο Concierge θα σας στείλει ένα μήνυμα για να σας ενημερώσει πότε θα είναι έτοιμη αυτή η ενσωμάτωση και πώς να τη συνδέσετε.', + }, setup: 'Σύνδεση', connectToSandbox: 'Σύνδεση στο sandbox', lastSync: (relativeDate: string) => `Τελευταίος συγχρονισμός ${relativeDate}`, @@ -7843,6 +7913,9 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ businessCentral: 'Dynamics 365 Business Central', qboConnectionExpiring: ({date}: {date: string}) => `Η σύνδεσή σας με το QuickBooks Online λήγει στις ${date}.`, qboConnectionExpired: ({date}: {date: string}) => `Η σύνδεσή σας με το QuickBooks Online έληξε στις ${date}.`, + netSuiteOAuthMigrationRequired: 'Αναβαθμίστε τη σύνδεσή σας με το NetSuite σε OAuth 2.0 έως τον Φεβρουάριο 2027.', + netSuiteOAuthMigrationRequiredUrgent: 'Η σύνδεσή σας με το NetSuite απαιτεί άμεση αναβάθμιση σε OAuth 2.0.', + netSuiteOAuthUpgradeNow: 'Αναβαθμίστε τώρα', }, hr: { title: 'HR', @@ -7922,7 +7995,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ dontSeeYourATS: `Δεν βλέπετε εδώ το ATS σας; Ρωτήστε το Concierge και μπορούμε να το προσθέσουμε.`, importSettings: 'Ρυθμίσεις εισαγωγής', defaultApprover: 'Προεπιλεγμένος εγκρίνων', - approverFields: {recruiter: 'Στρατολογητής', recruitingCoordinator: 'Συντονιστής προσλήψεων'}, + approverFields: {recruiter: 'Στρατολογητής', recruitingCoordinator: 'Συντονιστής προσλήψεων', hiringManager: 'Υπεύθυνος προσλήψεων'}, filters: { description: (providerName: string) => `Επιλέξτε ποια μέλη θα εισαχθούν από το ${providerName}. Μπορείτε να επιλέξετε από στάδια εργασίας, ετικέτες και γραφεία.`, stages: { @@ -7943,7 +8016,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ toggleTitle: 'Γραφεία', allSelected: 'Όλα τα γραφεία', }, - enableJobStagesOrTags: 'Ενεργοποιήστε τα στάδια εργασίας ή τις ετικέτες για να συνεχίσετε', + enableJobStagesOrTags: 'Επιλέξτε τουλάχιστον ένα στάδιο εργασίας ή ετικέτα για να συνεχίσετε', }, subtitle: 'Συνδέστε τα εργαλεία προσλήψεων και διατηρήστε τις εγκρίσεις υποψηφίων συγχρονισμένες.', syncResults: { @@ -7956,8 +8029,12 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ finalApprover: `Τελική έγκριση`, finalApproverOptional: 'Τελικός εγκρίτης (προαιρετικό)', approvalModeDescription: (providerName: string) => `Ορίστε τον εγκριτή για τα νέα μέλη που εισάγονται από το ${providerName} στο Expensify.`, - approverFieldDescription: (providerName: string) => - `Επιλέξτε τον πρώτο εγκρίνοντα για τους υποψηφίους σας: είτε τον υπεύθυνο προσλήψεών τους είτε τον συντονιστή τους που έχει οριστεί στο ${providerName}.`, + approverFieldDescription: (providerName: string, approverFields: string[]) => { + const fieldNames = approverFields.map((approverField) => approverField.toLowerCase()); + const lastFieldName = fieldNames.at(-1) ?? ''; + const fieldList = fieldNames.length > 2 ? `το ${fieldNames.slice(0, -1).join(', ')} τους ή το ${lastFieldName} τους` : `είτε ο/η ${fieldNames.join('ή την/τον/τα')} τους`; + return `Επιλέξτε τον πρώτο εγκριτή για τους υποψηφίους σας: ${fieldList} που έχουν ανατεθεί στο ${providerName}.`; + }, approvalModeDescriptions: { basic: 'Επιλέξτε έναν μόνο εγκρίνοντα', advanced: `Ο υπεύθυνος προσλήψεων ή ο συντονιστής της υποψηφιότητας γίνεται εγκρίνων των εξόδων τους`, @@ -8190,6 +8267,21 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ 'Απαιτείται μια διεύθυνση χώρου εργασίας για να ενεργοποιήσετε το Expensify Travel. Παρακαλούμε εισαγάγετε μια διεύθυνση που να σχετίζεται με την επιχείρησή σας.', policy: 'Πολιτική εξόδων', }, + officeLocations: { + title: 'Τοποθεσίες γραφείων', + subtitle: 'Προσθέστε τοποθεσίες γραφείων για να τις αντιστοιχίσετε στα μέλη για λειτουργίες όπως η παρακολούθηση αποστάσεων.', + addOfficeLocation: 'Προσθήκη τοποθεσίας γραφείου', + editOfficeLocation: 'Επεξεργασία τοποθεσίας γραφείου', + locationName: 'Όνομα τοποθεσίας', + locationNameHint: 'Επιλέξτε ένα ψευδώνυμο τοποθεσίας αν προσθέτετε πολλές τοποθεσίες γραφείου', + defaultName: ({officeNumber}: {officeNumber: number}) => `Τοποθεσία γραφείου ${officeNumber}`, + setAsPrimary: 'Ορισμός ως κύριο', + primary: 'Κύριο', + deleteOfficeLocation: 'Να διαγραφεί η τοποθεσία γραφείου;', + deleteOfficeLocationConfirmation: 'Θέλετε σίγουρα να διαγράψετε αυτήν την τοποθεσία γραφείου; Τα μέλη θα επαναχωριστούν στο κύριο γραφείο.', + existingOfficeLocationError: 'Υπάρχει ήδη τοποθεσία γραφείου με αυτό το όνομα', + setCompanyAddressAsPrimaryConfirmation: 'Να οριστεί η διεύθυνση της εταιρείας ως κύριο γραφείο;', + }, bankAccount: { continueWithSetup: 'Συνεχίστε τη ρύθμιση', youAreAlmostDone: @@ -8799,6 +8891,8 @@ ${reportName}`, unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `Προσθέστε ${featureName} για να ξεκλειδώσετε αυτήν τη δυνατότητα.`, enableFeatureSubtitle: (featureName: string, moreFeaturesLink?: string) => `Μεταβείτε στις [περισσότερες δυνατότητες](${moreFeaturesLink}) και ενεργοποιήστε το ${featureName} για να ξεκλειδώσετε αυτήν τη λειτουργία.`, + preventPayoutNonReimbursableReportsTitle: 'Αποτροπή επισήμανσης μη αποζημιώσιμων αναφορών ως πληρωμένων', + preventPayoutNonReimbursableReportsSubtitle: 'Να μην επιτρέπεται στους διαχειριστές να σημειώνουν μη επιστρέψιμες αναφορές ως πληρωμένες.', }, agentsPromoBanner: { title: 'Δεν βλέπετε τον κανόνα που χρειάζεστε; Προσθέστε έναν εκπρόσωπο', @@ -8853,7 +8947,6 @@ ${reportName}`, importColumnUpdatedDescription: 'Ενημερωμένη περιγραφή', importColumnUpdatedVendor: 'Ενημερώθηκε ο προμηθευτής', vendorUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', - supplierUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', confirmErrorCategory: 'Παρακαλώ επιλέξτε κατηγορία', confirmErrorCategoryTax: 'Επιλέξτε έναν φορολογικό συντελεστή', confirmErrorCondition: 'Παρακαλούμε εισαγάγετε έναν έμπορο', @@ -8875,16 +8968,21 @@ ${reportName}`, newRule: { title: 'Νέος κανόνας', subtitle: 'Τι θέλετε να κάνετε;', + startWithTemplate: 'Ξεκινήστε με ένα πρότυπο', restrictCardSpend: 'Περιορισμός δαπανών κάρτας', restrictCardSpendDescription: 'Μπλοκάρετε ή περιορίστε τις δαπάνες στο σημείο πώλησης', flagForReview: 'Σημείωση για έλεγχο', - flagForReviewDescription: 'Ειδοποίηση εγκριτών όταν τα έξοδα υπερβαίνουν τα όρια κατηγορίας', + flagForReviewDescription: 'Ειδοποίηση όταν πληρούνται οι προϋποθέσεις σας', requireFields: 'Ορίστε απαιτήσεις πεδίου', - requireFieldsDescription: 'Απαιτήστε συγκεκριμένα πεδία εξόδων ή παραλείψτε την απαίτησή τους.', + requireFieldsShort: 'Απαίτηση πεδίων', + requireFieldsDescription: 'Αποδείξεις, κατηγορίες κ.λπ.', applyExpenseDefaults: 'Εφαρμογή προεπιλογών δαπανών', - applyExpenseDefaultsDescription: 'Ενημέρωση πεδίων χωρίς να χρειάζεται ο υποβάλλων να κάνει τίποτα', + applyExpenseDefaultsDescription: 'Αυτόματη ενημέρωση πεδίων', createAgentRule: 'Περιγράψτε τον κανόνα πράκτορα', createAgentRuleDescription: 'Δημιουργήστε ευέλικτους κανόνες που εκτελούνται όταν τους χρειάζεστε.', + describeItBelow: 'Ή απλώς περιγράψτε τον παρακάτω', + describeRulePlaceholder: 'Αποκλεισμός όλων των δαπανών άνω των 2.000 $', + selectTemplateOrDescribe: 'Επιλέξτε ένα πρότυπο ή περιγράψτε έναν κανόνα για να συνεχίσετε', describe: 'Περιγραφή', describeRule: 'Περιγράψτε τον κανόνα σας με απλά λόγια και ο Concierge θα τον δημιουργήσει για εσάς', describeRuleInputLabel: 'Περιγράψτε τον κανόνα', @@ -8892,6 +8990,15 @@ ${reportName}`, unsupported: 'Αυτός ο κανόνας δεν υποστηρίζεται ακόμη — το δουλεύουμε. Δοκιμάστε προς το παρόν έναν διαφορετικό κανόνα.', unintelligible: 'Δοκιμάστε να διατυπώσετε τον κανόνα σας έτσι: αν οι δαπάνες ξεπερνούν τα 100 $, να απαιτείται απόδειξη.', multipleRules: 'Έναν κανόνα τη φορά — δοκιμάστε να περιγράψετε έναν μόνο κανόνα, π.χ.: αποκλεισμός αγορών λιανικής σε όλες τις κάρτες.', + categoryHasRule: 'Αυτή η κατηγορία έχει ήδη κανόνα. Επεξεργαστείτε τον υπάρχοντα κανόνα ή περιγράψτε έναν κανόνα για άλλη κατηγορία.', + }, + impossible: { + noTrigger: 'Οι κανόνες δεν μπορούν να ξεκινούν από αυτό το συμβάν.', + noCapability: 'Οι κανόνες δεν μπορούν να το κάνουν αυτό.', + missingData: 'Η Expensify δεν διαθέτει αυτές τις πληροφορίες.', + retroactive: 'Οι κανόνες ισχύουν μόνο για νέα δραστηριότητα.', + historyLimit: 'Οι κανόνες δεν μπορούν να ανατρέξουν τόσο πίσω.', + tryThisInstead: 'Δοκιμάστε αυτό αντί για εκείνο', }, }, expenseDefaultsTable: { @@ -9175,6 +9282,9 @@ ${reportName}`, describeRuleHeadline: 'Περιγράψτε τον κανόνα σας', describeRuleForConcierge: 'Περιγράψτε τον κανόνα σας και το Concierge θα τον δημιουργήσει', createRule: 'Δημιουργία κανόνα', + fallbackBanner: + 'Αυτός δεν είναι ένας τυπικός κανόνας, οπότε τον διαμορφώσαμε ως κανόνα πράκτορα. Οι κανόνες πράκτορα εφαρμόζονται αφού αλλάξει μια δαπάνη ή μια αναφορά, για παράδειγμα κρατώντας τη δαπάνη ή απορρίπτοντας την αναφορά.', + fallbackBannerWithExplanation: (explanation: string) => `Αυτός δεν είναι ένας τυπικός κανόνας, οπότε τον συντάξαμε ως κανόνα εκπροσώπου. ${explanation}`, suggestionsTab: 'Προτεινόμενο', writeTab: 'Επεξεργασία', findSuggestion: 'Βρείτε έναν κανόνα', @@ -9241,6 +9351,11 @@ ${reportName}`, emptySubtitle: 'Οι προμηθευτές θα εμφανιστούν εδώ μετά την ολοκλήρωση του συγχρονισμού λογιστικής σας.', findVendor: 'Εύρεση προμηθευτή', managedInAccountingSoftware: 'Οι προμηθευτές διαχειρίζονται στο', + enableVendor: 'Ενεργοποίηση προμηθευτή', + enableVendors: 'Ενεργοποίηση προμηθευτών', + disableVendor: 'Απενεργοποίηση προμηθευτή', + disableVendors: 'Απενεργοποίηση προμηθευτών', + updateFailureMessage: 'Προέκυψε σφάλμα κατά την ενημέρωση του προμηθευτή, δοκιμάστε ξανά', }, campfire: { campfireSetup: 'Ρύθμιση campfire', @@ -9334,6 +9449,9 @@ ${reportName}`, }, }, }, + zohoBooks: { + organization: 'Οργανισμός Zoho Books', + }, businessCentral: { businessCentralSetup: 'Ρύθμιση Dynamics 365 Business Central', prerequisitesTitle: 'Πριν συνδεθείτε...', @@ -9394,6 +9512,50 @@ ${reportName}`, noBankAccountsFoundDescription: 'Προσθέστε τραπεζικούς λογαριασμούς στο Dynamics 365 Business Central και συγχρονίστε ξανά τη σύνδεση', noPaymentMethodsFound: 'Δεν βρέθηκαν μέθοδοι πληρωμής', noPaymentMethodsFoundDescription: 'Προσθέστε μεθόδους πληρωμής στο Dynamics 365 Business Central και συγχρονίστε ξανά τη σύνδεση', + advanced: { + autoSyncDescription: 'Συγχρονίστε αυτόματα το Dynamics 365 Business Central και το Expensify, κάθε μέρα. Οι αναφορές συγχρονίζονται σε πραγματικό χρόνο.', + accountingMethods: { + label: 'Μέθοδος εξαγωγής', + description: 'Επιλέξτε πότε θα εξαχθούν οι δαπάνες.', + values: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Δεδουλευμένη βάση', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Μετρητά', + }, + alternateText: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Οι δαπάνες από ίδια κεφάλαια θα εξαχθούν όταν λάβουν την τελική έγκριση', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Οι εκτός τσέπης δαπάνες θα εξαχθούν όταν πληρωθούν', + }, + }, + postingMode: { + label: 'Λειτουργία καταχώρισης', + description: 'Επιλέξτε αν οι εγγραφές καταχωρίζονται αυτόματα ή απαιτούν πρώτα χειροκίνητο έλεγχο.', + note: 'Σημείωση: Το Expensify καταχωρίζει κάθε ανοιχτό ημερολόγιο στη δέσμη Γενικού Ημερολογίου που επιλέξατε στη σελίδα Expensify Setup στο Business Central, όχι μόνο αυτά που δημιούργησε. Χρησιμοποιήστε μια δέσμη μόνο για το Expensify.', + values: { + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_AND_POST]: { + label: 'Δημιουργία και καταχώριση', + description: 'Αυτόματη καταχώριση εγγραφών στο Γενικό Καθολικό κατά την εξαγωγή', + }, + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_ONLY]: { + label: 'Μόνο δημιουργία', + description: 'Χειροκίνητη καταχώριση εγγραφών στο Dynamics 365 Business Central μετά την εξαγωγή', + }, + }, + }, + autoCreateEntities: 'Αυτόματη δημιουργία υπαλλήλων/προμηθευτών', + exportToNextOpenPeriod: 'Εξαγωγή στην επόμενη ανοικτή περίοδο', + exportToNextOpenPeriodDescription: 'Όταν μια αναφορά έχει ημερομηνία σε κλειστή περίοδο του Dynamics 365 Business Central, εξάγεται στην επόμενη ανοιχτή περίοδο.', + syncReimbursedReports: 'Συγχρονισμός αποζημιωμένων αναφορών', + syncReimbursedReportsDescription: 'Όταν μια αναφορά εξοφλείται μέσω ACH, θα δημιουργείται μια γραμμή γενικής λογιστικής εγγραφής σε αυτόν τον λογαριασμό.', + reimbursementBankAccount: { + label: 'Τραπεζικός λογαριασμός πληρωμών', + description: 'Επιλέξτε από πού θα πληρώνονται οι λογαριασμοί και θα δημιουργήσουμε την πληρωμή στο Dynamics 365 Business Central.', + }, + syncExpensifyCardSettlements: 'Συγχρονισμός διακανονισμών Κάρτας Expensify', + settlementsBankAccount: { + label: 'λογαριασμός εκκαθάρισης Κάρτας Expensify', + description: 'Επιλέξτε τον λογαριασμό διακανονισμού σας και θα δημιουργήσουμε την πληρωμή στο Dynamics 365 Business Central.', + }, + }, accountsDescription: 'Οι λογαριασμοί σας στο Dynamics 365 Business Central θα εισαχθούν ως κατηγορίες.', dimensionsImportAsTags: 'Όλες οι διαστάσεις του Dynamics 365 Business Central εισάγονται ως ετικέτες', customers: 'Πελάτες', @@ -9587,6 +9749,7 @@ ${reportName}`, addCustomUnitRateWithAmountAndEndDate: (rateName: string, rateValue: string, endDate: string) => `προστέθηκε συντελεστής «${rateName}» αξίας ${rateValue}, σε ισχύ έως ${endDate}`, addCustomUnitRateWithAmountAndDates: (rateName: string, rateValue: string, startDate: string, endDate: string) => `προστέθηκε συντελεστής «${rateName}» αξίας ${rateValue}, σε ισχύ από ${startDate} έως ${endDate}`, + addCustomUnitRateTaxReclaimableSuffix: (taxReclaimableAmount: string, taxRateName: string) => `, με ${taxReclaimableAmount} ανακτήσιμο φόρο στο «${taxRateName}»`, deleteCustomUnitRate: (customUnitName: string, rateName: string) => `αφαίρεσε τον συντελεστή «${customUnitName}» «${rateName}»`, updateCustomUnitSubRate: (customUnitName: string, customUnitRateName: string, customUnitSubRateName: string, oldValue: string, newValue: string, updatedField: string) => `άλλαξε την τιμή της μονάδας «${customUnitName}», της υποτιμής «${customUnitRateName}» του υποποσού «${customUnitSubRateName}», ${updatedField}, σε «${newValue}» (προηγουμένως «${oldValue}»)`, @@ -9839,6 +10002,20 @@ ${reportName}`, }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `προστέθηκε το "${prohibitedExpense}" στις απαγορευμένες δαπάνες`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `αφαιρέσατε το «${prohibitedExpense}» από τις απαγορευμένες δαπάνες`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `πρόσθεσε την τοποθεσία γραφείου «${name}» (${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `μετονομάστηκε η τοποθεσία γραφείου «${oldName}» σε «${newName}»`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `άλλαξε τη διεύθυνση της τοποθεσίας γραφείου «${name}» σε «${newAddress}» (προηγουμένως «${oldAddress}»)`, + setAsPrimary: ({name}: {name: string}) => `ορίστε την τοποθεσία γραφείου «${name}» ως κύρια`, + setCompanyAddressAsPrimary: 'ορίστε τη διεύθυνση της εταιρείας ως κύρια', + removed: ({name}: {name: string}) => `αφαίρεσε την τοποθεσία γραφείου «${name}»`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `άλλαξε την τοποθεσία γραφείου του/της ${displayName} σε ${officeLocation} (προηγουμένως ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `«${name}»`, + companyAddress: 'η διεύθυνση της εταιρείας', + primaryOffice: 'το κύριο γραφείο', + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `ορίστε την προεπιλεγμένη εργασιακή ρύθμιση σε ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -10121,6 +10298,8 @@ ${reportName}`, `άλλαξε τη ρύθμιση εργασίας του/της ${displayName} σε ${newArrangement} (προηγουμένως: ${oldArrangement})`, updatedDefaultWorkArrangement: ({newArrangement, oldArrangement}: {newArrangement: string; oldArrangement: string}) => `άλλαξε τη προεπιλεγμένη εργασιακή ρύθμιση σε ${newArrangement} (προηγουμένως ${oldArrangement})`, + preventPayoutNonReimbursableReports: (oldValue: string, newValue: string) => + `ενημερώθηκε το «αποτροπή επισήμανσης μη αποζημιώσιμων αναφορών ως πληρωμένες» σε «${newValue === 'true' ? 'Ενεργοποιημένο' : 'Απενεργοποιημένο'}» (προηγουμένως «${oldValue === 'true' ? 'Ενεργοποιημένο' : 'Απενεργοποιημένο'}»)`, }, roomMembersPage: { memberNotFound: 'Το μέλος δεν βρέθηκε.', @@ -10175,6 +10354,8 @@ ${reportName}`, fallbackTitle: 'Αίτημα υποστήριξης', resolved: 'Αυτό το αίτημα υποστήριξης επιλύθηκε.', surveyPrompt: 'Πώς ήταν η εμπειρία σας με την υποστήριξη;', + great: 'Εξαιρετική', + poor: 'Κακή', reopenTicket: 'Επανέναρξη αιτήματος', }, statementPage: { @@ -10338,6 +10519,12 @@ ${reportName}`, }, customDay: 'Προσαρμοσμένη ημέρα', }, + describeSearch: { + title: 'Περιγράψτε την αναζήτησή σας', + description: 'Περιγράψτε στα απλά αγγλικά ποιες δαπάνες θέλετε να δείτε και θα εφαρμόσουμε αυτόματα τα κατάλληλα φίλτρα.', + inputLabel: 'Περιγράψτε την αναζήτησή σας', + buttonText: 'Εφαρμογή', + }, status: 'Κατάσταση', keyword: 'Λέξη-κλειδί', keywords: 'Λέξεις-κλειδιά', @@ -10376,6 +10563,7 @@ ${reportName}`, past: 'Παρελθόν', submitted: 'Υποβλήθηκε', approved: 'Εγκρίθηκε', + anyApproval: 'Οποιαδήποτε έγκριση', firstApprover: 'Πρώτος εγκρίνων', firstApproved: 'Πρώτη έγκριση', paidBy: 'Πληρώθηκε από', @@ -10384,6 +10572,8 @@ ${reportName}`, posted: 'Καταχωρισμένο', withdrawn: 'Ανακλήθηκε', billable: 'Χρεώσιμη', + rejected: 'Απορρίφθηκε', + held: 'Σε αναμονή', reimbursable: 'Επανεντάξιμο', purchaseCurrency: 'Νόμισμα αγοράς', sortOrder: { @@ -10422,6 +10612,11 @@ ${reportName}`, has: {positive: 'έχει', negative: 'δεν έχει'}, is: {positive: 'είναι', negative: 'δεν είναι'}, }, + merchant: { + matchType: 'Τύπος αντιστοίχισης', + contains: (merchant?: string) => (merchant ? `Περιέχει "${merchant}"` : 'Περιέχει'), + equalTo: (merchant?: string) => (merchant ? `Ίσο με "${merchant}"` : 'Ίσο με'), + }, created: 'Δημιουργήθηκε', createdDate: 'Ημερομηνία δημιουργίας', transactionStatus: { @@ -10593,6 +10788,8 @@ ${reportName}`, type: { changeField: (oldValue, newValue, fieldName) => `άλλαξε το ${fieldName} σε «${newValue}» (προηγουμένως «${oldValue}»)`, changeFieldEmpty: (newValue, fieldName) => `ορίστε το ${fieldName} σε «${newValue}»`, + changeFieldRemoved: (oldValue, fieldName) => `αφαίρεσε το ${fieldName} (προηγουμένως «${oldValue}»)`, + changeFieldCleared: (fieldName) => `αφαίρεσε το ${fieldName}`, changeReportPolicy: (toPolicyName: string, fromPolicyName?: string) => { if (!toPolicyName) { return `άλλαξε τον χώρο εργασίας${fromPolicyName ? `(προηγουμένως ${fromPolicyName})` : ''}`; @@ -11084,7 +11281,7 @@ ${reportName}`, duplicatedTransaction: 'Πιθανό διπλότυπο', fieldRequired: 'Τα πεδία αναφοράς είναι υποχρεωτικά', futureDate: 'Η μελλοντική ημερομηνία δεν επιτρέπεται', - inactiveVendor: (isSupplier = false) => (isSupplier ? 'Ο προμηθευτής δεν είναι πλέον έγκυρος' : 'Ο προμηθευτής δεν είναι πλέον έγκυρος'), + inactiveVendor: 'Ο προμηθευτής δεν είναι πλέον έγκυρος', invoiceMarkup: (invoiceMarkup: number) => `Με επιπλέον χρέωση ${invoiceMarkup}%`, maxAge: (maxAge: number) => `Η ημερομηνία είναι παλαιότερη από ${maxAge} ημέρες`, missingCategory: 'Λείπει κατηγορία', @@ -11409,6 +11606,7 @@ ${reportName}`, }, systemMessage: { mergedWithCashTransaction: 'έγινε αντιστοίχιση μιας απόδειξης με αυτή τη συναλλαγή', + mergedExpenseDeleted: 'συγχώνευσε έξοδα και μετέφερε ένα στα Διαγραμμένα', }, subscription: { authenticatePaymentCard: 'Πιστοποιήστε την κάρτα πληρωμής', @@ -11652,6 +11850,9 @@ ${reportName}`, refundedOn: ({date}: {date: string}) => `Επιστράφηκε στις ${date}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `Επιστράφηκαν ${amount} στις ${date}`, }, + billedTo: 'Χρέωση σε', + viewReceipt: 'Προβολή απόδειξης', + viewInvoice: 'Προβολή τιμολογίου', }, subscriptionSettings: { title: 'Ρυθμίσεις συνδρομής', @@ -12200,22 +12401,6 @@ ${reportName}`, inviteBoss: 'Προσκαλέστε το/τη διευθυντή/ρια σας όταν είστε έτοιμοι', }, }, - aiFeaturesPromoModal: { - subtitle: 'Νέο στο Concierge AI', - confirmText: 'Πάμε!', - spendAnalysis: { - title: 'Διαδραστική ανάλυση δαπανών', - description: `Το Concierge εμφανίζει πληροφορίες για τις μηνιαίες δαπάνες και σας επιτρέπει να εξερευνήσετε τις λεπτομέρειες πίσω από κάθε αριθμό. Μάθετε περισσότερα.`, - }, - expenseAssistant: { - title: 'Γνωρίστε τον νέο σας βοηθό εξόδων', - description: `Συνομιλήστε με το Concierge για να δημιουργείτε και να ενημερώνετε δαπάνες, απευθείας στην εφαρμογή ή μέσω email ή μηνύματος κειμένου. Μάθετε περισσότερα.`, - }, - customAgents: { - title: 'Δημιουργήστε τους δικούς σας πράκτορες', - description: `Δημιουργήστε προσαρμοσμένους agents για να ελέγχετε, να εγκρίνετε και να δρομολογείτε δαπάνες με βάση κανόνες που ορίζετε εσείς. Μάθετε περισσότερα.`, - }, - }, productMarketingWindow: { heading: 'Συνδέστε το Expensify με το Claude', body: 'Αναζητήστε, αναλύστε και συνοψίστε δεδομένα δαπανών απευθείας στο Claude.', @@ -12289,5 +12474,6 @@ ${reportName}`, mobileRenewPrompt: 'Επισκεφθείτε το Expensify στον περιηγητή ιστού σας για να ανανεώσετε νωρίτερα.', mobileClaimPrompt: 'Επισκεφθείτε το Expensify στον φυλλομετρητή σας για να διεκδικήσετε την έκπτωση ανανέωσης.', }, + bulkActionBar: {label: 'Μαζικές ενέργειες', clearSelection: 'Καθαρισμός επιλογής', loadingSelection: 'Φόρτωση επιλογής'}, }; export default translations; diff --git a/src/languages/en.ts b/src/languages/en.ts index 082a4aed7b8d..2488b2b88054 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -43,6 +43,11 @@ type States = Record; type AllCountries = Record; const translations = { + bulkActionBar: { + label: 'Bulk actions', + clearSelection: 'Clear selection', + loadingSelection: 'Loading selection', + }, earlyRenewal: { title: 'Renew your Expensify subscription', subtitle: 'One less thing to do before the new year.', @@ -356,6 +361,8 @@ const translations = { assignee: 'Assignee', with: 'with', shareCode: 'Share code', + shareReport: 'Share report', + reportShared: 'Report shared', share: 'Share', per: 'per', // @context Unit label for “mile.” Should be treated as a measurement unit and may require capitalization depending on locale conventions. @@ -378,7 +385,6 @@ const translations = { change: 'Change', category: 'Category', vendor: 'Vendor', - supplier: 'Supplier', report: 'Report', billable: 'Billable', nonBillable: 'Non-billable', @@ -1110,6 +1116,10 @@ const translations = { // @context Home reminder shown to a payment recipient when a reimbursement is waiting for them to add a personal deposit account. title: 'Add a bank account to get reimbursed', }, + addDepositAccount: { + // @context Home reminder shown to a member of a workspace that can reimburse them, prompting them to add a personal deposit account. + title: 'Add a bank account to receive payments', + }, activateCard: { title: 'Activate your Expensify Card', subtitle: 'Validate your card and start spending.', @@ -1157,6 +1167,9 @@ const translations = { title: 'Signer info needed', subtitle: ({bankAccountLastFour}: {bankAccountLastFour: string}) => `Bank account ${bankAccountLastFour}`, }, + upgradeNetSuiteConnection: { + title: 'Upgrade your NetSuite connection', + }, }, freeTrialSection: { title: ({count}: {count: number}) => ({ @@ -1305,6 +1318,13 @@ const translations = { label: 'Compare', previousPeriod: 'Previous period', average: 'Average', + dayLabel: (day?: number) => (day === undefined ? 'Day' : `Day ${day}`), + weekLabel: (week?: number) => (week === undefined ? 'Week' : `Week ${week}`), + monthLabel: (month?: number) => (month === undefined ? 'Month' : `Month ${month}`), + quarterLabel: (quarter?: number) => (quarter === undefined ? 'Quarter' : `Quarter ${quarter}`), + yearLabel: (year?: number) => (year === undefined ? 'Year' : `Year ${year}`), + increase: (percentage: string) => `Up ${percentage}`, + decrease: (percentage: string) => `Down ${percentage}`, }, emptyState: { title: 'Nothing to show', @@ -2328,6 +2348,19 @@ const translations = { return `Waiting for payment to complete${formattedETA}.`; }, + [CONST.NEXT_STEP.MESSAGE_KEY.WAITING_FOR_CANCELLED_REIMBURSEMENT]: ( + _actor: string, + _actorType: ValueOf, + eta?: string, + etaType?: ValueOf, + ) => { + let formattedETA = ''; + if (eta) { + formattedETA = etaType === CONST.NEXT_STEP.ETA_TYPE.DATE_TIME ? ` by ${eta}` : ` ${eta}`; + } + + return `Waiting for the cancelled payment to finish processing${formattedETA}.`; + }, [CONST.NEXT_STEP.MESSAGE_KEY.SUBMITTING_TO_SELF]: ( _actor: string, _actorType: ValueOf, @@ -2894,6 +2927,16 @@ const translations = { editNickname: 'Edit nickname', nickname: 'Nickname', editNicknameInstruction: 'Give the bank account a nickname that sets it apart from others.', + wiseGlobalReimbursementsIntro: { + headerTitle: 'Global reimbursements', + title: 'Set up global reimbursements?', + subtitle: 'Pay members worldwide in their local currency.', + getStarted: 'Get started', + }, + wiseGlobalReimbursementsBanner: { + title: 'Pay people worldwide', + subtitle: 'Enable global reimbursements on your USD account.', + }, deleteCard: 'Delete card', deleteCardConfirmation: 'All unsubmitted card transactions, including those on open reports, will be removed. Are you sure you want to delete this card? You cannot undo this action.', @@ -4118,6 +4161,11 @@ 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}`, + until: (date: string) => `Until ${date}`, + removeDelegate: 'Remove vacation delegate', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -4210,6 +4258,10 @@ const translations = { sameDepositAndWithdrawalAccount: 'The deposit and withdrawal accounts are the same.', }, }, + listField: { + editItem: ({name}: {name: string}) => `Edit ${name}`, + removeItem: ({name}: {name: string}) => `Remove ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Example: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4221,7 +4273,13 @@ const translations = { invalidFormat: ({example}: {example?: string}) => (example ? `Invalid format. Example: ${example}` : 'Invalid format'), invalidOption: 'Choose one of the available options', outOfRange: ({min, max}: {min: number; max: number}) => `Enter a value between ${min} and ${max}`, + tooFewItems: ({min}: {min: number}) => `Add at least ${min}`, + tooManyItems: ({max}: {max: number}) => `Add at most ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `Add ${item}`, + removeItemTitle: ({item}: {item: string}) => `Remove ${item}?`, + removeItemPrompt: ({name}: {name: string}) => `Are you sure you want to remove ${name}?`, }, addPersonalBankAccount: { swiftBicFormatError: 'SWIFT/BIC must be 8 or 11 characters long, with 6 letters followed by 2 or 5 letters or numbers.', @@ -4262,6 +4320,7 @@ const translations = { formLabel: 'View PDF', }, attachmentNotFound: 'Attachment not found', + loadTimedOut: 'This image is taking too long to load.', retry: 'Retry', }, messages: { @@ -5283,7 +5342,10 @@ const translations = { }, requestEmptyList: { title: 'No per diem rates yet', - subtitle: "This workspace doesn't have any per diem rates, ask your Admin to add some.", + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} doesn't have any per diem rates. Ask an admin to add some.` + : "This workspace doesn't have any per diem rates. Ask an admin to add some.", adminSubtitle: 'Add per diem rates to organize your spend.', }, importPerDiemRates: 'Import per diem rates', @@ -5291,6 +5353,7 @@ const translations = { editPerDiemRates: 'Edit per diem rates', editDestinationSubtitle: (destination: string) => `Updating this destination will change it for all ${destination} per diem subrates.`, editCurrencySubtitle: (destination: string) => `Updating this currency will change it for all ${destination} per diem subrates.`, + missingPerDiemUnit: "Per diem isn't set up correctly on this workspace. Please reach out to Concierge for help.", }, qbd: { exportOutOfPocketExpensesDescription: 'Set how out-of-pocket expenses export to QuickBooks Desktop.', @@ -5618,8 +5681,8 @@ const translations = { }, noAccountsFound: 'No accounts found', noAccountsFoundDescription: 'Please add the account in Xero and sync the connection again', - noSuppliersFound: 'No suppliers found', - noSuppliersFoundDescription: 'Please add the supplier in Xero and sync the connection again.', + noVendorsFound: 'No vendors found', + noVendorsFoundDescription: 'Please add vendors in Xero and sync the connection again', accountingMethods: { label: 'When to Export', description: 'Choose when to export the expenses:', @@ -6568,6 +6631,9 @@ const translations = { label: 'Travel Invoicing payable account', }, }, + zohoBooks: { + organization: 'Zoho Books organization', + }, businessCentral: { businessCentralSetup: 'Dynamics 365 Business Central setup', prerequisitesTitle: 'Before you connect...', @@ -6635,6 +6701,50 @@ const translations = { noBankAccountsFoundDescription: 'Please add bank accounts in Dynamics 365 Business Central and sync the connection again', noPaymentMethodsFound: 'No payment methods found', noPaymentMethodsFoundDescription: 'Please add payment methods in Dynamics 365 Business Central and sync the connection again', + advanced: { + autoSyncDescription: 'Sync Dynamics 365 Business Central and Expensify automatically, every day. Reports sync in realtime.', + accountingMethods: { + label: 'Export method', + description: 'Choose when to export expenses.', + values: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Accrual', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Cash', + }, + alternateText: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Out-of-pocket expenses will export when final approved', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Out-of-pocket expenses will export when paid', + }, + }, + postingMode: { + label: 'Posting mode', + description: 'Choose whether entries post automatically or require manual review first.', + note: 'Note: Expensify posts every open journal in the General Journal batch you chose on the Expensify Setup page in Business Central, not just the ones it created. Use a batch only for Expensify.', + values: { + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_AND_POST]: { + label: 'Create and post', + description: 'Post entries against the GL automatically upon export', + }, + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_ONLY]: { + label: 'Create only', + description: 'Manually post entries in Dynamics 365 Business Central after export', + }, + }, + }, + autoCreateEntities: 'Auto-create employees/vendors', + exportToNextOpenPeriod: 'Export to next open period', + exportToNextOpenPeriodDescription: 'When a report is dated in a closed Dynamics 365 Business Central period, export it to the next open period.', + syncReimbursedReports: 'Sync reimbursed reports', + syncReimbursedReportsDescription: 'When a report is paid via ACH, a general journal line will be generated in this account.', + reimbursementBankAccount: { + label: 'Payment bank account', + description: "Choose where to pay bills from and we'll create the payment in Dynamics 365 Business Central.", + }, + syncExpensifyCardSettlements: 'Sync Expensify Card settlements', + settlementsBankAccount: { + label: 'Expensify Card settlement account', + description: "Choose your settlement account and we'll create the payment in Dynamics 365 Business Central.", + }, + }, }, type: { free: 'Free', @@ -6984,6 +7094,8 @@ const translations = { distanceRates: { title: 'Distance rates', subtitle: 'Add, update, and enforce rates.', + commuterExclusionsOnTitle: 'Commute exclusions are on', + disableLockedByCommuterExclusionsPrompt: 'To turn off Distance rates, first set Exclude commutes to “Do not exclude commutes.”', }, perDiem: { title: 'Per diem', @@ -7352,6 +7464,11 @@ const translations = { emptySubtitle: 'Vendors will appear here after your accounting sync completes.', findVendor: 'Find vendor', managedInAccountingSoftware: 'Vendors are managed in your', + enableVendor: 'Enable vendor', + enableVendors: 'Enable vendors', + disableVendor: 'Disable vendor', + disableVendors: 'Disable vendors', + updateFailureMessage: 'An error occurred while updating the vendor, please try again', }, tags: { tagName: 'Tag name', @@ -7399,6 +7516,7 @@ const translations = { tagRules: 'Tag rules', approverDescription: 'Approver', importTags: 'Import tags', + appendCustomTagList: 'Append custom tag list', importTagsSupportingText: 'Code your expenses with one type of tag or many.', configureMultiLevelTags: 'Configure your list of tags for multi-level tagging.', importMultiLevelTagsSupportingText: `Here's a preview of your tags. If everything looks good, click below to import them.`, @@ -7588,12 +7706,17 @@ const translations = { optionNoRegularWorkspaceHelp: "Member works from home or travels between locations with no regular office, so commute rules don't apply.", futureOnlyNote: 'Changes apply only to future mileage calculations. Existing mileage expenses are not recalculated.', }, + officeLocation: 'Office location', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Change office location', + changeWorkArrangement: 'Change work arrangement', removeWorkspaceMemberButtonTitle: 'Remove from workspace', removeGroupMemberButtonTitle: 'Remove from group', removeRoomMemberButtonTitle: 'Remove from chat', removeMemberPrompt: (memberName: string) => `Are you sure you want to remove ${memberName}?`, removeMemberTitle: 'Remove member', transferOwner: 'Transfer owner', + changeRole: 'Change role', makeMember: () => ({ one: 'Make member', other: 'Make members', @@ -7781,6 +7904,12 @@ const translations = { errorODIntegration: (oldDotPolicyConnectionsURL: string) => `There's an error with a connection that's been set up in Expensify Classic. [Go to Expensify Classic to fix this issue.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Go to Expensify Classic to manage your settings.', + exactOnline: { + comingSoon: 'Coming soon', + joinWaitlist: 'Join waitlist', + onWaitlist: 'You’re on the waitlist', + waitlistDescription: 'Concierge will send you a message to let you know when this integration is ready and how to connect.', + }, setup: 'Connect', connectToSandbox: 'Connect to sandbox', lastSync: (relativeDate: string) => `Last synced ${relativeDate}`, @@ -7817,6 +7946,9 @@ const translations = { updateCredentials: 'Update credentials', qboConnectionExpiring: ({date}: {date: string}) => `Your QuickBooks Online connection expires on ${date}.`, qboConnectionExpired: ({date}: {date: string}) => `Your QuickBooks Online connection expired on ${date}.`, + netSuiteOAuthMigrationRequired: 'Upgrade your NetSuite connection to OAuth 2.0 by February 2027.', + netSuiteOAuthMigrationRequiredUrgent: 'Your NetSuite connection requires immediate upgrade to OAuth 2.0.', + netSuiteOAuthUpgradeNow: 'Upgrade now', claimOffer: { badgeText: 'Offer available!', xero: { @@ -8153,7 +8285,12 @@ const translations = { finalApprover: `Final approver`, finalApproverOptional: 'Final approver (optional)', approvalModeDescription: (providerName: string) => `Set the approver for new members being imported from ${providerName} to Expensify.`, - approverFieldDescription: (providerName: string) => `Choose the first approver for your candidates: either their Recruiter or their Coordinator assigned in ${providerName}.`, + approverFieldDescription: (providerName: string, approverFields: string[]) => { + const fieldNames = approverFields.map((approverField) => approverField.toLowerCase()); + const lastFieldName = fieldNames.at(-1) ?? ''; + const fieldList = fieldNames.length > 2 ? `their ${fieldNames.slice(0, -1).join(', ')}, or ${lastFieldName}` : `either their ${fieldNames.join(' or their ')}`; + return `Choose the first approver for your candidates: ${fieldList} assigned in ${providerName}.`; + }, approvalModeDescriptions: { basic: 'Choose a single approver', advanced: `Candidate's recruiter or coordinator becomes their expense approver`, @@ -8161,11 +8298,12 @@ const translations = { }, approverFields: { recruiter: 'Recruiter', + hiringManager: 'Hiring manager', recruitingCoordinator: 'Recruiting coordinator', }, filters: { description: (providerName: string) => `Select which members get imported from ${providerName}. You can choose from job stages, tags, and offices.`, - enableJobStagesOrTags: 'Enable Job stages or Tags to continue', + enableJobStagesOrTags: 'Select at least one Job stage or Tag to continue', stages: { title: 'Job stage', toggleTitle: 'Job stages', @@ -8406,6 +8544,21 @@ const translations = { addressContext: 'A Workspace Address is required to enable Expensify Travel. Please enter an address associated with your business.', policy: 'Expense policy', }, + officeLocations: { + title: 'Office locations', + subtitle: 'Add office locations to assign to members for things like distance tracking.', + addOfficeLocation: 'Add office location', + editOfficeLocation: 'Edit office location', + locationName: 'Location name', + locationNameHint: 'Choose a location nickname if you are adding multiple office locations', + defaultName: ({officeNumber}: {officeNumber: number}) => `Office location ${officeNumber}`, + setAsPrimary: 'Set as primary', + primary: 'Primary', + deleteOfficeLocation: 'Delete office location?', + deleteOfficeLocationConfirmation: 'Do you really want to delete this office location? Members will be reassigned to the primary office.', + existingOfficeLocationError: 'An office location with this name already exists', + setCompanyAddressAsPrimaryConfirmation: 'Set the company address as primary office?', + }, bankAccount: { continueWithSetup: 'Continue setup', youAreAlmostDone: "You're almost done setting up your bank account, which will let you issue corporate cards, reimburse expenses, collect invoices, and pay bills.", @@ -8967,6 +9120,8 @@ const translations = { subtitle: 'Automate expense report compliance, approvals, and payment.', preventSelfApprovalsTitle: 'Prevent self-approvals', preventSelfApprovalsSubtitle: 'Prevent workspace members from approving their own expense reports.', + preventPayoutNonReimbursableReportsTitle: 'Prevent marking non-reimbursable reports as paid', + preventPayoutNonReimbursableReportsSubtitle: "Don't allow admins to mark non-reimbursable reports as paid.", autoApproveCompliantReportsTitle: 'Auto-approve compliant reports', autoApproveCompliantReportsSubtitle: 'Configure which expense reports are eligible for auto-approval.', autoApproveReportsUnderTitle: 'Auto-approve reports with all expenses under', @@ -9026,7 +9181,6 @@ const translations = { ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Mark as "${reimbursable ? 'reimbursable' : 'non-reimbursable'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Mark as "${billable ? 'billable' : 'non-billable'}"`, vendorUnavailable: 'Vendor unavailable', - supplierUnavailable: 'Supplier unavailable', matchType: 'Match type', matchTypeContains: 'Contains', matchTypeExact: 'Exactly matches', @@ -9061,16 +9215,21 @@ const translations = { newRule: { title: 'New rule', subtitle: 'What do you want to do?', + startWithTemplate: 'Start with a template', restrictCardSpend: 'Restrict card spend', restrictCardSpendDescription: 'Block or limit spend at the point of sale', flagForReview: 'Flag for review', - flagForReviewDescription: 'Notify approvers when expenses exceed category limits', + flagForReviewDescription: 'Notify when your conditions are met', requireFields: 'Set field requirements', - requireFieldsDescription: 'Require specific expense fields or waive requiring them.', + requireFieldsShort: 'Require fields', + requireFieldsDescription: 'Receipts, categories, etc.', applyExpenseDefaults: 'Apply expense defaults', - applyExpenseDefaultsDescription: 'Update fields without submitter doing anything', + applyExpenseDefaultsDescription: 'Update fields automatically', createAgentRule: 'Describe agent rule', createAgentRuleDescription: 'Create flexible rules that run when you need.', + describeItBelow: 'Or just describe it below', + describeRulePlaceholder: 'Block all spend above $2,000', + selectTemplateOrDescribe: 'Select a template or describe a rule to continue', describe: 'Describe', describeRule: 'Describe your rule in plain English and have Concierge build the rule for you', describeRuleInputLabel: 'Describe the rule', @@ -9078,6 +9237,15 @@ const translations = { unsupported: "That rule isn't supported yet — we're working on it. Please try a different rule for now.", unintelligible: 'Try phrasing your rule like: if expenses have a value over $100, then require a receipt.', multipleRules: 'One rule at a time, please — try describing a single rule, like: block retail purchases on all cards.', + categoryHasRule: 'That category already has a rule. Edit the existing rule or describe a rule for another category.', + }, + impossible: { + noTrigger: "Rules can't start from that event.", + noCapability: "Rules can't do that.", + missingData: "Expensify doesn't have that information.", + retroactive: 'Rules only apply to new activity.', + historyLimit: "Rules can't look back that far.", + tryThisInstead: 'Try this instead', }, }, expenseDefaultsTable: { @@ -9376,6 +9544,9 @@ const translations = { describeRuleForConcierge: 'Describe your rule and Concierge will build it', disclaimer: 'AI agents can make mistakes.', createRule: 'Create rule', + fallbackBanner: + "This isn't a standard rule, so we drafted it as an agent rule. Agent rules act after an expense or report changes, for example by holding the expense or rejecting the report.", + fallbackBannerWithExplanation: (explanation: string) => `This isn't a standard rule, so we drafted it as an agent rule. ${explanation}`, agentCreatedTitle: 'RuleBot has been added to your workspace!', agentCreatedDescription: (agentsRoute: string) => `To enforce your agent rules, we’ve created an agent for you and added it as an admin to your workspace.

Edit your agent’s details in Account > Agents.
`, @@ -9602,6 +9773,7 @@ const translations = { addCustomUnitRateWithAmountAndEndDate: (rateName: string, rateValue: string, endDate: string) => `added "${rateName}" rate of ${rateValue}, valid until ${endDate}`, addCustomUnitRateWithAmountAndDates: (rateName: string, rateValue: string, startDate: string, endDate: string) => `added "${rateName}" rate of ${rateValue}, valid from ${startDate} - ${endDate}`, + addCustomUnitRateTaxReclaimableSuffix: (taxReclaimableAmount: string, taxRateName: string) => `, with ${taxReclaimableAmount} tax reclaimable under "${taxRateName}"`, deleteCustomUnitRate: (customUnitName: string, rateName: string) => `removed the "${customUnitName}" rate "${rateName}"`, updateCustomUnitSubRate: (customUnitName: string, customUnitRateName: string, customUnitSubRateName: string, oldValue: string, newValue: string, updatedField: string) => `changed "${customUnitName}" rate "${customUnitRateName}" sub-rate "${customUnitSubRateName}" ${updatedField} to "${newValue}" (previously "${oldValue}")`, @@ -9731,6 +9903,8 @@ const translations = { }, preventSelfApproval: (oldValue: string, newValue: string) => `updated "Prevent self-approval" to "${newValue === 'true' ? 'Enabled' : 'Disabled'}" (previously "${oldValue === 'true' ? 'Enabled' : 'Disabled'}")`, + preventPayoutNonReimbursableReports: (oldValue: string, newValue: string) => + `updated "Prevent marking non-reimbursable reports as paid" to "${newValue === 'true' ? 'Enabled' : 'Disabled'}" (previously "${oldValue === 'true' ? 'Enabled' : 'Disabled'}")`, updateMonthlyOffset: (oldValue: string, newValue: string) => { if (!oldValue) { return `set the monthly report submission date to "${newValue}"`; @@ -9856,6 +10030,20 @@ const translations = { }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `added "${prohibitedExpense}" to prohibited expenses`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `removed "${prohibitedExpense}" from prohibited expenses`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `added the office location "${name}" (${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `renamed the office location "${oldName}" to "${newName}"`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `changed the address of the office location "${name}" to "${newAddress}" (previously "${oldAddress}")`, + setAsPrimary: ({name}: {name: string}) => `set the office location "${name}" as primary`, + setCompanyAddressAsPrimary: 'set the company address as primary', + removed: ({name}: {name: string}) => `removed the office location "${name}"`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `changed ${displayName}'s office location to ${officeLocation} (previously ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `"${name}"`, + companyAddress: 'the company address', + primaryOffice: 'the primary office', + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `set the default work arrangement to ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -10191,6 +10379,8 @@ const translations = { fallbackTitle: 'Support ticket', resolved: 'This support ticket is resolved.', surveyPrompt: 'How was your support experience?', + great: 'Great', + poor: 'Poor', reopenTicket: 'Reopen ticket', }, statementPage: { @@ -10372,6 +10562,12 @@ const translations = { [CONST.SEARCH.DATE_PRESETS.LAST_STATEMENT]: 'Last statement', }, }, + describeSearch: { + title: 'Describe your search', + description: "Describe what expenses you want to see in plain English, and we'll apply the right filters automatically.", + inputLabel: 'Describe your search', + buttonText: 'Apply', + }, status: 'Status', keyword: 'Keyword', keywords: 'Keywords', @@ -10390,6 +10586,11 @@ const translations = { }, equalTo: (amount?: string) => `Equal to ${amount ?? ''}`, }, + merchant: { + matchType: 'Match type', + contains: (merchant?: string) => (merchant ? `Contains "${merchant}"` : 'Contains'), + equalTo: (merchant?: string) => (merchant ? `Equal to "${merchant}"` : 'Equal to'), + }, card: { expensify: 'Expensify', travelInvoicing: 'Consolidated Travel Billing', @@ -10414,6 +10615,7 @@ const translations = { past: 'Past', submitted: 'Submitted', approved: 'Approved', + anyApproval: 'Any approval', firstApprover: 'First approver', firstApproved: 'First approved', paidBy: 'Paid by', @@ -10424,6 +10626,8 @@ const translations = { createdDate: 'Created date', withdrawn: 'Withdrawn', billable: 'Billable', + rejected: 'Rejected', + held: 'Held', reimbursable: 'Reimbursable', purchaseCurrency: 'Purchase currency', sortOrder: { @@ -10628,6 +10832,8 @@ const translations = { type: { changeField: (oldValue: string | undefined, newValue: string, fieldName: string) => `changed ${fieldName} to "${newValue}" (previously "${oldValue}")`, changeFieldEmpty: (newValue: string, fieldName: string) => `set ${fieldName} to "${newValue}"`, + changeFieldRemoved: (oldValue: string, fieldName: string) => `removed ${fieldName} (previously "${oldValue}")`, + changeFieldCleared: (fieldName: string) => `removed ${fieldName}`, changeReportPolicy: (toPolicyName: string, fromPolicyName?: string) => { if (!toPolicyName) { return `changed the workspace${fromPolicyName ? ` (previously ${fromPolicyName})` : ''}`; @@ -11117,7 +11323,7 @@ const translations = { duplicatedTransaction: 'Potential duplicate', fieldRequired: 'Report fields are required', futureDate: 'Future date not allowed', - inactiveVendor: (isSupplier = false) => (isSupplier ? 'Supplier no longer valid' : 'Vendor no longer valid'), + inactiveVendor: 'Vendor no longer valid', invoiceMarkup: (invoiceMarkup: number) => `Marked up by ${invoiceMarkup}%`, maxAge: (maxAge: number) => `Date older than ${maxAge} days`, missingCategory: 'Missing category', @@ -11450,6 +11656,7 @@ const translations = { }, systemMessage: { mergedWithCashTransaction: 'matched a receipt to this transaction', + mergedExpenseDeleted: 'merged expenses and moved one to Deleted', }, subscription: { authenticatePaymentCard: 'Authenticate payment card', @@ -11696,6 +11903,9 @@ const translations = { refundedOn: ({date}: PaymentDetailsBadgeDateParams) => `Refunded on ${date}`, refundedAmountOn: ({amount, date}: PaymentDetailsPartialRefundParams) => `Refunded ${amount} on ${date}`, }, + billedTo: 'Billed to', + viewReceipt: 'View receipt', + viewInvoice: 'View invoice', }, subscriptionSettings: { title: 'Subscription settings', @@ -11921,22 +12131,6 @@ const translations = { inviteBoss: "Invite your boss when you're ready", }, }, - aiFeaturesPromoModal: { - subtitle: 'New to Concierge AI', - confirmText: "Let's go!", - spendAnalysis: { - title: 'Interactive spend analysis', - description: `Concierge surfaces monthly spend insights and lets you drill into the details behind every number. Learn more.`, - }, - expenseAssistant: { - title: 'Meet your new expense assistant', - description: `Chat with Concierge to create and update expenses, right in the app or by email or text. Learn more.`, - }, - customAgents: { - title: 'Build your own agents', - description: `Create custom agents to review, approve, and route expenses based on rules you set. Learn more.`, - }, - }, productMarketingWindow: { heading: 'Connect Expensify to Claude', body: 'Search, analyze, and summarize expense data directly in Claude.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5a4f6962d2ca..1c46fe8fcc1a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -301,7 +301,6 @@ const translations: TranslationDeepObject = { change: 'Cambio', category: 'Categoría', vendor: 'Proveedor', - supplier: 'Proveedor', report: 'Informe', billable: 'Facturable', nonBillable: 'No facturable', @@ -519,6 +518,8 @@ const translations: TranslationDeepObject = { commuter: 'viajero diario', noResultsFoundSubtitle: 'Sin resultados. Intenta ajustar tus filtros o la búsqueda.', exportsTo: 'Exporta a', + shareReport: 'Compartir informe', + reportShared: 'Informe compartido', }, socials: { podcast: 'Síguenos en Podcast', @@ -982,6 +983,7 @@ const translations: TranslationDeepObject = { cta: 'Añadir', }, addBankAccount: {title: 'Añade una cuenta bancaria para recibir el reembolso'}, + addDepositAccount: {title: 'Añade una cuenta bancaria para recibir pagos'}, activateCard: { title: 'Activa tu Tarjeta Expensify', subtitle: 'Valida tu tarjeta y empieza a gastar.', @@ -1019,6 +1021,7 @@ const translations: TranslationDeepObject = { cta: 'Gestionar', }, emailDeliveryFailure: {title: 'No podemos enviarte notificaciones por correo electrónico', subtitle: 'Cuenta'}, + upgradeNetSuiteConnection: {title: 'Mejora tu conexión con NetSuite'}, }, freeTrialSection: { title: ({count}: {count: number}) => ({ @@ -1169,7 +1172,18 @@ const translations: TranslationDeepObject = { viewOnSpend: 'Ver en Gastos', emptyState: {title: 'Nada que mostrar', subtitle: 'Prueba a ajustar tus criterios de arriba'}, noExpensesState: {title: 'Ve adónde va tu dinero', subtitle: 'Una vez que tengas gastos, encontrarás tendencias de gasto, principales comercios y mucho más.'}, - compare: {label: 'Comparar', previousPeriod: 'Periodo anterior', average: 'Promedio'}, + compare: { + label: 'Comparar', + previousPeriod: 'Periodo anterior', + average: 'Promedio', + dayLabel: (day?: number) => (day === undefined ? 'Día' : `Día ${day}`), + weekLabel: (week?: number) => (week === undefined ? 'Semana' : `Semana ${week}`), + monthLabel: (month?: number) => (month === undefined ? 'Mes' : `Mes ${month}`), + quarterLabel: (quarter?: number) => (quarter === undefined ? 'Trimestre' : `Trimestre ${quarter}`), + yearLabel: (year?: number) => (year === undefined ? 'Año' : `Año ${year}`), + increase: (percentage: string) => `Sube ${percentage}`, + decrease: (percentage: string) => `Baja ${percentage}`, + }, }, allSettingsScreen: { subscription: 'Suscripcion', @@ -2105,6 +2119,13 @@ const translations: TranslationDeepObject = { } return `Esperando a que se complete el pago${formattedETA}.`; }, + [CONST.NEXT_STEP.MESSAGE_KEY.WAITING_FOR_CANCELLED_REIMBURSEMENT]: (_actor, _actorType, eta, etaType) => { + let formattedETA = ''; + if (eta) { + formattedETA = etaType === CONST.NEXT_STEP.ETA_TYPE.DATE_TIME ? ` para el ${eta}` : ` ${eta}`; + } + return `Esperando a que termine de procesarse el pago cancelado${formattedETA}.`; + }, [CONST.NEXT_STEP.MESSAGE_KEY.SUBMITTING_TO_SELF]: (_actor, _actorType, _eta, _etaType) => `¡Ups! Parece que estás enviando el informe a ti mismo. Aprobar tus propios informes está prohibido por tu espacio de trabajo. Por favor, envía este informe a otra persona o contacta a tu administrador para cambiar la persona a la que lo envías.`, [CONST.NEXT_STEP.MESSAGE_KEY.REJECTED_REPORT]: (actor, actorType) => { @@ -2735,6 +2756,13 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'Esta cuenta requiere atención', unlock: 'Desbloquear', }, + wiseGlobalReimbursementsIntro: { + title: '¿Configurar reembolsos globales?', + subtitle: 'Paga a miembros de todo el mundo en su moneda local.', + getStarted: 'Empieza', + headerTitle: 'Reembolsos globales', + }, + wiseGlobalReimbursementsBanner: {title: 'Paga a personas de todo el mundo', subtitle: 'Habilita los reembolsos globales en tu cuenta en USD.'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Añadir tarjeta a ${walletName}`, @@ -3866,6 +3894,11 @@ ${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}`, + until: (date: string) => `Hasta el ${date}`, + removeDelegate: 'Eliminar delegado de vacaciones', }, }, stepCounter: (step, total, text) => { @@ -3957,6 +3990,10 @@ ${amount} para ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Solicitud ya enviada', unlockAlreadyRequestedDescription: 'Tu solicitud para desbloquear esta cuenta bancaria ya ha sido enviada. Concierge se pondrá en contacto contigo si se necesita algo más.', }, + listField: { + editItem: ({name}: {name: string}) => `Editar ${name}`, + removeItem: ({name}: {name: string}) => `Eliminar ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Ejemplo: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -3968,7 +4005,13 @@ ${amount} para ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Formato inválido. Ejemplo: ${example}` : 'Formato inválido'), invalidOption: 'Elige una de las opciones disponibles', outOfRange: ({min, max}: {min: number; max: number}) => `Introduce un valor entre ${min} y ${max}`, + tooFewItems: ({min}: {min: number}) => `Añade al menos ${min}`, + tooManyItems: ({max}: {max: number}) => `Añade como máximo ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `Añadir ${item}`, + removeItemTitle: ({item}: {item: string}) => `¿Eliminar ${item}?`, + removeItemPrompt: ({name}: {name: string}) => `¿Seguro que quieres eliminar a ${name}?`, }, addPersonalBankAccount: { swiftBicFormatError: 'El SWIFT/BIC debe tener 8 u 11 caracteres, con 6 letras seguidas de 2 o 5 letras o números.', @@ -4009,6 +4052,7 @@ ${amount} para ${merchant} - ${date}`, formLabel: 'Ver PDF', }, attachmentNotFound: 'Archivo adjunto no encontrado', + loadTimedOut: 'Esta imagen está tardando demasiado en cargarse.', retry: 'Reintentar', }, messages: { @@ -5024,7 +5068,10 @@ ${amount} para ${merchant} - ${date}`, }, requestEmptyList: { title: 'Aún no hay tasas de Per diem', - subtitle: 'Este espacio de trabajo no tiene ninguna tasa de per diem, pide a tu administrador que añada algunas.', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} no tiene ninguna tasa de per diem. Pídele a un administrador que añada alguna.` + : 'Este espacio de trabajo no tiene ninguna tasa de per diem. Pídele a un administrador que añada alguna.', adminSubtitle: 'Añade tasas de per diem para organizar tus gastos.', }, importPerDiemRates: 'Importar tasas de per diem', @@ -5032,6 +5079,7 @@ ${amount} para ${merchant} - ${date}`, editPerDiemRates: 'Editar las tasas de per diem', editDestinationSubtitle: (destination) => `Actualizar este destino lo modificará para todas las subtasas per diem de ${destination}.`, editCurrencySubtitle: (destination) => `Actualizar esta moneda la modificará para todas las subtasas per diem de ${destination}.`, + missingPerDiemUnit: 'El per diem no está configurado correctamente en este espacio de trabajo. Ponte en contacto con Concierge para obtener ayuda.', }, qbd: { exportOutOfPocketExpensesDescription: 'Establezca cómo se exportan los gastos de bolsillo a QuickBooks Desktop.', @@ -5363,8 +5411,8 @@ ${amount} para ${merchant} - ${date}`, }, noAccountsFound: 'No se ha encontrado ninguna cuenta', noAccountsFoundDescription: 'Añade la cuenta en Xero y sincroniza de nuevo la conexión', - noSuppliersFound: 'No se encontraron proveedores', - noSuppliersFoundDescription: 'Por favor, añade el proveedor en Xero y sincroniza de nuevo la conexión.', + noVendorsFound: 'No se encontraron proveedores', + noVendorsFoundDescription: 'Por favor, añade proveedores en Xero y sincroniza la conexión de nuevo', accountingMethods: { label: 'Cuándo Exportar', description: 'Elige cuándo exportar los gastos:', @@ -6507,6 +6555,8 @@ ${amount} para ${merchant} - ${date}`, distanceRates: { title: 'Tasas de distancia', subtitle: 'Añade, actualiza y haz cumplir las tasas.', + commuterExclusionsOnTitle: 'Las exclusiones de desplazamiento están activadas', + disableLockedByCommuterExclusionsPrompt: 'Para desactivar las tarifas de distancia, primero establece Excluir desplazamientos a “No excluir desplazamientos”.', }, perDiem: { title: 'Per diem', @@ -6868,6 +6918,11 @@ ${amount} para ${merchant} - ${date}`, emptySubtitle: 'Los proveedores aparecerán aquí una vez que se complete la sincronización de contabilidad.', findVendor: 'Buscar proveedor', managedInAccountingSoftware: 'Los proveedores se gestionan en tu', + enableVendor: 'Habilitar proveedor', + enableVendors: 'Habilitar proveedores', + disableVendor: 'Deshabilitar proveedor', + disableVendors: 'Deshabilitar proveedores', + updateFailureMessage: 'Se ha producido un error al actualizar el proveedor, inténtalo de nuevo por favor', }, tags: { tagName: 'Nombre de etiqueta', @@ -6911,6 +6966,7 @@ ${amount} para ${merchant} - ${date}`, tagRules: 'Reglas de etiquetas', approverDescription: 'Aprobador', importTags: 'Importar categorías', + appendCustomTagList: 'Añadir lista de etiquetas personalizada', importTagsSupportingText: 'Clasifica tus gastos con un tipo de etiqueta o con varios.', configureMultiLevelTags: 'Configura etiquetas multinivel', importMultiLevelTagsSupportingText: `Aquí tienes una vista previa de tus etiquetas. Si todo se ve bien, haz clic abajo para importarlas.`, @@ -7101,6 +7157,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, removeMemberPrompt: (memberName) => `¿Estás seguro de que deseas eliminar a ${memberName}?`, removeMemberTitle: 'Eliminar miembro', transferOwner: 'Transferir la propiedad', + changeRole: 'Cambiar rol', makeMember: () => ({ one: 'Convertir en miembro', other: 'Convertir en miembros', @@ -7180,6 +7237,10 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, 'La persona miembro trabaja desde casa o viaja entre ubicaciones sin una oficina fija, por lo que no se aplican las normas sobre desplazamientos.', futureOnlyNote: 'Los cambios se aplican solo a los cálculos de kilometraje futuros. Los gastos de kilometraje existentes no se recalculan.', }, + officeLocation: 'Ubicación de la oficina', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Cambiar ubicación de la oficina', + changeWorkArrangement: 'Cambiar modalidad de trabajo', }, accounting: { settings: 'configuración', @@ -7228,6 +7289,12 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, errorODIntegration: (oldDotPolicyConnectionsURL) => `Hay un error con una conexión que se ha configurado en Expensify Classic. [Ve a Expensify Classic para solucionar este problema.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Ve a Expensify Classic para gestionar tus configuraciones.', + exactOnline: { + comingSoon: 'Próximamente', + joinWaitlist: 'Unirse a la lista de espera', + onWaitlist: 'Estás en la lista de espera', + waitlistDescription: 'Concierge te enviará un mensaje para avisarte cuando esta integración esté lista y cómo conectarla.', + }, setup: 'Configurar', connectToSandbox: 'Conectar con Sandbox', lastSync: (relativeDate) => `Recién sincronizado ${relativeDate}`, @@ -7511,6 +7578,9 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, businessCentral: 'Dynamics 365 Business Central', qboConnectionExpiring: ({date}: {date: string}) => `Tu conexión con QuickBooks Online caduca el ${date}.`, qboConnectionExpired: ({date}: {date: string}) => `Tu conexión con QuickBooks Online caducó el ${date}.`, + netSuiteOAuthMigrationRequired: 'Actualiza tu conexión con NetSuite a OAuth 2.0 antes de febrero de 2027.', + netSuiteOAuthMigrationRequiredUrgent: 'Tu conexión con NetSuite requiere una actualización inmediata a OAuth 2.0.', + netSuiteOAuthUpgradeNow: 'Mejorar ahora', }, card: { issueCard: 'Emitir tarjeta', @@ -7654,7 +7724,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, dontSeeYourATS: `¿No ves tu ATS aquí? Pregunta a Concierge y podremos añadirlo.`, importSettings: 'Importar ajustes', defaultApprover: 'Aprobador predeterminado', - approverFields: {recruiter: 'Reclutador', recruitingCoordinator: 'Coordinador de selección'}, + approverFields: {recruiter: 'Reclutador', recruitingCoordinator: 'Coordinador de selección', hiringManager: 'responsable de contratación'}, filters: { description: (providerName: string) => `Selecciona qué miembros se importan desde ${providerName}. Puedes elegir entre etapas de trabajo, etiquetas y oficinas.`, stages: { @@ -7675,7 +7745,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, toggleTitle: 'Oficinas', allSelected: 'Todas las oficinas', }, - enableJobStagesOrTags: 'Habilita las etapas de trabajo o las etiquetas para continuar', + enableJobStagesOrTags: 'Selecciona al menos una etapa de trabajo o etiqueta para continuar', }, subtitle: 'Conecta herramientas de selección y mantén sincronizadas las aprobaciones de candidatos.', syncResults: { @@ -7688,7 +7758,12 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, finalApprover: `Aprobador final`, finalApproverOptional: 'Aprobador final (opcional)', approvalModeDescription: (providerName: string) => `Establece el aprobador para los nuevos miembros que se importen desde ${providerName} a Expensify.`, - approverFieldDescription: (providerName: string) => `Elige el primer aprobador para tus candidatos: puede ser su reclutador o su coordinador asignado en ${providerName}.`, + approverFieldDescription: (providerName: string, approverFields: string[]) => { + const fieldNames = approverFields.map((approverField) => approverField.toLowerCase()); + const lastFieldName = fieldNames.at(-1) ?? ''; + const fieldList = fieldNames.length > 2 ? `su ${fieldNames.slice(0, -1).join(', ')}, o ${lastFieldName}` : `ya sea su ${fieldNames.join('o su')}`; + return `Elige al primer aprobador para tus candidatos: ${fieldList} asignado en ${providerName}.`; + }, approvalModeDescriptions: { basic: 'Elige un único aprobador', advanced: `El reclutador o coordinador del candidato se convierte en su aprobador de gastos`, @@ -7912,6 +7987,21 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, addressContext: 'Se requiere una dirección para habilitar Expensify Travel. Por favor, introduce una dirección asociada con tu negocio.', policy: 'Política de gastos', }, + officeLocations: { + title: 'Ubicaciones de oficinas', + subtitle: 'Añade ubicaciones de oficina para asignarlas a miembros para cosas como el seguimiento de distancias.', + addOfficeLocation: 'Añadir ubicación de la oficina', + editOfficeLocation: 'Editar ubicación de la oficina', + locationName: 'Nombre de la ubicación', + locationNameHint: 'Elige un nombre abreviado para la ubicación si estás añadiendo varias oficinas', + defaultName: ({officeNumber}: {officeNumber: number}) => `Ubicación de la oficina ${officeNumber}`, + setAsPrimary: 'Establecer como principal', + primary: 'Principal', + deleteOfficeLocation: '¿Eliminar la ubicación de la oficina?', + deleteOfficeLocationConfirmation: '¿Seguro que quieres eliminar esta ubicación de oficina? Los miembros serán reasignados a la oficina principal.', + existingOfficeLocationError: 'Ya existe una ubicación de oficina con este nombre', + setCompanyAddressAsPrimaryConfirmation: '¿Establecer la dirección de la empresa como oficina principal?', + }, bankAccount: { continueWithSetup: 'Continuar con la configuración', youAreAlmostDone: 'Casi has acabado de configurar tu cuenta bancaria, que te permitirá emitir tarjetas corporativas, reembolsar gastos y cobrar pagar facturas.', @@ -8460,6 +8550,8 @@ ${reportName}`, autoPayReportsUnderDescription: 'Los informes de gastos totalmente conformes por debajo de este importe se pagarán automáticamente.', unlockFeatureEnableWorkflowsSubtitle: (featureName) => `Añade ${featureName} para desbloquear esta función.`, enableFeatureSubtitle: (featureName, moreFeaturesLink) => `Ir a [más características](${moreFeaturesLink}) y habilita ${featureName} para desbloquear esta función.`, + preventPayoutNonReimbursableReportsTitle: 'Evitar marcar como pagados los informes no reembolsables', + preventPayoutNonReimbursableReportsSubtitle: 'No permitir que los administradores marquen los informes no reembolsables como pagados.', }, agentsPromoBanner: { title: '¿No ves la regla que necesitas? Añade un agente', @@ -8503,7 +8595,6 @@ ${reportName}`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Marcar como "${reimbursable ? 'reembolsable' : 'no reembolsable'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Marcar como "${billable ? 'facturable' : 'no facturable'}"`, vendorUnavailable: 'Proveedor no disponible', - supplierUnavailable: 'Proveedor no disponible', matchType: 'Tipo de coincidencia', matchTypeContains: 'Contiene', matchTypeExact: 'Coincide exactamente', @@ -8727,6 +8818,9 @@ ${reportName}`, describeRuleForConcierge: 'Describe tu regla y Concierge la creará', gotIt: 'Entendido', createRule: 'Crear regla', + fallbackBanner: + 'Esta no es una regla estándar, así que la redactamos como una regla de agente. Las reglas de agente actúan después de que un gasto o informe cambia, por ejemplo reteniendo el gasto o rechazando el informe.', + fallbackBannerWithExplanation: (explanation: string) => `Esta no es una regla estándar, así que la redactamos como una regla de agente. ${explanation}`, }, tabs: { general: 'General', @@ -8789,16 +8883,21 @@ ${reportName}`, newRule: { title: 'Nueva regla', subtitle: '¿Qué quieres hacer?', + startWithTemplate: 'Empieza con una plantilla', restrictCardSpend: 'Restringir gasto con la tarjeta', restrictCardSpendDescription: 'Bloquea o limita el gasto en el punto de venta', applyExpenseDefaults: 'Aplicar valores predeterminados de gastos', - applyExpenseDefaultsDescription: 'Actualizar campos sin que quien los envía haga nada', + applyExpenseDefaultsDescription: 'Actualizar campos automáticamente', flagForReview: 'Marcar para revisión', - flagForReviewDescription: 'Notificar a los aprobadores cuando los gastos superen los límites de categoría', + flagForReviewDescription: 'Notificar cuando se cumplan tus condiciones', requireFields: 'Configurar requisitos de campos', - requireFieldsDescription: 'Exige campos específicos de gasto o renuncia a exigirlos.', + requireFieldsShort: 'Exigir campos', + requireFieldsDescription: 'Recibos, categorías, etc.', createAgentRule: 'Describir regla de agente', createAgentRuleDescription: 'Crea reglas flexibles que se ejecuten cuando las necesites.', + describeItBelow: 'O simplemente descríbela abajo', + describeRulePlaceholder: 'Bloquear todos los gastos superiores a $2,000', + selectTemplateOrDescribe: 'Selecciona una plantilla o describe una regla para continuar', describe: 'Describir', describeRule: 'Describe tu regla con tus propias palabras y Concierge la creará por ti', describeRuleInputLabel: 'Describe la regla', @@ -8806,6 +8905,15 @@ ${reportName}`, unsupported: 'Esa regla aún no es compatible: estamos trabajando en ello. Prueba con otra regla por ahora.', unintelligible: 'Prueba a formular tu regla así: si los gastos superan los $100, exige un recibo.', multipleRules: 'Una regla a la vez, por favor. Prueba a describir una sola regla, como: bloquear compras en comercios minoristas en todas las tarjetas.', + categoryHasRule: 'Esa categoría ya tiene una regla. Edita la regla existente o describe una regla para otra categoría.', + }, + impossible: { + noTrigger: 'Las reglas no pueden comenzar desde ese evento.', + noCapability: 'Las reglas no pueden hacer eso.', + missingData: 'Expensify no tiene esa información.', + retroactive: 'Las reglas solo se aplican a la actividad nueva.', + historyLimit: 'Las reglas no pueden mirar tan atrás.', + tryThisInstead: 'Prueba esto en su lugar', }, }, expenseDefaultsTable: { @@ -8991,6 +9099,9 @@ ${reportName}`, }, }, }, + zohoBooks: { + organization: 'Organización Zoho Books', + }, businessCentral: { businessCentralSetup: 'Configuración de Dynamics 365 Business Central', prerequisitesTitle: 'Antes de conectar...', @@ -9051,6 +9162,47 @@ ${reportName}`, noBankAccountsFoundDescription: 'Añade cuentas bancarias en Dynamics 365 Business Central y vuelve a sincronizar la conexión', noPaymentMethodsFound: 'No se encontraron formas de pago', noPaymentMethodsFoundDescription: 'Añade formas de pago en Dynamics 365 Business Central y vuelve a sincronizar la conexión', + advanced: { + autoSyncDescription: 'Sincroniza Dynamics 365 Business Central y Expensify automáticamente, todos los días. Los informes se sincronizan en tiempo real.', + accountingMethods: { + label: 'Método de exportación', + description: 'Elige cuándo exportar los gastos.', + values: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Devengo', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Efectivo', + }, + alternateText: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Los gastos por cuenta propia se exportarán cuando se aprueben de forma definitiva', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Los gastos por cuenta propia se exportarán cuando se paguen', + }, + }, + postingMode: { + label: 'Modo de contabilización', + description: 'Elige si los asientos se contabilizan automáticamente o requieren una revisión manual primero.', + note: 'Nota: Expensify contabiliza todos los diarios abiertos de la sección de diario general que elegiste en la página Expensify Setup de Business Central, no solo los que creó. Usa una sección solo para Expensify.', + values: { + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_AND_POST]: { + label: 'Crear y contabilizar', + description: 'Contabiliza los asientos en el libro mayor automáticamente al exportar', + }, + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_ONLY]: { + label: 'Solo crear', + description: 'Contabiliza los asientos manualmente en Dynamics 365 Business Central después de exportar', + }, + }, + }, + autoCreateEntities: 'Crear empleados/proveedores automáticamente', + exportToNextOpenPeriod: 'Exportar al siguiente período abierto', + exportToNextOpenPeriodDescription: 'Cuando la fecha de un informe cae en un período cerrado de Dynamics 365 Business Central, se exporta al siguiente período abierto.', + syncReimbursedReports: 'Sincronizar informes reembolsados', + syncReimbursedReportsDescription: 'Cuando un informe se pague mediante ACH, se generará un asiento de diario general en esta cuenta.', + reimbursementBankAccount: {label: 'Cuenta bancaria de pago', description: 'Elige desde dónde pagar las facturas y crearemos el pago en Dynamics 365 Business Central.'}, + syncExpensifyCardSettlements: 'Sincronizar liquidaciones de la Tarjeta Expensify', + settlementsBankAccount: { + label: 'Cuenta de liquidación de la Tarjeta Expensify', + description: 'Elige tu cuenta de liquidación y crearemos el pago en Dynamics 365 Business Central.', + }, + }, accountsDescription: 'Tus cuentas de Dynamics 365 Business Central se importarán como categorías.', dimensionsImportAsTags: 'Todas las dimensiones de Dynamics 365 Business Central se importan como etiquetas', customers: 'Clientes', @@ -9486,6 +9638,20 @@ ${reportName}`, updatedTimeRate: (newRate, oldRate) => `cambió la tarifa por hora a "${newRate}" (anteriormente "${oldRate}")`, addedProhibitedExpense: ({prohibitedExpense}) => `añadió "${prohibitedExpense}" a los gastos prohibidos`, removedProhibitedExpense: ({prohibitedExpense}) => `eliminó "${prohibitedExpense}" de los gastos prohibidos`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `añadió la ubicación de la oficina «${name}» (${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `cambió el nombre de la ubicación de la oficina de "${oldName}" a "${newName}"`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `cambió la dirección de la oficina "${name}" a "${newAddress}" (previamente "${oldAddress}")`, + setAsPrimary: ({name}: {name: string}) => `establecer la ubicación de la oficina «${name}» como principal`, + setCompanyAddressAsPrimary: 'establecer la dirección de la empresa como principal', + removed: ({name}: {name: string}) => `eliminó la ubicación de la oficina «${name}»`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `cambió la ubicación de la oficina de ${displayName} a ${officeLocation} (previamente ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `"${name}"`, + companyAddress: 'la dirección de la empresa', + primaryOffice: 'la oficina principal', + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `establecer la modalidad de trabajo predeterminada en ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -9669,6 +9835,7 @@ ${reportName}`, addCustomUnitRateWithAmountAndEndDate: (rateName: string, rateValue: string, endDate: string) => `añadió la tasa "${rateName}" de ${rateValue}, válida hasta ${endDate}`, addCustomUnitRateWithAmountAndDates: (rateName: string, rateValue: string, startDate: string, endDate: string) => `añadió la tasa "${rateName}" de ${rateValue}, válida del ${startDate} al ${endDate}`, + addCustomUnitRateTaxReclaimableSuffix: (taxReclaimableAmount: string, taxRateName: string) => `, con ${taxReclaimableAmount} de impuesto recuperable en "${taxRateName}"`, updatedCustomUnitRateDateRange: (rateName: string, newDateRange: string, oldDateRange: string) => `actualizó la tasa de distancia «${rateName}» para aplicar ${newDateRange} (previamente ${oldDateRange})`, customUnitRateDateRangeStartToEnd: (startDate: string, endDate: string) => `${startDate} - ${endDate}`, @@ -9759,6 +9926,8 @@ ${reportName}`, `cambió el acuerdo de trabajo de ${displayName} a ${newArrangement} (previamente ${oldArrangement})`, updatedDefaultWorkArrangement: ({newArrangement, oldArrangement}: {newArrangement: string; oldArrangement: string}) => `cambió la modalidad de trabajo predeterminada a ${newArrangement} (previamente ${oldArrangement})`, + preventPayoutNonReimbursableReports: (oldValue: string, newValue: string) => + `actualizó "Impedir marcar los informes no reembolsables como pagados" a "${newValue === 'true' ? 'Habilitado' : 'Deshabilitado'}" (previamente "${oldValue === 'true' ? 'Habilitado' : 'Deshabilitado'}")`, }, roomMembersPage: { memberNotFound: 'Miembro no encontrado.', @@ -9813,6 +9982,8 @@ ${reportName}`, fallbackTitle: 'Ticket de soporte', resolved: 'Este ticket de soporte está resuelto.', surveyPrompt: '¿Cómo fue tu experiencia con el soporte?', + great: 'Excelente', + poor: 'Mala', reopenTicket: 'Reabrir ticket', }, statementPage: { @@ -9986,6 +10157,12 @@ ${reportName}`, }, customDay: 'Día personalizado', }, + describeSearch: { + title: 'Describe tu búsqueda', + description: 'Describe en un inglés sencillo qué gastos quieres ver y aplicaremos los filtros adecuados automáticamente.', + inputLabel: 'Describe tu búsqueda', + buttonText: 'Aplicar', + }, status: 'Estado', keyword: 'Palabra clave', keywords: 'Palabras clave', @@ -10019,6 +10196,7 @@ ${reportName}`, past: 'Anterior', submitted: 'Envío', approved: 'Aprobación', + anyApproval: 'Cualquier aprobación', firstApprover: 'Primer aprobador', firstApproved: 'Primera aprobación', paidBy: 'Pagado por', @@ -10027,6 +10205,8 @@ ${reportName}`, posted: 'Contabilización', withdrawn: 'Retirada', billable: 'Facturable', + rejected: 'Rechazado', + held: 'Retenido', reimbursable: 'Reembolsable', purchaseCurrency: 'Moneda de compra', sortOrder: { @@ -10062,6 +10242,11 @@ ${reportName}`, }, reportField: (name, value) => `${name} es ${value}`, filterType: {label: 'Tipo de filtro', has: {positive: 'tiene', negative: 'no tiene'}, is: {positive: 'es', negative: 'no es'}}, + merchant: { + matchType: 'Tipo de coincidencia', + contains: (merchant) => (merchant ? `Contiene "${merchant}"` : 'Contiene'), + equalTo: (merchant) => (merchant ? `Igual a "${merchant}"` : 'Igual a'), + }, created: 'Creado', createdDate: 'Fecha de creación', transactionStatus: { @@ -10206,6 +10391,8 @@ ${reportName}`, type: { changeField: (oldValue, newValue, fieldName) => `cambió ${fieldName} a "${newValue}" (previamente "${oldValue}")`, changeFieldEmpty: (newValue, fieldName) => `estableció ${fieldName} a ${newValue}`, + changeFieldRemoved: (oldValue, fieldName) => `eliminó ${fieldName} (previamente "${oldValue}")`, + changeFieldCleared: (fieldName) => `eliminó ${fieldName}`, changeReportPolicy: (toPolicyName, fromPolicyName) => { if (!toPolicyName) { return `cambió el espacio de trabajo${fromPolicyName ? ` (previamente ${fromPolicyName})` : ''}`; @@ -11073,7 +11260,7 @@ ${reportName}`, duplicatedTransaction: 'Posible duplicado', fieldRequired: 'Los campos del informe son obligatorios', futureDate: 'Fecha futura no permitida', - inactiveVendor: (isSupplier = false) => (isSupplier ? 'El proveedor ya no es válido' : 'El proveedor ya no es válido'), + inactiveVendor: 'El proveedor ya no es válido', invoiceMarkup: (invoiceMarkup) => `Incrementado un ${invoiceMarkup}%`, maxAge: (maxAge) => `Fecha de más de ${maxAge} días`, missingCategory: 'Falta categoría', @@ -11390,6 +11577,7 @@ ${reportName}`, }, systemMessage: { mergedWithCashTransaction: 'encontró un recibo para esta transacción', + mergedExpenseDeleted: 'combinó gastos y movió uno a Eliminados', }, subscription: { authenticatePaymentCard: 'Autenticar tarjeta de pago', @@ -11626,6 +11814,9 @@ ${reportName}`, refundedOn: ({date}: {date: string}) => `Reembolsado el ${date}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `Reembolsado ${amount} el ${date}`, }, + billedTo: 'Facturado a', + viewReceipt: 'Ver recibo', + viewInvoice: 'Ver factura', }, subscriptionSettings: { title: 'Configuración de suscripción', @@ -12259,22 +12450,6 @@ ${reportName}`, lockScreenTrackingText: 'Siguiendo...', }, }, - aiFeaturesPromoModal: { - subtitle: 'Nuevo en Concierge AI', - confirmText: '¡Vamos!', - spendAnalysis: { - title: 'Análisis interactivo del gasto', - description: `Concierge muestra información mensual sobre gastos y te permite profundizar en los detalles detrás de cada cifra. Más información.`, - }, - expenseAssistant: { - title: 'Conoce a tu nuevo asistente de gastos', - description: `Chatea con Concierge para crear y actualizar gastos, directamente en la aplicación o por correo electrónico o mensaje de texto. Más información.`, - }, - customAgents: { - title: 'Crea tus propios agentes', - description: `Crea agentes personalizados para revisar, aprobar y asignar gastos según las reglas que configures. Más información.`, - }, - }, emailIssuePage: { title: 'Problema de correo electrónico', intro: (login: string) => `Nuestro proveedor de correo electrónico ha pausado el envío a ${login} debido a problemas de entrega. Para solucionar este problema:`, @@ -12315,5 +12490,6 @@ ${reportName}`, mobileRenewPrompt: 'Visita Expensify en tu navegador web para renovar antes.', mobileClaimPrompt: 'Visita Expensify en tu navegador web para obtener tu descuento de renovación.', }, + bulkActionBar: {label: 'Acciones en bloque', clearSelection: 'Borrar selección', loadingSelection: 'Cargando selección'}, }; export default translations; diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1397788a7159..c8efb0d6a3aa 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -307,7 +307,6 @@ const translations: TranslationDeepObject = { change: 'Modifier', category: 'Catégorie', vendor: 'Fournisseur', - supplier: 'Fournisseur', report: 'Note de frais', billable: 'Facturable', nonBillable: 'Non refacturable', @@ -531,6 +530,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'Le graphique n’a pas pu être chargé. Veuillez actualiser la page et réessayer.', apiKey: 'Clé API', exportsTo: 'Exports vers', + shareReport: 'Partager la note de frais', + reportShared: 'Note de frais partagée', }, socials: { podcast: 'Suivez-nous sur Podcast', @@ -982,6 +983,7 @@ const translations: TranslationDeepObject = { addShippingAddress: {title: 'Nous avons besoin de votre adresse de livraison', subtitle: 'Indiquez une adresse pour recevoir votre Carte Expensify.', cta: 'Ajouter'}, addPaymentCard: {title: 'Ajoutez une carte de paiement pour continuer à utiliser Expensify', subtitle: 'Compte > Abonnement', cta: 'Ajouter'}, addBankAccount: {title: 'Ajoutez un compte bancaire pour être remboursé'}, + addDepositAccount: {title: 'Ajoutez un compte bancaire pour recevoir des paiements'}, activateCard: {title: 'Activer votre Carte Expensify', subtitle: 'Validez votre carte et commencez à dépenser.', cta: 'Activer'}, confirmDigitalWalletAddition: { subtitle: 'Carte Expensify', @@ -1028,6 +1030,7 @@ const translations: TranslationDeepObject = { cta: 'Gérer', }, emailDeliveryFailure: {title: 'Nous ne pouvons pas vous envoyer de notifications par e-mail', subtitle: 'Compte'}, + upgradeNetSuiteConnection: {title: 'Mettre à niveau votre connexion NetSuite'}, }, forYouSection: { submit: ({count}: {count: number}) => ({ @@ -1178,7 +1181,18 @@ const translations: TranslationDeepObject = { title: 'Voyez où va votre argent', subtitle: 'Une fois que vous aurez des dépenses, vous verrez des tendances de dépenses, les principaux marchands et plus encore.', }, - compare: {label: 'Comparer', previousPeriod: 'Période précédente', average: 'Moyenne'}, + compare: { + label: 'Comparer', + previousPeriod: 'Période précédente', + average: 'Moyenne', + dayLabel: (day?: number) => (day === undefined ? 'Jour' : `Jour ${day}`), + weekLabel: (week?: number) => (week === undefined ? 'Semaine' : `Semaine ${week}`), + monthLabel: (month?: number) => (month === undefined ? 'Mois' : `Mois ${month}`), + quarterLabel: (quarter?: number) => (quarter === undefined ? 'Trimestre' : `Trimestre ${quarter}`), + yearLabel: (year?: number) => (year === undefined ? 'Année' : `Année ${year}`), + increase: (percentage: string) => `En hausse de ${percentage}`, + decrease: (percentage: string) => `En baisse de ${percentage}`, + }, }, allSettingsScreen: { subscription: 'Abonnement', @@ -2189,6 +2203,18 @@ const translations: TranslationDeepObject = { } return `En attente de la finalisation du paiement${formattedETA}.`; }, + [CONST.NEXT_STEP.MESSAGE_KEY.WAITING_FOR_CANCELLED_REIMBURSEMENT]: ( + _actor: string, + _actorType: ValueOf, + eta?: string, + etaType?: ValueOf, + ) => { + let formattedETA = ''; + if (eta) { + formattedETA = etaType === CONST.NEXT_STEP.ETA_TYPE.DATE_TIME ? ` d’ici ${eta}` : ` ${eta}`; + } + return `En attente de la fin du traitement du paiement annulé${formattedETA}.`; + }, [CONST.NEXT_STEP.MESSAGE_KEY.SUBMITTING_TO_SELF]: ( _actor: string, _actorType: ValueOf, @@ -2830,6 +2856,13 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'Ce compte nécessite une attention particulière', unlock: 'Déverrouiller', }, + wiseGlobalReimbursementsIntro: { + title: 'Configurer les remboursements globaux ?', + subtitle: 'Payez des membres partout dans le monde dans leur devise locale.', + getStarted: 'Commencer', + headerTitle: 'Remboursements globaux', + }, + wiseGlobalReimbursementsBanner: {title: 'Payer des personnes dans le monde entier', subtitle: 'Activez les remboursements globaux sur votre compte en USD.'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Ajouter la carte à ${walletName}`, @@ -3961,6 +3994,11 @@ ${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}`, + until: (date: string) => `Jusqu’au ${date}`, + removeDelegate: 'Supprimer le délégué de vacances', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -4053,6 +4091,10 @@ ${amount} pour ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Demande déjà soumise', unlockAlreadyRequestedDescription: 'Votre demande de déverrouillage de ce compte bancaire a déjà été envoyée. Concierge vous contactera si autre chose est nécessaire.', }, + listField: { + editItem: ({name}: {name: string}) => `Modifier ${name}`, + removeItem: ({name}: {name: string}) => `Supprimer ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Exemple : ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4064,7 +4106,13 @@ ${amount} pour ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Format invalide. Exemple : ${example}` : 'Format invalide'), invalidOption: "Choisissez l'une des options disponibles", outOfRange: ({min, max}: {min: number; max: number}) => `Saisissez une valeur entre ${min} et ${max}`, + tooFewItems: ({min}: {min: number}) => `Ajoutez au moins ${min}`, + tooManyItems: ({max}: {max: number}) => `Ajoutez au plus ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item} : ${error}`, }, + addItem: ({item}: {item: string}) => `Ajouter ${item}`, + removeItemTitle: ({item}: {item: string}) => `Supprimer ${item} ?`, + removeItemPrompt: ({name}: {name: string}) => `Voulez-vous vraiment supprimer ${name} ?`, }, addPersonalBankAccount: { swiftBicFormatError: 'Le code SWIFT/BIC doit comporter 8 ou 11 caractères, avec 6 lettres suivies de 2 ou 5 lettres ou chiffres.', @@ -4105,6 +4153,7 @@ ${amount} pour ${merchant} - ${date}`, formLabel: 'Voir le PDF', }, attachmentNotFound: 'Pièce jointe introuvable', + loadTimedOut: 'Cette image met trop de temps à se charger.', retry: 'Réessayer', }, messages: { @@ -5108,7 +5157,10 @@ ${amount} pour ${merchant} - ${date}`, }, requestEmptyList: { title: 'Aucun taux de per diem pour le moment', - subtitle: 'Cet espace de travail n’a aucun taux de per diem, demandez à votre Administrateur d’en ajouter.', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} n’a aucun taux de per diem. Demandez à un administrateur d’en ajouter.` + : 'Cet espace de travail n’a aucun taux de per diem. Demandez à un administrateur d’en ajouter.', adminSubtitle: 'Ajoutez des taux de per diem pour organiser vos dépenses.', }, importPerDiemRates: 'Importer des taux de per diem', @@ -5116,6 +5168,7 @@ ${amount} pour ${merchant} - ${date}`, editPerDiemRates: 'Modifier les taux de per diem', editDestinationSubtitle: (destination: string) => `La mise à jour de cette destination la modifiera pour tous les sous-taux de per diem ${destination}.`, editCurrencySubtitle: (destination: string) => `La mise à jour de cette devise la modifiera pour tous les sous-taux de per diem de ${destination}.`, + missingPerDiemUnit: 'Le per diem n’est pas configuré correctement sur cet espace de travail. Veuillez contacter Concierge pour obtenir de l’aide.', }, qbd: { exportOutOfPocketExpensesDescription: 'Définissez comment les dépenses hors poche sont exportées vers QuickBooks Desktop.', @@ -5449,8 +5502,8 @@ ${amount} pour ${merchant} - ${date}`, }, noAccountsFound: 'Aucun compte trouvé', noAccountsFoundDescription: 'Veuillez ajouter le compte dans Xero et synchroniser à nouveau la connexion', - noSuppliersFound: 'Aucun fournisseur trouvé', - noSuppliersFoundDescription: 'Veuillez ajouter le fournisseur dans Xero et synchroniser à nouveau la connexion.', + noVendorsFound: 'Aucun fournisseur trouvé', + noVendorsFoundDescription: 'Veuillez ajouter des fournisseurs dans Xero et synchroniser de nouveau la connexion', accountingMethods: { label: 'Quand exporter', description: 'Choisissez quand exporter les dépenses :', @@ -6659,6 +6712,9 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. distanceRates: { title: 'Taux de distance', subtitle: 'Ajoutez, mettez à jour et appliquez les taux.', + commuterExclusionsOnTitle: 'Les exclusions de trajet domicile-travail sont activées', + disableLockedByCommuterExclusionsPrompt: + 'Pour désactiver les taux de distance, commencez par définir Exclure les trajets domicile-travail sur « Ne pas exclure les trajets domicile-travail ».', }, perDiem: { title: 'Indemnité journalière', @@ -7027,6 +7083,11 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. emptySubtitle: 'Les fournisseurs apparaîtront ici une fois la synchronisation comptable terminée.', findVendor: 'Trouver un fournisseur', managedInAccountingSoftware: 'Les fournisseurs sont gérés dans votre', + enableVendor: 'Activer le fournisseur', + enableVendors: 'Activer les fournisseurs', + disableVendor: 'Désactiver le fournisseur', + disableVendors: 'Désactiver les fournisseurs', + updateFailureMessage: 'Une erreur s’est produite lors de la mise à jour du fournisseur, veuillez réessayer', }, tags: { tagName: 'Nom du tag', @@ -7070,6 +7131,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. tagRules: 'Règles de tags', approverDescription: 'Approbateur', importTags: 'Importer des tags', + appendCustomTagList: 'Ajouter une liste de tags personnalisée', importTagsSupportingText: 'Codez vos dépenses avec un seul type de tag ou plusieurs.', configureMultiLevelTags: 'Configurez votre liste de tags pour le multi-niveau.', importMultiLevelTagsSupportingText: `Voici un aperçu de vos tags. Si tout semble correct, cliquez ci-dessous pour les importer.`, @@ -7336,6 +7398,11 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, 'Le membre travaille à domicile ou se déplace entre plusieurs sites sans bureau fixe, donc les règles relatives aux trajets domicile-travail ne s’appliquent pas.', futureOnlyNote: 'Les modifications s’appliquent uniquement aux calculs de kilométrage futurs. Les dépenses de kilométrage existantes ne sont pas recalculées.', }, + officeLocation: 'Lieu de bureau', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Modifier le lieu de travail', + changeWorkArrangement: 'Modifier l’organisation du travail', + changeRole: 'Modifier le rôle', }, card: { getStartedIssuing: 'Commencez par émettre votre première carte virtuelle ou physique.', @@ -7449,6 +7516,12 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Une erreur s’est produite avec une connexion configurée dans Expensify Classic. [Accédez à Expensify Classic pour corriger ce problème.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Accédez à Expensify Classic pour gérer vos paramètres.', + exactOnline: { + comingSoon: 'Bientôt disponible', + joinWaitlist: 'Rejoindre la liste d’attente', + onWaitlist: 'Vous êtes sur la liste d’attente', + waitlistDescription: 'Concierge vous enverra un message pour vous informer lorsque cette intégration sera prête et vous expliquer comment la connecter.', + }, setup: 'Connecter', connectToSandbox: 'Se connecter à Sandbox', lastSync: (relativeDate: string) => `Dernière synchronisation ${relativeDate}`, @@ -7732,6 +7805,9 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, businessCentral: 'Dynamics 365 Business Central', qboConnectionExpiring: ({date}: {date: string}) => `Votre connexion QuickBooks Online expire le ${date}.`, qboConnectionExpired: ({date}: {date: string}) => `Votre connexion à QuickBooks Online a expiré le ${date}.`, + netSuiteOAuthMigrationRequired: 'Mettez à niveau votre connexion NetSuite vers OAuth 2.0 d’ici février 2027.', + netSuiteOAuthMigrationRequiredUrgent: 'Votre connexion NetSuite nécessite une mise à niveau immédiate vers OAuth 2.0.', + netSuiteOAuthUpgradeNow: 'Mettre à niveau maintenant', }, export: { notReadyHeading: 'Pas prêt à être exporté', @@ -7911,6 +7987,21 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, addressContext: 'Une adresse d’espace de travail est requise pour activer Expensify Travel. Veuillez saisir une adresse associée à votre entreprise.', policy: 'Politique de dépenses', }, + officeLocations: { + title: 'Sites de bureau', + subtitle: 'Ajoutez des bureaux pour les assigner aux membres, par exemple pour le suivi des distances.', + addOfficeLocation: 'Ajouter un lieu de travail', + editOfficeLocation: 'Modifier le lieu de travail', + locationName: 'Nom du lieu', + locationNameHint: 'Choisissez un surnom de lieu si vous ajoutez plusieurs sites de bureau', + defaultName: ({officeNumber}: {officeNumber: number}) => `Bureau ${officeNumber}`, + setAsPrimary: 'Définir comme principal', + primary: 'Principal', + deleteOfficeLocation: 'Supprimer le site ?', + deleteOfficeLocationConfirmation: 'Voulez-vous vraiment supprimer ce bureau ? Les membres seront réassignés au bureau principal.', + existingOfficeLocationError: 'Un bureau portant ce nom existe déjà', + setCompanyAddressAsPrimaryConfirmation: 'Définir l’adresse de l’entreprise comme bureau principal ?', + }, bankAccount: { continueWithSetup: 'Continuer la configuration', youAreAlmostDone: @@ -8450,6 +8541,8 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `Ajoutez ${featureName} pour déverrouiller cette fonctionnalité.`, enableFeatureSubtitle: (featureName: string, moreFeaturesLink?: string) => `Allez dans [plus de fonctionnalités](${moreFeaturesLink}) et activez ${featureName} pour déverrouiller cette fonctionnalité.`, + preventPayoutNonReimbursableReportsTitle: 'Empêcher de marquer les notes de frais non remboursables comme payées', + preventPayoutNonReimbursableReportsSubtitle: 'Ne pas autoriser les administrateurs à marquer les notes de frais non remboursables comme payées.', }, agentsPromoBanner: { title: 'Vous ne voyez pas la règle dont vous avez besoin ? Ajoutez un agent', @@ -8504,7 +8597,6 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip ifAnyExpenseMatches: 'Si une dépense correspond :', thenApplyFollowingDefaults: 'Appliquez ensuite les valeurs par défaut suivantes :', vendorUnavailable: 'Fournisseur indisponible', - supplierUnavailable: 'Fournisseur indisponible', confirmErrorCategory: 'Veuillez sélectionner une catégorie', confirmErrorCategoryTax: 'Veuillez sélectionner un taux de taxe', confirmErrorCondition: 'Veuillez saisir un commerçant', @@ -8744,6 +8836,9 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e describeRuleForConcierge: 'Décrivez votre règle et Concierge la créera', gotIt: 'Compris', createRule: 'Créer une règle', + fallbackBanner: + 'Ce n’est pas une règle standard, nous l’avons donc rédigée comme une règle d’agent. Les règles d’agent agissent après la modification d’une dépense ou d’une note de frais, par exemple en bloquant la dépense ou en rejetant la note de frais.', + fallbackBannerWithExplanation: (explanation: string) => `Ce n’est pas une règle standard, nous l’avons donc rédigée comme une règle d’agent. ${explanation}`, }, tabs: { general: 'Général', @@ -8806,16 +8901,21 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e newRule: { title: 'Nouvelle règle', subtitle: 'Que voulez-vous faire ?', + startWithTemplate: 'Commencer avec un modèle', restrictCardSpend: 'Limiter les dépenses de la carte', restrictCardSpendDescription: 'Bloquer ou limiter les dépenses au point de vente', applyExpenseDefaults: 'Appliquer les valeurs de dépense par défaut', - applyExpenseDefaultsDescription: 'Mettre à jour les champs sans que le déclarant ne fasse quoi que ce soit', + applyExpenseDefaultsDescription: 'Mettre à jour les champs automatiquement', flagForReview: 'Marquer pour examen', - flagForReviewDescription: 'Avertir les approbateurs lorsque les dépenses dépassent les limites de catégorie', + flagForReviewDescription: 'Avertir lorsque vos conditions sont remplies', requireFields: 'Définir les exigences des champs', - requireFieldsDescription: 'Rendre certains champs de dépense obligatoires ou renoncer à les exiger.', + requireFieldsShort: 'Exiger des champs', + requireFieldsDescription: 'Reçus, catégories, etc.', createAgentRule: 'Décrire la règle de l’agent', createAgentRuleDescription: 'Créez des règles flexibles qui s’exécutent quand vous en avez besoin.', + describeItBelow: 'Ou décrivez-la simplement ci-dessous', + describeRulePlaceholder: 'Bloquer toutes les dépenses supérieures à 2 000 $', + selectTemplateOrDescribe: 'Sélectionnez un modèle ou décrivez une règle pour continuer', describe: 'Décrire', describeRule: 'Décrivez votre règle avec vos propres mots et Concierge la créera pour vous', describeRuleInputLabel: 'Décrivez la règle', @@ -8823,6 +8923,15 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e unsupported: 'Cette règle n’est pas encore prise en charge — nous y travaillons. Essayez une autre règle pour le moment.', unintelligible: 'Essayez de formuler votre règle ainsi : si les dépenses dépassent 100 $, exigez un reçu.', multipleRules: 'Une règle à la fois, s’il vous plaît — essayez de décrire une seule règle, comme : bloquer les achats en magasin sur toutes les cartes.', + categoryHasRule: 'Cette catégorie a déjà une règle. Modifiez la règle existante ou décrivez une règle pour une autre catégorie.', + }, + impossible: { + noTrigger: 'Les règles ne peuvent pas commencer à partir de cet événement.', + noCapability: 'Les règles ne peuvent pas faire cela.', + missingData: 'Expensify n’a pas ces informations.', + retroactive: 'Les règles ne s’appliquent qu’aux nouvelles activités.', + historyLimit: 'Les règles ne peuvent pas remonter aussi loin dans le temps.', + tryThisInstead: 'Essayez plutôt ceci', }, }, expenseDefaultsTable: { @@ -9015,7 +9124,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e dontSeeYourATS: `Vous ne voyez pas votre ATS ici ? Demandez à Concierge et nous pourrons l’ajouter.`, importSettings: 'Paramètres d’importation', defaultApprover: 'Approbateur par défaut', - approverFields: {recruiter: 'Recruteur', recruitingCoordinator: 'Coordinateur recrutement'}, + approverFields: {recruiter: 'Recruteur', recruitingCoordinator: 'Coordinateur recrutement', hiringManager: 'Responsable du recrutement'}, filters: { description: (providerName: string) => `Sélectionnez quels membres sont importés depuis ${providerName}. Vous pouvez choisir selon les étapes de poste, les tags et les bureaux.`, @@ -9037,7 +9146,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e toggleTitle: 'Bureaux', allSelected: 'Tous les bureaux', }, - enableJobStagesOrTags: 'Activez les étapes de mission ou les Tags pour continuer', + enableJobStagesOrTags: 'Sélectionnez au moins une étape de poste ou un tag pour continuer', }, subtitle: 'Connectez les outils de recrutement et synchronisez les validations de candidats.', syncResults: { @@ -9050,8 +9159,12 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e finalApprover: `Approbateur final`, finalApproverOptional: 'Approbateur final (facultatif)', approvalModeDescription: (providerName: string) => `Définissez l’approbateur pour les nouveaux membres importés de ${providerName} vers Expensify.`, - approverFieldDescription: (providerName: string) => - `Choisissez le premier approbateur pour vos candidat·es : soit leur recruteur·se, soit leur coordinateur·rice assigné·e dans ${providerName}.`, + approverFieldDescription: (providerName: string, approverFields: string[]) => { + const fieldNames = approverFields.map((approverField) => approverField.toLowerCase()); + const lastFieldName = fieldNames.at(-1) ?? ''; + const fieldList = fieldNames.length > 2 ? `leur ${fieldNames.slice(0, -1).join(', ')}, ou leur ${lastFieldName}` : `soit leur ${fieldNames.join('ou leur')}`; + return `Choisissez le premier approbateur pour vos candidats : ${fieldList} assigné dans ${providerName}.`; + }, approvalModeDescriptions: { basic: 'Choisir un seul approbateur', advanced: `Le recruteur ou le coordinateur du candidat devient la personne qui approuve ses dépenses`, @@ -9195,6 +9308,9 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, }, }, + zohoBooks: { + organization: 'Organisation Zoho Books', + }, businessCentral: { businessCentralSetup: 'Configuration de Dynamics 365 Business Central', prerequisitesTitle: 'Avant de vous connecter...', @@ -9255,6 +9371,51 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e noBankAccountsFoundDescription: 'Veuillez ajouter des comptes bancaires dans Dynamics 365 Business Central et synchroniser à nouveau la connexion', noPaymentMethodsFound: 'Aucun mode de règlement trouvé', noPaymentMethodsFoundDescription: 'Veuillez ajouter des modes de règlement dans Dynamics 365 Business Central et synchroniser à nouveau la connexion', + advanced: { + autoSyncDescription: 'Synchronisez Dynamics 365 Business Central et Expensify automatiquement, chaque jour. Les notes de frais se synchronisent en temps réel.', + accountingMethods: { + label: 'Méthode d’exportation', + description: 'Choisissez quand exporter les dépenses.', + values: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Comptabilité d’engagement', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Espèces', + }, + alternateText: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Les dépenses hors poche seront exportées une fois approuvées définitivement', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Les dépenses hors poche seront exportées une fois payées', + }, + }, + postingMode: { + label: 'Mode de validation', + description: 'Choisissez si les écritures sont validées automatiquement ou nécessitent d’abord une vérification manuelle.', + note: 'Remarque : Expensify valide chaque journal ouvert dans le lot de journal général que vous avez choisi sur la page Expensify Setup de Business Central, et pas seulement ceux qu’il a créés. Utilisez un lot réservé à Expensify.', + values: { + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_AND_POST]: { + label: 'Créer et valider', + description: 'Valider automatiquement les écritures dans le grand livre lors de l’export', + }, + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_ONLY]: { + label: 'Créer uniquement', + description: 'Valider manuellement les écritures dans Dynamics 365 Business Central après l’export', + }, + }, + }, + autoCreateEntities: 'Créer automatiquement des employés/fournisseurs', + exportToNextOpenPeriod: 'Exporter vers la prochaine période ouverte', + exportToNextOpenPeriodDescription: + 'Lorsqu’une note de frais est datée dans une période clôturée de Dynamics 365 Business Central, elle est exportée vers la période ouverte suivante.', + syncReimbursedReports: 'Synchroniser les notes de frais remboursées', + syncReimbursedReportsDescription: 'Lorsqu’une note de frais est payée par ACH, une écriture de journal général sera générée dans ce compte.', + reimbursementBankAccount: { + label: 'Compte bancaire de paiement', + description: 'Choisissez d’où payer les factures et nous créerons le paiement dans Dynamics 365 Business Central.', + }, + syncExpensifyCardSettlements: 'Synchroniser les règlements de Carte Expensify', + settlementsBankAccount: { + label: 'Compte de règlement de la Carte Expensify', + description: 'Choisissez votre compte de règlement et nous créerons le paiement dans Dynamics 365 Business Central.', + }, + }, accountsDescription: 'Vos comptes Dynamics 365 Business Central seront importés en tant que catégories.', dimensionsImportAsTags: 'Toutes les dimensions Dynamics 365 Business Central sont importées en tant que tags', customers: 'Clients', @@ -9739,6 +9900,20 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `a ajouté « ${prohibitedExpense} » aux dépenses interdites`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `a supprimé « ${prohibitedExpense} » des dépenses interdites`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `a ajouté le lieu de bureau « ${name} » (${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `a renommé le lieu de bureau « ${oldName} » en « ${newName} »`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `a modifié l’adresse du bureau « ${name} » en « ${newAddress} » (auparavant « ${oldAddress} »)`, + setAsPrimary: ({name}: {name: string}) => `définir le lieu de travail « ${name} » comme principal`, + setCompanyAddressAsPrimary: 'définir l’adresse de l’entreprise comme principale', + removed: ({name}: {name: string}) => `a supprimé le lieu de bureau « ${name} »`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `a modifié le lieu de travail de ${displayName} en ${officeLocation} (précédemment ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `« ${name} »`, + companyAddress: 'l’adresse de l’entreprise', + primaryOffice: 'le bureau principal', + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `définir l’organisation de travail par défaut sur ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -9876,6 +10051,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e addCustomUnitRateWithAmountAndEndDate: (rateName: string, rateValue: string, endDate: string) => `a ajouté le taux « ${rateName} » de ${rateValue}, valable jusqu’au ${endDate}`, addCustomUnitRateWithAmountAndDates: (rateName: string, rateValue: string, startDate: string, endDate: string) => `a ajouté le taux « ${rateName} » de ${rateValue}, valable du ${startDate} au ${endDate}`, + addCustomUnitRateTaxReclaimableSuffix: (taxReclaimableAmount: string, taxRateName: string) => `, avec ${taxReclaimableAmount} de taxe récupérable au titre de « ${taxRateName} »`, updatedCustomUnitRateDateRange: (rateName: string, newDateRange: string, oldDateRange: string) => `a mis à jour le taux kilométrique « ${rateName} » pour s’appliquer à ${newDateRange} (précédemment ${oldDateRange})`, customUnitRateDateRangeStartToEnd: (startDate: string, endDate: string) => `${startDate} - ${endDate}`, @@ -9966,6 +10142,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e `a modifié l’organisation du travail de ${displayName} en ${newArrangement} (auparavant ${oldArrangement})`, updatedDefaultWorkArrangement: ({newArrangement, oldArrangement}: {newArrangement: string; oldArrangement: string}) => `a modifié le mode de travail par défaut en ${newArrangement} (auparavant ${oldArrangement})`, + preventPayoutNonReimbursableReports: (oldValue: string, newValue: string) => + `mis à jour « Empêcher de marquer les notes de frais non remboursables comme payées » en « ${newValue === 'true' ? 'Activé' : 'Désactivé'} » (auparavant « ${oldValue === 'true' ? 'Activé' : 'Désactivé'} »)`, }, roomMembersPage: { memberNotFound: 'Membre introuvable.', @@ -10020,6 +10198,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e fallbackTitle: 'Ticket d’assistance', resolved: 'Ce ticket d’assistance est résolu.', surveyPrompt: 'Comment s’est passée votre expérience avec l’assistance ?', + great: 'Excellente', + poor: 'Mauvaise', reopenTicket: 'Rouvrir le ticket', }, statementPage: { @@ -10164,6 +10344,12 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, customDay: 'Jour personnalisé', }, + describeSearch: { + title: 'Décrivez votre recherche', + description: 'Décrivez en anglais simple les dépenses que vous souhaitez voir et nous appliquerons automatiquement les bons filtres.', + inputLabel: 'Décrivez votre recherche', + buttonText: 'Appliquer', + }, status: 'Statut', keyword: 'Mot-clé', keywords: 'Mots-clés', @@ -10199,6 +10385,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e past: 'Passé', submitted: 'Soumis', approved: 'Approuvé', + anyApproval: 'Toute approbation', firstApprover: 'Premier approbateur', firstApproved: 'Approuvé en premier', paidBy: 'Payé par', @@ -10207,6 +10394,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e posted: 'Publié', withdrawn: 'Retiré', billable: 'Facturable', + rejected: 'Rejeté', + held: 'En attente', reimbursable: 'Remboursable', purchaseCurrency: 'Devise d’achat', sortOrder: { @@ -10241,6 +10430,11 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Exporter', }, filterType: {label: 'Type de filtre', has: {positive: 'a', negative: 'n’a pas'}, is: {positive: 'est', negative: 'n’est pas'}}, + merchant: { + matchType: 'Type de correspondance', + contains: (merchant?: string) => (merchant ? `Contient "${merchant}"` : 'Contient'), + equalTo: (merchant?: string) => (merchant ? `Égal à "${merchant}"` : 'Égal à'), + }, created: 'Créé', createdDate: 'Date de création', transactionStatus: { @@ -10420,6 +10614,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e type: { changeField: (oldValue: string | undefined, newValue: string, fieldName: string) => `a modifié ${fieldName} en « ${newValue} » (auparavant « ${oldValue} »)`, changeFieldEmpty: (newValue: string, fieldName: string) => `définir ${fieldName} sur « ${newValue} »`, + changeFieldRemoved: (oldValue: string, fieldName: string) => `a supprimé ${fieldName} (auparavant « ${oldValue} »)`, + changeFieldCleared: (fieldName: string) => `a supprimé ${fieldName}`, changeReportPolicy: (toPolicyName: string, fromPolicyName?: string) => { if (!toPolicyName) { return `a modifié l’espace de travail${fromPolicyName ? `(précédemment ${fromPolicyName})` : ''}`; @@ -10904,7 +11100,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e duplicatedTransaction: 'Doublon potentiel', fieldRequired: 'Les champs de note de frais sont obligatoires', futureDate: 'Date future non autorisée', - inactiveVendor: (isSupplier = false) => (isSupplier ? 'Fournisseur plus valide' : 'Fournisseur plus valide'), + inactiveVendor: 'Fournisseur plus valide', invoiceMarkup: (invoiceMarkup: number) => `Majoration de ${invoiceMarkup} %`, maxAge: (maxAge: number) => `Date antérieure de plus de ${maxAge} jours`, missingCategory: 'Catégorie manquante', @@ -11221,6 +11417,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, systemMessage: { mergedWithCashTransaction: 'a fait correspondre un reçu à cette transaction', + mergedExpenseDeleted: 'a fusionné des dépenses et en a déplacé une vers Supprimées', }, subscription: { authenticatePaymentCard: 'Authentifier la carte de paiement', @@ -11463,6 +11660,9 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e refundedOn: ({date}: {date: string}) => `Remboursé le ${date}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${amount} remboursé le ${date}`, }, + billedTo: 'Facturé à', + viewReceipt: 'Voir le reçu', + viewInvoice: 'Voir la facture', }, subscriptionSettings: { title: 'Paramètres d’abonnement', @@ -12024,22 +12224,6 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, addPaymentCardButton: 'Ajouter une carte de paiement', }, monthPickerPage: {month: 'Mois', selectMonth: 'Veuillez sélectionner un mois'}, - aiFeaturesPromoModal: { - subtitle: 'Nouveau dans Concierge IA', - confirmText: 'Allons-y !', - spendAnalysis: { - title: 'Analyse interactive des dépenses', - description: `Concierge met en avant des analyses mensuelles des dépenses et vous permet d’examiner en détail chaque chiffre. En savoir plus.`, - }, - expenseAssistant: { - title: 'Découvrez votre nouvel assistant de dépenses', - description: `Discutez avec Concierge pour créer et mettre à jour des dépenses, directement dans l’application ou par e-mail ou SMS. En savoir plus.`, - }, - customAgents: { - title: 'Créez vos propres agents', - description: `Créez des agents personnalisés pour examiner, approuver et acheminer les dépenses selon les règles que vous définissez. En savoir plus.`, - }, - }, emailIssuePage: { title: 'Problème d’e-mail', intro: (login: string) => `Notre fournisseur d’e-mails a interrompu l’envoi vers ${login} en raison de problèmes de distribution. Pour résoudre ce problème :`, @@ -12080,5 +12264,6 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, mobileRenewPrompt: 'Visitez Expensify dans votre navigateur web pour renouveler en avance.', mobileClaimPrompt: 'Visitez Expensify dans votre navigateur web pour bénéficier de votre réduction de renouvellement.', }, + bulkActionBar: {label: 'Actions groupées', clearSelection: 'Effacer la sélection', loadingSelection: 'Chargement de la sélection'}, }; export default translations; diff --git a/src/languages/it.ts b/src/languages/it.ts index 9b5a9461ab45..e9c710d24ec1 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -307,7 +307,6 @@ const translations: TranslationDeepObject = { change: 'Modifica', category: 'Categoria', vendor: 'Fornitore', - supplier: 'Fornitore', report: 'Report', billable: 'Fatturabile', nonBillable: 'Non fatturabile', @@ -531,6 +530,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'Impossibile caricare il grafico. Aggiorna la pagina e riprova.', apiKey: 'Chiave API', exportsTo: 'Esporta in', + shareReport: 'Condividi report', + reportShared: 'Report condiviso', }, socials: { podcast: 'Seguici su Podcast', @@ -981,6 +982,7 @@ const translations: TranslationDeepObject = { addShippingAddress: {title: 'Ci serve il tuo indirizzo di spedizione', subtitle: 'Fornisci un indirizzo per ricevere la tua Carta Expensify.', cta: 'Aggiungi'}, addPaymentCard: {title: 'Aggiungi una carta di pagamento per continuare a usare Expensify', subtitle: 'Account > Abbonamento', cta: 'Aggiungi'}, addBankAccount: {title: 'Aggiungi un conto bancario per ricevere il rimborso'}, + addDepositAccount: {title: 'Aggiungi un conto bancario per ricevere pagamenti'}, activateCard: {title: 'Attiva la tua Carta Expensify', subtitle: 'Convalida la tua carta e inizia a spendere.', cta: 'Attiva'}, confirmDigitalWalletAddition: { subtitle: 'Carta Expensify', @@ -1027,6 +1029,7 @@ const translations: TranslationDeepObject = { cta: 'Gestisci', }, emailDeliveryFailure: {title: 'Non possiamo inviarti notifiche via email', subtitle: 'Account'}, + upgradeNetSuiteConnection: {title: 'Aggiorna la connessione a NetSuite'}, }, forYouSection: { submit: ({count}: {count: number}) => ({ @@ -1174,7 +1177,18 @@ const translations: TranslationDeepObject = { viewOnSpend: 'Visualizza in Spese', emptyState: {title: 'Niente da mostrare', subtitle: 'Prova a modificare i criteri qui sopra'}, noExpensesState: {title: 'Scopri dove va il tuo denaro', subtitle: 'Una volta che avrai delle spese, troverai tendenze di spesa, principali esercenti e altro ancora.'}, - compare: {label: 'Confronta', previousPeriod: 'Periodo precedente', average: 'Media'}, + compare: { + label: 'Confronta', + previousPeriod: 'Periodo precedente', + average: 'Media', + dayLabel: (day?: number) => (day === undefined ? 'Giorno' : `Giorno ${day}`), + weekLabel: (week?: number) => (week === undefined ? 'Settimana' : `Settimana ${week}`), + monthLabel: (month?: number) => (month === undefined ? 'Mese' : `Mese ${month}`), + quarterLabel: (quarter?: number) => (quarter === undefined ? 'Trimestre' : `Trimestre ${quarter}`), + yearLabel: (year?: number) => (year === undefined ? 'Anno' : `Anno ${year}`), + increase: (percentage: string) => `In aumento del ${percentage}`, + decrease: (percentage: string) => `In calo del ${percentage}`, + }, }, allSettingsScreen: { subscription: 'Abbonamento', @@ -2177,6 +2191,18 @@ const translations: TranslationDeepObject = { } return `In attesa che il pagamento venga completato${formattedETA}.`; }, + [CONST.NEXT_STEP.MESSAGE_KEY.WAITING_FOR_CANCELLED_REIMBURSEMENT]: ( + _actor: string, + _actorType: ValueOf, + eta?: string, + etaType?: ValueOf, + ) => { + let formattedETA = ''; + if (eta) { + formattedETA = etaType === CONST.NEXT_STEP.ETA_TYPE.DATE_TIME ? ` entro le ${eta}` : ` ${eta}`; + } + return `In attesa che il pagamento annullato completi l'elaborazione${formattedETA}.`; + }, [CONST.NEXT_STEP.MESSAGE_KEY.SUBMITTING_TO_SELF]: ( _actor: string, _actorType: ValueOf, @@ -2814,6 +2840,13 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'Questo conto richiede attenzione', unlock: 'Sblocca', }, + wiseGlobalReimbursementsIntro: { + title: 'Configurare i rimborsi globali?', + subtitle: 'Paga i membri in tutto il mondo nella loro valuta locale.', + getStarted: 'Inizia', + headerTitle: 'Rimborsi globali', + }, + wiseGlobalReimbursementsBanner: {title: 'Paga persone in tutto il mondo', subtitle: 'Abilita i rimborsi globali sul tuo conto in USD.'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Aggiungi carta a ${walletName}`, @@ -3934,6 +3967,11 @@ ${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}`, + until: (date: string) => `Fino al ${date}`, + removeDelegate: 'Rimuovi delega ferie', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -4024,6 +4062,10 @@ ${amount} per ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Richiesta già inviata', unlockAlreadyRequestedDescription: 'La tua richiesta di sblocco di questo conto bancario è già stata inviata. Concierge ti contatterà se servirà altro.', }, + listField: { + editItem: ({name}: {name: string}) => `Modifica ${name}`, + removeItem: ({name}: {name: string}) => `Rimuovi ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Esempio: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4035,7 +4077,13 @@ ${amount} per ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Formato non valido. Esempio: ${example}` : 'Formato non valido'), invalidOption: 'Scegli una delle opzioni disponibili', outOfRange: ({min, max}: {min: number; max: number}) => `Inserisci un valore tra ${min} e ${max}`, + tooFewItems: ({min}: {min: number}) => `Aggiungi almeno ${min}`, + tooManyItems: ({max}: {max: number}) => `Aggiungi al massimo ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `Aggiungi ${item}`, + removeItemTitle: ({item}: {item: string}) => `Rimuovere ${item}?`, + removeItemPrompt: ({name}: {name: string}) => `Vuoi davvero rimuovere ${name}?`, }, addPersonalBankAccount: { swiftBicFormatError: 'Lo SWIFT/BIC deve essere lungo 8 o 11 caratteri, con 6 lettere seguite da 2 oppure 5 lettere o numeri.', @@ -4076,6 +4124,7 @@ ${amount} per ${merchant} - ${date}`, formLabel: 'Visualizza PDF', }, attachmentNotFound: 'Allegato non trovato', + loadTimedOut: 'Questa immagine impiega troppo tempo per caricarsi.', retry: 'Riprova', }, messages: { @@ -5070,7 +5119,10 @@ ${amount} per ${merchant} - ${date}`, }, requestEmptyList: { title: 'Ancuna nessuna tariffa di diaria', - subtitle: 'Questo spazio di lavoro non ha nessuna diaria, chiedi al tuo amministratore di aggiungerne.', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} non ha nessuna tariffa di diaria. Chiedi a un amministratore di aggiungerne.` + : 'Questo spazio di lavoro non ha nessuna tariffa di diaria. Chiedi a un amministratore di aggiungerne.', adminSubtitle: 'Aggiungi le tariffe giornaliere per organizzare le tue spese.', }, importPerDiemRates: 'Importa tariffe di diaria', @@ -5078,6 +5130,7 @@ ${amount} per ${merchant} - ${date}`, editPerDiemRates: 'Modifica le tariffe di diaria', editDestinationSubtitle: (destination: string) => `L’aggiornamento di questa destinazione la modificherà per tutte le sottotariffe di diaria ${destination}.`, editCurrencySubtitle: (destination: string) => `L’aggiornamento di questa valuta la modificherà per tutte le sottotariffe di diaria ${destination}.`, + missingPerDiemUnit: 'Il per diem non è configurato correttamente in questo workspace. Contatta Concierge per ricevere aiuto.', }, qbd: { exportOutOfPocketExpensesDescription: 'Imposta come esportare le spese anticipate in QuickBooks Desktop.', @@ -5406,8 +5459,8 @@ ${amount} per ${merchant} - ${date}`, }, noAccountsFound: 'Nessun account trovato', noAccountsFoundDescription: 'Aggiungi l’account in Xero e sincronizza nuovamente la connessione', - noSuppliersFound: 'Nessun fornitore trovato', - noSuppliersFoundDescription: 'Aggiungi il fornitore in Xero e sincronizza di nuovo la connessione.', + noVendorsFound: 'Nessun fornitore trovato', + noVendorsFoundDescription: 'Aggiungi fornitori in Xero e sincronizza di nuovo la connessione', accountingMethods: { label: 'Quando esportare', description: 'Scegli quando esportare le spese:', @@ -6604,6 +6657,8 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. distanceRates: { title: 'Tariffe chilometriche', subtitle: 'Aggiungi, aggiorna e applica le tariffe.', + commuterExclusionsOnTitle: 'Le esclusioni del tragitto casa-lavoro sono attive', + disableLockedByCommuterExclusionsPrompt: 'Per disattivare le tariffe distanza, prima imposta Escludi spostamenti casa-lavoro su “Non escludere spostamenti casa-lavoro”.', }, perDiem: { title: 'Diaria', @@ -6967,6 +7022,11 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. emptySubtitle: 'I fornitori verranno visualizzati qui al termine della sincronizzazione contabile.', findVendor: 'Trova fornitore', managedInAccountingSoftware: 'I fornitori sono gestiti nel tuo', + enableVendor: 'Abilita fornitore', + enableVendors: 'Abilita fornitori', + disableVendor: 'Disabilita fornitore', + disableVendors: 'Disattiva fornitori', + updateFailureMessage: "Si è verificato un errore durante l'aggiornamento del fornitore, riprova per favore", }, tags: { tagName: 'Nome tag', @@ -7010,6 +7070,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. tagRules: 'Regole dei tag', approverDescription: 'Approvante', importTags: 'Importa tag', + appendCustomTagList: 'Aggiungi elenco di tag personalizzato', importTagsSupportingText: 'Codifica le tue spese con un solo tipo di tag o con molti.', configureMultiLevelTags: 'Configura il tuo elenco di tag per la codifica multilivello.', importMultiLevelTagsSupportingText: `Ecco un’anteprima dei tuoi tag. Se ti sembra tutto corretto, fai clic qui sotto per importarli.`, @@ -7275,6 +7336,11 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, 'Il membro lavora da casa o si sposta tra diverse sedi senza un ufficio fisso, quindi le regole relative al tragitto casa-lavoro non si applicano.', futureOnlyNote: 'Le modifiche si applicano solo ai calcoli chilometrici futuri. Le spese chilometriche esistenti non vengono ricalcolate.', }, + officeLocation: 'Sede ufficio', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Cambia sede dell’ufficio', + changeWorkArrangement: 'Modifica modalità di lavoro', + changeRole: 'Cambia ruolo', }, card: { getStartedIssuing: 'Inizia emettendo la tua prima carta virtuale o fisica.', @@ -7387,6 +7453,12 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Si è verificato un errore con una connessione configurata in Expensify Classic. [Vai a Expensify Classic per risolvere questo problema.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Vai a Expensify Classic per gestire le tue impostazioni.', + exactOnline: { + comingSoon: 'Presto in arrivo', + joinWaitlist: 'Unisciti alla lista d’attesa', + onWaitlist: 'Sei in lista d’attesa', + waitlistDescription: 'Concierge ti invierà un messaggio per farti sapere quando questa integrazione sarà pronta e come collegarla.', + }, setup: 'Connetti', connectToSandbox: 'Connetti alla sandbox', lastSync: (relativeDate: string) => `Ultima sincronizzazione ${relativeDate}`, @@ -7669,6 +7741,9 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, businessCentral: 'Dynamics 365 Business Central', qboConnectionExpiring: ({date}: {date: string}) => `La tua connessione a QuickBooks Online scade il ${date}.`, qboConnectionExpired: ({date}: {date: string}) => `La tua connessione a QuickBooks Online è scaduta il ${date}.`, + netSuiteOAuthMigrationRequired: 'Aggiorna la tua connessione NetSuite a OAuth 2.0 entro febbraio 2027.', + netSuiteOAuthMigrationRequiredUrgent: 'La tua connessione NetSuite richiede un aggiornamento immediato a OAuth 2.0.', + netSuiteOAuthUpgradeNow: 'Aggiorna ora', }, export: { notReadyHeading: 'Non pronto per l’esportazione', @@ -7845,6 +7920,21 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, addressContext: 'Per abilitare Expensify Travel è necessario un indirizzo dell’area di lavoro. Inserisci un indirizzo associato alla tua azienda.', policy: 'Politica di spesa', }, + officeLocations: { + title: 'Sedi degli uffici', + subtitle: 'Aggiungi sedi dell’ufficio da assegnare ai membri per attività come il monitoraggio delle distanze.', + addOfficeLocation: 'Aggiungi sede dell’ufficio', + editOfficeLocation: 'Modifica sede ufficio', + locationName: 'Nome località', + locationNameHint: 'Scegli un nome abbreviato per la sede se stai aggiungendo più sedi di ufficio', + defaultName: ({officeNumber}: {officeNumber: number}) => `Sede ${officeNumber}`, + setAsPrimary: 'Imposta come principale', + primary: 'Principale', + deleteOfficeLocation: 'Eliminare la sede?', + deleteOfficeLocationConfirmation: 'Vuoi davvero eliminare questa sede? I membri verranno riassegnati alla sede principale.', + existingOfficeLocationError: 'Esiste già una sede con questo nome', + setCompanyAddressAsPrimaryConfirmation: 'Impostare l’indirizzo dell’azienda come sede principale?', + }, bankAccount: { continueWithSetup: 'Continua configurazione', youAreAlmostDone: 'Hai quasi finito di configurare il tuo conto bancario, che ti permetterà di emettere carte aziendali, rimborsare spese, incassare fatture e pagare bollette.', @@ -8380,6 +8470,8 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `Aggiungi ${featureName} per sbloccare questa funzione.`, enableFeatureSubtitle: (featureName: string, moreFeaturesLink?: string) => `Vai su [altre funzionalità](${moreFeaturesLink}) e abilita ${featureName} per sbloccare questa funzione.`, + preventPayoutNonReimbursableReportsTitle: 'Impedisci di contrassegnare come pagati i report non rimborsabili', + preventPayoutNonReimbursableReportsSubtitle: 'Non consentire agli amministratori di contrassegnare come pagati i report non rimborsabili.', }, agentsPromoBanner: { title: 'Non vedi la regola che ti serve? Aggiungi un agente', @@ -8434,7 +8526,6 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo ifAnyExpenseMatches: 'Se una qualsiasi spesa corrisponde a:', thenApplyFollowingDefaults: 'Quindi applica le seguenti impostazioni predefinite:', vendorUnavailable: 'Fornitore non disponibile', - supplierUnavailable: 'Fornitore non disponibile', confirmErrorCategory: 'Seleziona una categoria', confirmErrorCategoryTax: "Seleziona un'aliquota fiscale", confirmErrorCondition: 'Inserisci un esercente', @@ -8673,6 +8764,9 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, describeRuleForConcierge: 'Descrivi la tua regola e Concierge la creerà', gotIt: 'Capito', createRule: 'Crea regola', + fallbackBanner: + 'Questa non è una regola standard, quindi l’abbiamo creata come regola agente. Le regole agente agiscono dopo che una spesa o un report vengono modificati, ad esempio bloccando la spesa o rifiutando il report.', + fallbackBannerWithExplanation: (explanation: string) => `Questa non è una regola standard, quindi l’abbiamo formulata come regola dell’agente. ${explanation}`, }, tabs: { general: 'Generale', @@ -8735,16 +8829,21 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, newRule: { title: 'Nuova regola', subtitle: 'Che cosa vuoi fare?', + startWithTemplate: 'Inizia con un modello', restrictCardSpend: 'Limita spesa carta', restrictCardSpendDescription: 'Blocca o limita la spesa al punto vendita', applyExpenseDefaults: 'Applica impostazioni predefinite spese', - applyExpenseDefaultsDescription: 'Aggiorna i campi senza che chi invia debba fare nulla', + applyExpenseDefaultsDescription: 'Aggiorna i campi automaticamente', flagForReview: 'Contrassegna per revisione', - flagForReviewDescription: 'Avvisa gli approvatori quando le spese superano i limiti di categoria', + flagForReviewDescription: 'Avvisa quando le tue condizioni sono soddisfatte', requireFields: 'Imposta i requisiti del campo', - requireFieldsDescription: 'Richiedi campi specifici per le spese oppure rinuncia a renderli obbligatori.', + requireFieldsShort: 'Richiedi campi', + requireFieldsDescription: 'Ricevute, categorie, ecc.', createAgentRule: 'Descrivi regola agente', createAgentRuleDescription: 'Crea regole flessibili che vengono eseguite quando ne hai bisogno.', + describeItBelow: 'Oppure descrivila qui sotto', + describeRulePlaceholder: 'Blocca tutte le spese superiori a 2.000 $', + selectTemplateOrDescribe: 'Seleziona un modello o descrivi una regola per continuare', describe: 'Descrivi', describeRule: 'Descrivi la tua regola con parole semplici e Concierge la creerà per te', describeRuleInputLabel: 'Descrivi la regola', @@ -8752,6 +8851,15 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, unsupported: 'Questa regola non è ancora supportata: ci stiamo lavorando. Per ora prova una regola diversa.', unintelligible: 'Prova a formulare la regola così: se le spese superano i 100 $, richiedi una ricevuta.', multipleRules: 'Una regola alla volta, per favore: prova a descrivere una singola regola, ad esempio bloccare gli acquisti al dettaglio su tutte le carte.', + categoryHasRule: "Questa categoria ha già una regola. Modifica la regola esistente o descrivi una regola per un'altra categoria.", + }, + impossible: { + noTrigger: 'Le regole non possono iniziare da quell’evento.', + noCapability: 'Le regole non possono farlo.', + missingData: 'Expensify non ha queste informazioni.', + retroactive: 'Le regole si applicano solo alla nuova attività.', + historyLimit: 'Le regole non possono risalire così indietro.', + tryThisInstead: 'Prova questo invece', }, }, expenseDefaultsTable: { @@ -8940,7 +9048,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, dontSeeYourATS: `Non vedi il tuo ATS qui? Chiedi a Concierge e possiamo aggiungerlo.`, importSettings: 'Impostazioni di importazione', defaultApprover: 'Approvatore predefinito', - approverFields: {recruiter: 'Recruiter', recruitingCoordinator: 'Coordinatore recruiting'}, + approverFields: {recruiter: 'Recruiter', recruitingCoordinator: 'Coordinatore recruiting', hiringManager: 'Responsabile delle assunzioni'}, filters: { description: (providerName: string) => `Seleziona quali membri importare da ${providerName}. Puoi scegliere in base alle fasi lavorative, ai tag e alle sedi.`, stages: { @@ -8961,7 +9069,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, toggleTitle: 'Uffici', allSelected: 'Tutti gli uffici', }, - enableJobStagesOrTags: 'Abilita le fasi di lavoro o i tag per continuare', + enableJobStagesOrTags: 'Seleziona almeno una fase del lavoro o un tag per continuare', }, subtitle: 'Collega gli strumenti di recruiting e mantieni sincronizzate le approvazioni dei candidati.', syncResults: { @@ -8974,8 +9082,12 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, finalApprover: `Approvatore finale`, finalApproverOptional: 'Approvatore finale (facoltativo)', approvalModeDescription: (providerName: string) => `Imposta il responsabile delle approvazioni per i nuovi membri importati da ${providerName} in Expensify.`, - approverFieldDescription: (providerName: string) => - `Scegli il primo approvatore per i tuoi candidati: il loro Recruiter oppure il loro Coordinator assegnato in ${providerName}.`, + approverFieldDescription: (providerName: string, approverFields: string[]) => { + const fieldNames = approverFields.map((approverField) => approverField.toLowerCase()); + const lastFieldName = fieldNames.at(-1) ?? ''; + const fieldList = fieldNames.length > 2 ? `il loro ${fieldNames.slice(0, -1).join(', ')} o ${lastFieldName}` : `o il loro ${fieldNames.join('o il loro')}`; + return `Scegli il primo approvatore per i tuoi candidati: ${fieldList} assegnato in ${providerName}.`; + }, approvalModeDescriptions: { basic: 'Scegli un solo approvatore', advanced: `Il recruiter o il coordinatore del candidato diventa il suo approvatore di spese`, @@ -9116,6 +9228,9 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, }, }, + zohoBooks: { + organization: 'Organizzazione Zoho Books', + }, businessCentral: { businessCentralSetup: 'Configurazione Dynamics 365 Business Central', prerequisitesTitle: 'Prima di connetterti...', @@ -9176,6 +9291,47 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, noBankAccountsFoundDescription: 'Aggiungi conti bancari in Dynamics 365 Business Central e sincronizza di nuovo la connessione', noPaymentMethodsFound: 'Nessun metodo di pagamento trovato', noPaymentMethodsFoundDescription: 'Aggiungi metodi di pagamento in Dynamics 365 Business Central e sincronizza di nuovo la connessione', + advanced: { + autoSyncDescription: 'Sincronizza Dynamics 365 Business Central ed Expensify automaticamente, ogni giorno. I report si sincronizzano in tempo reale.', + accountingMethods: { + label: 'Metodo di esportazione', + description: 'Scegli quando esportare le spese.', + values: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Competenza', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Contanti', + }, + alternateText: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Le spese anticipate verranno esportate al momento dell’approvazione finale', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Le spese anticipate verranno esportate al momento del pagamento', + }, + }, + postingMode: { + label: 'Modalità di registrazione', + description: 'Scegli se le registrazioni vengono contabilizzate automaticamente o richiedono prima una revisione manuale.', + note: 'Nota: Expensify registra ogni giornale aperto nel batch di giornale generale che hai scelto nella pagina Expensify Setup di Business Central, non solo quelli che ha creato. Usa un batch dedicato solo a Expensify.', + values: { + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_AND_POST]: { + label: 'Crea e registra', + description: 'Registra automaticamente le scritture nella contabilità generale al momento dell’esportazione', + }, + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_ONLY]: { + label: 'Solo crea', + description: 'Registra manualmente le scritture in Dynamics 365 Business Central dopo l’esportazione', + }, + }, + }, + autoCreateEntities: 'Crea automaticamente dipendenti/fornitori', + exportToNextOpenPeriod: 'Esporta nel prossimo periodo aperto', + exportToNextOpenPeriodDescription: 'Quando un report ha una data in un periodo chiuso di Dynamics 365 Business Central, viene esportato nel periodo aperto successivo.', + syncReimbursedReports: 'Sincronizza i report rimborsati', + syncReimbursedReportsDescription: 'Quando un report viene pagato tramite ACH, in questo conto verrà generata una riga di prima nota generale.', + reimbursementBankAccount: {label: 'Conto bancario di pagamento', description: 'Scegli da dove pagare le fatture e creeremo il pagamento in Dynamics 365 Business Central.'}, + syncExpensifyCardSettlements: 'Sincronizza le liquidazioni della Carta Expensify', + settlementsBankAccount: { + label: 'Conto di regolamento Carta Expensify', + description: 'Scegli il tuo conto di regolamento e creeremo il pagamento in Dynamics 365 Business Central.', + }, + }, accountsDescription: 'I tuoi conti Dynamics 365 Business Central verranno importati come categorie.', dimensionsImportAsTags: 'Tutte le dimensioni di Dynamics 365 Business Central vengono importate come tag', customers: 'Clienti', @@ -9664,6 +9820,20 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `ha aggiunto "${prohibitedExpense}" alle spese vietate`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `ha rimosso "${prohibitedExpense}" dalle spese vietate`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `ha aggiunto la sede "${name}" (${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `ha rinominato la sede "${oldName}" in "${newName}"`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `ha modificato l’indirizzo della sede "${name}" in "${newAddress}" (in precedenza "${oldAddress}")`, + setAsPrimary: ({name}: {name: string}) => `imposta la sede "${name}" come principale`, + setCompanyAddressAsPrimary: 'imposta l’indirizzo aziendale come principale', + removed: ({name}: {name: string}) => `ha rimosso la sede "${name}"`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `ha cambiato la sede di ${displayName} in ${officeLocation} (in precedenza ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `"${name}"`, + companyAddress: 'l’indirizzo dell’azienda', + primaryOffice: 'l’ufficio principale', + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `imposta la modalità di lavoro predefinita su ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -9801,6 +9971,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, addCustomUnitRateWithAmountAndEndDate: (rateName: string, rateValue: string, endDate: string) => `ha aggiunto la tariffa "${rateName}" di ${rateValue}, valida fino al ${endDate}`, addCustomUnitRateWithAmountAndDates: (rateName: string, rateValue: string, startDate: string, endDate: string) => `ha aggiunto la tariffa "${rateName}" di ${rateValue}, valida dal ${startDate} al ${endDate}`, + addCustomUnitRateTaxReclaimableSuffix: (taxReclaimableAmount: string, taxRateName: string) => `, con ${taxReclaimableAmount} di imposta recuperabile in "${taxRateName}"`, updatedCustomUnitRateDateRange: (rateName: string, newDateRange: string, oldDateRange: string) => `ha aggiornato la tariffa chilometrica "${rateName}" per applicarla a ${newDateRange} (in precedenza ${oldDateRange})`, customUnitRateDateRangeStartToEnd: (startDate: string, endDate: string) => `${startDate} - ${endDate}`, @@ -9893,6 +10064,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, `ha modificato l'accordo di lavoro di ${displayName} in ${newArrangement} (in precedenza ${oldArrangement})`, updatedDefaultWorkArrangement: ({newArrangement, oldArrangement}: {newArrangement: string; oldArrangement: string}) => `ha modificato la modalità di lavoro predefinita in ${newArrangement} (precedentemente ${oldArrangement})`, + preventPayoutNonReimbursableReports: (oldValue: string, newValue: string) => + `aggiornato "Impedisci di contrassegnare come pagati i report non rimborsabili" in "${newValue === 'true' ? 'Abilitato' : 'Disabilitato'}" (precedentemente "${oldValue === 'true' ? 'Abilitato' : 'Disabilitato'}")`, }, roomMembersPage: { memberNotFound: 'Membro non trovato.', @@ -9947,6 +10120,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, fallbackTitle: 'Ticket di supporto', resolved: 'Questo ticket di assistenza è risolto.', surveyPrompt: 'Com’è stata la tua esperienza con l’assistenza?', + great: 'Ottima', + poor: 'Scarsa', reopenTicket: 'Riapri ticket', }, statementPage: { @@ -10091,6 +10266,12 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, customDay: 'Giorno personalizzato', }, + describeSearch: { + title: 'Descrivi la tua ricerca', + description: 'Descrivi in inglese semplice quali spese vuoi vedere e applicheremo automaticamente i filtri giusti.', + inputLabel: 'Descrivi la tua ricerca', + buttonText: 'Applica', + }, status: 'Stato', keyword: 'Parola chiave', keywords: 'Parole chiave', @@ -10126,6 +10307,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, past: 'Passato', submitted: 'Inviato', approved: 'Approvato', + anyApproval: 'Qualsiasi approvazione', firstApprover: 'Primo approvatore', firstApproved: 'Approvato per primo', paidBy: 'Pagato da', @@ -10134,6 +10316,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, posted: 'Pubblicato', withdrawn: 'Ritirata', billable: 'Fatturabile', + rejected: 'Rifiutato', + held: 'In sospeso', reimbursable: 'Rimborsabile', purchaseCurrency: 'Valuta di acquisto', sortOrder: { @@ -10168,6 +10352,11 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Esporta', }, filterType: {label: 'Tipo di filtro', has: {positive: 'ha', negative: 'non ha'}, is: {positive: 'è', negative: 'non è'}}, + merchant: { + matchType: 'Tipo di corrispondenza', + contains: (merchant?: string) => (merchant ? `Contiene "${merchant}"` : 'Contiene'), + equalTo: (merchant?: string) => (merchant ? `Uguale a "${merchant}"` : 'Uguale a'), + }, created: 'Creato', createdDate: 'Data di creazione', transactionStatus: { @@ -10347,6 +10536,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, type: { changeField: (oldValue: string | undefined, newValue: string, fieldName: string) => `ha modificato ${fieldName} in "${newValue}" (in precedenza "${oldValue}")`, changeFieldEmpty: (newValue: string, fieldName: string) => `imposta ${fieldName} su "${newValue}"`, + changeFieldRemoved: (oldValue: string, fieldName: string) => `ha rimosso ${fieldName} (in precedenza "${oldValue}")`, + changeFieldCleared: (fieldName: string) => `ha rimosso ${fieldName}`, changeReportPolicy: (toPolicyName: string, fromPolicyName?: string) => { if (!toPolicyName) { return `ha modificato il workspace${fromPolicyName ? `(precedentemente ${fromPolicyName})` : ''}`; @@ -10831,7 +11022,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, duplicatedTransaction: 'Duplice potenziale', fieldRequired: 'I campi del report sono obbligatori', futureDate: 'Data futura non consentita', - inactiveVendor: (isSupplier = false) => (isSupplier ? 'Fornitore non più valido' : 'Fornitore non più valido'), + inactiveVendor: 'Fornitore non più valido', invoiceMarkup: (invoiceMarkup: number) => `Maggiorato del ${invoiceMarkup}%`, maxAge: (maxAge: number) => `Data precedente a ${maxAge} giorni`, missingCategory: 'Categoria mancante', @@ -11149,6 +11340,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, systemMessage: { mergedWithCashTransaction: 'ha associato una ricevuta a questa transazione', + mergedExpenseDeleted: 'ha unito delle spese e ne ha spostata una in Eliminate', }, subscription: { authenticatePaymentCard: 'Autentica carta di pagamento', @@ -11388,6 +11580,9 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, refundedOn: ({date}: {date: string}) => `Rimborsato il ${date}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `Rimborsati ${amount} il ${date}`, }, + billedTo: 'Fatturato a', + viewReceipt: 'Visualizza ricevuta', + viewInvoice: 'Visualizza fattura', }, subscriptionSettings: { title: 'Impostazioni abbonamento', @@ -11948,22 +12143,6 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, addPaymentCardButton: 'Aggiungi carta di pagamento', }, monthPickerPage: {month: 'Mese', selectMonth: 'Seleziona un mese'}, - aiFeaturesPromoModal: { - subtitle: 'Nuovo in Concierge AI', - confirmText: 'Andiamo!', - spendAnalysis: { - title: 'Analisi interattiva delle spese', - description: `Concierge mette in evidenza approfondimenti sulla spesa mensile e ti permette di approfondire i dettagli dietro ogni numero. Scopri di più.`, - }, - expenseAssistant: { - title: 'Incontra il tuo nuovo assistente per le spese', - description: `Chatta con Concierge per creare e aggiornare le spese direttamente nell’app o via email o SMS. Scopri di più.`, - }, - customAgents: { - title: 'Crea i tuoi agenti', - description: `Crea agenti personalizzati per verificare, approvare e instradare le spese in base alle regole che imposti. Scopri di più.`, - }, - }, emailIssuePage: { title: 'Problema email', intro: (login: string) => `Il nostro provider di posta elettronica ha sospeso l’invio a ${login} a causa di problemi di consegna. Per risolvere questo problema:`, @@ -12004,5 +12183,6 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, mobileRenewPrompt: 'Visita Expensify nel browser web per rinnovare in anticipo.', mobileClaimPrompt: 'Visita Expensify nel tuo browser web per richiedere lo sconto di rinnovo.', }, + bulkActionBar: {label: 'Azioni collettive', clearSelection: 'Cancella selezione', loadingSelection: 'Caricamento selezione'}, }; export default translations; diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d4b6395a03d0..fa9d90a89fd1 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -307,7 +307,6 @@ const translations: TranslationDeepObject = { change: '変更', category: 'カテゴリ', vendor: 'ベンダー', - supplier: 'サプライヤー', report: 'レポート', billable: '請求可能', nonBillable: '請求不可', @@ -530,6 +529,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'グラフを読み込めませんでした。ページを再読み込みして、もう一度お試しください。', apiKey: 'API キー', exportsTo: 'エクスポート先', + shareReport: 'レポートを共有', + reportShared: 'レポートを共有しました', }, socials: { podcast: 'ポッドキャストでフォロー', @@ -972,6 +973,7 @@ const translations: TranslationDeepObject = { addShippingAddress: {title: '配送先住所が必要です', subtitle: 'Expensify カードを受け取る住所を入力してください。', cta: '追加'}, addPaymentCard: {title: 'Expensify を引き続きご利用いただくには、支払いカードを追加してください', subtitle: 'アカウント > サブスクリプション', cta: '追加'}, addBankAccount: {title: '銀行口座を追加して払い戻しを受け取りましょう'}, + addDepositAccount: {title: '銀行口座を追加して支払いを受け取りましょう'}, activateCard: {title: 'Expensify カードを有効化する', subtitle: 'カードを認証して支出を始めましょう。', cta: '有効化'}, confirmDigitalWalletAddition: { subtitle: 'Expensify カード', @@ -1015,6 +1017,7 @@ const translations: TranslationDeepObject = { cta: '管理', }, emailDeliveryFailure: {title: 'メール通知をお送りできません', subtitle: 'アカウント'}, + upgradeNetSuiteConnection: {title: 'NetSuite 接続をアップグレードする'}, }, forYouSection: { submit: ({count}: {count: number}) => ({ @@ -1162,7 +1165,18 @@ const translations: TranslationDeepObject = { viewOnSpend: '支出で表示', emptyState: {title: '表示するものはありません', subtitle: '上の条件を調整してみてください'}, noExpensesState: {title: 'お金の使い道を確認する', subtitle: '経費が登録されると、支出の傾向や上位の加盟店など、さまざまな情報を確認できるようになります。'}, - compare: {label: '比較', previousPeriod: '前の期間', average: '平均'}, + compare: { + label: '比較', + previousPeriod: '前の期間', + average: '平均', + dayLabel: (day?: number) => (day === undefined ? '日' : `${day}日目`), + weekLabel: (week?: number) => (week === undefined ? '週' : `${week}週目`), + monthLabel: (month?: number) => (month === undefined ? '月' : `${month}か月目`), + quarterLabel: (quarter?: number) => (quarter === undefined ? '四半期' : `第${quarter}四半期`), + yearLabel: (year?: number) => (year === undefined ? '年' : `${year}年目`), + increase: (percentage: string) => `${percentage} 増加`, + decrease: (percentage: string) => `${percentage} 減少`, + }, }, allSettingsScreen: { subscription: 'サブスクリプション', @@ -2159,6 +2173,18 @@ const translations: TranslationDeepObject = { } return `支払いの完了を待機しています${formattedETA}。`; }, + [CONST.NEXT_STEP.MESSAGE_KEY.WAITING_FOR_CANCELLED_REIMBURSEMENT]: ( + _actor: string, + _actorType: ValueOf, + eta?: string, + etaType?: ValueOf, + ) => { + let formattedETA = ''; + if (eta) { + formattedETA = etaType === CONST.NEXT_STEP.ETA_TYPE.DATE_TIME ? `${eta}までに` : ` ${eta}`; + } + return `キャンセルされた支払いの処理完了を待機しています${formattedETA}。`; + }, [CONST.NEXT_STEP.MESSAGE_KEY.SUBMITTING_TO_SELF]: ( _actor: string, _actorType: ValueOf, @@ -2793,6 +2819,13 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'このアカウントには対応が必要です', unlock: 'ロック解除', }, + wiseGlobalReimbursementsIntro: { + title: 'グローバル払い戻しを設定しますか?', + subtitle: '世界中のメンバーに現地通貨で支払えます。', + getStarted: '使い始める', + headerTitle: 'グローバル払い戻し', + }, + wiseGlobalReimbursementsBanner: {title: '世界中の人に支払いましょう', subtitle: 'USD アカウントでグローバル払い戻しを有効にします。'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `${walletName} にカードを追加`, @@ -3903,6 +3936,11 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' `${delegate}は以下のワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。あなたが管理者になっているワークスペースには今すぐ招待しますか?`, youAreAMemberOf: 'あなたはこれらのワークスペースのメンバーです:', youAreAnAdminOf: 'あなたはこれらのワークスペースの管理者です:', + chooseDelegate: '代理人を選択', + clearAfterRecommended: 'クリア日(推奨)', + willClearOn: (date: string) => `休暇代理人は${date}に解除されます`, + until: (date: string) => `${date}まで`, + removeDelegate: '休暇代理人を削除', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -3992,6 +4030,10 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' unlockAlreadyRequestedTitle: 'リクエストは既に送信されています', unlockAlreadyRequestedDescription: 'この銀行口座のロック解除リクエストは既に送信されています。追加で必要なことがある場合は、Concierge からご連絡します。', }, + listField: { + editItem: ({name}: {name: string}) => `${name}を編集`, + removeItem: ({name}: {name: string}) => `${name}を削除`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `例:${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4003,7 +4045,13 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' invalidFormat: ({example}: {example?: string}) => (example ? `形式が正しくありません。例:${example}` : '形式が正しくありません'), invalidOption: '利用可能な選択肢から選んでください', outOfRange: ({min, max}: {min: number; max: number}) => `${min}から${max}の間の値を入力してください`, + tooFewItems: ({min}: {min: number}) => `${min}件以上追加してください`, + tooManyItems: ({max}: {max: number}) => `${max}件以内で追加してください`, + itemError: ({item, error}: {item: string; error: string}) => `${item}:${error}`, }, + addItem: ({item}: {item: string}) => `${item}を追加`, + removeItemTitle: ({item}: {item: string}) => `${item}を削除しますか?`, + removeItemPrompt: ({name}: {name: string}) => `本当に${name}を削除しますか?`, }, addPersonalBankAccount: { swiftBicFormatError: 'SWIFT/BIC は 8 文字または 11 文字で、最初の 6 文字はアルファベット、続く 2 文字または 5 文字はアルファベットまたは数字である必要があります。', @@ -4044,6 +4092,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' formLabel: 'PDFを表示', }, attachmentNotFound: '添付ファイルが見つかりません', + loadTimedOut: 'この画像の読み込みに時間がかかりすぎています。', retry: '再試行', }, messages: { @@ -5033,7 +5082,10 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' }, requestEmptyList: { title: '日当レートがまだありません', - subtitle: 'このワークスペースには日当レートがありません。管理者に追加してもらってください。', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} には日当レートがありません。管理者に追加してもらってください。` + : 'このワークスペースには日当レートがありません。管理者に追加してもらってください。', adminSubtitle: '日当額を追加して、支出を整理しましょう。', }, importPerDiemRates: '日当レートをインポート', @@ -5041,6 +5093,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' editPerDiemRates: '日当レートを編集', editDestinationSubtitle: (destination: string) => `この行き先を更新すると、すべての ${destination} 日当サブレートに適用されます。`, editCurrencySubtitle: (destination: string) => `この通貨を更新すると、すべての${destination}の日当サブレートの通貨が変更されます。`, + missingPerDiemUnit: 'このワークスペースでは日当が正しく設定されていません。ヘルプが必要な場合は Concierge までお問い合わせください。', }, qbd: { exportOutOfPocketExpensesDescription: '自己負担経費を QuickBooks Desktop にエクスポートする方法を設定します。', @@ -5361,8 +5414,8 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' }, noAccountsFound: 'アカウントが見つかりません', noAccountsFoundDescription: 'Xero にアカウントを追加して、もう一度同期してください', - noSuppliersFound: '仕入先が見つかりません', - noSuppliersFoundDescription: 'Xero に仕入先を追加して、もう一度同期してください。', + noVendorsFound: '取引先が見つかりません', + noVendorsFoundDescription: 'Xero にベンダーを追加して、もう一度接続を同期してください', accountingMethods: { label: 'エクスポートのタイミング', description: '経費をエクスポートするタイミングを選択:', @@ -6528,6 +6581,8 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO distanceRates: { title: '距離単価', subtitle: 'レートを追加、更新し、適用します。', + commuterExclusionsOnTitle: '通勤除外が有効になっています', + disableLockedByCommuterExclusionsPrompt: '距離レートをオフにするには、まず「通勤を除外しない」に通勤の除外設定を変更してください。', }, perDiem: { title: '日当', @@ -6878,6 +6933,11 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO emptySubtitle: '会計システムとの同期が完了すると、ここに取引先が表示されます。', findVendor: '取引先を検索', managedInAccountingSoftware: '取引先は次の場所で管理されます:', + enableVendor: 'ベンダーを有効にする', + enableVendors: 'ベンダーを有効にする', + disableVendor: '取引先を無効にする', + disableVendors: '取引先を無効化', + updateFailureMessage: '仕入先の更新中にエラーが発生しました。もう一度お試しください。', }, tags: { tagName: 'タグ名', @@ -6921,6 +6981,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO tagRules: 'タグルール', approverDescription: '承認者', importTags: 'タグをインポート', + appendCustomTagList: 'カスタムタグリストを追加', importTagsSupportingText: '経費に 1 種類または複数のタグを付けて分類しましょう。', configureMultiLevelTags: '複数レベルのタグ付けのために、タグの一覧を設定します。', importMultiLevelTagsSupportingText: `こちらがタグのプレビューです。問題なければ、下をクリックしてインポートしてください。`, @@ -7184,6 +7245,11 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から optionNoRegularWorkspaceHelp: 'メンバーは在宅勤務、または決まったオフィスがなく拠点間を移動しているため、通勤規程は適用されません。', futureOnlyNote: '変更は今後の走行距離計算にのみ適用されます。既存の走行距離経費は再計算されません。', }, + officeLocation: 'オフィス所在地', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name}(${address})`, + changeOfficeLocation: 'オフィスの所在地を変更', + changeWorkArrangement: '勤務形態を変更', + changeRole: 'ロールを変更', }, card: { getStartedIssuing: 'まずは最初のバーチャルカードまたは物理カードを発行しましょう。', @@ -7296,6 +7362,12 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Expensify Classic で設定されている接続にエラーがあります。[この問題を解決するには Expensify Classic に移動してください。](${oldDotPolicyConnectionsURL})`, goToODToSettings: '設定を管理するには、Expensify Classic に移動してください。', + exactOnline: { + comingSoon: '近日公開予定', + joinWaitlist: 'ウェイトリストに参加', + onWaitlist: '順番待ちリストに登録されています', + waitlistDescription: 'この連携の準備が整い、接続方法が決まりましたら、Concierge からメッセージでお知らせします。', + }, setup: '接続', connectToSandbox: 'Sandbox に接続', lastSync: (relativeDate: string) => `最終同期:${relativeDate}`, @@ -7578,6 +7650,9 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から businessCentral: 'Dynamics 365 Business Central', qboConnectionExpiring: ({date}: {date: string}) => `QuickBooks Online との接続は ${date} に有効期限が切れます。`, qboConnectionExpired: ({date}: {date: string}) => `QuickBooks Online との接続は ${date} に期限切れになりました。`, + netSuiteOAuthMigrationRequired: '2027年2月までに、NetSuite 接続を OAuth 2.0 にアップグレードしてください。', + netSuiteOAuthMigrationRequiredUrgent: 'NetSuite との接続は、ただちに OAuth 2.0 へのアップグレードが必要です。', + netSuiteOAuthUpgradeNow: '今すぐアップグレード', }, export: { notReadyHeading: 'エクスポートの準備ができていません', @@ -7749,6 +7824,21 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から addressContext: 'Expensify Travel を有効にするには、ワークスペースの住所が必要です。お客様のビジネスに関連付けられた住所を入力してください。', policy: '経費ポリシー', }, + officeLocations: { + title: 'オフィス所在地', + subtitle: '距離の追跡などのためにメンバーに割り当てられるオフィス所在地を追加します。', + addOfficeLocation: 'オフィス所在地を追加', + editOfficeLocation: 'オフィス所在地を編集', + locationName: '場所名', + locationNameHint: '複数のオフィス所在地を追加する場合は、所在地のニックネームを選択してください', + defaultName: ({officeNumber}: {officeNumber: number}) => `オフィス所在地 ${officeNumber}`, + setAsPrimary: '優先に設定', + primary: 'メイン', + deleteOfficeLocation: 'オフィス所在地を削除しますか?', + deleteOfficeLocationConfirmation: 'このオフィス所在地を本当に削除しますか?メンバーはプライマリのオフィスに再割り当てされます。', + existingOfficeLocationError: 'この名前のオフィス所在地はすでに存在します', + setCompanyAddressAsPrimaryConfirmation: '会社住所を主なオフィス所在地として設定しますか?', + }, bankAccount: { continueWithSetup: 'セットアップを続行', youAreAlmostDone: '銀行口座の設定はほぼ完了です。これにより、コーポレートカードの発行、経費精算、請求書の回収、請求書の支払いが行えるようになります。', @@ -8274,6 +8364,8 @@ ${reportName}`, unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `この機能を利用するには、${featureName} を追加してください。`, enableFeatureSubtitle: (featureName: string, moreFeaturesLink?: string) => `[その他の機能](${moreFeaturesLink})に移動し、${featureName} を有効にしてこの機能を利用できるようにしてください。`, + preventPayoutNonReimbursableReportsTitle: '精算対象外のレポートを支払済みにできないようにする', + preventPayoutNonReimbursableReportsSubtitle: '管理者が非精算レポートを支払済みにマークできないようにします。', }, agentsPromoBanner: { title: '必要なルールが見つかりませんか?エージェントを追加してください', @@ -8327,7 +8419,6 @@ ${reportName}`, ifAnyExpenseMatches: 'いずれかの経費が次の条件に一致する場合:', thenApplyFollowingDefaults: '次に、以下のデフォルトを適用します。', vendorUnavailable: 'ベンダーを利用できません', - supplierUnavailable: 'サプライヤーを利用できません', confirmErrorCategory: 'カテゴリを選択してください', confirmErrorCategoryTax: '税率を選択してください', confirmErrorCondition: '店舗名を入力してください', @@ -8562,6 +8653,9 @@ ${reportName}`, describeRuleForConcierge: 'ルールの内容を入力すると、Concierge が自動作成します', gotIt: '了解しました', createRule: 'ルールを作成', + fallbackBanner: + 'これは標準ルールではないため、エージェントルールとして作成しました。エージェントルールは、経費やレポートに変更があった後に動作し、例えば経費を保留にしたり、レポートを却下したりします。', + fallbackBannerWithExplanation: (explanation: string) => `これは標準ルールではないため、エージェントルールとして作成しました。${explanation}`, }, tabs: { general: '一般', @@ -8624,16 +8718,21 @@ ${reportName}`, newRule: { title: '新しいルール', subtitle: '何をしたいですか?', + startWithTemplate: 'テンプレートから始める', restrictCardSpend: 'カード利用を制限', restrictCardSpendDescription: '販売時点で支出をブロックまたは制限する', applyExpenseDefaults: '経費のデフォルトを適用', - applyExpenseDefaultsDescription: '申請者が何も操作しなくてもフィールドを更新する', + applyExpenseDefaultsDescription: 'フィールドを自動で更新する', flagForReview: '確認のためにフラグを付ける', - flagForReviewDescription: '条件が満たされたときに通知します。', + flagForReviewDescription: '条件が満たされたときに通知する', requireFields: 'フィールドの必須条件を設定', - requireFieldsDescription: '特定の経費項目フィールドを必須にするか、必須条件を免除します。', + requireFieldsShort: '必須フィールド', + requireFieldsDescription: '領収書、カテゴリなど', createAgentRule: 'エージェントルールの説明', createAgentRuleDescription: '必要なときに実行される柔軟なルールを作成できます。', + describeItBelow: 'または、下で説明してください', + describeRulePlaceholder: '2,000ドルを超えるすべての支出をブロック', + selectTemplateOrDescribe: '続行するには、テンプレートを選択するかルールを説明してください', describe: '説明する', describeRule: 'ルールを自然な言葉で説明すると、Concierge がルールを作成します', describeRuleInputLabel: 'ルールを説明', @@ -8641,6 +8740,15 @@ ${reportName}`, unsupported: 'そのルールはまだ対応していません。現在対応を進めています。ほかのルールをお試しください。', unintelligible: '「経費が100ドルを超える場合は領収書を必須にする」のように書いてみてください。', multipleRules: '一度に1つのルールのみです。「すべてのカードで小売店での購入をブロックする」のように、1つのルールを説明してください。', + categoryHasRule: 'このカテゴリにはすでにルールがあります。既存のルールを編集するか、別のカテゴリのルールを説明してください。', + }, + impossible: { + noTrigger: 'そのイベントからルールを開始することはできません。', + noCapability: 'ルールではその操作はできません。', + missingData: 'Expensify はその情報を持っていません。', + retroactive: 'ルールは新しいアクティビティにのみ適用されます。', + historyLimit: 'ルールはそれほど前までさかのぼることができません。', + tryThisInstead: '代わりにこちらをお試しください', }, }, expenseDefaultsTable: { @@ -8826,7 +8934,7 @@ ${reportName}`, dontSeeYourATS: `ご利用のATSが見つかりませんか?Concierge にお問い合わせいただければ、追加できます。`, importSettings: 'インポート設定', defaultApprover: 'デフォルト承認者', - approverFields: {recruiter: '採用担当者', recruitingCoordinator: '採用コーディネーター'}, + approverFields: {recruiter: '採用担当者', recruitingCoordinator: '採用コーディネーター', hiringManager: '採用マネージャー'}, filters: { description: (providerName: string) => `${providerName} からインポートするメンバーを選択してください。採用ステージ、タグ、オフィスから選択できます。`, stages: { @@ -8842,7 +8950,7 @@ ${reportName}`, allSelected: 'すべてのタグ', }, offices: {title: 'オフィス', description: 'このワークスペースと同期したい候補者のオフィスを選択してください', toggleTitle: 'オフィス', allSelected: 'すべてのオフィス'}, - enableJobStagesOrTags: '続行するにはジョブステージまたはタグを有効にしてください', + enableJobStagesOrTags: '続行するには、少なくとも 1 つのジョブのステージまたはタグを選択してください', }, subtitle: '採用ツールを連携して、候補者の承認を常に同期させます。', syncResults: { @@ -8855,8 +8963,12 @@ ${reportName}`, finalApprover: `最終承認者`, finalApproverOptional: '最終承認者(任意)', approvalModeDescription: (providerName: string) => `${providerName} から Expensify にインポートされる新しいメンバーの承認者を設定します。`, - approverFieldDescription: (providerName: string) => - `候補者の最初の承認者を選択します。${providerName} で割り当てられているリクルーターまたはコーディネーターのいずれかを指定してください。`, + approverFieldDescription: (providerName: string, approverFields: string[]) => { + const fieldNames = approverFields.map((approverField) => approverField.toLowerCase()); + const lastFieldName = fieldNames.at(-1) ?? ''; + const fieldList = fieldNames.length > 2 ? `${fieldNames.slice(0, -1).join(', ')} または ${lastFieldName}` : `または ${fieldNames.join('またはその')} のいずれか`; + return `候補者の最初の承認者を選択してください:${providerName} で割り当てられている ${fieldList}`; + }, approvalModeDescriptions: { basic: '承認者を 1 名選択してください', advanced: `候補者のリクルーターまたはコーディネーターが、その候補者の経費承認者になります`, @@ -8991,6 +9103,9 @@ ${reportName}`, }, }, }, + zohoBooks: { + organization: 'Zoho Books 組織', + }, businessCentral: { businessCentralSetup: 'Dynamics 365 Business Central のセットアップ', prerequisitesTitle: '接続する前に…', @@ -9051,6 +9166,47 @@ ${reportName}`, noBankAccountsFoundDescription: 'Dynamics 365 Business Central で銀行口座を追加し、接続を再度同期してください', noPaymentMethodsFound: '支払方法が見つかりません', noPaymentMethodsFoundDescription: 'Dynamics 365 Business Central で支払方法を追加し、接続を再度同期してください', + advanced: { + autoSyncDescription: 'Dynamics 365 Business Central と Expensify を毎日自動で同期します。レポートはリアルタイムで同期されます。', + accountingMethods: { + label: 'エクスポート方法', + description: '経費をいつエクスポートするか選択します。', + values: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: '発生主義', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: '現金', + }, + alternateText: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: '立替経費は最終承認後にエクスポートされます', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: '立替経費は支払われるとエクスポートされます', + }, + }, + postingMode: { + label: '転記モード', + description: '仕訳を自動で転記するか、先に手動で確認するかを選択します。', + note: '注: Expensify は、Business Central の Expensify Setup ページで選択した一般仕訳帳バッチ内の未転記の仕訳をすべて転記します。Expensify が作成したものだけではありません。Expensify 専用のバッチを使用してください。', + values: { + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_AND_POST]: { + label: '作成して転記', + description: 'エクスポート時に仕訳を総勘定元帳へ自動で転記します', + }, + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_ONLY]: { + label: '作成のみ', + description: 'エクスポート後に Dynamics 365 Business Central で仕訳を手動で転記します', + }, + }, + }, + autoCreateEntities: '従業員/取引先を自動作成', + exportToNextOpenPeriod: '次の未締め期間にエクスポート', + exportToNextOpenPeriodDescription: 'レポートの日付が Dynamics 365 Business Central の締め済み期間にある場合、次の未締め期間にエクスポートします。', + syncReimbursedReports: '精算済みレポートを同期', + syncReimbursedReportsDescription: 'レポートが ACH で支払われると、この勘定科目に総勘定元帳の仕訳行が作成されます。', + reimbursementBankAccount: {label: '支払用銀行口座', description: '請求書の支払い元を選択すると、Dynamics 365 Business Central 内に支払いを作成します。'}, + syncExpensifyCardSettlements: 'Expensify カード清算を同期する', + settlementsBankAccount: { + label: 'Expensify カードの決済口座', + description: '精算に使う口座を選択してください。選択された口座で、Dynamics 365 Business Central に支払いを作成します。', + }, + }, accountsDescription: 'Dynamics 365 Business Central の勘定科目はカテゴリとしてインポートされます。', dimensionsImportAsTags: 'すべての Dynamics 365 Business Central のディメンションがタグとしてインポートされます', customers: '顧客', @@ -9528,6 +9684,20 @@ ${reportName}`, }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `禁止経費に「${prohibitedExpense}」を追加しました`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `禁止経費から「${prohibitedExpense}」を削除しました`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `オフィス所在地「${name}」(${address})を追加しました`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `オフィス所在地名を「${oldName}」から「${newName}」に変更しました`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `オフィス所在地「${name}」の住所を「${newAddress}」(以前は「${oldAddress}」)に変更しました`, + setAsPrimary: ({name}: {name: string}) => `オフィス所在地「${name}」を主勤務地に設定します`, + setCompanyAddressAsPrimary: '会社の住所を主要に設定', + removed: ({name}: {name: string}) => `オフィス所在地「${name}」を削除しました`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `${displayName}さんのオフィス所在地を${officeLocation}(以前は${previousOfficeLocation})に変更しました`, + quotedName: ({name}: {name: string}) => `「${name}」`, + companyAddress: '会社の住所', + primaryOffice: '主なオフィス', + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `デフォルトの勤務形態を${arrangement}に設定します`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -9660,6 +9830,7 @@ ${reportName}`, addCustomUnitRateWithAmountAndEndDate: (rateName: string, rateValue: string, endDate: string) => `「${rateName}」レート(${rateValue})を${endDate}まで有効として追加しました`, addCustomUnitRateWithAmountAndDates: (rateName: string, rateValue: string, startDate: string, endDate: string) => `「${rateName}」レート(${rateValue})を追加しました。有効期間:${startDate}〜${endDate}`, + addCustomUnitRateTaxReclaimableSuffix: (taxReclaimableAmount: string, taxRateName: string) => `。「${taxRateName}」で${taxReclaimableAmount}の税額を還付できます`, updatedCustomUnitRateDateRange: (rateName: string, newDateRange: string, oldDateRange: string) => `距離レート「${rateName}」を更新し、${newDateRange} に適用しました(以前は ${oldDateRange})`, customUnitRateDateRangeStartToEnd: (startDate: string, endDate: string) => `${startDate} - ${endDate}`, @@ -9750,6 +9921,8 @@ ${reportName}`, `${displayName}さんの勤務形態を${newArrangement}(以前は${oldArrangement})に変更しました`, updatedDefaultWorkArrangement: ({newArrangement, oldArrangement}: {newArrangement: string; oldArrangement: string}) => `デフォルトの勤務形態を${newArrangement}(以前は${oldArrangement})に変更しました`, + preventPayoutNonReimbursableReports: (oldValue: string, newValue: string) => + `「精算不要レポートを支払済みにしない」を「${newValue === 'true' ? '有効' : '無効'}」(以前は「${oldValue === 'true' ? '有効' : '無効'}」)に更新しました`, }, roomMembersPage: { memberNotFound: 'メンバーが見つかりません。', @@ -9804,6 +9977,8 @@ ${reportName}`, fallbackTitle: 'サポートチケット', resolved: 'このサポートチケットは解決済みです。', surveyPrompt: 'サポートの対応はいかがでしたか?', + great: '素晴らしい', + poor: '悪い', reopenTicket: 'チケットを再開', }, statementPage: { @@ -9948,6 +10123,12 @@ ${reportName}`, }, customDay: 'カスタム日', }, + describeSearch: { + title: '検索内容を入力してください', + description: '表示したい経費をわかりやすい英語で入力してください。適切なフィルターを自動的に適用します。', + inputLabel: '検索内容を入力してください', + buttonText: '適用', + }, status: 'ステータス', keyword: 'キーワード', keywords: 'キーワード', @@ -9983,6 +10164,7 @@ ${reportName}`, past: '過去', submitted: '送信済み', approved: '承認済み', + anyApproval: '任意の承認', firstApprover: '最初の承認者', firstApproved: '最初に承認済み', paidBy: '支払者', @@ -9991,6 +10173,8 @@ ${reportName}`, posted: '投稿日', withdrawn: '取下済み', billable: '請求可能', + rejected: '却下済み', + held: '保留中', reimbursable: '払い戻し対象', purchaseCurrency: '購入通貨', sortOrder: { @@ -10025,6 +10209,11 @@ ${reportName}`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'エクスポート', }, filterType: {label: 'フィルタータイプ', has: {positive: '持っています', negative: '持っていません'}, is: {positive: 'は', negative: 'ではありません'}}, + merchant: { + matchType: 'マッチタイプ', + contains: (merchant?: string) => (merchant ? `"${merchant}"を含む` : '含む'), + equalTo: (merchant?: string) => (merchant ? `"${merchant}"に等しい` : '等しい'), + }, created: '作成しました', createdDate: '作成日', transactionStatus: { @@ -10193,6 +10382,8 @@ ${reportName}`, type: { changeField: (oldValue: string | undefined, newValue: string, fieldName: string) => `${fieldName} を「${newValue}」(以前は「${oldValue}」)に変更しました`, changeFieldEmpty: (newValue: string, fieldName: string) => `${fieldName} を「${newValue}」に設定`, + changeFieldRemoved: (oldValue: string, fieldName: string) => `${fieldName} を削除しました(以前は「${oldValue}」)`, + changeFieldCleared: (fieldName: string) => `${fieldName} を削除しました`, changeReportPolicy: (toPolicyName: string, fromPolicyName?: string) => { if (!toPolicyName) { return `ワークスペース${fromPolicyName ? `(以前は${fromPolicyName})` : ''}を変更しました`; @@ -10671,7 +10862,7 @@ ${reportName}`, duplicatedTransaction: '重複の可能性', fieldRequired: 'レポートの項目は必須です', futureDate: '将来の日付は使用できません', - inactiveVendor: (isSupplier = false) => (isSupplier ? 'サプライヤーは無効です' : 'ベンダーは無効です'), + inactiveVendor: 'ベンダーは無効です', invoiceMarkup: (invoiceMarkup: number) => `${invoiceMarkup}%値上げ済み`, maxAge: (maxAge: number) => `日付が${maxAge}日より前です`, missingCategory: 'カテゴリが未選択です', @@ -10986,6 +11177,7 @@ ${reportName}`, }, systemMessage: { mergedWithCashTransaction: 'この取引にレシートを照合しました', + mergedExpenseDeleted: '経費を統合し、1件を削除済みに移動しました', }, subscription: { authenticatePaymentCard: '支払カードを認証', @@ -11222,6 +11414,9 @@ ${reportName}`, refundedOn: ({date}: {date: string}) => `${date} に返金`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${date} に ${amount} を返金`, }, + billedTo: '請求先', + viewReceipt: '領収書を表示', + viewInvoice: '請求書を表示', }, subscriptionSettings: { title: 'サブスクリプション設定', @@ -11774,22 +11969,6 @@ ${reportName}`, addPaymentCardButton: '支払いカードを追加', }, monthPickerPage: {month: '月', selectMonth: '月を選択してください'}, - aiFeaturesPromoModal: { - subtitle: 'はじめての Concierge AI', - confirmText: '始めましょう!', - spendAnalysis: { - title: 'インタラクティブな支出分析', - description: `Concierge は毎月の支出インサイトを提示し、すべての数値の内訳を詳しく確認できるようにします。詳しく見る。`, - }, - expenseAssistant: { - title: '新しい経費アシスタントをご紹介します', - description: `アプリ内やメール、テキストメッセージでConciergeとチャットして、経費を作成・更新できます。詳しく見る。`, - }, - customAgents: { - title: '独自のエージェントを作成する', - description: `設定したルールに基づいて経費を確認、承認、振り分けるカスタムエージェントを作成できます。さらに詳しく。`, - }, - }, emailIssuePage: { title: 'メールの問題', intro: (login: string) => `配信エラーのため、メールプロバイダーが${login} 宛ての送信を一時停止しました。この問題を解決するには:`, @@ -11830,5 +12009,6 @@ ${reportName}`, mobileRenewPrompt: '早期に更新するには、ウェブブラウザで Expensify にアクセスしてください。', mobileClaimPrompt: '更新割引を受け取るには、Web ブラウザで Expensify にアクセスしてください。', }, + bulkActionBar: {label: '一括操作', clearSelection: '選択をクリア', loadingSelection: '選択内容を読み込み中'}, }; export default translations; diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4fd0023754ec..c974a09b93aa 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -307,7 +307,6 @@ const translations: TranslationDeepObject = { change: 'Wijzigen', category: 'Categorie', vendor: 'Leverancier', - supplier: 'Leverancier', report: 'Rapport', billable: 'Factureerbaar', nonBillable: 'Niet-factureerbaar', @@ -531,6 +530,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'De grafiek kon niet worden geladen. Vernieuw de pagina en probeer het opnieuw.', apiKey: 'API-sleutel', exportsTo: 'Exporteert naar', + shareReport: 'Rapport delen', + reportShared: 'Rapport gedeeld', }, socials: { podcast: 'Volg ons op Podcast', @@ -980,6 +981,7 @@ const translations: TranslationDeepObject = { addShippingAddress: {title: 'We hebben je verzendadres nodig', subtitle: 'Geef een adres op om je Expensify Kaart te ontvangen.', cta: 'Toevoegen'}, addPaymentCard: {title: 'Voeg een betaalkaart toe om Expensify te blijven gebruiken', subtitle: 'Account > Abonnement', cta: 'Toevoegen'}, addBankAccount: {title: 'Voeg een bankrekening toe om je terugbetaling te ontvangen'}, + addDepositAccount: {title: 'Voeg een bankrekening toe om betalingen te ontvangen'}, activateCard: {title: 'Activeer je Expensify Kaart', subtitle: 'Valideer je kaart en begin met uitgeven.', cta: 'Activeren'}, confirmDigitalWalletAddition: { subtitle: 'Expensify Kaart', @@ -1026,6 +1028,7 @@ const translations: TranslationDeepObject = { cta: 'Beheren', }, emailDeliveryFailure: {title: 'We kunnen je geen e-mailmeldingen sturen', subtitle: 'Account'}, + upgradeNetSuiteConnection: {title: 'Upgrade je NetSuite-verbinding'}, }, forYouSection: { submit: ({count}: {count: number}) => ({ @@ -1173,7 +1176,18 @@ const translations: TranslationDeepObject = { viewOnSpend: 'Bekijken in Uitgaven', emptyState: {title: 'Niets om weer te geven', subtitle: 'Probeer je criteria hierboven aan te passen'}, noExpensesState: {title: 'Zie waar je geld naartoe gaat', subtitle: 'Zodra je uitgaven hebt, zie je bestedingspatronen, topverkopers en meer.'}, - compare: {label: 'Vergelijken', previousPeriod: 'Vorige periode', average: 'Gemiddeld'}, + compare: { + label: 'Vergelijken', + previousPeriod: 'Vorige periode', + average: 'Gemiddeld', + dayLabel: (day?: number) => (day === undefined ? 'Dag' : `Dag ${day}`), + weekLabel: (week?: number) => (week === undefined ? 'Week' : `Week ${week}`), + monthLabel: (month?: number) => (month === undefined ? 'Maand' : `Maand ${month}`), + quarterLabel: (quarter?: number) => (quarter === undefined ? 'Kwartaal' : `Kwartaal ${quarter}`), + yearLabel: (year?: number) => (year === undefined ? 'Jaar' : `Jaar ${year}`), + increase: (percentage: string) => `Gestegen met ${percentage}`, + decrease: (percentage: string) => `Gedaald met ${percentage}`, + }, }, allSettingsScreen: { subscription: 'Abonnement', @@ -2175,6 +2189,18 @@ const translations: TranslationDeepObject = { } return `Wachten tot de betaling is voltooid${formattedETA}.`; }, + [CONST.NEXT_STEP.MESSAGE_KEY.WAITING_FOR_CANCELLED_REIMBURSEMENT]: ( + _actor: string, + _actorType: ValueOf, + eta?: string, + etaType?: ValueOf, + ) => { + let formattedETA = ''; + if (eta) { + formattedETA = etaType === CONST.NEXT_STEP.ETA_TYPE.DATE_TIME ? ` vóór ${eta}` : ` ${eta}`; + } + return `Wachten tot de geannuleerde betaling is verwerkt${formattedETA}.`; + }, [CONST.NEXT_STEP.MESSAGE_KEY.SUBMITTING_TO_SELF]: ( _actor: string, _actorType: ValueOf, @@ -2818,6 +2844,13 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'Deze account vereist aandacht', unlock: 'Ontgrendelen', }, + wiseGlobalReimbursementsIntro: { + title: 'Wereldwijde terugbetalingen instellen?', + subtitle: 'Betaal leden wereldwijd in hun eigen lokale valuta.', + getStarted: 'Aan de slag', + headerTitle: 'Wereldwijde terugbetalingen', + }, + wiseGlobalReimbursementsBanner: {title: 'Betaal mensen wereldwijd', subtitle: 'Schakel wereldwijde terugbetalingen in op je USD-rekening.'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Kaart toevoegen aan ${walletName}`, @@ -3936,6 +3969,11 @@ ${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: 'Vertegenwoordiger kiezen', + 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) => { @@ -4026,6 +4064,10 @@ ${amount} voor ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Verzoek al ingediend', unlockAlreadyRequestedDescription: 'Je verzoek om deze bankrekening te deblokkeren is al verzonden. Concierge neemt contact met je op als er nog iets anders nodig is.', }, + listField: { + editItem: ({name}: {name: string}) => `${name} bewerken`, + removeItem: ({name}: {name: string}) => `${name} verwijderen`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Voorbeeld: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4037,7 +4079,13 @@ ${amount} voor ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Ongeldige indeling. Voorbeeld: ${example}` : 'Ongeldige indeling'), invalidOption: 'Kies een van de beschikbare opties', outOfRange: ({min, max}: {min: number; max: number}) => `Voer een waarde in tussen ${min} en ${max}`, + tooFewItems: ({min}: {min: number}) => `Voeg er minimaal ${min} toe`, + tooManyItems: ({max}: {max: number}) => `Voeg er maximaal ${max} toe`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `${item} toevoegen`, + removeItemTitle: ({item}: {item: string}) => `${item} verwijderen?`, + removeItemPrompt: ({name}: {name: string}) => `Weet je zeker dat je ${name} wilt verwijderen?`, }, addPersonalBankAccount: { swiftBicFormatError: 'SWIFT/BIC moet 8 of 11 tekens lang zijn, met 6 letters gevolgd door 2 of 5 letters of cijfers.', @@ -4078,6 +4126,7 @@ ${amount} voor ${merchant} - ${date}`, formLabel: 'PDF bekijken', }, attachmentNotFound: 'Bijlage niet gevonden', + loadTimedOut: 'Het laden van deze afbeelding duurt te lang.', retry: 'Opnieuw proberen', }, messages: { @@ -5070,7 +5119,10 @@ ${amount} voor ${merchant} - ${date}`, }, requestEmptyList: { title: 'Nog geen dagvergoedingen', - subtitle: 'Deze workspace heeft geen dagvergoedingen, vraag je admin om er een paar toe te voegen.', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} heeft geen dagvergoedingen. Vraag een beheerder om er een paar toe te voegen.` + : 'Deze workspace heeft geen dagvergoedingen. Vraag een beheerder om er een paar toe te voegen.', adminSubtitle: 'Voeg dagvergoedingen toe om je uitgaven te organiseren.', }, importPerDiemRates: 'Daggeldtarieven importeren', @@ -5078,6 +5130,7 @@ ${amount} voor ${merchant} - ${date}`, editPerDiemRates: 'Dagvergoedingen bewerken', editDestinationSubtitle: (destination: string) => `Als je deze bestemming bijwerkt, wordt deze gewijzigd voor alle per diem-deeltarieven van ${destination}.`, editCurrencySubtitle: (destination: string) => `Als u deze valuta bijwerkt, wordt deze gewijzigd voor alle ${destination} daggeldsubtarieven.`, + missingPerDiemUnit: 'Per diem is niet goed ingesteld in deze workspace. Neem contact op met Concierge voor hulp.', }, qbd: { exportOutOfPocketExpensesDescription: 'Stel in hoe contante uitgaven worden geëxporteerd naar QuickBooks Desktop.', @@ -5405,8 +5458,8 @@ ${amount} voor ${merchant} - ${date}`, }, noAccountsFound: 'Geen accounts gevonden', noAccountsFoundDescription: 'Voeg het account toe in Xero en synchroniseer de verbinding opnieuw', - noSuppliersFound: 'Geen leveranciers gevonden', - noSuppliersFoundDescription: 'Voeg de leverancier toe in Xero en synchroniseer de koppeling opnieuw.', + noVendorsFound: 'Geen leveranciers gevonden', + noVendorsFoundDescription: 'Voeg leveranciers toe in Xero en synchroniseer de verbinding opnieuw', accountingMethods: { label: 'Wanneer exporteren', description: 'Kies wanneer de onkosten moeten worden geëxporteerd:', @@ -6596,6 +6649,8 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ distanceRates: { title: 'Kilometertarieven', subtitle: 'Tarieven toevoegen, bijwerken en afdwingen.', + commuterExclusionsOnTitle: 'Woon-werkuitzonderingen zijn ingeschakeld', + disableLockedByCommuterExclusionsPrompt: 'Om afstandstarieven uit te schakelen, stel je eerst Woon-werkverkeer uitsluiten in op ‘Woon-werkverkeer niet uitsluiten’.', }, perDiem: { title: 'Dagvergoeding', @@ -6951,6 +7006,11 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ emptySubtitle: 'Leveranciers verschijnen hier zodra je boekhoudkundige synchronisatie is voltooid.', findVendor: 'Leverancier zoeken', managedInAccountingSoftware: 'Leveranciers worden beheerd in je', + enableVendor: 'Leverancier inschakelen', + enableVendors: 'Leveranciers inschakelen', + disableVendor: 'Leverancier uitschakelen', + disableVendors: 'Leveranciers uitschakelen', + updateFailureMessage: 'Er is een fout opgetreden bij het bijwerken van de leverancier, probeer het alsjeblieft opnieuw', }, tags: { tagName: 'Tagnaam', @@ -6994,6 +7054,7 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ tagRules: 'Tagregels', approverDescription: 'Fiatteur', importTags: 'Labels importeren', + appendCustomTagList: 'Aangepaste taglijst toevoegen', importTagsSupportingText: 'Codeer je onkosten met één type tag of met meerdere.', configureMultiLevelTags: 'Configureer je lijst met tags voor taggen op meerdere niveaus.', importMultiLevelTagsSupportingText: `Hier is een voorbeeldweergave van je tags. Als alles er goed uitziet, klik hieronder om ze te importeren.`, @@ -7258,6 +7319,11 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, optionNoRegularWorkspaceHelp: 'Lid werkt thuis of reist tussen locaties zonder vaste kantoorlocatie, waardoor woon-werkregels niet van toepassing zijn.', futureOnlyNote: 'Wijzigingen zijn alleen van toepassing op toekomstige kilometerberekeningen. Bestaande kilometerdeclaraties worden niet opnieuw berekend.', }, + officeLocation: 'Kantoorlocatie', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Locatie kantoor wijzigen', + changeWorkArrangement: 'Werkregeling wijzigen', + changeRole: 'Rol wijzigen', }, card: { getStartedIssuing: 'Begin met het uitgeven van je eerste virtuele of fysieke kaart.', @@ -7370,6 +7436,12 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Er is een fout opgetreden met een verbinding die is ingesteld in Expensify Classic. [Ga naar Expensify Classic om dit probleem op te lossen.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Ga naar Expensify Classic om je instellingen te beheren.', + exactOnline: { + comingSoon: 'Binnenkort beschikbaar', + joinWaitlist: 'Aanmelden voor wachtlijst', + onWaitlist: 'Je staat op de wachtlijst', + waitlistDescription: 'Concierge stuurt je een bericht om je te laten weten wanneer deze integratie klaar is en hoe je verbinding kunt maken.', + }, setup: 'Verbinden', connectToSandbox: 'Verbinden met sandbox', lastSync: (relativeDate: string) => `Laatst gesynchroniseerd ${relativeDate}`, @@ -7652,6 +7724,9 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, businessCentral: 'Dynamics 365 Business Central', qboConnectionExpiring: ({date}: {date: string}) => `Je QuickBooks Online-verbinding verloopt op ${date}.`, qboConnectionExpired: ({date}: {date: string}) => `Je QuickBooks Online-verbinding is op ${date} verlopen.`, + netSuiteOAuthMigrationRequired: 'Upgrade je NetSuite-verbinding naar OAuth 2.0 vóór februari 2027.', + netSuiteOAuthMigrationRequiredUrgent: 'Je NetSuite-verbinding moet onmiddellijk worden bijgewerkt naar OAuth 2.0.', + netSuiteOAuthUpgradeNow: 'Nu upgraden', }, export: { notReadyHeading: 'Niet klaar om te exporteren', @@ -7825,6 +7900,21 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, addressContext: 'Een werkruimte-adres is vereist om Expensify Travel in te schakelen. Voer een adres in dat bij uw bedrijf hoort.', policy: 'Declaratiebeleid', }, + officeLocations: { + title: 'Kantoorlokaties', + subtitle: 'Voeg kantoorlocaties toe om aan leden toe te wijzen voor zaken als afstandsregistratie.', + addOfficeLocation: 'Kantoorlokatie toevoegen', + editOfficeLocation: 'Kantoorlocatie bewerken', + locationName: 'Locatienaam', + locationNameHint: 'Kies een bijnaam voor de locatie als je meerdere kantoorlocaties toevoegt', + defaultName: ({officeNumber}: {officeNumber: number}) => `Kantoorlocatie ${officeNumber}`, + setAsPrimary: 'Als primair instellen', + primary: 'Primair', + deleteOfficeLocation: 'Kantoorlocatie verwijderen?', + deleteOfficeLocationConfirmation: 'Wil je deze kantoorlocatie echt verwijderen? Leden worden opnieuw toegewezen aan het hoofdkantoor.', + existingOfficeLocationError: 'Er bestaat al een kantoorlocatie met deze naam', + setCompanyAddressAsPrimaryConfirmation: 'Het bedrijfsadres instellen als hoofdvestiging?', + }, bankAccount: { continueWithSetup: 'Setup voortzetten', youAreAlmostDone: @@ -8357,6 +8447,8 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `Voeg ${featureName} toe om deze functie te ontgrendelen.`, enableFeatureSubtitle: (featureName: string, moreFeaturesLink?: string) => `Ga naar [meer functies](${moreFeaturesLink}) en schakel ${featureName} in om deze functie te ontgrendelen.`, + preventPayoutNonReimbursableReportsTitle: 'Voorkom dat niet-declarabele rapporten als betaald worden gemarkeerd', + preventPayoutNonReimbursableReportsSubtitle: 'Sta beheerders niet toe om niet-vergoedbare rapporten als betaald te markeren.', }, agentsPromoBanner: { title: 'Zie je de regel die je nodig hebt niet? Voeg een agent toe', @@ -8410,7 +8502,6 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar ifAnyExpenseMatches: 'Als een uitgave overeenkomt:', thenApplyFollowingDefaults: 'Pas vervolgens de volgende standaardinstellingen toe:', vendorUnavailable: 'Leverancier niet beschikbaar', - supplierUnavailable: 'Leverancier niet beschikbaar', confirmErrorCategory: 'Selecteer een categorie', confirmErrorCategoryTax: 'Selecteer een belastingtarief', confirmErrorCondition: 'Voer een leverancier in', @@ -8647,6 +8738,9 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, describeRuleForConcierge: 'Beschrijf je regel en Concierge bouwt hem voor je', gotIt: 'Begrepen', createRule: 'Regel maken', + fallbackBanner: + 'Dit is geen standaardregel, dus hebben we hem opgesteld als een agentregel. Agentregels treden in werking nadat een uitgave of rapport verandert, bijvoorbeeld door de uitgave vast te houden of het rapport af te wijzen.', + fallbackBannerWithExplanation: (explanation: string) => `Dit is geen standaardregel, dus hebben we het opgesteld als een agentregel. ${explanation}`, }, tabs: { general: 'Algemeen', @@ -8709,16 +8803,21 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, newRule: { title: 'Nieuwe regel', subtitle: 'Wat wil je doen?', + startWithTemplate: 'Begin met een sjabloon', restrictCardSpend: 'Kaartuitgaven beperken', restrictCardSpendDescription: 'Blokkeer of beperk uitgaven bij het verkooppunt', applyExpenseDefaults: 'Standaardinstellingen voor uitgaven toepassen', - applyExpenseDefaultsDescription: 'Velden bijwerken zonder dat de indiener iets hoeft te doen', + applyExpenseDefaultsDescription: 'Velden automatisch bijwerken', flagForReview: 'Markeren voor controle', - flagForReviewDescription: 'Goedkeurders informeren wanneer onkosten de categorielimieten overschrijden', + flagForReviewDescription: 'Melding wanneer aan je voorwaarden is voldaan', requireFields: 'Veldvereisten instellen', - requireFieldsDescription: 'Specifieke onkostenvakken verplicht stellen of afzien van het verplicht stellen ervan.', + requireFieldsShort: 'Velden verplichten', + requireFieldsDescription: 'Bonnen, categorieën, enz.', createAgentRule: 'Agentregel beschrijven', createAgentRuleDescription: 'Maak flexibele regels die worden uitgevoerd wanneer jij dat nodig hebt.', + describeItBelow: 'Of beschrijf hem hieronder', + describeRulePlaceholder: 'Blokkeer alle uitgaven boven $2.000', + selectTemplateOrDescribe: 'Selecteer een sjabloon of beschrijf een regel om door te gaan', describe: 'Beschrijven', describeRule: 'Beschrijf je regel in gewone taal en Concierge maakt de regel voor je', describeRuleInputLabel: 'Beschrijf de regel', @@ -8726,6 +8825,15 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, unsupported: 'Die regel wordt nog niet ondersteund — we werken eraan. Probeer voorlopig een andere regel.', unintelligible: 'Formuleer je regel bijvoorbeeld zo: als uitgaven hoger zijn dan $100, vraag dan een bon.', multipleRules: 'Eén regel per keer graag — beschrijf één enkele regel, bijvoorbeeld: retailaankopen op alle kaarten blokkeren.', + categoryHasRule: 'Deze categorie heeft al een regel. Bewerk de bestaande regel of beschrijf een regel voor een andere categorie.', + }, + impossible: { + noTrigger: 'Regels kunnen niet vanaf die gebeurtenis beginnen.', + noCapability: 'Regels kunnen dat niet.', + missingData: 'Expensify heeft die informatie niet.', + retroactive: 'Regels zijn alleen van toepassing op nieuwe activiteit.', + historyLimit: 'Regels kunnen niet zo ver terugkijken.', + tryThisInstead: 'Probeer dit in plaats daarvan', }, }, expenseDefaultsTable: { @@ -8918,7 +9026,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, dontSeeYourATS: `Zie je je ATS hier niet? Vraag het aan Concierge en we kunnen het toevoegen.`, importSettings: 'Importinstellingen', defaultApprover: 'Standaardgoedkeurder', - approverFields: {recruiter: 'Recruiter', recruitingCoordinator: 'Coördinator werving'}, + approverFields: {recruiter: 'Recruiter', recruitingCoordinator: 'Coördinator werving', hiringManager: 'Wervingsmanager'}, filters: { description: (providerName: string) => `Selecteer welke leden worden geïmporteerd uit ${providerName}. Je kunt kiezen op basis van functieniveaus, tags en kantoren.`, stages: { @@ -8939,7 +9047,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, toggleTitle: 'Kantoren', allSelected: 'Alle kantoren', }, - enableJobStagesOrTags: 'Schakel functiestadia of labels in om door te gaan', + enableJobStagesOrTags: 'Selecteer minstens één functiestap of tag om door te gaan', }, subtitle: 'Koppel wervingstools en houd kandidaategoedkeuringen gesynchroniseerd.', syncResults: { @@ -8952,7 +9060,12 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, finalApprover: `Laatste goedkeurder`, finalApproverOptional: 'Laatste fiatteur (optioneel)', approvalModeDescription: (providerName: string) => `Stel de fiatteur in voor nieuwe leden die vanuit ${providerName} in Expensify worden geïmporteerd.`, - approverFieldDescription: (providerName: string) => `Kies de eerste fiatteur voor je kandidaten: ofwel hun recruiter of hun coördinator die is toegewezen in ${providerName}.`, + approverFieldDescription: (providerName: string, approverFields: string[]) => { + const fieldNames = approverFields.map((approverField) => approverField.toLowerCase()); + const lastFieldName = fieldNames.at(-1) ?? ''; + const fieldList = fieldNames.length > 2 ? `hun ${fieldNames.slice(0, -1).join(', ')}, of ${lastFieldName}` : `hun ${fieldNames.join('of hun')}`; + return `Kies de eerste fiatteur voor je kandidaten: ${fieldList} toegewezen in ${providerName}.`; + }, approvalModeDescriptions: { basic: 'Kies één fiatteur', advanced: `Recruiter of coördinator van de kandidaat wordt zijn/haar onkostengoedkeurder`, @@ -9090,6 +9203,9 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, }, }, + zohoBooks: { + organization: 'Zoho Books-organisatie', + }, businessCentral: { businessCentralSetup: 'Dynamics 365 Business Central-configuratie', prerequisitesTitle: 'Voordat je verbinding maakt...', @@ -9150,6 +9266,51 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, noBankAccountsFoundDescription: 'Voeg bankrekeningen toe in Dynamics 365 Business Central en synchroniseer de verbinding opnieuw', noPaymentMethodsFound: 'Geen betalingswijzen gevonden', noPaymentMethodsFoundDescription: 'Voeg betalingswijzen toe in Dynamics 365 Business Central en synchroniseer de verbinding opnieuw', + advanced: { + autoSyncDescription: 'Synchroniseer Dynamics 365 Business Central en Expensify elke dag automatisch. Rapporten worden in realtime gesynchroniseerd.', + accountingMethods: { + label: 'Exportmethode', + description: 'Kies wanneer je onkosten exporteert.', + values: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Transitorisch', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Contant', + }, + alternateText: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Uit eigen zak betaalde uitgaven worden geëxporteerd zodra ze definitief zijn goedgekeurd', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Uitgaven uit eigen zak worden geëxporteerd zodra ze zijn betaald', + }, + }, + postingMode: { + label: 'Boekingsmodus', + description: 'Kies of boekingen automatisch worden geboekt of eerst handmatig moeten worden gecontroleerd.', + note: 'Let op: Expensify boekt elk open dagboek in de batch van het algemeen dagboek die je hebt gekozen op de pagina Expensify Setup in Business Central, niet alleen de dagboeken die het zelf heeft gemaakt. Gebruik een batch alleen voor Expensify.', + values: { + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_AND_POST]: { + label: 'Maken en boeken', + description: 'Boekingen bij export automatisch in het grootboek boeken', + }, + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_ONLY]: { + label: 'Alleen maken', + description: 'Boekingen na export handmatig boeken in Dynamics 365 Business Central', + }, + }, + }, + autoCreateEntities: 'Medewerkers/leveranciers automatisch aanmaken', + exportToNextOpenPeriod: 'Exporteren naar volgende open periode', + exportToNextOpenPeriodDescription: + 'Als een rapport is gedateerd in een afgesloten periode in Dynamics 365 Business Central, wordt het geëxporteerd naar de volgende open periode.', + syncReimbursedReports: 'Vergoede rapporten synchroniseren', + syncReimbursedReportsDescription: 'Wanneer een rapport via ACH wordt betaald, wordt er in deze rekening een journaalpostregel aangemaakt.', + reimbursementBankAccount: { + label: 'Betaalbankrekening', + description: 'Kies van waar je rekeningen wilt betalen en we maken de betaling aan in Dynamics 365 Business Central.', + }, + syncExpensifyCardSettlements: 'Expensify Kaartafrekeningen synchroniseren', + settlementsBankAccount: { + label: 'Expensify Kaart-verrekeningsrekening', + description: 'Kies je afrekenrekening en we maken de betaling aan in Dynamics 365 Business Central.', + }, + }, accountsDescription: 'Je Dynamics 365 Business Central-accounts worden geïmporteerd als categorieën.', dimensionsImportAsTags: 'Alle Dynamics 365 Business Central-dimensies worden als tags geïmporteerd', customers: 'Klanten', @@ -9635,6 +9796,20 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `heeft ‘${prohibitedExpense}’ toegevoegd aan verboden uitgaven`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `heeft „${prohibitedExpense}” verwijderd uit verboden uitgaven`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `heeft de kantoorlocatie "${name}" (${address}) toegevoegd`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `heeft de kantoorlokatie “${oldName}” gewijzigd in “${newName}”`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `heeft het adres van kantoorlocatie "${name}" gewijzigd naar "${newAddress}" (voorheen "${oldAddress}")`, + setAsPrimary: ({name}: {name: string}) => `stel de kantoorlocatie "${name}" in als primair`, + setCompanyAddressAsPrimary: 'stel het bedrijfsadres in als primair', + removed: ({name}: {name: string}) => `heeft de kantoorlocatie "${name}" verwijderd`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `heeft de kantoorlokatie van ${displayName} gewijzigd naar ${officeLocation} (voorheen ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `‘${name}’`, + companyAddress: 'het bedrijfsadres', + primaryOffice: 'het primaire kantoor', + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `stel de standaard werkregeling in op ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -9769,6 +9944,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, addCustomUnitRateWithAmountAndEndDate: (rateName: string, rateValue: string, endDate: string) => `heeft tarief „${rateName}” van ${rateValue} toegevoegd, geldig tot ${endDate}`, addCustomUnitRateWithAmountAndDates: (rateName: string, rateValue: string, startDate: string, endDate: string) => `heeft tarief "${rateName}" van ${rateValue} toegevoegd, geldig van ${startDate} - ${endDate}`, + addCustomUnitRateTaxReclaimableSuffix: (taxReclaimableAmount: string, taxRateName: string) => `, met ${taxReclaimableAmount} terugvorderbare belasting onder "${taxRateName}"`, updatedCustomUnitRateDateRange: (rateName: string, newDateRange: string, oldDateRange: string) => `heeft het kilometertarief "${rateName}" bijgewerkt zodat het geldt van ${newDateRange} (voorheen ${oldDateRange})`, customUnitRateDateRangeStartToEnd: (startDate: string, endDate: string) => `${startDate} - ${endDate}`, @@ -9859,6 +10035,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, `heeft de werkregeling van ${displayName} gewijzigd naar ${newArrangement} (voorheen ${oldArrangement})`, updatedDefaultWorkArrangement: ({newArrangement, oldArrangement}: {newArrangement: string; oldArrangement: string}) => `heeft de standaardwerkregeling gewijzigd naar ${newArrangement} (voorheen ${oldArrangement})`, + preventPayoutNonReimbursableReports: (oldValue: string, newValue: string) => + `heeft "Voorkom dat niet-vergoedbare rapporten als betaald worden gemarkeerd" bijgewerkt naar "${newValue === 'true' ? 'Ingeschakeld' : 'Uitgeschakeld'}" (voorheen "${oldValue === 'true' ? 'Ingeschakeld' : 'Uitgeschakeld'}")`, }, roomMembersPage: { memberNotFound: 'Lid niet gevonden.', @@ -9913,6 +10091,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, fallbackTitle: 'Supportticket', resolved: 'Dit supportticket is opgelost.', surveyPrompt: 'Hoe was je ervaring met de ondersteuning?', + great: 'Geweldig', + poor: 'Slecht', reopenTicket: 'Ticket opnieuw openen', }, statementPage: { @@ -10057,6 +10237,12 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, customDay: 'Aangepaste dag', }, + describeSearch: { + title: 'Beschrijf je zoekopdracht', + description: 'Beschrijf in gewone taal welke uitgaven je wilt zien, dan passen wij automatisch de juiste filters toe.', + inputLabel: 'Beschrijf je zoekopdracht', + buttonText: 'Toepassen', + }, status: 'Status', keyword: 'Trefwoord', keywords: 'Trefwoorden', @@ -10092,6 +10278,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, past: 'Verleden', submitted: 'Ingediend', approved: 'Goedgekeurd', + anyApproval: 'Elke goedkeuring', firstApprover: 'Eerste gemachtigde', firstApproved: 'Eerst goedgekeurd', paidBy: 'Betaald door', @@ -10100,6 +10287,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, posted: 'Gepost', withdrawn: 'Ingetrokken', billable: 'Factureerbaar', + rejected: 'Afgekeurd', + held: 'In de wacht', reimbursable: 'Vergoedbaar', purchaseCurrency: 'Aankoopvaluta', sortOrder: { @@ -10134,6 +10323,11 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Exporteren', }, filterType: {label: 'Filtertype', has: {positive: 'heeft', negative: 'heeft niet'}, is: {positive: 'is', negative: 'is niet'}}, + merchant: { + matchType: 'Overeenkomsttype', + contains: (merchant?: string) => (merchant ? `Bevat "${merchant}"` : 'Bevat'), + equalTo: (merchant?: string) => (merchant ? `Gelijk aan "${merchant}"` : 'Gelijk aan'), + }, created: 'Aangemaakt', createdDate: 'Aanmaakdatum', transactionStatus: { @@ -10313,6 +10507,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, type: { changeField: (oldValue: string | undefined, newValue: string, fieldName: string) => `heeft ${fieldName} gewijzigd in „${newValue}” (voorheen „${oldValue}”)`, changeFieldEmpty: (newValue: string, fieldName: string) => `stel ${fieldName} in op "${newValue}"`, + changeFieldRemoved: (oldValue: string, fieldName: string) => `heeft ${fieldName} verwijderd (voorheen „${oldValue}”)`, + changeFieldCleared: (fieldName: string) => `heeft ${fieldName} verwijderd`, changeReportPolicy: (toPolicyName: string, fromPolicyName?: string) => { if (!toPolicyName) { return `heeft de werkruimte${fromPolicyName ? `(voorheen ${fromPolicyName})` : ''} gewijzigd`; @@ -10796,7 +10992,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, duplicatedTransaction: 'Mogelijke duplicaat', fieldRequired: 'Rapportvelden zijn verplicht', futureDate: 'Toekomstige datum niet toegestaan', - inactiveVendor: (isSupplier = false) => (isSupplier ? 'Leverancier niet meer geldig' : 'Leverancier niet meer geldig'), + inactiveVendor: 'Leverancier niet meer geldig', invoiceMarkup: (invoiceMarkup: number) => `Met ${invoiceMarkup}% verhoogd`, maxAge: (maxAge: number) => `Datum ouder dan ${maxAge} dagen`, missingCategory: 'Ontbrekende categorie', @@ -11112,6 +11308,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, systemMessage: { mergedWithCashTransaction: 'heeft een bonnetje aan deze transactie gekoppeld', + mergedExpenseDeleted: 'heeft uitgaven samengevoegd en er één naar Verwijderd verplaatst', }, subscription: { authenticatePaymentCard: 'Betalingskaart verifiëren', @@ -11348,6 +11545,9 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, refundedOn: ({date}: {date: string}) => `Terugbetaald op ${date}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${amount} terugbetaald op ${date}`, }, + billedTo: 'Gefactureerd aan', + viewReceipt: 'Bon bekijken', + viewInvoice: 'Factuur bekijken', }, subscriptionSettings: { title: 'Abonnementsinstellingen', @@ -11908,22 +12108,6 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, addPaymentCardButton: 'Betaalkaart toevoegen', }, monthPickerPage: {month: 'Maand', selectMonth: 'Selecteer een maand'}, - aiFeaturesPromoModal: { - subtitle: 'Nieuw bij Concierge AI', - confirmText: 'Laten we gaan!', - spendAnalysis: { - title: 'Interactieve uitgavenanalyse', - description: `Concierge toont maandelijkse uitgaveninzichten en laat je inzoomen op de details achter elk getal. Meer informatie.`, - }, - expenseAssistant: { - title: 'Maak kennis met je nieuwe declaratie-assistent', - description: `Chat met Concierge om uitgaven aan te maken en bij te werken, rechtstreeks in de app of via e-mail of sms. Meer informatie.`, - }, - customAgents: { - title: 'Bouw je eigen agents', - description: `Maak aangepaste agents om uitgaven te beoordelen, goed te keuren en door te sturen op basis van regels die jij instelt. Meer informatie.`, - }, - }, emailIssuePage: { title: 'E-mailprobleem', intro: (login: string) => `Onze e-mailprovider is gestopt met verzenden naar ${login} vanwege afleveringsproblemen. Om dit op te lossen:`, @@ -11964,5 +12148,6 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, mobileRenewPrompt: 'Bezoek Expensify in je webbrowser om vroegtijdig te verlengen.', mobileClaimPrompt: 'Bezoek Expensify in je webbrowser om je verlengingskorting te claimen.', }, + bulkActionBar: {label: 'Bulkacties', clearSelection: 'Selectie wissen', loadingSelection: 'Selectie laden'}, }; export default translations; diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 829cfa260527..92299529ac25 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -311,7 +311,6 @@ const translations: TranslationDeepObject = { change: 'Zmień', category: 'Kategoria', vendor: 'Dostawca', - supplier: 'Dostawca', report: 'Raport', billable: 'Fakturowalne', nonBillable: 'Nierozliczalne', @@ -534,6 +533,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'Nie udało się załadować wykresu. Odśwież stronę i spróbuj ponownie.', apiKey: 'Klucz API', exportsTo: 'Eksporty do', + shareReport: 'Udostępnij raport', + reportShared: 'Udostępniono raport', }, socials: { podcast: 'Śledź nas na Podcast', @@ -988,6 +989,7 @@ const translations: TranslationDeepObject = { addShippingAddress: {title: 'Potrzebujemy Twojego adresu wysyłki', subtitle: 'Podaj adres, na który mamy wysłać twoją Kartę Expensify.', cta: 'Dodaj'}, addPaymentCard: {title: 'Dodaj kartę płatniczą, żeby dalej korzystać z Expensify', subtitle: 'Konto > Subskrypcja', cta: 'Dodaj'}, addBankAccount: {title: 'Dodaj konto bankowe, aby otrzymać zwrot'}, + addDepositAccount: {title: 'Dodaj konto bankowe, aby otrzymywać płatności'}, activateCard: {title: 'Aktywuj swoją Kartę Expensify', subtitle: 'Zatwierdź swoją kartę i zacznij wydawać.', cta: 'Aktywuj'}, confirmDigitalWalletAddition: { subtitle: 'Karta Expensify', @@ -1034,6 +1036,7 @@ const translations: TranslationDeepObject = { cta: 'Zarządzaj', }, emailDeliveryFailure: {title: 'Nie możemy wysyłać ci powiadomień e-mail', subtitle: 'Konto'}, + upgradeNetSuiteConnection: {title: 'Zaktualizuj swoje połączenie z NetSuite'}, }, forYouSection: { submit: ({count}: {count: number}) => ({ @@ -1195,7 +1198,18 @@ const translations: TranslationDeepObject = { viewOnSpend: 'Zobacz w Wydatkach', emptyState: {title: 'Nic do wyświetlenia', subtitle: 'Spróbuj zmienić kryteria powyżej'}, noExpensesState: {title: 'Zobacz, na co idą twoje pieniądze', subtitle: 'Gdy będziesz mieć wydatki, zobaczysz trendy w wydawaniu, najważniejszych sprzedawców i więcej.'}, - compare: {label: 'Porównaj', previousPeriod: 'Poprzedni okres', average: 'Średnia'}, + compare: { + label: 'Porównaj', + previousPeriod: 'Poprzedni okres', + average: 'Średnia', + dayLabel: (day?: number) => (day === undefined ? 'Dzień' : `Dzień ${day}`), + weekLabel: (week?: number) => (week === undefined ? 'Tydzień' : `Tydzień ${week}`), + monthLabel: (month?: number) => (month === undefined ? 'Miesiąc' : `Miesiąc ${month}`), + quarterLabel: (quarter?: number) => (quarter === undefined ? 'Kwartał' : `Kwartał ${quarter}`), + yearLabel: (year?: number) => (year === undefined ? 'Rok' : `Rok ${year}`), + increase: (percentage: string) => `Wzrost o ${percentage}`, + decrease: (percentage: string) => `Spadek o ${percentage}`, + }, }, allSettingsScreen: { subscription: 'Subskrypcja', @@ -2211,6 +2225,18 @@ const translations: TranslationDeepObject = { } return `Oczekiwanie na zakończenie płatności${formattedETA}.`; }, + [CONST.NEXT_STEP.MESSAGE_KEY.WAITING_FOR_CANCELLED_REIMBURSEMENT]: ( + _actor: string, + _actorType: ValueOf, + eta?: string, + etaType?: ValueOf, + ) => { + let formattedETA = ''; + if (eta) { + formattedETA = etaType === CONST.NEXT_STEP.ETA_TYPE.DATE_TIME ? ` do ${eta}` : ` ${eta}`; + } + return `Oczekiwanie na zakończenie przetwarzania anulowanej płatności${formattedETA}.`; + }, [CONST.NEXT_STEP.MESSAGE_KEY.SUBMITTING_TO_SELF]: ( _actor: string, _actorType: ValueOf, @@ -2852,6 +2878,13 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'To konto wymaga uwagi', unlock: 'Odblokuj', }, + wiseGlobalReimbursementsIntro: { + title: 'Skonfigurować globalne zwroty kosztów?', + subtitle: 'Płać członkom na całym świecie w ich lokalnej walucie.', + getStarted: 'Zacznij', + headerTitle: 'Globalne zwroty kosztów', + }, + wiseGlobalReimbursementsBanner: {title: 'Płać ludziom na całym świecie', subtitle: 'Włącz globalne zwroty wydatków na swoim koncie w USD.'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Dodaj kartę do ${walletName}`, @@ -3966,6 +3999,11 @@ ${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}`, + until: (date: string) => `Do ${date}`, + removeDelegate: 'Usuń zastępcę urlopowego', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -4056,6 +4094,10 @@ ${amount} dla ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Wniosek został już złożony', unlockAlreadyRequestedDescription: 'Twoja prośba o odblokowanie tego konta bankowego została już wysłana. Concierge skontaktuje się z tobą, jeśli będzie potrzebne coś jeszcze.', }, + listField: { + editItem: ({name}: {name: string}) => `Edytuj ${name}`, + removeItem: ({name}: {name: string}) => `Usuń ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Przykład: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4069,7 +4111,13 @@ ${amount} dla ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Nieprawidłowy format. Przykład: ${example}` : 'Nieprawidłowy format'), invalidOption: 'Wybierz jedną z dostępnych opcji', outOfRange: ({min, max}: {min: number; max: number}) => `Wprowadź wartość od ${min} do ${max}`, + tooFewItems: ({min}: {min: number}) => `Dodaj co najmniej ${min}`, + tooManyItems: ({max}: {max: number}) => `Dodaj maksymalnie ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `Dodaj ${item}`, + removeItemTitle: ({item}: {item: string}) => `Usunąć ${item}?`, + removeItemPrompt: ({name}: {name: string}) => `Czy na pewno chcesz usunąć ${name}?`, }, addPersonalBankAccount: { swiftBicFormatError: 'Kod SWIFT/BIC musi mieć długość 8 lub 11 znaków, z 6 literami, po których następują 2 lub 5 liter lub cyfr.', @@ -4110,6 +4158,7 @@ ${amount} dla ${merchant} - ${date}`, formLabel: 'Wyświetl PDF', }, attachmentNotFound: 'Załącznik nie został znaleziony', + loadTimedOut: 'Ładowanie tego obrazu trwa zbyt długo.', retry: 'Ponów próbę', }, messages: { @@ -5102,7 +5151,10 @@ ${amount} dla ${merchant} - ${date}`, }, requestEmptyList: { title: 'Nie ma jeszcze stawek diet', - subtitle: 'W tym obszarze roboczym nie ma żadnych stawek diet, poproś swojego administratora o dodanie kilku.', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} nie ma żadnych stawek diet. Poproś administratora, żeby je dodał.` + : 'Ten obszar roboczy nie ma żadnych stawek diet. Poproś administratora, żeby je dodał.', adminSubtitle: 'Dodaj stawki diet, żeby uporządkować swoje wydatki.', }, importPerDiemRates: 'Zaimportuj stawki diet', @@ -5110,6 +5162,7 @@ ${amount} dla ${merchant} - ${date}`, editPerDiemRates: 'Edytuj stawki diet', editDestinationSubtitle: (destination: string) => `Zaktualizowanie tego miejsca docelowego zmieni je dla wszystkich podstawek diet ${destination}.`, editCurrencySubtitle: (destination: string) => `Zaktualizowanie tej waluty spowoduje jej zmianę dla wszystkich podrzędnych stawek diet w ${destination}.`, + missingPerDiemUnit: 'Dieta nie jest poprawnie skonfigurowana w tym workspace. Skontaktuj się z Concierge, żeby uzyskać pomoc.', }, qbd: { exportOutOfPocketExpensesDescription: 'Ustaw sposób eksportu wydatków z własnej kieszeni do QuickBooks Desktop.', @@ -5435,8 +5488,8 @@ ${amount} dla ${merchant} - ${date}`, }, noAccountsFound: 'Nie znaleziono kont', noAccountsFoundDescription: 'Dodaj proszę konto w Xero i zsynchronizuj połączenie ponownie', - noSuppliersFound: 'Nie znaleziono dostawców', - noSuppliersFoundDescription: 'Dodaj dostawcę w Xero i zsynchronizuj połączenie ponownie.', + noVendorsFound: 'Nie znaleziono dostawców', + noVendorsFoundDescription: 'Dodaj proszę dostawców w Xero i zsynchronizuj połączenie ponownie', accountingMethods: { label: 'Kiedy eksportować', description: 'Wybierz, kiedy eksportować wydatki:', @@ -6620,6 +6673,8 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy distanceRates: { title: 'Stawki za dystans', subtitle: 'Dodawaj, aktualizuj i egzekwuj stawki.', + commuterExclusionsOnTitle: 'Wykluczenia dojazdów są włączone', + disableLockedByCommuterExclusionsPrompt: 'Aby wyłączyć stawki za dystans, najpierw ustaw Wykluczanie dojazdów na „Nie wykluczaj dojazdów”.', }, perDiem: { title: 'Dieta', @@ -6977,6 +7032,11 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy emptySubtitle: 'Dostawcy pojawią się tutaj po zakończeniu synchronizacji z księgowością.', findVendor: 'Znajdź dostawcę', managedInAccountingSoftware: 'Dostawcy są zarządzani w twoim', + enableVendor: 'Włącz kontrahenta', + enableVendors: 'Włącz dostawców', + disableVendor: 'Wyłącz dostawcę', + disableVendors: 'Wyłącz dostawców', + updateFailureMessage: 'Wystąpił błąd podczas aktualizowania dostawcy, spróbuj ponownie', }, tags: { tagName: 'Nazwa tagu', @@ -7020,6 +7080,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy tagRules: 'Zasady tagów', approverDescription: 'Osoba zatwierdzająca', importTags: 'Importuj tagi', + appendCustomTagList: 'Dołącz niestandardową listę tagów', importTagsSupportingText: 'Oznaczaj wydatki jednym typem taga lub wieloma.', configureMultiLevelTags: 'Skonfiguruj swoją listę tagów do tagowania wielopoziomowego.', importMultiLevelTagsSupportingText: `Oto podgląd Twoich tagów. Jeśli wszystko wygląda dobrze, kliknij poniżej, aby je zaimportować.`, @@ -7284,6 +7345,11 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, optionNoRegularWorkspaceHelp: 'Członek pracuje z domu lub podróżuje między lokalizacjami bez stałego biura, więc zasady dotyczące dojazdów do pracy nie mają zastosowania.', futureOnlyNote: 'Zmiany mają zastosowanie tylko do przyszłych obliczeń kilometrówki. Istniejące wydatki za przejazdy nie są przeliczane ponownie.', }, + officeLocation: 'Lokalizacja biura', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Zmień lokalizację biura', + changeWorkArrangement: 'Zmień tryb pracy', + changeRole: 'Zmień rolę', }, card: { getStartedIssuing: 'Zacznij od wydania swojej pierwszej wirtualnej lub fizycznej karty.', @@ -7396,6 +7462,12 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Wystąpił błąd z połączeniem skonfigurowanym w Expensify Classic. [Przejdź do Expensify Classic, aby rozwiązać ten problem.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Przejdź do Expensify Classic, aby zarządzać swoimi ustawieniami.', + exactOnline: { + comingSoon: 'Wkrótce dostępne', + joinWaitlist: 'Dołącz do listy oczekujących', + onWaitlist: 'Jesteś na liście oczekujących', + waitlistDescription: 'Concierge wyśle ci wiadomość, żeby dać znać, kiedy ta integracja będzie gotowa i jak ją połączyć.', + }, setup: 'Połącz', connectToSandbox: 'Połącz z sandboxem', lastSync: (relativeDate: string) => `Ostatnia synchronizacja ${relativeDate}`, @@ -7677,6 +7749,9 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, businessCentral: 'Dynamics 365 Business Central', qboConnectionExpiring: ({date}: {date: string}) => `Twoje połączenie z QuickBooks Online wygaśnie ${date}.`, qboConnectionExpired: ({date}: {date: string}) => `Twoje połączenie z QuickBooks Online wygasło ${date}.`, + netSuiteOAuthMigrationRequired: 'Zaktualizuj swoje połączenie z NetSuite do OAuth 2.0 do lutego 2027.', + netSuiteOAuthMigrationRequiredUrgent: 'Twoje połączenie z NetSuite wymaga natychmiastowej aktualizacji do OAuth 2.0.', + netSuiteOAuthUpgradeNow: 'Ulepsz teraz', }, export: { notReadyHeading: 'Niegotowe do eksportu', @@ -7849,6 +7924,21 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, addressContext: 'Adres miejsca pracy jest wymagany, aby włączyć Expensify Travel. Wprowadź adres powiązany z Twoją firmą.', policy: 'Polityka wydatków', }, + officeLocations: { + title: 'Lokalizacje biur', + subtitle: 'Dodaj lokalizacje biur, aby przypisywać je członkom do rzeczy takich jak śledzenie dystansu.', + addOfficeLocation: 'Dodaj lokalizację biura', + editOfficeLocation: 'Edytuj lokalizację biura', + locationName: 'Nazwa lokalizacji', + locationNameHint: 'Wybierz nazwę lokalizacji, jeśli dodajesz wiele biur', + defaultName: ({officeNumber}: {officeNumber: number}) => `Lokalizacja biura ${officeNumber}`, + setAsPrimary: 'Ustaw jako główny', + primary: 'Główny', + deleteOfficeLocation: 'Usunąć lokalizację biura?', + deleteOfficeLocationConfirmation: 'Czy na pewno chcesz usunąć tę lokalizację biura? Członkowie zostaną przypisani do głównego biura.', + existingOfficeLocationError: 'Lokalizacja biura o tej nazwie już istnieje', + setCompanyAddressAsPrimaryConfirmation: 'Ustawić adres firmy jako główne biuro?', + }, bankAccount: { continueWithSetup: 'Kontynuuj konfigurację', youAreAlmostDone: 'Prawie zakończyłeś konfigurację konta bankowego, które pozwoli Ci wydawać firmowe karty, zwracać wydatki, pobierać faktury i opłacać rachunki.', @@ -8384,6 +8474,8 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `Dodaj ${featureName}, aby odblokować tę funkcję.`, enableFeatureSubtitle: (featureName: string, moreFeaturesLink?: string) => `Przejdź do [więcej funkcji](${moreFeaturesLink}) i włącz ${featureName}, aby odblokować tę funkcję.`, + preventPayoutNonReimbursableReportsTitle: 'Zapobiegaj oznaczaniu nierozliczanych raportów jako opłaconych', + preventPayoutNonReimbursableReportsSubtitle: 'Nie pozwalaj administratorom oznaczać nierozliczanych raportów jako opłacone.', }, agentsPromoBanner: {title: 'Nie widzisz potrzebnej reguły? Dodaj agenta', subtitle: 'Dodaj złożone reguły i ogranicz ręczne akceptacje dzięki własnym agentom.', cta: 'Wypróbuj'}, merchantRules: { @@ -8434,7 +8526,6 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i ifAnyExpenseMatches: 'Jeśli którykolwiek wydatek pasuje:', thenApplyFollowingDefaults: 'Następnie zastosuj następujące domyślne ustawienia:', vendorUnavailable: 'Dostawca niedostępny', - supplierUnavailable: 'Dostawca niedostępny', confirmErrorCategory: 'Wybierz kategorię', confirmErrorCategoryTax: 'Wybierz stawkę podatku', confirmErrorCondition: 'Wpisz sprzedawcę', @@ -8672,6 +8763,9 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, describeRuleForConcierge: 'Opisz swoją regułę, a Concierge ją utworzy', gotIt: 'Jasne', createRule: 'Utwórz regułę', + fallbackBanner: + 'To nie jest standardowa reguła, więc przygotowaliśmy ją jako regułę agenta. Reguły agenta działają po zmianie wydatku lub raportu, na przykład wstrzymując wydatek albo odrzucając raport.', + fallbackBannerWithExplanation: (explanation: string) => `To nie jest standardowa reguła, więc przygotowaliśmy ją jako regułę agenta. ${explanation}`, }, tabs: { general: 'Ogólne', @@ -8734,16 +8828,21 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, newRule: { title: 'Nowa reguła', subtitle: 'Co chcesz zrobić?', + startWithTemplate: 'Zacznij od szablonu', restrictCardSpend: 'Ogranicz wydatki kartą', restrictCardSpendDescription: 'Blokuj lub ograniczaj wydatki w punkcie sprzedaży', applyExpenseDefaults: 'Zastosuj domyślne ustawienia wydatków', - applyExpenseDefaultsDescription: 'Aktualizuj pola bez wymagania działania od osoby zgłaszającej', + applyExpenseDefaultsDescription: 'Aktualizuj pola automatycznie', flagForReview: 'Oznacz do przejrzenia', - flagForReviewDescription: 'Powiadamiaj zatwierdzających, gdy wydatki przekraczają limity kategorii', + flagForReviewDescription: 'Powiadamiaj, gdy Twoje warunki zostaną spełnione', requireFields: 'Ustaw wymagania pola', - requireFieldsDescription: 'Wymagaj konkretnych pól wydatku lub zrezygnuj z ich wymagania.', + requireFieldsShort: 'Wymagaj pól', + requireFieldsDescription: 'Paragony, kategorie itp.', createAgentRule: 'Opisz regułę agenta', createAgentRuleDescription: 'Twórz elastyczne reguły, które uruchamiają się wtedy, gdy ich potrzebujesz.', + describeItBelow: 'Lub po prostu opisz ją poniżej', + describeRulePlaceholder: 'Blokuj wszystkie wydatki powyżej 2000 $', + selectTemplateOrDescribe: 'Wybierz szablon lub opisz regułę, aby kontynuować', describe: 'Opisz', describeRule: 'Opisz swoją regułę własnymi słowami, a Concierge utworzy ją za Ciebie', describeRuleInputLabel: 'Opisz regułę', @@ -8751,6 +8850,15 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, unsupported: 'Ta reguła nie jest jeszcze obsługiwana — pracujemy nad tym. Na razie spróbuj innej reguły.', unintelligible: 'Spróbuj sformułować regułę tak: jeśli wydatek przekracza 100 $, wymagaj paragonu.', multipleRules: 'Jedna reguła naraz — opisz pojedynczą regułę, na przykład: zablokuj zakupy detaliczne na wszystkich kartach.', + categoryHasRule: 'Ta kategoria ma już regułę. Edytuj istniejącą regułę lub opisz regułę dla innej kategorii.', + }, + impossible: { + noTrigger: 'Reguły nie mogą zaczynać się od tego zdarzenia.', + noCapability: 'Reguły nie mogą tego zrobić.', + missingData: 'Expensify nie ma tych informacji.', + retroactive: 'Zasady mają zastosowanie tylko do nowej aktywności.', + historyLimit: 'Reguły nie mogą sięgać tak daleko wstecz.', + tryThisInstead: 'Spróbuj tego zamiast tego', }, }, expenseDefaultsTable: { @@ -8939,7 +9047,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, dontSeeYourATS: `Nie widzisz tutaj swojego ATS? Zapytaj Concierge, a my możemy go dodać.`, importSettings: 'Ustawienia importu', defaultApprover: 'Domyślny zatwierdzający', - approverFields: {recruiter: 'Rekruter', recruitingCoordinator: 'Koordynator rekrutacji'}, + approverFields: {recruiter: 'Rekruter', recruitingCoordinator: 'Koordynator rekrutacji', hiringManager: 'Rekruter'}, filters: { description: (providerName: string) => `Wybierz, którzy członkowie zostaną zaimportowani z ${providerName}. Możesz wybierać spośród etapów rekrutacji, tagów i biur.`, stages: { @@ -8955,7 +9063,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, allSelected: 'Wszystkie tagi', }, offices: {title: 'Biuro', description: 'Wybierz biura kandydatów, które chcesz zsynchronizować z tym miejscem pracy', toggleTitle: 'Biura', allSelected: 'Wszystkie biura'}, - enableJobStagesOrTags: 'Włącz etapy zleceń lub tagi, aby kontynuować', + enableJobStagesOrTags: 'Wybierz co najmniej jeden etap zadania lub znacznik, aby kontynuować', }, subtitle: 'Połącz narzędzia rekrutacyjne i utrzymuj zgody kandydatów w synchronizacji.', syncResults: { @@ -8968,7 +9076,12 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, finalApprover: `Ostateczny akceptujący`, finalApproverOptional: 'Ostateczny zatwierdzający (opcjonalnie)', approvalModeDescription: (providerName: string) => `Ustaw osobę zatwierdzającą dla nowych członków importowanych z ${providerName} do Expensify.`, - approverFieldDescription: (providerName: string) => `Wybierz pierwszą osobę akceptującą dla swoich kandydatów: Rekrutera lub Koordynatora przypisanego w ${providerName}.`, + approverFieldDescription: (providerName: string, approverFields: string[]) => { + const fieldNames = approverFields.map((approverField) => approverField.toLowerCase()); + const lastFieldName = fieldNames.at(-1) ?? ''; + const fieldList = fieldNames.length > 2 ? `ich ${fieldNames.slice(0, -1).join(', ')} lub ${lastFieldName}` : `albo ich ${fieldNames.join('lub ich')}`; + return `Wybierz pierwszą osobę zatwierdzającą dla swoich kandydatów: ${fieldList} przypisane w ${providerName}.`; + }, approvalModeDescriptions: { basic: 'Wybierz jedną osobę zatwierdzającą', advanced: `Rekruter lub koordynator kandydata zostaje jego akceptującym wydatki`, @@ -9106,6 +9219,9 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, }, }, + zohoBooks: { + organization: 'Organizacja Zoho Books', + }, businessCentral: { businessCentralSetup: 'Konfiguracja Dynamics 365 Business Central', prerequisitesTitle: 'Zanim się połączysz...', @@ -9166,6 +9282,50 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, noBankAccountsFoundDescription: 'Dodaj konta bankowe w Dynamics 365 Business Central i ponownie zsynchronizuj połączenie', noPaymentMethodsFound: 'Nie znaleziono metod płatności', noPaymentMethodsFoundDescription: 'Dodaj metody płatności w Dynamics 365 Business Central i ponownie zsynchronizuj połączenie', + advanced: { + autoSyncDescription: 'Synchronizuj Dynamics 365 Business Central i Expensify automatycznie, każdego dnia. Raporty synchronizują się w czasie rzeczywistym.', + accountingMethods: { + label: 'Metoda eksportu', + description: 'Wybierz, kiedy eksportować wydatki.', + values: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Memoria', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Gotówka', + }, + alternateText: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Wydatki z własnej kieszeni zostaną wyeksportowane po ostatecznym zatwierdzeniu', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Wydatki z własnej kieszeni zostaną wyeksportowane po opłaceniu', + }, + }, + postingMode: { + label: 'Tryb księgowania', + description: 'Wybierz, czy zapisy mają być księgowane automatycznie, czy najpierw wymagają ręcznej weryfikacji.', + note: 'Uwaga: Expensify księguje każdy otwarty dziennik w partii dziennika głównego wybranej na stronie Expensify Setup w Business Central, a nie tylko te, które samo utworzyło. Używaj partii tylko dla Expensify.', + values: { + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_AND_POST]: { + label: 'Utwórz i zaksięguj', + description: 'Automatycznie księguj zapisy w księdze głównej podczas eksportu', + }, + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_ONLY]: { + label: 'Tylko utwórz', + description: 'Ręcznie księguj zapisy w Dynamics 365 Business Central po eksporcie', + }, + }, + }, + autoCreateEntities: 'Automatycznie twórz pracowników/dostawców', + exportToNextOpenPeriod: 'Eksportuj do następnego otwartego okresu', + exportToNextOpenPeriodDescription: 'Gdy raport ma datę w zamkniętym okresie w Dynamics 365 Business Central, zostanie wyeksportowany do następnego otwartego okresu.', + syncReimbursedReports: 'Synchronizuj rozliczone raporty', + syncReimbursedReportsDescription: 'Gdy raport zostanie opłacony przez ACH, w tym koncie zostanie wygenerowany wiersz dziennika ogólnego.', + reimbursementBankAccount: { + label: 'Rachunek bankowy do płatności', + description: 'Wybierz, z którego konta chcesz opłacać rachunki, a my utworzymy płatność w Dynamics 365 Business Central.', + }, + syncExpensifyCardSettlements: 'Synchronizuj rozliczenia Karty Expensify', + settlementsBankAccount: { + label: 'Rachunek rozliczeniowy Karty Expensify', + description: 'Wybierz swoje konto rozliczeniowe, a my utworzymy płatność w Dynamics 365 Business Central.', + }, + }, accountsDescription: 'Twoje konta Dynamics 365 Business Central zostaną zaimportowane jako kategorie.', dimensionsImportAsTags: 'Wszystkie wymiary z Dynamics 365 Business Central są importowane jako tagi', customers: 'Klienci', @@ -9647,6 +9807,20 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `dodano „${prohibitedExpense}” do zabronionych wydatków`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `usunięto „${prohibitedExpense}” z wydatków zabronionych`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `dodał(a) lokalizację biura „${name}” (${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `zmienił(a) nazwę lokalizacji biura z „${oldName}” na „${newName}”`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `zmienił(a) adres lokalizacji biura „${name}” na „${newAddress}” (wcześniej „${oldAddress}”)`, + setAsPrimary: ({name}: {name: string}) => `ustaw lokalizację biura „${name}” jako główną`, + setCompanyAddressAsPrimary: 'ustaw firmowy adres jako główny', + removed: ({name}: {name: string}) => `usunięto lokalizację biura „${name}”`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `zmienił(-a) lokalizację biura użytkownika ${displayName} na ${officeLocation} (poprzednio ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `„${name}”`, + companyAddress: 'adres firmy', + primaryOffice: 'główne biuro', + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `ustaw domyślny tryb pracy na ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -9780,6 +9954,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, addCustomUnitRateWithAmountAndEndDate: (rateName: string, rateValue: string, endDate: string) => `dodano stawkę „${rateName}” w wysokości ${rateValue}, obowiązującą do ${endDate}`, addCustomUnitRateWithAmountAndDates: (rateName: string, rateValue: string, startDate: string, endDate: string) => `dodano stawkę „${rateName}” w wysokości ${rateValue}, obowiązującą od ${startDate} do ${endDate}`, + addCustomUnitRateTaxReclaimableSuffix: (taxReclaimableAmount: string, taxRateName: string) => `, z ${taxReclaimableAmount} podatku do odzyskania w ramach „${taxRateName}”`, updatedCustomUnitRateDateRange: (rateName: string, newDateRange: string, oldDateRange: string) => `zaktualizowano stawkę za dystans „${rateName}”, aby obowiązywała ${newDateRange} (wcześniej ${oldDateRange})`, customUnitRateDateRangeStartToEnd: (startDate: string, endDate: string) => `${startDate} - ${endDate}`, @@ -9870,6 +10045,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, `zmienił(a) tryb pracy użytkownika ${displayName} na ${newArrangement} (wcześniej ${oldArrangement})`, updatedDefaultWorkArrangement: ({newArrangement, oldArrangement}: {newArrangement: string; oldArrangement: string}) => `zmienił domyślny tryb pracy na ${newArrangement} (wcześniej ${oldArrangement})`, + preventPayoutNonReimbursableReports: (oldValue: string, newValue: string) => + `zaktualizowano „Zapobiegaj oznaczaniu raportów niepodlegających zwrotowi jako opłacone” na „${newValue === 'true' ? 'Włączone' : 'Wyłączone'}” (wcześniej „${oldValue === 'true' ? 'Włączone' : 'Wyłączone'}”)`, }, roomMembersPage: { memberNotFound: 'Nie znaleziono członka.', @@ -9924,6 +10101,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, fallbackTitle: 'Zgłoszenie do pomocy technicznej', resolved: 'To zgłoszenie do pomocy technicznej zostało rozwiązane.', surveyPrompt: 'Jak oceniasz swoje doświadczenie ze wsparciem?', + great: 'Świetne', + poor: 'Słabe', reopenTicket: 'Otwórz zgłoszenie ponownie', }, statementPage: { @@ -10074,6 +10253,12 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, customDay: 'Niestandardowy dzień', }, + describeSearch: { + title: 'Opisz swoje wyszukiwanie', + description: 'Opisz po prostu po angielsku, jakie wydatki chcesz zobaczyć, a my automatycznie zastosujemy odpowiednie filtry.', + inputLabel: 'Opisz swoje wyszukiwanie', + buttonText: 'Zastosuj', + }, status: 'Status', keyword: 'Słowo kluczowe', keywords: 'Słowa kluczowe', @@ -10109,6 +10294,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, past: 'Przeszłe', submitted: 'Przesłano', approved: 'Zatwierdzono', + anyApproval: 'Jakiekolwiek zatwierdzenie', firstApprover: 'Pierwszy zatwierdzający', firstApproved: 'Najpierw zatwierdzono', paidBy: 'Zapłacone przez', @@ -10117,6 +10303,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, posted: 'Opublikowano', withdrawn: 'Wycofano', billable: 'Fakturowalne', + rejected: 'Odrzucone', + held: 'Wstrzymane', reimbursable: 'Podlegające zwrotowi', purchaseCurrency: 'Waluta zakupu', sortOrder: { @@ -10151,6 +10339,11 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Eksportuj', }, filterType: {label: 'Typ filtra', has: {positive: 'ma', negative: 'nie ma'}, is: {positive: 'jest', negative: 'nie jest'}}, + merchant: { + matchType: 'Typ dopasowania', + contains: (merchant?: string) => (merchant ? `Zawiera "${merchant}"` : 'Zawiera'), + equalTo: (merchant?: string) => (merchant ? `Równe "${merchant}"` : 'Równe'), + }, created: 'Utworzono', createdDate: 'Data utworzenia', transactionStatus: { @@ -10329,6 +10522,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, type: { changeField: (oldValue: string | undefined, newValue: string, fieldName: string) => `zmieniono ${fieldName} na „${newValue}” (wcześniej „${oldValue}”)`, changeFieldEmpty: (newValue: string, fieldName: string) => `ustaw ${fieldName} na „${newValue}”`, + changeFieldRemoved: (oldValue: string, fieldName: string) => `usunięto ${fieldName} (wcześniej „${oldValue}”)`, + changeFieldCleared: (fieldName: string) => `usunięto ${fieldName}`, changeReportPolicy: (toPolicyName: string, fromPolicyName?: string) => { if (!toPolicyName) { return `zmieniono przestrzeń roboczą${fromPolicyName ? `(wcześniej ${fromPolicyName})` : ''}`; @@ -10810,7 +11005,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, duplicatedTransaction: 'Potencjalny duplikat', fieldRequired: 'Pola raportu są wymagane', futureDate: 'Przyszła data jest niedozwolona', - inactiveVendor: (isSupplier = false) => (isSupplier ? 'Dostawca nie jest już prawidłowy' : 'Dostawca nie jest już prawidłowy'), + inactiveVendor: 'Dostawca nie jest już prawidłowy', invoiceMarkup: (invoiceMarkup: number) => `Podwyższono o ${invoiceMarkup}%`, maxAge: (maxAge: number) => `Data starsza niż ${maxAge} dni`, missingCategory: 'Brak kategorii', @@ -11126,6 +11321,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, systemMessage: { mergedWithCashTransaction: 'dopasowano paragon do tej transakcji', + mergedExpenseDeleted: 'scalono wydatki i przeniesiono jeden do Usuniętych', }, subscription: { authenticatePaymentCard: 'Uwierzytelnij kartę płatniczą', @@ -11370,6 +11566,9 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, refundedOn: ({date}: {date: string}) => `Zwrócono ${date}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `Zwrócono ${amount} ${date}`, }, + billedTo: 'Obciążono', + viewReceipt: 'Wyświetl paragon', + viewInvoice: 'Wyświetl fakturę', }, subscriptionSettings: { title: 'Ustawienia subskrypcji', @@ -11927,22 +12126,6 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, addPaymentCardButton: 'Dodaj kartę płatniczą', }, monthPickerPage: {month: 'Miesiąc', selectMonth: 'Wybierz miesiąc'}, - aiFeaturesPromoModal: { - subtitle: 'Nowość w Concierge AI', - confirmText: 'Jedziemy!', - spendAnalysis: { - title: 'Interaktywna analiza wydatków', - description: `Concierge przedstawia miesięczne informacje o wydatkach i pozwala zagłębić się w szczegóły stojące za każdą liczbą. Dowiedz się więcej.`, - }, - expenseAssistant: { - title: 'Poznaj swojego nowego asystenta wydatków', - description: `Rozmawiaj z Concierge, aby tworzyć i aktualizować wydatki bezpośrednio w aplikacji, e-mailem lub SMS-em. Dowiedz się więcej.`, - }, - customAgents: { - title: 'Zbuduj własne agentów', - description: `Twórz niestandardowych agentów do przeglądania, zatwierdzania i kierowania wydatków na podstawie ustalonych przez siebie zasad. Dowiedz się więcej.`, - }, - }, emailIssuePage: { title: 'Problem z e‑mailem', intro: (login: string) => `Nasz dostawca poczty wstrzymał wysyłanie na adres ${login} z powodu problemów z dostarczaniem. Aby rozwiązać ten problem:`, @@ -11983,5 +12166,6 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, mobileRenewPrompt: 'Odwiedź Expensify w przeglądarce internetowej, żeby odnowić wcześniej.', mobileClaimPrompt: 'Odwiedź Expensify w swojej przeglądarce internetowej, aby odebrać zniżkę na odnowienie.', }, + bulkActionBar: {label: 'Akcje zbiorcze', clearSelection: 'Wyczyść wybór', loadingSelection: 'Wczytywanie zaznaczenia'}, }; export default translations; diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c48f53b822ab..1e791068623a 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -307,7 +307,6 @@ const translations: TranslationDeepObject = { change: 'Alterar', category: 'Categoria', vendor: 'Fornecedor', - supplier: 'Fornecedor', report: 'Relatório', billable: 'Faturável', nonBillable: 'Não faturável', @@ -530,6 +529,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'Não foi possível carregar o gráfico. Atualize a página e tente novamente.', apiKey: 'Chave de API', exportsTo: 'Exportações para', + shareReport: 'Compartilhar relatório', + reportShared: 'Relatório compartilhado', }, socials: { podcast: 'Siga-nos no Podcast', @@ -980,6 +981,7 @@ const translations: TranslationDeepObject = { addShippingAddress: {title: 'Precisamos do seu endereço de entrega', subtitle: 'Informe um endereço para receber seu Cartão Expensify.', cta: 'Adicionar'}, addPaymentCard: {title: 'Adicione um cartão de pagamento para continuar usando o Expensify', subtitle: 'Conta > Assinatura', cta: 'Adicionar'}, addBankAccount: {title: 'Adicione uma conta bancária para ser reembolsado'}, + addDepositAccount: {title: 'Adicione uma conta bancária para receber pagamentos'}, activateCard: {title: 'Ative seu Cartão Expensify', subtitle: 'Valide seu cartão e comece a gastar.', cta: 'Ativar'}, confirmDigitalWalletAddition: { subtitle: 'Cartão Expensify', @@ -1026,6 +1028,7 @@ const translations: TranslationDeepObject = { cta: 'Gerenciar', }, emailDeliveryFailure: {title: 'Não podemos enviar notificações por e-mail para você', subtitle: 'Conta'}, + upgradeNetSuiteConnection: {title: 'Atualize sua conexão com o NetSuite'}, }, forYouSection: { submit: ({count}: {count: number}) => ({ @@ -1173,7 +1176,18 @@ const translations: TranslationDeepObject = { viewOnSpend: 'Ver em Gastos', emptyState: {title: 'Nada para mostrar', subtitle: 'Tente ajustar seus critérios acima'}, noExpensesState: {title: 'Veja para onde vai o seu dinheiro', subtitle: 'Quando você tiver despesas, vai encontrar tendências de gastos, principais estabelecimentos e muito mais.'}, - compare: {label: 'Comparar', previousPeriod: 'Período anterior', average: 'Média'}, + compare: { + label: 'Comparar', + previousPeriod: 'Período anterior', + average: 'Média', + dayLabel: (day?: number) => (day === undefined ? 'Dia' : `Dia ${day}`), + weekLabel: (week?: number) => (week === undefined ? 'Semana' : `Semana ${week}`), + monthLabel: (month?: number) => (month === undefined ? 'Mês' : `Mês ${month}`), + quarterLabel: (quarter?: number) => (quarter === undefined ? 'Trimestre' : `Trimestre ${quarter}`), + yearLabel: (year?: number) => (year === undefined ? 'Ano' : `Ano ${year}`), + increase: (percentage: string) => `Alta de ${percentage}`, + decrease: (percentage: string) => `Queda de ${percentage}`, + }, }, allSettingsScreen: { subscription: 'Assinatura', @@ -2168,6 +2182,18 @@ const translations: TranslationDeepObject = { } return `Aguardando o pagamento ser concluído${formattedETA}.`; }, + [CONST.NEXT_STEP.MESSAGE_KEY.WAITING_FOR_CANCELLED_REIMBURSEMENT]: ( + _actor: string, + _actorType: ValueOf, + eta?: string, + etaType?: ValueOf, + ) => { + let formattedETA = ''; + if (eta) { + formattedETA = etaType === CONST.NEXT_STEP.ETA_TYPE.DATE_TIME ? ` até ${eta}` : ` ${eta}`; + } + return `Aguardando a conclusão do processamento do pagamento cancelado${formattedETA}.`; + }, [CONST.NEXT_STEP.MESSAGE_KEY.SUBMITTING_TO_SELF]: ( _actor: string, _actorType: ValueOf, @@ -2809,6 +2835,13 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'Esta conta precisa de atenção', unlock: 'Desbloquear', }, + wiseGlobalReimbursementsIntro: { + title: 'Configurar reembolsos globais?', + subtitle: 'Pague membros no mundo todo na moeda local deles.', + getStarted: 'Começar', + headerTitle: 'Reembolsos globais', + }, + wiseGlobalReimbursementsBanner: {title: 'Pague pessoas no mundo todo', subtitle: 'Ative reembolsos globais na sua conta em USD.'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Adicionar cartão à ${walletName}`, @@ -3923,6 +3956,11 @@ ${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}`, + until: (date: string) => `Até ${date}`, + removeDelegate: 'Remover delegado de férias', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -4013,6 +4051,10 @@ ${amount} para ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Solicitação já enviada', unlockAlreadyRequestedDescription: 'Sua solicitação para desbloquear esta conta bancária já foi enviada. O Concierge vai entrar em contato se for necessário mais alguma coisa.', }, + listField: { + editItem: ({name}: {name: string}) => `Editar ${name}`, + removeItem: ({name}: {name: string}) => `Remover ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Exemplo: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4024,7 +4066,13 @@ ${amount} para ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Formato inválido. Exemplo: ${example}` : 'Formato inválido'), invalidOption: 'Escolha uma das opções disponíveis', outOfRange: ({min, max}: {min: number; max: number}) => `Insira um valor entre ${min} e ${max}`, + tooFewItems: ({min}: {min: number}) => `Adicione pelo menos ${min}`, + tooManyItems: ({max}: {max: number}) => `Adicione no máximo ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, + addItem: ({item}: {item: string}) => `Adicionar ${item}`, + removeItemTitle: ({item}: {item: string}) => `Remover ${item}?`, + removeItemPrompt: ({name}: {name: string}) => `Tem certeza de que deseja remover ${name}?`, }, addPersonalBankAccount: { swiftBicFormatError: 'O SWIFT/BIC deve ter 8 ou 11 caracteres, com 6 letras seguidas de 2 ou 5 letras ou números.', @@ -4065,6 +4113,7 @@ ${amount} para ${merchant} - ${date}`, formLabel: 'Ver PDF', }, attachmentNotFound: 'Anexo não encontrado', + loadTimedOut: 'Esta imagem está demorando muito para carregar.', retry: 'Tentar novamente', }, messages: { @@ -5062,7 +5111,10 @@ ${amount} para ${merchant} - ${date}`, }, requestEmptyList: { title: 'Ainda não há diárias', - subtitle: 'Este espaço de trabalho não tem nenhuma diária definida, peça para o seu administrador adicionar algumas.', + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} não tem nenhuma taxa de diária. Peça para um administrador adicionar algumas.` + : 'Este espaço de trabalho não tem nenhuma taxa de diária. Peça para um administrador adicionar algumas.', adminSubtitle: 'Adicione diárias para organizar seus gastos.', }, importPerDiemRates: 'Importar tarifas de diária', @@ -5070,6 +5122,7 @@ ${amount} para ${merchant} - ${date}`, editPerDiemRates: 'Editar diárias', editDestinationSubtitle: (destination: string) => `Atualizar este destino irá alterá-lo para todas as subdiárias de ${destination}.`, editCurrencySubtitle: (destination: string) => `Atualizar esta moeda irá alterá-la para todas as subdiárias de ${destination}.`, + missingPerDiemUnit: 'O per diem não está configurado corretamente neste workspace. Fale com o Concierge para obter ajuda.', }, qbd: { exportOutOfPocketExpensesDescription: 'Defina como as despesas reembolsáveis serão exportadas para o QuickBooks Desktop.', @@ -5399,8 +5452,8 @@ ${amount} para ${merchant} - ${date}`, }, noAccountsFound: 'Nenhuma conta encontrada', noAccountsFoundDescription: 'Adicione a conta no Xero e sincronize a conexão novamente', - noSuppliersFound: 'Nenhum fornecedor encontrado', - noSuppliersFoundDescription: 'Adicione o fornecedor no Xero e sincronize a conexão novamente.', + noVendorsFound: 'Nenhum fornecedor encontrado', + noVendorsFoundDescription: 'Adicione fornecedores no Xero e sincronize a conexão novamente', accountingMethods: { label: 'Quando Exportar', description: 'Escolha quando exportar as despesas:', @@ -6594,6 +6647,8 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS distanceRates: { title: 'Taxas de distância', subtitle: 'Adicione, atualize e aplique tarifas.', + commuterExclusionsOnTitle: 'Exclusões de deslocamento estão ativadas', + disableLockedByCommuterExclusionsPrompt: 'Para desativar as tarifas de distância, primeiro defina Excluir deslocamentos como “Não excluir deslocamentos”.', }, perDiem: { title: 'Diária', @@ -6953,6 +7008,11 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS emptySubtitle: 'Os fornecedores vão aparecer aqui depois que a sincronização contábil for concluída.', findVendor: 'Encontrar fornecedor', managedInAccountingSoftware: 'Fornecedores são gerenciados no seu', + enableVendor: 'Ativar fornecedor', + enableVendors: 'Ativar fornecedores', + disableVendor: 'Desativar fornecedor', + disableVendors: 'Desativar fornecedores', + updateFailureMessage: 'Ocorreu um erro ao atualizar o fornecedor, tente novamente por favor', }, tags: { tagName: 'Nome da tag', @@ -6996,6 +7056,7 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS tagRules: 'Regras de tag', approverDescription: 'Aprovador', importTags: 'Importar tags', + appendCustomTagList: 'Anexar lista de tags personalizada', importTagsSupportingText: 'Classifique suas despesas com um tipo de etiqueta ou várias.', configureMultiLevelTags: 'Configure sua lista de tags para marcação em vários níveis.', importMultiLevelTagsSupportingText: `Aqui está uma prévia de suas tags. Se tudo estiver certo, clique abaixo para importá-las.`, @@ -7260,6 +7321,11 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, optionNoRegularWorkspaceHelp: 'O membro trabalha em casa ou viaja entre locais sem um escritório fixo, portanto as regras de deslocamento não se aplicam.', futureOnlyNote: 'As alterações se aplicam apenas aos cálculos de quilometragem futuros. As despesas de quilometragem existentes não são recalculadas.', }, + officeLocation: 'Local do escritório', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Alterar local do escritório', + changeWorkArrangement: 'Alterar regime de trabalho', + changeRole: 'Alterar função', }, card: { getStartedIssuing: 'Comece emitindo seu primeiro cartão virtual ou físico.', @@ -7372,6 +7438,12 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Há um erro em uma conexão configurada no Expensify Classic. [Acesse o Expensify Classic para corrigir esse problema.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Vá para o Expensify Classic para gerenciar suas configurações.', + exactOnline: { + comingSoon: 'Em breve', + joinWaitlist: 'Entrar na lista de espera', + onWaitlist: 'Você está na lista de espera', + waitlistDescription: 'O Concierge vai enviar uma mensagem para avisar quando essa integração estiver pronta e como conectá-la.', + }, setup: 'Conectar', connectToSandbox: 'Conectar ao sandbox', lastSync: (relativeDate: string) => `Última sincronização ${relativeDate}`, @@ -7654,6 +7726,9 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, businessCentral: 'Dynamics 365 Business Central', qboConnectionExpiring: ({date}: {date: string}) => `Sua conexão com o QuickBooks Online expira em ${date}.`, qboConnectionExpired: ({date}: {date: string}) => `Sua conexão com o QuickBooks Online expirou em ${date}.`, + netSuiteOAuthMigrationRequired: 'Atualize sua conexão com o NetSuite para OAuth 2.0 até fevereiro de 2027.', + netSuiteOAuthMigrationRequiredUrgent: 'Sua conexão com o NetSuite precisa ser atualizada imediatamente para OAuth 2.0.', + netSuiteOAuthUpgradeNow: 'Atualize agora', }, export: { notReadyHeading: 'Não está pronto para exportar', @@ -7830,6 +7905,21 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, addressContext: 'É necessário um Endereço do Espaço de Trabalho para ativar o Expensify Travel. Insira um endereço associado à sua empresa.', policy: 'Política de despesas', }, + officeLocations: { + title: 'Locais do escritório', + subtitle: 'Adicione locais de escritório para atribuir aos membros, por exemplo, para rastreamento de distância.', + addOfficeLocation: 'Adicionar local do escritório', + editOfficeLocation: 'Editar localização do escritório', + locationName: 'Nome do local', + locationNameHint: 'Escolha um apelido para o local se você estiver adicionando vários escritórios', + defaultName: ({officeNumber}: {officeNumber: number}) => `Local do escritório ${officeNumber}`, + setAsPrimary: 'Definir como principal', + primary: 'Principal', + deleteOfficeLocation: 'Excluir local do escritório?', + deleteOfficeLocationConfirmation: 'Você realmente quer excluir este escritório? Os membros serão realocados para o escritório principal.', + existingOfficeLocationError: 'Já existe um local de escritório com esse nome', + setCompanyAddressAsPrimaryConfirmation: 'Definir o endereço da empresa como escritório principal?', + }, bankAccount: { continueWithSetup: 'Continuar configuração', youAreAlmostDone: @@ -8363,6 +8453,8 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `Adicione ${featureName} para desbloquear este recurso.`, enableFeatureSubtitle: (featureName: string, moreFeaturesLink?: string) => `Vá para [mais recursos](${moreFeaturesLink}) e ative ${featureName} para desbloquear este recurso.`, + preventPayoutNonReimbursableReportsTitle: 'Impedir marcar relatórios não reembolsáveis como pagos', + preventPayoutNonReimbursableReportsSubtitle: 'Não permitir que admins marquem relatórios não reembolsáveis como pagos.', }, agentsPromoBanner: { title: 'Não encontrou a regra que precisa? Adicione um agente', @@ -8416,7 +8508,6 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e ifAnyExpenseMatches: 'Se alguma despesa corresponder:', thenApplyFollowingDefaults: 'Em seguida, aplica os seguintes padrões:', vendorUnavailable: 'Fornecedor indisponível', - supplierUnavailable: 'Fornecedor indisponível', confirmErrorCategory: 'Selecione uma categoria', confirmErrorCategoryTax: 'Selecione uma alíquota de imposto', confirmErrorCondition: 'Insira um estabelecimento', @@ -8654,6 +8745,9 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, describeRuleForConcierge: 'Descreva sua regra e o Concierge vai criá-la', gotIt: 'Entendi', createRule: 'Criar regra', + fallbackBanner: + 'Esta não é uma regra padrão, então a criamos como uma regra de agente. As regras de agente entram em ação depois que uma despesa ou um relatório é alterado, por exemplo, ao reter a despesa ou rejeitar o relatório.', + fallbackBannerWithExplanation: (explanation: string) => `Esta não é uma regra padrão, então nós a elaboramos como uma regra de agente. ${explanation}`, }, tabs: { general: 'Geral', @@ -8716,16 +8810,21 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, newRule: { title: 'Nova regra', subtitle: 'O que você quer fazer?', + startWithTemplate: 'Comece com um modelo', restrictCardSpend: 'Restringir gastos do cartão', restrictCardSpendDescription: 'Bloqueie ou limite gastos no ponto de venda', applyExpenseDefaults: 'Aplicar padrões de despesa', - applyExpenseDefaultsDescription: 'Atualizar campos sem o responsável pelo envio fazer nada', + applyExpenseDefaultsDescription: 'Atualizar campos automaticamente', flagForReview: 'Marcar para revisão', - flagForReviewDescription: 'Notificar aprovadores quando as despesas excederem os limites da categoria', + flagForReviewDescription: 'Notificar quando suas condições forem atendidas', requireFields: 'Definir requisitos de campo', - requireFieldsDescription: 'Exigir campos específicos de despesa ou dispensar a exigência deles.', + requireFieldsShort: 'Exigir campos', + requireFieldsDescription: 'Recibos, categorias etc.', createAgentRule: 'Descrever regra do agente', createAgentRuleDescription: 'Crie regras flexíveis que sejam executadas quando você precisar.', + describeItBelow: 'Ou simplesmente descreva abaixo', + describeRulePlaceholder: 'Bloquear todos os gastos acima de $2.000', + selectTemplateOrDescribe: 'Selecione um modelo ou descreva uma regra para continuar', describe: 'Descrever', describeRule: 'Descreva sua regra em linguagem simples e o Concierge criará a regra para você', describeRuleInputLabel: 'Descreva a regra', @@ -8733,6 +8832,15 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, unsupported: 'Essa regra ainda não tem suporte — estamos trabalhando nisso. Tente uma regra diferente por enquanto.', unintelligible: 'Tente formular sua regra assim: se a despesa for maior que $100, exija um recibo.', multipleRules: 'Uma regra por vez, por favor — tente descrever uma única regra, como: bloquear compras no varejo em todos os cartões.', + categoryHasRule: 'Essa categoria já tem uma regra. Edite a regra existente ou descreva uma regra para outra categoria.', + }, + impossible: { + noTrigger: 'Regras não podem começar a partir desse evento.', + noCapability: 'Regras não podem fazer isso.', + missingData: 'A Expensify não tem essa informação.', + retroactive: 'As regras se aplicam apenas à nova atividade.', + historyLimit: 'As regras não conseguem olhar tão para trás.', + tryThisInstead: 'Tente isto em vez disso', }, }, expenseDefaultsTable: { @@ -8921,7 +9029,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, dontSeeYourATS: `Não está vendo seu ATS aqui? Pergunte ao Concierge e podemos adicioná-lo.`, importSettings: 'Importar configurações', defaultApprover: 'Aprovador padrão', - approverFields: {recruiter: 'Recrutador', recruitingCoordinator: 'Coordenador de recrutamento'}, + approverFields: {recruiter: 'Recrutador', recruitingCoordinator: 'Coordenador de recrutamento', hiringManager: 'Gerente de contratação'}, filters: { description: (providerName: string) => `Selecione quais membros serão importados de ${providerName}. Você pode escolher por etapas do trabalho, tags e escritórios.`, stages: { @@ -8942,7 +9050,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, toggleTitle: 'Escritórios', allSelected: 'Todos os escritórios', }, - enableJobStagesOrTags: 'Ative Estágios de Trabalho ou Tags para continuar', + enableJobStagesOrTags: 'Selecione pelo menos um estágio de trabalho ou etiqueta para continuar', }, subtitle: 'Conecte ferramentas de recrutamento e mantenha as aprovações de candidatos em sincronia.', syncResults: { @@ -8955,7 +9063,12 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, finalApprover: `Aprovador final`, finalApproverOptional: 'Aprovador final (opcional)', approvalModeDescription: (providerName: string) => `Defina o aprovador para novos membros importados de ${providerName} para o Expensify.`, - approverFieldDescription: (providerName: string) => `Escolha o primeiro aprovador para seus candidatos: o recrutador ou o coordenador atribuídos a eles em ${providerName}.`, + approverFieldDescription: (providerName: string, approverFields: string[]) => { + const fieldNames = approverFields.map((approverField) => approverField.toLowerCase()); + const lastFieldName = fieldNames.at(-1) ?? ''; + const fieldList = fieldNames.length > 2 ? `seu(s) ${fieldNames.slice(0, -1).join(', ')} ou ${lastFieldName}` : `ou sua ${fieldNames.join('ou de seus')}`; + return `Escolha o primeiro aprovador para seus candidatos: ${fieldList} atribuídos em ${providerName}.`; + }, approvalModeDescriptions: { basic: 'Escolha um único aprovador', advanced: `O recrutador ou coordenador da pessoa candidata se torna o aprovador de despesas dela`, @@ -9096,6 +9209,9 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, }, }, + zohoBooks: { + organization: 'Organização Zoho Books', + }, businessCentral: { businessCentralSetup: 'Configuração do Dynamics 365 Business Central', prerequisitesTitle: 'Antes de você se conectar...', @@ -9156,6 +9272,50 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, noBankAccountsFoundDescription: 'Adicione contas bancárias no Dynamics 365 Business Central e sincronize a conexão novamente', noPaymentMethodsFound: 'Nenhuma forma de pagamento encontrada', noPaymentMethodsFoundDescription: 'Adicione formas de pagamento no Dynamics 365 Business Central e sincronize a conexão novamente', + advanced: { + autoSyncDescription: 'Sincronize o Dynamics 365 Business Central e o Expensify automaticamente, todos os dias. Os relatórios são sincronizados em tempo real.', + accountingMethods: { + label: 'Método de exportação', + description: 'Escolha quando exportar despesas.', + values: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Regime de competência', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Dinheiro', + }, + alternateText: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: 'Despesas reembolsáveis serão exportadas quando forem aprovadas em definitivo', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: 'Despesas reembolsáveis serão exportadas quando forem pagas', + }, + }, + postingMode: { + label: 'Modo de lançamento', + description: 'Escolha se os lançamentos são registrados automaticamente ou exigem revisão manual primeiro.', + note: 'Observação: o Expensify registra todos os diários abertos no lote do diário geral que você escolheu na página Expensify Setup no Business Central, não apenas os que ele criou. Use um lote somente para o Expensify.', + values: { + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_AND_POST]: { + label: 'Criar e registrar', + description: 'Registrar lançamentos no razão geral automaticamente ao exportar', + }, + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_ONLY]: { + label: 'Somente criar', + description: 'Registrar lançamentos manualmente no Dynamics 365 Business Central após a exportação', + }, + }, + }, + autoCreateEntities: 'Criar funcionários/fornecedores automaticamente', + exportToNextOpenPeriod: 'Exportar para o próximo período em aberto', + exportToNextOpenPeriodDescription: 'Quando um relatório tem data em um período fechado do Dynamics 365 Business Central, ele é exportado para o próximo período aberto.', + syncReimbursedReports: 'Sincronizar relatórios reembolsados', + syncReimbursedReportsDescription: 'Quando um relatório é pago via ACH, uma linha de lançamento contábil será gerada nesta conta.', + reimbursementBankAccount: { + label: 'Conta bancária de pagamento', + description: 'Escolha de onde pagar as contas e nós vamos criar o pagamento no Dynamics 365 Business Central.', + }, + syncExpensifyCardSettlements: 'Sincronizar liquidações do Cartão Expensify', + settlementsBankAccount: { + label: 'Conta de liquidação do Cartão Expensify', + description: 'Escolha sua conta de liquidação e nós vamos criar o pagamento no Dynamics 365 Business Central.', + }, + }, accountsDescription: 'Suas contas do Dynamics 365 Business Central serão importadas como categorias.', dimensionsImportAsTags: 'Todas as dimensões do Dynamics 365 Business Central são importadas como etiquetas', customers: 'Clientes', @@ -9633,6 +9793,20 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `adicionou "${prohibitedExpense}" às despesas proibidas`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `removeu "${prohibitedExpense}" das despesas proibidas`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `adicionou a localização do escritório "${name}" (${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `renomeou o local do escritório de "${oldName}" para "${newName}"`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `alterou o endereço da localização do escritório "${name}" para "${newAddress}" (anteriormente "${oldAddress}")`, + setAsPrimary: ({name}: {name: string}) => `definir o local de escritório "${name}" como principal`, + setCompanyAddressAsPrimary: 'definir o endereço da empresa como principal', + removed: ({name}: {name: string}) => `removeu o local do escritório "${name}"`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `alterou o local de trabalho de ${displayName} para ${officeLocation} (anteriormente ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `"${name}"`, + companyAddress: 'o endereço da empresa', + primaryOffice: 'o escritório principal', + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `definir o arranjo de trabalho padrão como ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => @@ -9767,6 +9941,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, addCustomUnitRateWithAmountAndEndDate: (rateName: string, rateValue: string, endDate: string) => `adicionou a taxa "${rateName}" de ${rateValue}, válida até ${endDate}`, addCustomUnitRateWithAmountAndDates: (rateName: string, rateValue: string, startDate: string, endDate: string) => `adicionou a tarifa "${rateName}" de ${rateValue}, válida de ${startDate} a ${endDate}`, + addCustomUnitRateTaxReclaimableSuffix: (taxReclaimableAmount: string, taxRateName: string) => `, com ${taxReclaimableAmount} de imposto recuperável em "${taxRateName}"`, updatedCustomUnitRateDateRange: (rateName: string, newDateRange: string, oldDateRange: string) => `atualizou a taxa de distância "${rateName}" para se aplicar a ${newDateRange} (anteriormente ${oldDateRange})`, customUnitRateDateRangeStartToEnd: (startDate: string, endDate: string) => `${startDate} - ${endDate}`, @@ -9857,6 +10032,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, `alterou o regime de trabalho de ${displayName} para ${newArrangement} (antes ${oldArrangement})`, updatedDefaultWorkArrangement: ({newArrangement, oldArrangement}: {newArrangement: string; oldArrangement: string}) => `alterou o regime de trabalho padrão para ${newArrangement} (antes ${oldArrangement})`, + preventPayoutNonReimbursableReports: (oldValue: string, newValue: string) => + `atualizou "Impedir marcar relatórios não reembolsáveis como pagos" para "${newValue === 'true' ? 'Ativado' : 'Desativado'}" (antes "${oldValue === 'true' ? 'Ativado' : 'Desativado'}")`, }, roomMembersPage: { memberNotFound: 'Membro não encontrado.', @@ -9911,6 +10088,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, fallbackTitle: 'Chamado de suporte', resolved: 'Este chamado de suporte foi resolvido.', surveyPrompt: 'Como foi sua experiência com o suporte?', + great: 'Ótima', + poor: 'Ruim', reopenTicket: 'Reabrir chamado', }, statementPage: { @@ -10055,6 +10234,12 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, customDay: 'Dia personalizado', }, + describeSearch: { + title: 'Descreva sua pesquisa', + description: 'Descreva em inglês simples quais despesas você quer ver e nós aplicaremos os filtros certos automaticamente.', + inputLabel: 'Descreva sua pesquisa', + buttonText: 'Aplicar', + }, status: 'Status', keyword: 'Palavra-chave', keywords: 'Palavras-chave', @@ -10090,6 +10275,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, past: 'Passado', submitted: 'Enviado', approved: 'Aprovado', + anyApproval: 'Qualquer aprovação', firstApprover: 'Primeira pessoa aprovadora', firstApproved: 'Primeira aprovada', paidBy: 'Pago por', @@ -10098,6 +10284,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, posted: 'Publicado', withdrawn: 'Retirado', billable: 'Faturável', + rejected: 'Rejeitado', + held: 'Em espera', reimbursable: 'Reembolsável', purchaseCurrency: 'Moeda de compra', sortOrder: { @@ -10132,6 +10320,11 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Exportar', }, filterType: {label: 'Tipo de filtro', has: {positive: 'tem', negative: 'não tem'}, is: {positive: 'é', negative: 'não é'}}, + merchant: { + matchType: 'Tipo de correspondência', + contains: (merchant?: string) => (merchant ? `Contém "${merchant}"` : 'Contém'), + equalTo: (merchant?: string) => (merchant ? `Igual a "${merchant}"` : 'Igual a'), + }, created: 'Criado', createdDate: 'Data de criação', transactionStatus: { @@ -10311,6 +10504,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, type: { changeField: (oldValue: string | undefined, newValue: string, fieldName: string) => `alterou ${fieldName} para "${newValue}" (antes "${oldValue}")`, changeFieldEmpty: (newValue: string, fieldName: string) => `definir ${fieldName} como "${newValue}"`, + changeFieldRemoved: (oldValue: string, fieldName: string) => `removeu ${fieldName} (antes "${oldValue}")`, + changeFieldCleared: (fieldName: string) => `removeu ${fieldName}`, changeReportPolicy: (toPolicyName: string, fromPolicyName?: string) => { if (!toPolicyName) { return `alterou o espaço de trabalho${fromPolicyName ? `(antes ${fromPolicyName})` : ''}`; @@ -10791,7 +10986,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, duplicatedTransaction: 'Possível duplicata', fieldRequired: 'Os campos do relatório são obrigatórios', futureDate: 'Data futura não permitida', - inactiveVendor: (isSupplier = false) => (isSupplier ? 'Fornecedor não é mais válido' : 'Fornecedor não é mais válido'), + inactiveVendor: 'Fornecedor não é mais válido', invoiceMarkup: (invoiceMarkup: number) => `Reajustado em ${invoiceMarkup}%`, maxAge: (maxAge: number) => `Data anterior a ${maxAge} dias`, missingCategory: 'Categoria ausente', @@ -11107,6 +11302,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, systemMessage: { mergedWithCashTransaction: 'correspondeu um recibo a esta transação', + mergedExpenseDeleted: 'mesclou despesas e moveu uma para Excluídas', }, subscription: { authenticatePaymentCard: 'Autenticar cartão de pagamento', @@ -11342,6 +11538,9 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, refundedOn: ({date}: {date: string}) => `Reembolsado em ${date}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${amount} reembolsado em ${date}`, }, + billedTo: 'Cobrado de', + viewReceipt: 'Ver recibo', + viewInvoice: 'Ver fatura', }, subscriptionSettings: { title: 'Configurações de assinatura', @@ -11900,22 +12099,6 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, addPaymentCardButton: 'Adicionar cartão de pagamento', }, monthPickerPage: {month: 'Mês', selectMonth: 'Selecione um mês por favor'}, - aiFeaturesPromoModal: { - subtitle: 'Novo no Concierge AI', - confirmText: 'Vamos lá!', - spendAnalysis: { - title: 'Análise interativa de gastos', - description: `O Concierge apresenta insights mensais de gastos e permite que você aprofunde nos detalhes por trás de cada número. Saiba mais.`, - }, - expenseAssistant: { - title: 'Conheça seu novo assistente de despesas', - description: `Converse com o Concierge para criar e atualizar despesas, direto no app ou por e-mail ou SMS. Saiba mais.`, - }, - customAgents: { - title: 'Crie seus próprios agentes', - description: `Crie agentes personalizados para revisar, aprovar e direcionar despesas com base nas regras que você definir. Saiba mais.`, - }, - }, emailIssuePage: { title: 'Problema de e-mail', intro: (login: string) => `Nosso provedor de e-mail pausou o envio para ${login} devido a problemas de entrega. Para resolver esse problema:`, @@ -11956,5 +12139,6 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, mobileRenewPrompt: 'Visite o Expensify no seu navegador para renovar antecipadamente.', mobileClaimPrompt: 'Visite o Expensify no seu navegador para resgatar seu desconto de renovação.', }, + bulkActionBar: {label: 'Ações em massa', clearSelection: 'Limpar seleção', loadingSelection: 'Carregando seleção'}, }; export default translations; diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 80c14fa5170f..782c3e3d9750 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -307,7 +307,6 @@ const translations: TranslationDeepObject = { change: '更改', category: '类别', vendor: '供应商', - supplier: '供应商', report: '报表', billable: '可计费', nonBillable: '不可计费', @@ -526,6 +525,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: '无法加载图表。请刷新页面后重试。', apiKey: 'API 密钥', exportsTo: '导出到', + shareReport: '分享报表', + reportShared: '报表已分享', }, socials: { podcast: '在播客上关注我们', @@ -946,6 +947,7 @@ const translations: TranslationDeepObject = { addShippingAddress: {title: '我们需要您的收货地址', subtitle: '请提供一个地址以接收您的 Expensify 卡。', cta: '添加'}, addPaymentCard: {title: '添加支付卡以继续使用 Expensify', subtitle: '账户 > 订阅', cta: '添加'}, addBankAccount: {title: '添加银行账户以接收报销'}, + addDepositAccount: {title: '添加银行账户以接收付款'}, activateCard: {title: '激活你的 Expensify 卡', subtitle: '验证您的银行卡并开始消费。', cta: '启用'}, confirmDigitalWalletAddition: { subtitle: 'Expensify 卡', @@ -989,6 +991,7 @@ const translations: TranslationDeepObject = { cta: '管理', }, emailDeliveryFailure: {title: '我们无法向你发送电子邮件通知', subtitle: '账户'}, + upgradeNetSuiteConnection: {title: '升级您的 NetSuite 连接'}, }, forYouSection: { submit: ({count}: {count: number}) => ({ @@ -1127,7 +1130,18 @@ const translations: TranslationDeepObject = { viewOnSpend: '在支出中查看', emptyState: {title: '没有可显示的内容', subtitle: '请尝试调整上面的条件'}, noExpensesState: {title: '查看你的资金流向', subtitle: '添加报销后,您就能查看消费趋势、主要商家等更多信息。'}, - compare: {label: '比较', previousPeriod: '上一期', average: '平均'}, + compare: { + label: '比较', + previousPeriod: '上一期', + average: '平均', + dayLabel: (day?: number) => (day === undefined ? '天' : `第 ${day} 天`), + weekLabel: (week?: number) => (week === undefined ? '周' : `第 ${week} 周`), + monthLabel: (month?: number) => (month === undefined ? '月' : `第 ${month} 个月`), + quarterLabel: (quarter?: number) => (quarter === undefined ? '季度' : `第 ${quarter} 季度`), + yearLabel: (year?: number) => (year === undefined ? '年份' : `第 ${year} 年`), + increase: (percentage: string) => `上升 ${percentage}`, + decrease: (percentage: string) => `下降 ${percentage}`, + }, }, allSettingsScreen: { subscription: '订阅', @@ -2099,6 +2113,18 @@ const translations: TranslationDeepObject = { } return `正在等待付款完成${formattedETA}。`; }, + [CONST.NEXT_STEP.MESSAGE_KEY.WAITING_FOR_CANCELLED_REIMBURSEMENT]: ( + _actor: string, + _actorType: ValueOf, + eta?: string, + etaType?: ValueOf, + ) => { + let formattedETA = ''; + if (eta) { + formattedETA = etaType === CONST.NEXT_STEP.ETA_TYPE.DATE_TIME ? `于 ${eta} 前` : ` ${eta}`; + } + return `正在等待已取消的付款完成处理${formattedETA}。`; + }, [CONST.NEXT_STEP.MESSAGE_KEY.SUBMITTING_TO_SELF]: ( _actor: string, _actorType: ValueOf, @@ -2719,6 +2745,8 @@ const translations: TranslationDeepObject = { accountRequiresAttention: '此账户需要处理', unlock: '解锁', }, + wiseGlobalReimbursementsIntro: {title: '设置全球报销?', subtitle: '按当地货币向全球成员付款。', getStarted: '开始使用', headerTitle: '全球报销'}, + wiseGlobalReimbursementsBanner: {title: '向全球付款', subtitle: '在您的美元账户中启用全球报销。'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `将卡添加到 ${walletName}`, @@ -3812,6 +3840,11 @@ ${amount},商户:${merchant} - 日期:${date}`, `${delegate} 不是以下工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。你要现在邀请他们加入你管理的工作区吗?`, youAreAMemberOf: '你是这些工作区的成员:', youAreAnAdminOf: '你是这些工作区的管理员:', + chooseDelegate: '选择代理人', + clearAfterRecommended: '清除日期(推荐)', + willClearOn: (date: string) => `休假代理将于 ${date} 清除`, + until: (date: string) => `直到 ${date}`, + removeDelegate: '移除休假代理', }, }, stepCounter: (step: number, total?: number, text?: string) => { @@ -3900,6 +3933,10 @@ ${amount},商户:${merchant} - 日期:${date}`, unlockAlreadyRequestedTitle: '请求已提交', unlockAlreadyRequestedDescription: '您解锁此银行账户的请求已发送。如需其他信息,Concierge 会与您联系。', }, + listField: { + editItem: ({name}: {name: string}) => `编辑${name}`, + removeItem: ({name}: {name: string}) => `移除${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `示例:${example}`, fileCount: ({count}: {count: number}) => ({ @@ -3911,7 +3948,13 @@ ${amount},商户:${merchant} - 日期:${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `格式无效。示例:${example}` : '格式无效'), invalidOption: '请从可用选项中选择', outOfRange: ({min, max}: {min: number; max: number}) => `请输入 ${min} 到 ${max} 之间的值`, + tooFewItems: ({min}: {min: number}) => `至少添加 ${min} 项`, + tooManyItems: ({max}: {max: number}) => `最多添加 ${max} 项`, + itemError: ({item, error}: {item: string; error: string}) => `${item}:${error}`, }, + addItem: ({item}: {item: string}) => `添加${item}`, + removeItemTitle: ({item}: {item: string}) => `移除${item}?`, + removeItemPrompt: ({name}: {name: string}) => `确定要移除${name}吗?`, }, addPersonalBankAccount: { swiftBicFormatError: 'SWIFT/BIC 必须为 8 或 11 个字符长度,由 6 个字母后接 2 或 5 个字母或数字组成。', @@ -3952,6 +3995,7 @@ ${amount},商户:${merchant} - 日期:${date}`, formLabel: '查看 PDF', }, attachmentNotFound: '未找到附件', + loadTimedOut: '此图片加载时间过长。', retry: '重试', }, messages: { @@ -4917,12 +4961,18 @@ ${amount},商户:${merchant} - 日期:${date}`, title: '每日津贴', subtitle: '设置每日津贴标准以控制员工每日支出。可从电子表格导入标准以开始使用。', }, - requestEmptyList: {title: '尚未设置每日津贴费率', subtitle: '此工作区尚未设置任何每日津贴费率,请联系管理员添加。', adminSubtitle: '添加每日津贴标准,以整理您的支出。'}, + requestEmptyList: { + title: '尚未设置每日津贴费率', + subtitle: (workspaceName?: string) => + workspaceName ? `${workspaceName} 没有任何每日津贴标准。请联系管理员添加一些。` : '此工作区没有任何每日津贴标准。请联系管理员添加一些。', + adminSubtitle: '添加每日津贴标准,以整理您的支出。', + }, importPerDiemRates: '导入每日津贴费率', editPerDiemRate: '编辑每日补贴费率', editPerDiemRates: '编辑每日津贴费率', editDestinationSubtitle: (destination: string) => `更新此目的地将会更改所有 ${destination} 按日津贴子费率的设置。`, editCurrencySubtitle: (destination: string) => `更新此货币将会更改所有 ${destination} 的每日津贴子费率。`, + missingPerDiemUnit: '此工作区的每日津贴设置不正确。请联系 Concierge 获取帮助。', }, qbd: { exportOutOfPocketExpensesDescription: '设置自掏腰包报销如何导出到 QuickBooks Desktop。', @@ -5231,8 +5281,8 @@ ${amount},商户:${merchant} - 日期:${date}`, }, noAccountsFound: '未找到账户', noAccountsFoundDescription: '请在 Xero 中添加该账户,然后再次同步连接', - noSuppliersFound: '未找到供应商', - noSuppliersFoundDescription: '请在 Xero 中添加该供应商,然后再次同步连接。', + noVendorsFound: '未找到供应商', + noVendorsFoundDescription: '请在 Xero 中添加供应商,然后再次同步连接', accountingMethods: { label: '何时导出', description: '选择何时导出报销:', @@ -6379,6 +6429,8 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM distanceRates: { title: '距离费率', subtitle: '添加、更新并执行费率。', + commuterExclusionsOnTitle: '通勤排除已开启', + disableLockedByCommuterExclusionsPrompt: '若要关闭距离费率,首先将“排除通勤”设置为“不要排除通勤”。', }, perDiem: { title: '每日津贴', @@ -6704,7 +6756,17 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM invoiceFieldInitialValueRequiredError: '请选择发票字段的初始值', addField: '添加字段', }, - vendors: {emptyTitle: '尚无供应商', emptySubtitle: '会计同步完成后,供应商将显示在此处。', findVendor: '查找供应商', managedInAccountingSoftware: '供应商在您的'}, + vendors: { + emptyTitle: '尚无供应商', + emptySubtitle: '会计同步完成后,供应商将显示在此处。', + findVendor: '查找供应商', + managedInAccountingSoftware: '供应商在您的', + enableVendor: '启用供应商', + enableVendors: '启用供应商', + disableVendor: '停用供应商', + disableVendors: '停用供应商', + updateFailureMessage: '更新供应商时出错,请重试', + }, tags: { tagName: '标签名称', requiresTag: '成员必须为所有报销添加标签', @@ -6747,6 +6809,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM tagRules: '标签规则', approverDescription: '审批人', importTags: '导入标签', + appendCustomTagList: '追加自定义标签列表', importTagsSupportingText: '为报销编码时可使用一种标签,也可使用多种标签。', configureMultiLevelTags: '为多级标签配置您的标签列表。', importMultiLevelTagsSupportingText: `这是您的标签预览。如果一切看起来都没问题,请点击下方以导入它们。`, @@ -7002,6 +7065,11 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM optionNoRegularWorkspaceHelp: '成员在家办公或在各地点之间出差,没有固定办公地点,因此通勤规则不适用。', futureOnlyNote: '更改仅适用于未来的里程计算,现有的里程报销将不会重新计算。', }, + officeLocation: '办公室地点', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name}(${address})`, + changeOfficeLocation: '更改办公室位置', + changeWorkArrangement: '更改工作安排', + changeRole: '更改角色', }, card: { getStartedIssuing: '从发放您的第一张虚拟卡或实体卡开始使用。', @@ -7113,6 +7181,12 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM }, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Expensify Classic 中已设置的某个连接出错。[前往 Expensify Classic 解决此问题](${oldDotPolicyConnectionsURL})`, goToODToSettings: '前往 Expensify Classic 管理您的设置。', + exactOnline: { + comingSoon: '即将推出', + joinWaitlist: '加入候补名单', + onWaitlist: '您在候补名单中', + waitlistDescription: '当此集成准备就绪并可连接时,Concierge 会向你发送消息通知你并说明如何连接。', + }, setup: '连接', connectToSandbox: '连接到沙盒', lastSync: (relativeDate: string) => `上次同步于 ${relativeDate}`, @@ -7389,6 +7463,9 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM businessCentral: 'Dynamics 365 Business Central', qboConnectionExpiring: ({date}: {date: string}) => `您的 QuickBooks Online 连接将于 ${date} 过期。`, qboConnectionExpired: ({date}: {date: string}) => `您与 QuickBooks Online 的连接已于 ${date} 过期。`, + netSuiteOAuthMigrationRequired: '请在 2027 年 2 月前将您的 NetSuite 连接升级到 OAuth 2.0。', + netSuiteOAuthMigrationRequiredUrgent: '您的 NetSuite 连接需要立即升级到 OAuth 2.0。', + netSuiteOAuthUpgradeNow: '立即升级', }, export: { notReadyHeading: '尚未准备好导出', @@ -7557,6 +7634,21 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM addressContext: '要启用 Expensify Travel,需要填写一个工作区地址。请输入与您企业关联的地址。', policy: '报销政策', }, + officeLocations: { + title: '办公地点', + subtitle: '添加办公地点,以便为成员分配用于里程跟踪等用途。', + addOfficeLocation: '添加办公地点', + editOfficeLocation: '编辑办公室位置', + locationName: '位置名称', + locationNameHint: '如果你要添加多个办公地点,请为每个地点选择一个昵称', + defaultName: ({officeNumber}: {officeNumber: number}) => `办公室位置 ${officeNumber}`, + setAsPrimary: '设为主要', + primary: '主要', + deleteOfficeLocation: '删除办公地点?', + deleteOfficeLocationConfirmation: '你确定要删除此办公地点吗?成员将被重新分配到主办公地点。', + existingOfficeLocationError: '已存在同名的办公地点', + setCompanyAddressAsPrimaryConfirmation: '将公司地址设为主要办公室?', + }, bankAccount: { continueWithSetup: '继续设置', youAreAlmostDone: '您即将完成企业银行账户的设置,这将使您能够发放公司卡、报销费用、收取发票和支付账单。', @@ -8071,6 +8163,8 @@ ${reportName}`, autoPayReportsUnderDescription: '在此金额以下且完全合规的报销单将被自动支付。', unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `添加 ${featureName} 以解锁此功能。`, enableFeatureSubtitle: (featureName: string, moreFeaturesLink?: string) => `前往[更多功能](${moreFeaturesLink})并启用 ${featureName} 以解锁此功能。`, + preventPayoutNonReimbursableReportsTitle: '防止将不可报销的报告标记为已支付', + preventPayoutNonReimbursableReportsSubtitle: '不允许管理员将不予报销的报表标记为已支付。', }, agentsPromoBanner: {title: '找不到需要的规则?添加代理人', subtitle: '添加复杂规则,并通过自定义代理减少人工审批。', cta: '试用一下'}, merchantRules: { @@ -8120,7 +8214,6 @@ ${reportName}`, ifAnyExpenseMatches: '如果任一报销符合以下条件:', thenApplyFollowingDefaults: '然后应用以下默认设置:', vendorUnavailable: '供应商不可用', - supplierUnavailable: '供应商不可用', confirmErrorCategory: '请选择一个类别', confirmErrorCategoryTax: '请选择税率', confirmErrorCondition: '请输入商家', @@ -8349,6 +8442,8 @@ ${reportName}`, describeRuleForConcierge: '描述你的规则,Concierge 会帮你创建', gotIt: '知道了', createRule: '创建规则', + fallbackBanner: '这不是一条标准规则,所以我们将其制定为代理规则。代理规则会在报销或报表发生变更后生效,例如暂扣该报销或驳回该报表。', + fallbackBannerWithExplanation: (explanation: string) => `这不是一条标准规则,因此我们将其起草为代理规则。${explanation}`, }, tabs: {general: '常规', cardRestrictions: '卡片限制', expenseDefaults: '报销默认设置', requireFields: '字段要求', flagForReview: '标记以供审核', agents: '代理人'}, bulkActions: { @@ -8404,16 +8499,21 @@ ${reportName}`, newRule: { title: '新规则', subtitle: '你想要做什么?', + startWithTemplate: '从模板开始', restrictCardSpend: '限制卡片支出', restrictCardSpendDescription: '在销售点阻止或限制支出', applyExpenseDefaults: '应用报销默认设置', - applyExpenseDefaultsDescription: '在提交人无须执行任何操作的情况下更新字段', + applyExpenseDefaultsDescription: '自动更新字段', flagForReview: '标记以供审核', - flagForReviewDescription: '当费用超出类别限额时通知审批人', + flagForReviewDescription: '满足你的条件时发送通知', requireFields: '设置字段要求', - requireFieldsDescription: '要求填写特定报销字段,或免除其必填限制。', + requireFieldsShort: '必填字段', + requireFieldsDescription: '收据、类别等', createAgentRule: '描述代理规则', createAgentRuleDescription: '创建可按需运行的灵活规则。', + describeItBelow: '或者直接在下方描述', + describeRulePlaceholder: '阻止所有超过 2,000 美元的支出', + selectTemplateOrDescribe: '请选择模板或描述规则以继续', describe: '描述', describeRule: '用通俗的语言描述你的规则,Concierge 会为你创建规则', describeRuleInputLabel: '描述规则', @@ -8421,6 +8521,15 @@ ${reportName}`, unsupported: '该规则暂不支持,我们正在开发中。请先尝试其他规则。', unintelligible: '可以这样描述规则:如果支出超过 100 美元,则需要收据。', multipleRules: '请一次描述一条规则,例如:在所有卡上阻止零售消费。', + categoryHasRule: '该类别已有规则。请编辑现有规则,或为其他类别描述规则。', + }, + impossible: { + noTrigger: '规则无法从该事件开始。', + noCapability: '规则无法执行此操作。', + missingData: 'Expensify 没有该信息。', + retroactive: '规则仅适用于新活动。', + historyLimit: '规则无法追溯到那么久以前。', + tryThisInstead: '试试这个', }, }, expenseDefaultsTable: { @@ -8597,7 +8706,7 @@ ${reportName}`, dontSeeYourATS: `没有看到你的 ATS?询问 Concierge,我们可以帮你添加。`, importSettings: '导入设置', defaultApprover: '默认审批人', - approverFields: {recruiter: '招聘人员', recruitingCoordinator: '招聘协调员'}, + approverFields: {recruiter: '招聘人员', recruitingCoordinator: '招聘协调员', hiringManager: '招聘经理'}, filters: { description: (providerName: string) => `选择要从 ${providerName} 导入的成员。您可以按工作阶段、标签和办公室进行选择。`, stages: { @@ -8608,7 +8717,7 @@ ${reportName}`, }, tags: {title: '标签', description: '选择要与此工作区同步的候选人标签', toggleTitle: '标签', allSelected: '所有标签'}, offices: {title: '办公室', description: '选择你想与此工作区同步的候选人办公地点', toggleTitle: '办公室', allSelected: '所有办公室'}, - enableJobStagesOrTags: '启用职位阶段或标签以继续', + enableJobStagesOrTags: '请选择至少一个工作阶段或标签以继续', }, subtitle: '连接招聘工具并保持候选人审批同步。', syncResults: { @@ -8621,7 +8730,12 @@ ${reportName}`, finalApprover: `最终审批人`, finalApproverOptional: '最终审批人(可选)', approvalModeDescription: (providerName: string) => `为从 ${providerName} 导入到 Expensify 的新成员设置审批人。`, - approverFieldDescription: (providerName: string) => `为候选人选择第一位审批人:可以是他们在 ${providerName} 中指定的招聘人员或协调员。`, + approverFieldDescription: (providerName: string, approverFields: string[]) => { + const fieldNames = approverFields.map((approverField) => approverField.toLowerCase()); + const lastFieldName = fieldNames.at(-1) ?? ''; + const fieldList = fieldNames.length > 2 ? `其 ${fieldNames.slice(0, -1).join(', ')} 或 ${lastFieldName}` : `其 ${fieldNames.join('或其')}`; + return `为您的候选人选择第一位审批人:在 ${providerName} 中分配的 ${fieldList}。`; + }, approvalModeDescriptions: {basic: '选择单个审批人', advanced: `候选人的招聘人员或协调人将成为其报销审批人`, custom: '在 Expensify 中手动设置审批人'}, }, merge: { @@ -8748,6 +8862,9 @@ ${reportName}`, }, }, }, + zohoBooks: { + organization: 'Zoho Books 组织', + }, businessCentral: { businessCentralSetup: 'Dynamics 365 Business Central 设置', prerequisitesTitle: '在你连接之前…', @@ -8808,6 +8925,47 @@ ${reportName}`, noBankAccountsFoundDescription: '请在 Dynamics 365 Business Central 中添加银行账户,然后重新同步连接', noPaymentMethodsFound: '未找到付款方式', noPaymentMethodsFoundDescription: '请在 Dynamics 365 Business Central 中添加付款方式,然后重新同步连接', + advanced: { + autoSyncDescription: '每天自动同步 Dynamics 365 Business Central 和 Expensify。报告实时同步。', + accountingMethods: { + label: '导出方式', + description: '选择何时导出报销。', + values: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: '应计', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: '现金', + }, + alternateText: { + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL]: '自掏腰包的报销将在最终批准后导出', + [COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH]: '自付费用将在报销后导出', + }, + }, + postingMode: { + label: '过账模式', + description: '选择分录是自动过账,还是需要先手动审核。', + note: '注意:Expensify 会过账您在 Business Central 的 Expensify Setup 页面中选择的一般日记账批次中的所有未过账日记账,而不只是它创建的日记账。请使用仅供 Expensify 使用的批次。', + values: { + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_AND_POST]: { + label: '创建并过账', + description: '导出时自动将分录过账到总账', + }, + [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_ONLY]: { + label: '仅创建', + description: '导出后在 Dynamics 365 Business Central 中手动过账分录', + }, + }, + }, + autoCreateEntities: '自动创建员工/供应商', + exportToNextOpenPeriod: '导出到下一个未结会计期间', + exportToNextOpenPeriodDescription: '当报告日期位于 Dynamics 365 Business Central 的已关闭期间时,将其导出到下一个未关闭期间。', + syncReimbursedReports: '同步已报销报表', + syncReimbursedReportsDescription: '当报表通过 ACH 支付时,将在此科目中生成一条普通日记账分录。', + reimbursementBankAccount: {label: '付款银行账户', description: '选择从哪里支付账单,我们会在 Dynamics 365 Business Central 中创建付款。'}, + syncExpensifyCardSettlements: '同步 Expensify 卡结算', + settlementsBankAccount: { + label: 'Expensify 卡结算账户', + description: '选择您的结算账户,我们会在 Dynamics 365 Business Central 中创建这笔付款。', + }, + }, accountsDescription: '您的 Dynamics 365 Business Central 账户将作为类别导入。', dimensionsImportAsTags: '所有 Dynamics 365 Business Central 维度都会作为标签导入', customers: '客户', @@ -9276,6 +9434,20 @@ ${reportName}`, }, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `已将“${prohibitedExpense}”添加到禁止报销的费用中`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `已从禁用报销类别中移除“${prohibitedExpense}”`, + officeLocation: { + added: ({name, address}: {name: string; address: string}) => `已添加办公地点“${name}”(${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `已将办公地点名称从“${oldName}”重命名为“${newName}”`, + changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => + `已将办公地点“${name}”的地址更改为“${newAddress}”(原为“${oldAddress}”)`, + setAsPrimary: ({name}: {name: string}) => `将办公地点“${name}”设为主要地点`, + setCompanyAddressAsPrimary: '将公司地址设为主要地址', + removed: ({name}: {name: string}) => `已移除办公室地点“${name}”`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `已将 ${displayName} 的办公地点更改为 ${officeLocation}(先前为 ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `“${name}”`, + companyAddress: '公司地址', + primaryOffice: '主办公地点', + }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `将默认工作安排设置为 ${arrangement}`, changed: ({arrangement, previousArrangement}: {arrangement: string; previousArrangement: string}) => `将默认工作安排更改为 ${arrangement}(之前为 ${previousArrangement})`, @@ -9400,6 +9572,7 @@ ${reportName}`, addCustomUnitRateWithAmountAndEndDate: (rateName: string, rateValue: string, endDate: string) => `已添加“${rateName}”费率 ${rateValue},有效期至 ${endDate}`, addCustomUnitRateWithAmountAndDates: (rateName: string, rateValue: string, startDate: string, endDate: string) => `已添加“${rateName}”费率 ${rateValue},有效期为 ${startDate} - ${endDate}`, + addCustomUnitRateTaxReclaimableSuffix: (taxReclaimableAmount: string, taxRateName: string) => `,其中 ${taxReclaimableAmount} 可按“${taxRateName}”退税`, updatedCustomUnitRateDateRange: (rateName: string, newDateRange: string, oldDateRange: string) => `已将距离费率“${rateName}”更新为适用于 ${newDateRange}(此前为 ${oldDateRange})`, customUnitRateDateRangeStartToEnd: (startDate: string, endDate: string) => `${startDate} - ${endDate}`, customUnitRateDateRangeFrom: (date: string) => `自 ${date} 起`, @@ -9489,6 +9662,8 @@ ${reportName}`, `已将 ${displayName} 的工作安排更改为 ${newArrangement}(之前为 ${oldArrangement})`, updatedDefaultWorkArrangement: ({newArrangement, oldArrangement}: {newArrangement: string; oldArrangement: string}) => `已将默认工作安排更改为 ${newArrangement}(之前为 ${oldArrangement})`, + preventPayoutNonReimbursableReports: (oldValue: string, newValue: string) => + `已将“防止将不可报销报表标记为已支付”更新为“${newValue === 'true' ? '已启用' : '已禁用'}”(之前为“${oldValue === 'true' ? '已启用' : '已禁用'}”)`, }, roomMembersPage: { memberNotFound: '未找到成员。', @@ -9543,6 +9718,8 @@ ${reportName}`, fallbackTitle: '支持工单', resolved: '此支持工单已解决。', surveyPrompt: '您对支持服务的体验如何?', + great: '很好', + poor: '很差', reopenTicket: '重新打开工单', }, statementPage: { @@ -9677,6 +9854,12 @@ ${reportName}`, }, customDay: '自定义日期', }, + describeSearch: { + title: '描述你的搜索', + description: '用简单的英文描述你想查看的报销记录,我们会自动为你应用合适的筛选条件。', + inputLabel: '描述你的搜索', + buttonText: '应用', + }, status: '状态', keyword: '关键词', keywords: '关键词', @@ -9712,6 +9895,7 @@ ${reportName}`, past: '过去', submitted: '已提交', approved: '已批准', + anyApproval: '任意审批', firstApprover: '首位审批人', firstApproved: '首次批准', paidBy: '付款人', @@ -9720,6 +9904,8 @@ ${reportName}`, posted: '已发布', withdrawn: '已撤回', billable: '可计费', + rejected: '已拒绝', + held: '已搁置', reimbursable: '可报销', purchaseCurrency: '购买货币', sortOrder: { @@ -9754,6 +9940,11 @@ ${reportName}`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: '导出', }, filterType: {label: '筛选类型', has: {positive: '有', negative: '没有'}, is: {positive: '是', negative: '不是'}}, + merchant: { + matchType: '匹配类型', + contains: (merchant?: string) => (merchant ? `包含 "${merchant}"` : '包含'), + equalTo: (merchant?: string) => (merchant ? `等于 "${merchant}"` : '等于'), + }, created: '已创建', createdDate: '创建日期', transactionStatus: { @@ -9916,6 +10107,8 @@ ${reportName}`, type: { changeField: (oldValue: string | undefined, newValue: string, fieldName: string) => `将${fieldName}更改为“${newValue}”(先前为“${oldValue}”)`, changeFieldEmpty: (newValue: string, fieldName: string) => `将 ${fieldName} 设置为“${newValue}”`, + changeFieldRemoved: (oldValue: string, fieldName: string) => `已移除${fieldName}(先前为“${oldValue}”)`, + changeFieldCleared: (fieldName: string) => `已移除${fieldName}`, changeReportPolicy: (toPolicyName: string, fromPolicyName?: string) => { if (!toPolicyName) { return `更改了工作区${fromPolicyName ? `(原为 ${fromPolicyName})` : ''}`; @@ -10382,7 +10575,7 @@ ${reportName}`, duplicatedTransaction: '可能重复', fieldRequired: '报表字段为必填项', futureDate: '不允许使用未来日期', - inactiveVendor: (isSupplier = false) => (isSupplier ? '供应商不再有效' : '供应商不再有效'), + inactiveVendor: '供应商不再有效', invoiceMarkup: (invoiceMarkup: number) => `加价 ${invoiceMarkup}%`, maxAge: (maxAge: number) => `日期早于 ${maxAge} 天`, missingCategory: '缺少类别', @@ -10698,6 +10891,7 @@ ${reportName}`, }, systemMessage: { mergedWithCashTransaction: '已将一张收据匹配到此交易', + mergedExpenseDeleted: '已合并报销并将其中一笔移至已删除', }, subscription: { authenticatePaymentCard: '验证支付卡', @@ -10927,6 +11121,9 @@ ${reportName}`, refundedOn: ({date}: {date: string}) => `${date} 已退款`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${date} 已退款 ${amount}`, }, + billedTo: '账单对象', + viewReceipt: '查看收据', + viewInvoice: '查看发票', }, subscriptionSettings: { title: '订阅设置', @@ -11464,22 +11661,6 @@ ${reportName}`, addPaymentCardButton: '添加付款卡', }, monthPickerPage: {month: '月份', selectMonth: '请选择月份'}, - aiFeaturesPromoModal: { - subtitle: 'Concierge AI 新手指南', - confirmText: '出发吧!', - spendAnalysis: { - title: '交互式支出分析', - description: `Concierge 会提供每月支出洞察,并让你深入查看每个数字背后的详细信息。了解详情。`, - }, - expenseAssistant: { - title: '认识你的全新报销助手', - description: `在应用内或通过电子邮件、短信与 Concierge 聊天来创建和更新报销。了解更多。`, - }, - customAgents: { - title: '构建你自己的代理', - description: `创建自定义代理,根据你设置的规则审核、批准和分配报销。了解更多。`, - }, - }, emailIssuePage: { title: '邮件问题', intro: (login: string) => `由于投递问题,我们的邮件服务提供商已暂停向 ${login} 发送邮件。要解决此问题:`, @@ -11520,5 +11701,6 @@ ${reportName}`, mobileRenewPrompt: '请在网页浏览器中访问 Expensify 以提前续订。', mobileClaimPrompt: '请在网页浏览器中访问 Expensify 以领取您的续订折扣。', }, + bulkActionBar: {label: '批量操作', clearSelection: '清除选择', loadingSelection: '正在加载所选内容'}, }; export default translations; diff --git a/src/libs/API/parameters/AddMembersToWorkspaceParams.ts b/src/libs/API/parameters/AddMembersToWorkspaceParams.ts index abfed55e2df3..53f3e4a00a93 100644 --- a/src/libs/API/parameters/AddMembersToWorkspaceParams.ts +++ b/src/libs/API/parameters/AddMembersToWorkspaceParams.ts @@ -2,6 +2,7 @@ type AddMembersToWorkspaceParams = { employees: string; welcomeNote: string; policyID: string; + hasOfficeWorkArrangement?: boolean; reportCreationData?: string; announceChatReportID?: string; announceCreatedReportActionID?: string; diff --git a/src/libs/API/parameters/AddOfficeLocationParams.ts b/src/libs/API/parameters/AddOfficeLocationParams.ts new file mode 100644 index 000000000000..d436044b5fbd --- /dev/null +++ b/src/libs/API/parameters/AddOfficeLocationParams.ts @@ -0,0 +1,17 @@ +type AddOfficeLocationParams = { + policyID: string; + + /** Client-generated ID of the new office */ + officeID: string; + + /** JSON-encoded address with addressStreet, city, state, zipCode and country, the same shape as the workspace address */ + address: string; + + /** Nickname of the office. Offices added without one are named "Office location N" */ + officeName?: string; + + /** Whether the office becomes the workspace default */ + isDefault?: boolean; +}; + +export default AddOfficeLocationParams; diff --git a/src/libs/API/parameters/CreateCompanyAgentParams.ts b/src/libs/API/parameters/CreateCompanyAgentParams.ts new file mode 100644 index 000000000000..0236edb08004 --- /dev/null +++ b/src/libs/API/parameters/CreateCompanyAgentParams.ts @@ -0,0 +1,19 @@ +import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; + +import type CONST from '@src/CONST'; + +import type {ValueOf} from 'type-fest'; + +type CreateCompanyAgentParams = { + prompt: string; + policyID: string; + role: ValueOf; + firstName?: string; + customExpensifyAvatarID?: string; + file?: File | CustomRNImageManipulatorResult; + optimisticAccountID: number; + optimisticReportID: string; + createdReportActionID: string; +}; + +export default CreateCompanyAgentParams; diff --git a/src/libs/API/parameters/CreateDigitalWalletParams.ts b/src/libs/API/parameters/CreateDigitalWalletParams.ts index 6aa9978758fe..713926022bbf 100644 --- a/src/libs/API/parameters/CreateDigitalWalletParams.ts +++ b/src/libs/API/parameters/CreateDigitalWalletParams.ts @@ -1,3 +1,5 @@ +import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types'; + type CreateDigitalAppleWalletParams = { platform: 'ios'; appVersion: string; @@ -16,6 +18,8 @@ type CreateDigitalGoogleWalletParams = { cardID: number; }; -type CreateDigitalWalletParams = CreateDigitalAppleWalletParams | CreateDigitalGoogleWalletParams; +type WalletAuthenticationParams = {signedChallenge: string; authenticationMethod: AuthTypeInfo['marqetaValue']} | {signedChallenge?: never; authenticationMethod?: never}; + +type CreateDigitalWalletParams = (CreateDigitalAppleWalletParams | CreateDigitalGoogleWalletParams) & WalletAuthenticationParams; export default CreateDigitalWalletParams; diff --git a/src/libs/API/parameters/DeleteOfficeLocationParams.ts b/src/libs/API/parameters/DeleteOfficeLocationParams.ts new file mode 100644 index 000000000000..2e825bc8d5ae --- /dev/null +++ b/src/libs/API/parameters/DeleteOfficeLocationParams.ts @@ -0,0 +1,6 @@ +type DeleteOfficeLocationParams = { + policyID: string; + officeID: string; +}; + +export default DeleteOfficeLocationParams; diff --git a/src/libs/API/parameters/DeleteRuleParams.ts b/src/libs/API/parameters/DeleteRuleParams.ts new file mode 100644 index 000000000000..eca99e59ccdb --- /dev/null +++ b/src/libs/API/parameters/DeleteRuleParams.ts @@ -0,0 +1,6 @@ +type DeleteRuleParams = { + /** The ID of the rule to delete */ + ruleID: string; +}; + +export default DeleteRuleParams; diff --git a/src/libs/API/parameters/GenerateRuleParams.ts b/src/libs/API/parameters/GenerateRuleParams.ts index 211b9e4ca5cb..6e2f8da701c6 100644 --- a/src/libs/API/parameters/GenerateRuleParams.ts +++ b/src/libs/API/parameters/GenerateRuleParams.ts @@ -3,7 +3,7 @@ import type {GeneratedRuleType} from '@src/types/onyx/GeneratedRule'; type GenerateRuleParams = { policyID: string; generationID: string; - ruleType: GeneratedRuleType; + ruleType?: GeneratedRuleType; prompt: string; }; diff --git a/src/libs/API/parameters/ImportMultiLevelTagsParams.ts b/src/libs/API/parameters/ImportMultiLevelTagsParams.ts index daa848b3d377..64f4af061199 100644 --- a/src/libs/API/parameters/ImportMultiLevelTagsParams.ts +++ b/src/libs/API/parameters/ImportMultiLevelTagsParams.ts @@ -3,6 +3,7 @@ type ImportMultiLevelTagsParams = { isFirstLineHeader: boolean; isIndependent: boolean; isGLAdjacent: boolean; + appendToExistingLists?: boolean; file: File; }; diff --git a/src/libs/API/parameters/OpenPaymentDetailsPageParams.ts b/src/libs/API/parameters/OpenPaymentDetailsPageParams.ts new file mode 100644 index 000000000000..817549815005 --- /dev/null +++ b/src/libs/API/parameters/OpenPaymentDetailsPageParams.ts @@ -0,0 +1,5 @@ +type OpenPaymentDetailsPageParams = { + purchaseID: number; +}; + +export default OpenPaymentDetailsPageParams; diff --git a/src/libs/API/parameters/OpenPolicyVendorsPageParams.ts b/src/libs/API/parameters/OpenPolicyVendorsPageParams.ts new file mode 100644 index 000000000000..7402b14c9ee2 --- /dev/null +++ b/src/libs/API/parameters/OpenPolicyVendorsPageParams.ts @@ -0,0 +1,5 @@ +type OpenPolicyVendorsPageParams = { + policyID: string; +}; + +export default OpenPolicyVendorsPageParams; diff --git a/src/libs/API/parameters/ParseExpenseFiltersParams.ts b/src/libs/API/parameters/ParseExpenseFiltersParams.ts new file mode 100644 index 000000000000..feb672338f19 --- /dev/null +++ b/src/libs/API/parameters/ParseExpenseFiltersParams.ts @@ -0,0 +1,7 @@ +type ParseExpenseFiltersParams = { + nlQuery: string; + policyID?: string; + today: string; +}; + +export default ParseExpenseFiltersParams; diff --git a/src/libs/API/parameters/SaveSearchFiltersParams.ts b/src/libs/API/parameters/SaveSearchFiltersParams.ts new file mode 100644 index 000000000000..ebd220b7a4c2 --- /dev/null +++ b/src/libs/API/parameters/SaveSearchFiltersParams.ts @@ -0,0 +1,6 @@ +type SaveSearchFiltersParams = { + /** Stringified JSON holding the `searchKey` the filters are saved under and their `inputQuery` */ + jsonQuery: string; +}; + +export default SaveSearchFiltersParams; diff --git a/src/libs/API/parameters/SetEmployeeWorkArrangementParams.ts b/src/libs/API/parameters/SetEmployeeWorkArrangementParams.ts index 706b7aff83f3..21f2844b528b 100644 --- a/src/libs/API/parameters/SetEmployeeWorkArrangementParams.ts +++ b/src/libs/API/parameters/SetEmployeeWorkArrangementParams.ts @@ -4,6 +4,9 @@ type SetEmployeeWorkArrangementParams = { /** Comma-separated accountIDs of the employees to update. Supports individual and bulk updates. */ employeeAccountIDList: string; + /** Comma-separated member logins, used to match updates queued before an offline invite receives its real accountID. */ + employeeLoginList: string; + /** True for office-based, false for no regular workspace. */ isOffice: boolean; }; diff --git a/src/libs/API/parameters/SetPolicyCodingRuleParams.ts b/src/libs/API/parameters/SetPolicyCodingRuleParams.ts deleted file mode 100644 index 0c1578d71970..000000000000 --- a/src/libs/API/parameters/SetPolicyCodingRuleParams.ts +++ /dev/null @@ -1,15 +0,0 @@ -type SetPolicyCodingRuleParams = { - /** The policy ID that the rule will be created or updated for */ - policyID: string; - - /** The existing codingRuleID, or an optimistic one to create the rule */ - codingRuleID: string; - - /** The JSON value of the merchant rule, stringified */ - codingRuleValue: string; - - /** Whether to update the transactions that match the rule */ - shouldUpdateMatchingTransactions: boolean; -}; - -export default SetPolicyCodingRuleParams; diff --git a/src/libs/API/parameters/SetPolicyPreventPayoutNonReimbursableReportsParams.ts b/src/libs/API/parameters/SetPolicyPreventPayoutNonReimbursableReportsParams.ts new file mode 100644 index 000000000000..91242085e6f2 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyPreventPayoutNonReimbursableReportsParams.ts @@ -0,0 +1,6 @@ +type SetPolicyPreventPayoutNonReimbursableReportsParams = { + policyID: string; + enabled: boolean; +}; + +export default SetPolicyPreventPayoutNonReimbursableReportsParams; diff --git a/src/libs/API/parameters/SetPolicyVendorsEnabledParams.ts b/src/libs/API/parameters/SetPolicyVendorsEnabledParams.ts new file mode 100644 index 000000000000..bba7ce74d104 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyVendorsEnabledParams.ts @@ -0,0 +1,11 @@ +type SetPolicyVendorsEnabledParams = { + policyID: string; + /** + * Stringified JSON array of vendor IDs, e.g.: + * string[] + */ + vendorIDs: string; + enabled: boolean; +}; + +export default SetPolicyVendorsEnabledParams; diff --git a/src/libs/API/parameters/SetRuleParams.ts b/src/libs/API/parameters/SetRuleParams.ts new file mode 100644 index 000000000000..1d13c125407d --- /dev/null +++ b/src/libs/API/parameters/SetRuleParams.ts @@ -0,0 +1,28 @@ +import type CONST from '@src/CONST'; + +import type {ValueOf} from 'type-fest'; + +type SetRuleParams = { + /** What kind of entity the rule is scoped to. Merchant rules are always scoped to a policy */ + scope: ValueOf; + + /** The ID of the scoped entity, i.e. the policyID for policy-scoped rules */ + scopeID: string; + + /** The ID of the rule being written. A new rule uses an optimistic `rand64()` value */ + ruleID: string; + + /** Determines the order rules are applied in when more than one matches */ + priority: number; + + /** + * The rule body, as a stringified `RuleValue`. It stays a string because the request is sent as + * `FormData`, which can only carry strings. + */ + value: string; + + /** Whether to apply the rule to the transactions that already match it */ + shouldUpdateMatchingTransactions: boolean; +}; + +export default SetRuleParams; 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/API/parameters/ShareReportParams.ts b/src/libs/API/parameters/ShareReportParams.ts new file mode 100644 index 000000000000..33338659745c --- /dev/null +++ b/src/libs/API/parameters/ShareReportParams.ts @@ -0,0 +1,7 @@ +type ShareReportParams = { + reportID: string; + shareToEmail: string; + permissions: string; +}; + +export default ShareReportParams; diff --git a/src/libs/API/parameters/UpdateBusinessCentralAccountingMethodParams.ts b/src/libs/API/parameters/UpdateBusinessCentralAccountingMethodParams.ts new file mode 100644 index 000000000000..27bfecd8d262 --- /dev/null +++ b/src/libs/API/parameters/UpdateBusinessCentralAccountingMethodParams.ts @@ -0,0 +1,8 @@ +import type {BusinessCentralExport} from '@src/types/onyx/Policy'; + +type UpdateBusinessCentralAccountingMethodParams = { + policyID: string; + accountingMethod: BusinessCentralExport['accountingMethod']; +}; + +export default UpdateBusinessCentralAccountingMethodParams; diff --git a/src/libs/API/parameters/UpdateBusinessCentralAutoCreateEntitiesParams.ts b/src/libs/API/parameters/UpdateBusinessCentralAutoCreateEntitiesParams.ts new file mode 100644 index 000000000000..6619ce64fd15 --- /dev/null +++ b/src/libs/API/parameters/UpdateBusinessCentralAutoCreateEntitiesParams.ts @@ -0,0 +1,6 @@ +type UpdateBusinessCentralAutoCreateEntitiesParams = { + policyID: string; + enabled: boolean; +}; + +export default UpdateBusinessCentralAutoCreateEntitiesParams; diff --git a/src/libs/API/parameters/UpdateBusinessCentralAutoSyncParams.ts b/src/libs/API/parameters/UpdateBusinessCentralAutoSyncParams.ts new file mode 100644 index 000000000000..c3811a897d75 --- /dev/null +++ b/src/libs/API/parameters/UpdateBusinessCentralAutoSyncParams.ts @@ -0,0 +1,6 @@ +type UpdateBusinessCentralAutoSyncParams = { + policyID: string; + enabled: boolean; +}; + +export default UpdateBusinessCentralAutoSyncParams; diff --git a/src/libs/API/parameters/UpdateBusinessCentralExportToNextOpenPeriodParams.ts b/src/libs/API/parameters/UpdateBusinessCentralExportToNextOpenPeriodParams.ts new file mode 100644 index 000000000000..875d850f8c7f --- /dev/null +++ b/src/libs/API/parameters/UpdateBusinessCentralExportToNextOpenPeriodParams.ts @@ -0,0 +1,6 @@ +type UpdateBusinessCentralExportToNextOpenPeriodParams = { + policyID: string; + enabled: boolean; +}; + +export default UpdateBusinessCentralExportToNextOpenPeriodParams; diff --git a/src/libs/API/parameters/UpdateBusinessCentralPostingModeParams.ts b/src/libs/API/parameters/UpdateBusinessCentralPostingModeParams.ts new file mode 100644 index 000000000000..974072ba2df0 --- /dev/null +++ b/src/libs/API/parameters/UpdateBusinessCentralPostingModeParams.ts @@ -0,0 +1,8 @@ +import type {BusinessCentralExport} from '@src/types/onyx/Policy'; + +type UpdateBusinessCentralPostingModeParams = { + policyID: string; + postingMode: BusinessCentralExport['postingMode']; +}; + +export default UpdateBusinessCentralPostingModeParams; diff --git a/src/libs/API/parameters/UpdateBusinessCentralReimbursementBankAccountParams.ts b/src/libs/API/parameters/UpdateBusinessCentralReimbursementBankAccountParams.ts new file mode 100644 index 000000000000..67cc5bf4b7c3 --- /dev/null +++ b/src/libs/API/parameters/UpdateBusinessCentralReimbursementBankAccountParams.ts @@ -0,0 +1,8 @@ +import type {BusinessCentralSync} from '@src/types/onyx/Policy'; + +type UpdateBusinessCentralReimbursementBankAccountParams = { + policyID: string; + reimbursementBankAccountID: BusinessCentralSync['reimbursementBankAccountID']; +}; + +export default UpdateBusinessCentralReimbursementBankAccountParams; diff --git a/src/libs/API/parameters/UpdateBusinessCentralSettlementsBankAccountParams.ts b/src/libs/API/parameters/UpdateBusinessCentralSettlementsBankAccountParams.ts new file mode 100644 index 000000000000..b0c337443c2a --- /dev/null +++ b/src/libs/API/parameters/UpdateBusinessCentralSettlementsBankAccountParams.ts @@ -0,0 +1,8 @@ +import type {BusinessCentralSync} from '@src/types/onyx/Policy'; + +type UpdateBusinessCentralSettlementsBankAccountParams = { + policyID: string; + settlementsBankAccountID: BusinessCentralSync['settlementsBankAccountID']; +}; + +export default UpdateBusinessCentralSettlementsBankAccountParams; diff --git a/src/libs/API/parameters/UpdateBusinessCentralSyncExpensifyCardSettlementsParams.ts b/src/libs/API/parameters/UpdateBusinessCentralSyncExpensifyCardSettlementsParams.ts new file mode 100644 index 000000000000..8c79ac381603 --- /dev/null +++ b/src/libs/API/parameters/UpdateBusinessCentralSyncExpensifyCardSettlementsParams.ts @@ -0,0 +1,6 @@ +type UpdateBusinessCentralSyncExpensifyCardSettlementsParams = { + policyID: string; + enabled: boolean; +}; + +export default UpdateBusinessCentralSyncExpensifyCardSettlementsParams; diff --git a/src/libs/API/parameters/UpdateBusinessCentralSyncReimbursedReportsParams.ts b/src/libs/API/parameters/UpdateBusinessCentralSyncReimbursedReportsParams.ts new file mode 100644 index 000000000000..a068c4a8c4c5 --- /dev/null +++ b/src/libs/API/parameters/UpdateBusinessCentralSyncReimbursedReportsParams.ts @@ -0,0 +1,6 @@ +type UpdateBusinessCentralSyncReimbursedReportsParams = { + policyID: string; + enabled: boolean; +}; + +export default UpdateBusinessCentralSyncReimbursedReportsParams; diff --git a/src/libs/API/parameters/UpdateEmployeeOfficeLocationParams.ts b/src/libs/API/parameters/UpdateEmployeeOfficeLocationParams.ts new file mode 100644 index 000000000000..ab59403b7733 --- /dev/null +++ b/src/libs/API/parameters/UpdateEmployeeOfficeLocationParams.ts @@ -0,0 +1,14 @@ +type UpdateEmployeeOfficeLocationParams = { + policyID: string; + + /** + * Comma-separated logins of the employees to update. Supports individual and bulk updates. Logins stay the same when + * an offline invite replaces a member's temporary accountID before the request runs. + */ + employeeLoginList: string; + + /** Office to assign, keyed in the workspace's officeLocations, or CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID for the company address */ + officeID: string; +}; + +export default UpdateEmployeeOfficeLocationParams; diff --git a/src/libs/API/parameters/UpdateOfficeLocationParams.ts b/src/libs/API/parameters/UpdateOfficeLocationParams.ts new file mode 100644 index 000000000000..f0668f7b8ec4 --- /dev/null +++ b/src/libs/API/parameters/UpdateOfficeLocationParams.ts @@ -0,0 +1,15 @@ +type UpdateOfficeLocationParams = { + policyID: string; + officeID: string; + + /** New nickname of the office */ + officeName?: string; + + /** JSON-encoded address with addressStreet, city, state, zipCode and country, the same shape as the workspace address */ + address?: string; + + /** Makes the office the workspace default */ + isDefault?: boolean; +}; + +export default UpdateOfficeLocationParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index ef78d3086b6e..2a50ecba2d99 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -139,6 +139,15 @@ export type {default as UpdateBusinessCentralReimbursableAccountParams} from './ export type {default as UpdateBusinessCentralNonreimbursableAccountParams} from './UpdateBusinessCentralNonreimbursableAccountParams'; export type {default as UpdateBusinessCentralDefaultVendorParams} from './UpdateBusinessCentralDefaultVendorParams'; export type {default as UpdateBusinessCentralPaymentMethodParams} from './UpdateBusinessCentralPaymentMethodParams'; +export type {default as UpdateBusinessCentralAutoSyncParams} from './UpdateBusinessCentralAutoSyncParams'; +export type {default as UpdateBusinessCentralSyncReimbursedReportsParams} from './UpdateBusinessCentralSyncReimbursedReportsParams'; +export type {default as UpdateBusinessCentralSyncExpensifyCardSettlementsParams} from './UpdateBusinessCentralSyncExpensifyCardSettlementsParams'; +export type {default as UpdateBusinessCentralExportToNextOpenPeriodParams} from './UpdateBusinessCentralExportToNextOpenPeriodParams'; +export type {default as UpdateBusinessCentralAutoCreateEntitiesParams} from './UpdateBusinessCentralAutoCreateEntitiesParams'; +export type {default as UpdateBusinessCentralReimbursementBankAccountParams} from './UpdateBusinessCentralReimbursementBankAccountParams'; +export type {default as UpdateBusinessCentralSettlementsBankAccountParams} from './UpdateBusinessCentralSettlementsBankAccountParams'; +export type {default as UpdateBusinessCentralAccountingMethodParams} from './UpdateBusinessCentralAccountingMethodParams'; +export type {default as UpdateBusinessCentralPostingModeParams} from './UpdateBusinessCentralPostingModeParams'; export type {default as UpdateNetSuiteAccountingMethodParams} from './UpdateNetSuiteAccountingMethodParams'; export type {default as UpdateQuickbooksOnlineAccountingMethodParams} from './UpdateQuickbooksOnlineAccountingMethodParams'; export type {default as UpdateXeroAccountingMethodParams} from './UpdateXeroAccountingMethodParams'; @@ -171,6 +180,7 @@ export type {default as OpenAppParams} from './OpenAppParams'; export type {default as OpenOldDotLinkParams} from './OpenOldDotLinkParams'; export type {default as OpenPlaidBankAccountSelectorParams} from './OpenPlaidBankAccountSelectorParams'; export type {default as OpenPlaidBankLoginParams} from './OpenPlaidBankLoginParams'; +export type {default as OpenPaymentDetailsPageParams} from './OpenPaymentDetailsPageParams'; export type {default as OpenPaymentsPageParams} from './OpenPaymentsPageParams'; export type {default as OpenPublicProfilePageParams} from './OpenPublicProfilePageParams'; export type {default as OpenReimbursementAccountPageParams} from './OpenReimbursementAccountPageParams'; @@ -295,6 +305,7 @@ export type {default as OpenWorkspaceMembersPageParams} from './OpenWorkspaceMem export type {default as OpenPolicyRoomsPageParams} from './OpenPolicyRoomsPageParams'; export type {default as OpenPolicyCategoriesPageParams} from './OpenPolicyCategoriesPageParams'; export type {default as OpenPolicyTagsPageParams} from './OpenPolicyTagsPageParams'; +export type {default as OpenPolicyVendorsPageParams} from './OpenPolicyVendorsPageParams'; export type {default as VerifyAddSecondaryLoginCodeParams} from './VerifyAddSecondaryLoginCodeParams'; export type {default as OpenDraftWorkspaceRequestParams} from './OpenDraftWorkspaceRequestParams'; export type {default as OpenDraftPerDiemExpenseParams} from './OpenDraftPerDiemExpenseParams'; @@ -337,6 +348,7 @@ export type {default as UpdateWorkspaceDescriptionParams} from './UpdateWorkspac export type {default as UpdateWorkspaceClientIDParams} from './UpdateWorkspaceClientIDParams'; export type {default as UpdateWorkspaceMembersRoleParams} from './UpdateWorkspaceMembersRoleParams'; export type {default as SetWorkspaceCategoriesEnabledParams} from './SetWorkspaceCategoriesEnabledParams'; +export type {default as SetPolicyVendorsEnabledParams} from './SetPolicyVendorsEnabledParams'; export type {default as CreateWorkspaceCategoriesParams} from './CreateWorkspaceCategoriesParams'; export type {default as RenameWorkspaceCategoriesParams} from './RenameWorkspaceCategoriesParams'; export type {default as SetWorkspaceRequiresCategoryParams} from './SetWorkspaceRequiresCategoryParams'; @@ -489,6 +501,7 @@ export type {default as ConnectAsDelegateParams} from './ConnectAsDelegateParams export type {default as SetPolicyRulesEnabledParams} from './SetPolicyRulesEnabledParams'; export type {default as SetPolicyDefaultReportTitleParams} from './SetPolicyDefaultReportTitle'; export type {default as SetPolicyPreventSelfApprovalParams} from './SetPolicyPreventSelfApproval'; +export type {default as SetPolicyPreventPayoutNonReimbursableReportsParams} from './SetPolicyPreventPayoutNonReimbursableReportsParams'; export type {default as SetPolicyAutomaticApprovalLimitParams} from './SetPolicyAutomaticApprovalLimit'; export type {default as SetPolicyAutomaticApprovalRateParams} from './SetPolicyAutomaticApprovalRate'; export type {default as SetPolicyPreventMemberCreatedTitleParams} from './SetPolicyPreventMemberCreatedTitleParams'; @@ -534,6 +547,7 @@ export type {default as RemoveDelegatorParams} from './RemoveDelegatorParams'; export type {default as SetPolicyTagApproverParams} from './SetPolicyTagApproverParams'; export type {default as SaveSearchParams} from './SaveSearch'; export type {default as DeleteSavedSearchParams} from './DeleteSavedSearch'; +export type {default as SaveSearchFiltersParams} from './SaveSearchFiltersParams'; export type {default as SetPolicyCategoryReceiptsRequiredParams} from './SetPolicyCategoryReceiptsRequiredParams'; export type {default as RemovePolicyCategoryReceiptsRequiredParams} from './RemovePolicyCategoryReceiptsRequiredParams'; export type {default as SetPolicyCategoryItemizedReceiptsRequiredParams} from './SetPolicyCategoryItemizedReceiptsRequiredParams'; @@ -597,6 +611,7 @@ export type {default as ImportPerDiemRatesParams} from './ImportPerDiemRatesPara export type {default as ExportPerDiemCSVParams} from './ExportPerDiemCSVParams'; export type {default as ExportReportPDFParams} from './ExportReportPDFParams'; export type {default as ExportReportsToPDFParams} from './ExportReportsToPDFParams'; +export type {default as ShareReportParams} from './ShareReportParams'; export type {default as ExportReceiptsToZipParams} from './ExportReceiptsToZipParams'; export type {default as UpdateWorkspaceCustomUnitParams} from './UpdateWorkspaceCustomUnitParams'; export type {default as DismissProductTrainingParams} from './DismissProductTraining'; @@ -626,6 +641,10 @@ export type {default as SetPolicyCommuterExclusionsParams} from './SetPolicyComm export type {default as SetPolicyWorkArrangementParams} from './SetPolicyWorkArrangementParams'; export type {default as SetEmployeeWorkArrangementParams} from './SetEmployeeWorkArrangementParams'; export type {default as DisablePolicyCommuterExclusionsParams} from './DisablePolicyCommuterExclusionsParams'; +export type {default as AddOfficeLocationParams} from './AddOfficeLocationParams'; +export type {default as UpdateOfficeLocationParams} from './UpdateOfficeLocationParams'; +export type {default as DeleteOfficeLocationParams} from './DeleteOfficeLocationParams'; +export type {default as UpdateEmployeeOfficeLocationParams} from './UpdateEmployeeOfficeLocationParams'; export type {default as SetPolicyRequireMapOrGPSParams} from './SetPolicyRequireMapOrGPSParams'; export type {default as CreateDigitalWalletParams} from './CreateDigitalWalletParams'; export type {default as GetGuideCallAvailabilityScheduleParams} from './GetGuideCallAvailabilitySchedule'; @@ -662,6 +681,7 @@ export type {default as UpdateSamlRequiredParams} from './UpdateSamlRequiredPara export type {default as AddMemberToDomainParams} from './AddMemberToDomainParams'; export type {default as GetInsightsParams} from './GetInsightsParams'; export type {default as OpenSearchPageParams} from './OpenSearchPageParams'; +export type {default as ParseExpenseFiltersParams} from './ParseExpenseFiltersParams'; export type {default as OpenSearchTagFiltersPageParams} from './OpenSearchTagFiltersPageParams'; export type {default as OpenSearchTagFiltersPageResponse} from './OpenSearchTagFiltersPageResponse'; export type {default as OpenBulkChangeApproverPageParams} from './OpenBulkChangeApproverPageParams'; @@ -675,7 +695,8 @@ export type {default as ChangeDomainSecurityGroupParams} from './ChangeDomainSec export type {default as DeleteDomainParams} from './DeleteDomainParams'; export type {default as GetDuplicateTransactionDetailsParams} from './GetDuplicateTransactionDetailsParams'; export type {default as SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams} from './SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams'; -export type {default as SetPolicyCodingRuleParams} from './SetPolicyCodingRuleParams'; +export type {default as SetRuleParams} from './SetRuleParams'; +export type {default as DeleteRuleParams} from './DeleteRuleParams'; export type {default as SetApprovalWorkflowParams} from './SetApprovalWorkflowParams'; export type {default as RegisterAuthenticationKeyParams} from './RegisterAuthenticationKeyParams'; export type {default as RevokeMultifactorAuthenticationCredentialsParams} from './RevokeMultifactorAuthenticationCredentialsParams'; @@ -698,6 +719,7 @@ export type {default as UpdateDomainSecurityGroupParams} from './UpdateDomainSec export type {default as SetDefaultDomainSecurityGroupParams} from './SetDefaultDomainSecurityGroupParams'; export type {default as DeleteDomainSecurityGroupParams} from './DeleteDomainSecurityGroupParams'; export type {default as CreateAgentParams} from './CreateAgentParams'; +export type {default as CreateCompanyAgentParams} from './CreateCompanyAgentParams'; export type {default as CreateDomainSecurityGroupParams} from './CreateDomainSecurityGroupParams'; export type {default as UpdateAgentNameParams} from './UpdateAgentNameParams'; export type {default as UpdateAgentPromptParams} from './UpdateAgentPromptParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index bbf7ebdb96e1..7719574713a3 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -30,6 +30,7 @@ const WRITE_COMMANDS = { SET_POLICY_DEFAULT_REPORT_TITLE: 'SetPolicyDefaultReportTitle', SET_POLICY_PREVENT_MEMBER_CREATED_TITLE: 'SetPolicyPreventMemberCreatedTitle', SET_POLICY_PREVENT_SELF_APPROVAL: 'SetPolicyPreventSelfApproval', + SET_POLICY_PREVENT_PAYOUT_NON_REIMBURSABLE_REPORTS: 'SetPolicyPreventPayoutNonReimbursableReports', SET_POLICY_AUTOMATIC_APPROVAL_LIMIT: 'SetPolicyAutomaticApprovalLimit', SET_POLICY_AUTOMATIC_APPROVAL_RATE: 'SetPolicyAutomaticApprovalRate', SET_POLICY_AUTO_REIMBURSEMENT_LIMIT: 'SetPolicyAutoReimbursementLimit', @@ -177,6 +178,7 @@ const WRITE_COMMANDS = { UPDATE_POLICY_MEMBER_DISPLAY_NAME: 'UpdatePolicyMemberDisplayName', UPDATE_POLICY_MEMBERS_CUSTOM_FIELDS: 'UpdatePolicyMembersCustomFields', SET_WORKSPACE_CATEGORIES_ENABLED: 'SetWorkspaceCategoriesEnabled', + SET_POLICY_VENDORS_ENABLED: 'SetPolicyVendorsEnabled', MOVE_IOU_REPORT_TO_POLICY_AND_INVITE_SUBMITTER: 'MoveIOUReportToPolicyAndInviteSubmitter', SET_POLICY_TAGS_ENABLED: 'SetPolicyTagsEnabled', CREATE_WORKSPACE_CATEGORIES: 'CreateWorkspaceCategories', @@ -191,6 +193,7 @@ const WRITE_COMMANDS = { MOVE_IOU_REPORT_TO_EXISTING_POLICY: 'MoveIOUReportToExistingPolicy', EXPORT_REPORT_TO_PDF: 'ExportReportToPDF', EXPORT_REPORTS_TO_PDF: 'ExportReportsToPDF', + SHARE_REPORT: 'ShareReport', EXPORT_RECEIPTS_TO_ZIP: 'ExportReceiptsToZip', RENAME_WORKSPACE_CATEGORY: 'RenameWorkspaceCategory', CREATE_POLICY_TAG: 'CreatePolicyTag', @@ -293,7 +296,8 @@ const WRITE_COMMANDS = { ENABLE_POLICY_TIME_TRACKING: 'EnablePolicyTimeTracking', SET_POLICY_TIME_TRACKING_DEFAULT_RATE: 'SetPolicyTimeTrackingDefaultRate', SET_POLICY_RULES_ENABLED: 'SetPolicyRulesEnabled', - SET_POLICY_CODING_RULE: 'SetPolicyCodingRule', + SET_RULE: 'SetRule', + DELETE_RULE: 'DeleteRule', SET_APPROVAL_WORKFLOW: 'SetApprovalWorkflow', SET_POLICY_EXPENSE_MAX_AMOUNT_NO_RECEIPT: 'SetPolicyExpenseMaxAmountNoReceipt', SET_POLICY_EXPENSE_MAX_AMOUNT_NO_ITEMIZED_RECEIPT: 'SetPolicyExpenseMaxAmountNoItemizedReceipt', @@ -303,6 +307,10 @@ const WRITE_COMMANDS = { SET_POLICY_WORK_ARRANGEMENT: 'SetPolicyWorkArrangement', SET_EMPLOYEE_WORK_ARRANGEMENT: 'SetEmployeeWorkArrangement', DISABLE_POLICY_COMMUTER_EXCLUSIONS: 'DisablePolicyCommuterExclusions', + ADD_OFFICE_LOCATION: 'AddOfficeLocation', + UPDATE_OFFICE_LOCATION: 'UpdateOfficeLocation', + DELETE_OFFICE_LOCATION: 'DeleteOfficeLocation', + UPDATE_EMPLOYEE_OFFICE_LOCATION: 'UpdateEmployeeOfficeLocation', SET_POLICY_REQUIRE_MAP_OR_GPS: 'SetPolicyRequireMapOrGPS', SET_POLICY_EXPENSE_MAX_AGE: 'SetPolicyExpenseMaxAge', ADD_POLICY_AGENT_RULE: 'AddPolicyAgentRule', @@ -600,6 +608,15 @@ const WRITE_COMMANDS = { UPDATE_BUSINESS_CENTRAL_DEFAULT_VENDOR: 'UpdateBusinessCentralDefaultVendor', UPDATE_BUSINESS_CENTRAL_PAYMENT_METHOD: 'UpdateBusinessCentralPaymentMethod', UPDATE_BUSINESS_CENTRAL_PROJECTS_MAPPING: 'UpdateBusinessCentralProjectsMapping', + UPDATE_BUSINESS_CENTRAL_AUTO_SYNC: 'UpdateBusinessCentralAutoSync', + UPDATE_BUSINESS_CENTRAL_ACCOUNTING_METHOD: 'UpdateBusinessCentralAccountingMethod', + UPDATE_BUSINESS_CENTRAL_EXPORT_TO_NEXT_OPEN_PERIOD: 'UpdateBusinessCentralExportToNextOpenPeriod', + UPDATE_BUSINESS_CENTRAL_SYNC_REIMBURSED_REPORTS: 'UpdateBusinessCentralSyncReimbursedReports', + UPDATE_BUSINESS_CENTRAL_REIMBURSEMENT_BANK_ACCOUNT: 'UpdateBusinessCentralReimbursementBankAccount', + UPDATE_BUSINESS_CENTRAL_SYNC_EXPENSIFY_CARD_SETTLEMENTS: 'UpdateBusinessCentralSyncExpensifyCardSettlements', + UPDATE_BUSINESS_CENTRAL_SETTLEMENTS_BANK_ACCOUNT: 'UpdateBusinessCentralSettlementsBankAccount', + UPDATE_BUSINESS_CENTRAL_POSTING_MODE: 'UpdateBusinessCentralPostingMode', + UPDATE_BUSINESS_CENTRAL_AUTO_CREATE_ENTITIES: 'UpdateBusinessCentralAutoCreateEntities', CONNECT_POLICY_TO_CAMPFIRE: 'ConnectPolicyToCampfire', UPDATE_CAMPFIRE_SUBSIDIARY: 'UpdateCampfireSubsidiary', UPDATE_CAMPFIRE_FIELD_MAPPING: 'UpdateCampfireFieldMapping', @@ -665,6 +682,7 @@ const WRITE_COMMANDS = { SET_POLICY_TAG_APPROVER: 'SetPolicyTagApprover', SAVE_SEARCH: 'SaveSearch', DELETE_SAVED_SEARCH: 'DeleteSavedSearch', + SAVE_SEARCH_FILTERS: 'SaveSearchFilters', UPDATE_CARD_SETTLEMENT_FREQUENCY: 'UpdateCardSettlementFrequency', UPDATE_CARD_SETTLEMENT_ACCOUNT: 'UpdateCardSettlementAccount', CONFIGURE_TRAVEL_BILLING_FOR_POLICY: 'ConfigureTravelBillingForPolicy', @@ -772,6 +790,7 @@ const WRITE_COMMANDS = { SET_DEFAULT_DOMAIN_SECURITY_GROUP: 'SetDefaultDomainSecurityGroup', DELETE_DOMAIN_SECURITY_GROUP: 'DeleteDomainSecurityGroup', CREATE_AGENT: 'CreateAgent', + CREATE_COMPANY_AGENT: 'CreateCompanyAgent', CREATE_DOMAIN_SECURITY_GROUP: 'CreateDomainSecurityGroup', UPDATE_AGENT_NAME: 'UpdateAgentName', UPDATE_AGENT_PROMPT: 'UpdateAgentPrompt', @@ -948,6 +967,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_POLICY_MEMBERS_CUSTOM_FIELDS]: Parameters.UpdatePolicyMembersCustomFieldsParams; [WRITE_COMMANDS.MOVE_IOU_REPORT_TO_EXISTING_POLICY]: Parameters.MoveIOUReportToExistingPolicyParams; [WRITE_COMMANDS.SET_WORKSPACE_CATEGORIES_ENABLED]: Parameters.SetWorkspaceCategoriesEnabledParams; + [WRITE_COMMANDS.SET_POLICY_VENDORS_ENABLED]: Parameters.SetPolicyVendorsEnabledParams; [WRITE_COMMANDS.MOVE_IOU_REPORT_TO_POLICY_AND_INVITE_SUBMITTER]: Parameters.MoveIOUReportToPolicyAndInviteSubmitterParams; [WRITE_COMMANDS.CREATE_WORKSPACE_CATEGORIES]: Parameters.CreateWorkspaceCategoriesParams; [WRITE_COMMANDS.IMPORT_CSV_COMPANY_CARDS]: Parameters.ImportCSVCompanyCardsParams; @@ -959,6 +979,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.EXPORT_PER_DIEM_CSV]: Parameters.ExportPerDiemCSVParams; [WRITE_COMMANDS.EXPORT_REPORT_TO_PDF]: Parameters.ExportReportPDFParams; [WRITE_COMMANDS.EXPORT_REPORTS_TO_PDF]: Parameters.ExportReportsToPDFParams; + [WRITE_COMMANDS.SHARE_REPORT]: Parameters.ShareReportParams; [WRITE_COMMANDS.EXPORT_RECEIPTS_TO_ZIP]: Parameters.ExportReceiptsToZipParams; [WRITE_COMMANDS.RENAME_WORKSPACE_CATEGORY]: Parameters.RenameWorkspaceCategoriesParams; [WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams; @@ -1048,6 +1069,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_DEFAULT_REPORT_TITLE]: Parameters.SetPolicyDefaultReportTitleParams; [WRITE_COMMANDS.SET_POLICY_PREVENT_MEMBER_CREATED_TITLE]: Parameters.SetPolicyPreventMemberCreatedTitleParams; [WRITE_COMMANDS.SET_POLICY_PREVENT_SELF_APPROVAL]: Parameters.SetPolicyPreventSelfApprovalParams; + [WRITE_COMMANDS.SET_POLICY_PREVENT_PAYOUT_NON_REIMBURSABLE_REPORTS]: Parameters.SetPolicyPreventPayoutNonReimbursableReportsParams; [WRITE_COMMANDS.SET_POLICY_AUTOMATIC_APPROVAL_LIMIT]: Parameters.SetPolicyAutomaticApprovalLimitParams; [WRITE_COMMANDS.SET_POLICY_AUTOMATIC_APPROVAL_RATE]: Parameters.SetPolicyAutomaticApprovalRateParams; [WRITE_COMMANDS.SET_POLICY_AUTO_REIMBURSEMENT_LIMIT]: Parameters.SetPolicyAutoReimbursementLimitParams; @@ -1077,7 +1099,8 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_POLICY_INVOICING]: Parameters.EnablePolicyInvoicingParams; [WRITE_COMMANDS.ENABLE_POLICY_TIME_TRACKING]: Parameters.EnablePolicyTimeTrackingParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; - [WRITE_COMMANDS.SET_POLICY_CODING_RULE]: Parameters.SetPolicyCodingRuleParams; + [WRITE_COMMANDS.SET_RULE]: Parameters.SetRuleParams; + [WRITE_COMMANDS.DELETE_RULE]: Parameters.DeleteRuleParams; [WRITE_COMMANDS.SET_APPROVAL_WORKFLOW]: Parameters.SetApprovalWorkflowParams; [WRITE_COMMANDS.SET_POLICY_REQUIRE_COMPANY_CARDS_ENABLED]: Parameters.SetPolicyRequireCompanyCardsEnabledParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED]: Parameters.SetPolicyCategoryDescriptionRequiredParams; @@ -1237,6 +1260,10 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_WORK_ARRANGEMENT]: Parameters.SetPolicyWorkArrangementParams; [WRITE_COMMANDS.SET_EMPLOYEE_WORK_ARRANGEMENT]: Parameters.SetEmployeeWorkArrangementParams; [WRITE_COMMANDS.DISABLE_POLICY_COMMUTER_EXCLUSIONS]: Parameters.DisablePolicyCommuterExclusionsParams; + [WRITE_COMMANDS.ADD_OFFICE_LOCATION]: Parameters.AddOfficeLocationParams; + [WRITE_COMMANDS.UPDATE_OFFICE_LOCATION]: Parameters.UpdateOfficeLocationParams; + [WRITE_COMMANDS.DELETE_OFFICE_LOCATION]: Parameters.DeleteOfficeLocationParams; + [WRITE_COMMANDS.UPDATE_EMPLOYEE_OFFICE_LOCATION]: Parameters.UpdateEmployeeOfficeLocationParams; [WRITE_COMMANDS.SET_POLICY_REQUIRE_MAP_OR_GPS]: Parameters.SetPolicyRequireMapOrGPSParams; [WRITE_COMMANDS.RETRACT_REPORT]: Parameters.RetractReportParams; [WRITE_COMMANDS.FINISH_CORPAY_BANK_ACCOUNT_ONBOARDING]: Parameters.FinishCorpayBankAccountOnboardingParams; @@ -1351,6 +1378,15 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_DEFAULT_VENDOR]: Parameters.UpdateBusinessCentralDefaultVendorParams; [WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_PAYMENT_METHOD]: Parameters.UpdateBusinessCentralPaymentMethodParams; [WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_PROJECTS_MAPPING]: Parameters.UpdateBusinessCentralCustomerMappingParams; + [WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_AUTO_SYNC]: Parameters.UpdateBusinessCentralAutoSyncParams; + [WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_ACCOUNTING_METHOD]: Parameters.UpdateBusinessCentralAccountingMethodParams; + [WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_EXPORT_TO_NEXT_OPEN_PERIOD]: Parameters.UpdateBusinessCentralExportToNextOpenPeriodParams; + [WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_SYNC_REIMBURSED_REPORTS]: Parameters.UpdateBusinessCentralSyncReimbursedReportsParams; + [WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_REIMBURSEMENT_BANK_ACCOUNT]: Parameters.UpdateBusinessCentralReimbursementBankAccountParams; + [WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_SYNC_EXPENSIFY_CARD_SETTLEMENTS]: Parameters.UpdateBusinessCentralSyncExpensifyCardSettlementsParams; + [WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_SETTLEMENTS_BANK_ACCOUNT]: Parameters.UpdateBusinessCentralSettlementsBankAccountParams; + [WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_POSTING_MODE]: Parameters.UpdateBusinessCentralPostingModeParams; + [WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_AUTO_CREATE_ENTITIES]: Parameters.UpdateBusinessCentralAutoCreateEntitiesParams; [WRITE_COMMANDS.CONNECT_POLICY_TO_CAMPFIRE]: Parameters.ConnectPolicyToCampfireParams; [WRITE_COMMANDS.UPDATE_CAMPFIRE_SUBSIDIARY]: Parameters.UpdateCampfireSubsidiaryParams; @@ -1493,6 +1529,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_CARD_RECONCILIATION_BANK_ACCOUNT]: Parameters.SetCardReconciliationBankAccountParams; [WRITE_COMMANDS.SAVE_SEARCH]: Parameters.SaveSearchParams; [WRITE_COMMANDS.DELETE_SAVED_SEARCH]: Parameters.DeleteSavedSearchParams; + [WRITE_COMMANDS.SAVE_SEARCH_FILTERS]: Parameters.SaveSearchFiltersParams; [WRITE_COMMANDS.UPDATE_CARD_SETTLEMENT_FREQUENCY]: Parameters.UpdateCardSettlementFrequencyParams; [WRITE_COMMANDS.UPDATE_CARD_SETTLEMENT_ACCOUNT]: Parameters.UpdateCardSettlementAccountParams; [WRITE_COMMANDS.CONFIGURE_TRAVEL_BILLING_FOR_POLICY]: Parameters.ConfigureTravelBillingForPolicyParams; @@ -1582,6 +1619,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_DEFAULT_DOMAIN_SECURITY_GROUP]: Parameters.SetDefaultDomainSecurityGroupParams; [WRITE_COMMANDS.DELETE_DOMAIN_SECURITY_GROUP]: Parameters.DeleteDomainSecurityGroupParams; [WRITE_COMMANDS.CREATE_AGENT]: Parameters.CreateAgentParams; + [WRITE_COMMANDS.CREATE_COMPANY_AGENT]: Parameters.CreateCompanyAgentParams; [WRITE_COMMANDS.UPDATE_AGENT_NAME]: Parameters.UpdateAgentNameParams; [WRITE_COMMANDS.UPDATE_AGENT_PROMPT]: Parameters.UpdateAgentPromptParams; [WRITE_COMMANDS.UPDATE_AGENT_AVATAR]: Parameters.UpdateAgentAvatarParams; @@ -1658,6 +1696,7 @@ const READ_COMMANDS = { OPEN_POLICY_CATEGORIES_PAGE: 'OpenPolicyCategoriesPage', OPEN_POLICY_ROOMS_PAGE: 'OpenPolicyRoomsPage', OPEN_POLICY_TAGS_PAGE: 'OpenPolicyTagsPage', + OPEN_POLICY_VENDORS_PAGE: 'OpenPolicyVendorsPage', OPEN_POLICY_TAXES_PAGE: 'OpenPolicyTaxesPage', OPEN_POLICY_REPORT_FIELDS_PAGE: 'OpenPolicyReportFieldsPage', OPEN_POLICY_INVOICES_PAGE: 'OpenPolicyInvoicesPage', @@ -1686,6 +1725,7 @@ const READ_COMMANDS = { OPEN_POLICY_INITIAL_PAGE: 'OpenPolicyInitialPage', OPEN_SUBSCRIPTION_PAGE: 'OpenSubscriptionPage', OPEN_HOME_PAGE: 'OpenHomePage', + OPEN_PAYMENT_DETAILS_PAGE: 'OpenPaymentDetailsPage', OPEN_PAYMENT_HISTORY_PAGE: 'OpenPaymentHistoryPage', OPEN_SAVE_THE_WORLD_PAGE: 'OpenSaveTheWorldPage', OPEN_DRAFT_DISTANCE_EXPENSE: 'OpenDraftDistanceExpense', @@ -1710,6 +1750,7 @@ const READ_COMMANDS = { GET_ASSIGNED_SUPPORT_DATA: 'GetAssignedSupportData', OPEN_AGENTS_PAGE: 'OpenAgentsPage', OPEN_PROFILE_PAGE: 'OpenProfilePage', + OPEN_RECIPIENT_PICKER: 'OpenRecipientPicker', } as const; type ReadCommand = ValueOf; @@ -1780,6 +1821,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_POLICY_CATEGORIES_PAGE]: Parameters.OpenPolicyCategoriesPageParams; [READ_COMMANDS.OPEN_POLICY_ROOMS_PAGE]: Parameters.OpenPolicyRoomsPageParams; [READ_COMMANDS.OPEN_POLICY_TAGS_PAGE]: Parameters.OpenPolicyTagsPageParams; + [READ_COMMANDS.OPEN_POLICY_VENDORS_PAGE]: Parameters.OpenPolicyVendorsPageParams; [READ_COMMANDS.OPEN_POLICY_TAXES_PAGE]: Parameters.OpenPolicyTaxesPageParams; [READ_COMMANDS.OPEN_POLICY_REPORT_FIELDS_PAGE]: Parameters.OpenPolicyReportFieldsPageParams; [READ_COMMANDS.OPEN_POLICY_INVOICES_PAGE]: Parameters.OpenPolicyReportFieldsPageParams; @@ -1808,6 +1850,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_POLICY_RECEIPT_PARTNERS_PAGE]: Parameters.OpenPolicyReceiptPartnersPageParams; [READ_COMMANDS.OPEN_SUBSCRIPTION_PAGE]: null; [READ_COMMANDS.OPEN_HOME_PAGE]: null; + [READ_COMMANDS.OPEN_PAYMENT_DETAILS_PAGE]: Parameters.OpenPaymentDetailsPageParams; [READ_COMMANDS.OPEN_PAYMENT_HISTORY_PAGE]: null; [READ_COMMANDS.OPEN_SAVE_THE_WORLD_PAGE]: null; [READ_COMMANDS.OPEN_DRAFT_DISTANCE_EXPENSE]: null; @@ -1834,6 +1877,7 @@ type ReadCommandParameters = { [READ_COMMANDS.GET_ASSIGNED_SUPPORT_DATA]: null; [READ_COMMANDS.OPEN_AGENTS_PAGE]: null; [READ_COMMANDS.OPEN_PROFILE_PAGE]: null; + [READ_COMMANDS.OPEN_RECIPIENT_PICKER]: null; }; const SIDE_EFFECT_REQUEST_COMMANDS = { @@ -1861,6 +1905,7 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { SEARCH: 'Search', GET_INSIGHTS: 'GetInsights', GET_SCIM_TOKEN: 'GetSCIMToken', + PARSE_EXPENSE_FILTERS: 'ParseExpenseFilters', LOG_OUT: 'LogOut', CREATE_DIGITAL_WALLET: 'CreateDigitalWallet', @@ -1919,6 +1964,7 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.CALCULATE_BILL_NEW_DOT]: null; [SIDE_EFFECT_REQUEST_COMMANDS.ACCEPT_SPOTNANA_TERMS]: Parameters.AcceptSpotnanaTermsParams; [SIDE_EFFECT_REQUEST_COMMANDS.GET_SCIM_TOKEN]: Parameters.GetScimTokenParams; + [SIDE_EFFECT_REQUEST_COMMANDS.PARSE_EXPENSE_FILTERS]: Parameters.ParseExpenseFiltersParams; [SIDE_EFFECT_REQUEST_COMMANDS.COMPLETE_GUIDED_SETUP]: Parameters.CompleteGuidedSetupParams; [SIDE_EFFECT_REQUEST_COMMANDS.GET_STATEMENT_PDF]: Parameters.GetStatementPDFParams; [SIDE_EFFECT_REQUEST_COMMANDS.GET_EXPENSIFY_CARD_STATEMENT_PDF]: Parameters.GetExpensifyCardStatementPDFParams; diff --git a/src/libs/AttendeeUtils.ts b/src/libs/AttendeeUtils.ts index 517b6ebb723f..2e8ac012cd5a 100644 --- a/src/libs/AttendeeUtils.ts +++ b/src/libs/AttendeeUtils.ts @@ -149,9 +149,9 @@ function enrichAndSortAttendees( return { ...a, accountID, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional || to fall back when personalDetails has an empty string + // intentional || to fall back when personalDetails has an empty string displayName: pd?.displayName || a?.displayName, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional || to fall back when personalDetails has an empty string + // intentional || to fall back when personalDetails has an empty string avatarUrl: freshAvatar || a?.avatarUrl, }; }), diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts index f34ad0e810ce..1ed341db2d52 100644 --- a/src/libs/BankAccountUtils.ts +++ b/src/libs/BankAccountUtils.ts @@ -136,6 +136,73 @@ function isFilterableBankAccount(bankAccount: OnyxEntry): return bankAccount?.accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS && !isBankAccountPartiallySetup(bankAccount?.accountData?.state); } +/** + * A verified (OPEN) USD business bank account that is not already set up for global reimbursements can start the Wise flow. + * Pass the record's top-level `bankCurrency` when available, since the nested `additionalData.currency` is optional. + */ +function isBankAccountEligibleForGlobalReimbursements(accountData: AccountData | undefined, bankCurrency?: string): boolean { + return ( + // `||` not `??`: bankCurrency can be an empty string, which should fall through to additionalData. + (bankCurrency || accountData?.additionalData?.currency) === CONST.CURRENCY.USD && + accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS && + !accountData.additionalData?.verifications?.corpay && + accountData.state === CONST.BANK_ACCOUNT.STATE.OPEN + ); +} + +/** + * Picks the bank account the Wallet global reimbursements banner points to: the most recently added eligible account. + */ +function getWiseGlobalReimbursementsBannerBankAccountID(bankAccountList: OnyxEntry): number | undefined { + const newest = Object.values(bankAccountList ?? {}).reduce((latest, bankAccount) => { + // Accounts deleted offline stay in the list until the request syncs + if (bankAccount.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return latest; + } + + const accountData = bankAccount.accountData; + if (!accountData?.bankAccountID || !isBankAccountEligibleForGlobalReimbursements(accountData, bankAccount.bankCurrency)) { + return latest; + } + + if (!latest) { + return accountData; + } + + // `created` is a DB timestamp, so string comparison orders accounts chronologically. When two accounts share the same second, the higher bank account ID wins. + const created = accountData.created ?? ''; + const latestCreated = latest.created ?? ''; + const isNewer = created > latestCreated || (created === latestCreated && accountData.bankAccountID > (latest.bankAccountID ?? CONST.DEFAULT_NUMBER_ID)); + + return isNewer ? accountData : latest; + }, undefined); + + return newest?.bankAccountID; +} + +/** + * Picks the bank account the Workspace Workflows global reimbursements banner points to: the workspace's connected account, when it is eligible. + */ +function getWorkspaceWiseGlobalReimbursementsBannerBankAccountID(policy: OnyxEntry, bankAccountList: OnyxEntry): number | undefined { + // Only "Reimburse via Expensify" pays out of the connected account, so the other choices have nothing to enable + if (policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES) { + return undefined; + } + + const bankAccountID = policy.achAccount?.bankAccountID; + if (!bankAccountID) { + return undefined; + } + + // Admins without a copy of the account can't see its currency, type or verifications, so the banner stays hidden for them + const bankAccount = bankAccountList?.[bankAccountID]; + if (bankAccount?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !isBankAccountEligibleForGlobalReimbursements(bankAccount?.accountData, bankAccount?.bankCurrency)) { + return undefined; + } + + return bankAccountID; +} + function doesPolicyHavePartiallySetupBankAccount(bankAccountList: OnyxEntry, policyID: string) { if (!bankAccountList) { return false; @@ -300,6 +367,9 @@ function showUnlockAlreadyRequestedModal(showConfirmModal: ReturnType, cardList: CardList | undefined, shouldFilterOutPersonalCards = false) { @@ -1106,7 +1109,6 @@ function getCustomOrFormattedFeedName( // Custom feed name can be empty. Fallback to default feed name // Fallback to feed key name for unknown feeds - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return customFeedName || formattedFeedName || feed; } diff --git a/src/libs/CategoryOptionListUtils.ts b/src/libs/CategoryOptionListUtils.ts index d63328b870ba..da81c50deb08 100644 --- a/src/libs/CategoryOptionListUtils.ts +++ b/src/libs/CategoryOptionListUtils.ts @@ -95,6 +95,7 @@ function getCategoryListSections({ maxRecentReportsToShow = CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, translate, shouldShowGLCode = false, + searchInputThreshold = CONST.STANDARD_LIST_ITEM_LIMIT, }: { categories: PolicyCategories; localeCompare: LocaleContextProps['localeCompare']; @@ -104,6 +105,7 @@ function getCategoryListSections({ maxRecentReportsToShow?: number; translate: LocalizedTranslate; shouldShowGLCode?: boolean; + searchInputThreshold?: number; }): CategoryTreeSection[] { const withGLCode = (category: Category): Category => (shouldShowGLCode ? {...category, glCode: getCategoryGLCode(categories, category.name)} : category); const sortedCategories = sortCategories(categories, localeCompare); @@ -202,7 +204,7 @@ function getCategoryListSections({ const selectedOptionNames = new Set(selectedOptions.map((selectedOption) => selectedOption.name)); const filteredCategories = enabledCategories.filter((category) => !selectedOptionNames.has(category.name)); - if (numberOfEnabledCategories < CONST.STANDARD_LIST_ITEM_LIMIT) { + if (numberOfEnabledCategories < searchInputThreshold) { const data = getCategoryOptionTree(filteredCategories, selectedOptionsWithDisabledState, shouldShowGLCode); categorySections.push({ // "All" section when items amount less than the threshold diff --git a/src/libs/CategoryTaxRulesUtils.ts b/src/libs/CategoryTaxRulesUtils.ts index b274d681983c..d6a04f73f2b0 100644 --- a/src/libs/CategoryTaxRulesUtils.ts +++ b/src/libs/CategoryTaxRulesUtils.ts @@ -9,6 +9,7 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {MerchantRuleForm} from '@src/types/form'; import type {Policy, PolicyCategories, TaxRate} from '@src/types/onyx'; +import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type {ExpenseRule} from '@src/types/onyx/Policy'; @@ -45,6 +46,13 @@ function isSelectableTaxRate(policy: Policy | undefined, taxID: string, taxRate: return !taxRate.isDisabled && taxRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !(isCategoryRule && taxID === policy?.taxRates?.defaultExternalID); } +/** Returns the category tax rule draft for generated values. It keeps the generated tax rate only when a category rule can use it. */ +function getCategoryTaxRuleDraftFromGeneratedValues({tax, ...values}: GeneratedRuleValues, policy: Policy | undefined): Partial { + const taxRate = tax ? policy?.taxRates?.taxes?.[tax] : undefined; + const selectableTax = tax && taxRate && isSelectableTaxRate(policy, tax, taxRate, true) ? tax : undefined; + return {...values, tax: selectableTax, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY}; +} + /** * Whether a new category tax default has any rate to choose. Holding rates is not enough: they can all be disabled, or * the workspace default can be the only one left, which a category rule can't use. @@ -194,6 +202,7 @@ function getCategoryNameFromTaxRuleKey(key: string): string { export { categoryHasTaxRule, + getCategoryTaxRuleDraftFromGeneratedValues, getCategoryNameFromTaxRuleKey, getCategoryTaxRulesTableData, getCategoryTaxRuleTaxID, diff --git a/src/libs/CategoryUtils.ts b/src/libs/CategoryUtils.ts index d0944c48c5ec..c4574a7ac310 100644 --- a/src/libs/CategoryUtils.ts +++ b/src/libs/CategoryUtils.ts @@ -144,6 +144,17 @@ function getCategoryGLCode(policyCategories: PolicyCategories | undefined, categ return glCode != null ? String(glCode).replaceAll('"', '') : ''; } +/** + * Whether at least one selectable category on the workspace has a non-blank GL code. Disabled and pending-deletion + * categories can't be picked in the category picker, so their GL codes don't count. + */ +function hasAnyCategoryGLCode(policyCategories: PolicyCategories | undefined): boolean { + return Object.entries(policyCategories ?? {}).some( + ([categoryName, category]) => + !!category?.enabled && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!getCategoryGLCode(policyCategories, categoryName).trim(), + ); +} + function getDecodedCategoryName(categoryName: string) { return Str.htmlDecode(categoryName); } @@ -302,6 +313,7 @@ export { isCategoryDescriptionRequired, getCategoryDescriptionHint, getCategoryGLCode, + hasAnyCategoryGLCode, getDecodedCategoryName, getDecodedLeafCategoryName, getDecodedFullCategoryName, diff --git a/src/libs/Clipboard/getClipboardText/index.native.ts b/src/libs/Clipboard/getClipboardText/index.native.ts index ef00c9c63637..861864a58a6e 100644 --- a/src/libs/Clipboard/getClipboardText/index.native.ts +++ b/src/libs/Clipboard/getClipboardText/index.native.ts @@ -1,5 +1,5 @@ import Parser from '@libs/Parser'; -export default function getClipboardText(selection: string, reportIDToName?: Record): string { - return Parser.htmlToMarkdown(selection, {reportIDToName}); +export default function getClipboardText(selection: string, reportIDToName?: Record, accountIDToName?: Record): string { + return Parser.htmlToMarkdown(selection, {reportIDToName, accountIDToName}); } diff --git a/src/libs/Clipboard/getClipboardText/index.ts b/src/libs/Clipboard/getClipboardText/index.ts index 772d0bc1f384..c8f444853902 100644 --- a/src/libs/Clipboard/getClipboardText/index.ts +++ b/src/libs/Clipboard/getClipboardText/index.ts @@ -1,5 +1,5 @@ import Parser from '@libs/Parser'; -export default function getClipboardText(selection: string, reportIDToName?: Record): string { - return Parser.htmlToText(selection, {reportIDToName}); +export default function getClipboardText(selection: string, reportIDToName?: Record, accountIDToName?: Record): string { + return Parser.htmlToText(selection, {reportIDToName, accountIDToName}); } diff --git a/src/libs/ComponentUtils/index.native.ts b/src/libs/ComponentUtils/index.native.ts index 01f45648d21f..3424d0a38f65 100644 --- a/src/libs/ComponentUtils/index.native.ts +++ b/src/libs/ComponentUtils/index.native.ts @@ -1,4 +1,6 @@ -import type {Component} from 'react'; +import type {ComposerRef} from '@components/Composer/types'; + +import type {HostInstance} from 'react-native'; import type {AnimatedRef} from 'react-native-reanimated'; import {dispatchCommand} from 'react-native-reanimated'; @@ -11,7 +13,7 @@ const ACCESSIBILITY_ROLE_FORM: AccessibilityRoleForm = 'none'; * Clears a text input on the UI thread using a custom clear command * that bypasses the event count check. */ -function forceClearInput(animatedInputRef: AnimatedRef) { +function forceClearInput(animatedInputRef: AnimatedRef) { 'worklet'; dispatchCommand(animatedInputRef, 'clear'); diff --git a/src/libs/ComponentUtils/index.ts b/src/libs/ComponentUtils/index.ts index ba4a031a5560..2ce5b9c1cecb 100644 --- a/src/libs/ComponentUtils/index.ts +++ b/src/libs/ComponentUtils/index.ts @@ -1,3 +1,5 @@ +import type {ComposerRef} from '@components/Composer/types'; + import ObjectUtils from '@src/types/utils/ObjectUtils'; import type {HostInstance} from 'react-native'; @@ -10,7 +12,7 @@ import type AccessibilityRoleForm from './types'; */ const ACCESSIBILITY_ROLE_FORM: AccessibilityRoleForm = 'form'; -function forceClearInput(animatedInputRef: AnimatedRef) { +function forceClearInput(animatedInputRef: AnimatedRef) { 'worklet'; const input = animatedInputRef.current; diff --git a/src/libs/ContactUtils.ts b/src/libs/ContactUtils.ts index a8b6dc33c20e..f0940653a5c7 100644 --- a/src/libs/ContactUtils.ts +++ b/src/libs/ContactUtils.ts @@ -44,7 +44,6 @@ function getContacts( return deviceContacts .map((contact) => { const email = sortEmailObjects(contact?.emailAddresses ?? [], localeCompare)?.at(0) ?? ''; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const avatarSource = (contact?.imageData || RandomAvatarUtils.getAvatarForContact(`${contact?.firstName}${email}${contact?.lastName}`)) ?? ''; const phoneNumber = contact.phoneNumbers?.[0]?.value ?? ''; const firstName = contact?.firstName ?? ''; diff --git a/src/libs/CopyPolicySettingsUtils.ts b/src/libs/CopyPolicySettingsUtils.ts index f2afc2c02017..697fd7f8b638 100644 --- a/src/libs/CopyPolicySettingsUtils.ts +++ b/src/libs/CopyPolicySettingsUtils.ts @@ -115,6 +115,8 @@ function getConnectionCompanyID(policy: Policy | undefined, connectionName: Conn return readCredentialsCompanyID(connections[CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]?.config); case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: return readCredentialsCompanyID(connections[CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]?.config); + case CONST.POLICY.CONNECTIONS.NAME.ZOHO_BOOKS: + return readCredentialsCompanyID(connections[CONST.POLICY.CONNECTIONS.NAME.ZOHO_BOOKS]?.config); default: return undefined; } diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 9f9639231ea1..a76717e108dc 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -1095,7 +1095,7 @@ const formatInTimeZoneWithFallback: typeof formatInTimeZone = (date, timeZone, f * @param timeZone - Target timezone to display the date in * @returns Date string in yyyy-MM-dd format, or empty string if invalid */ -function formatUTCDateTimeToDateInTimezone(utcDateTime: string, timeZone: SelectedTimezone | undefined, formatStr = CONST.DATE.FNS_FORMAT_STRING): string { +function formatUTCDateTimeToDateInTimezone(utcDateTime: string, timeZone: SelectedTimezone | undefined, formatStr: string = CONST.DATE.FNS_FORMAT_STRING): string { if (!utcDateTime || !timeZone) { return ''; } @@ -1196,7 +1196,7 @@ function isDateStringInMonth(dateString: string, year: number, month: number): b return datePart >= monthStart && datePart <= monthEnd; } -/** Returns a compact day label, e.g. "Sep 15, ’26". */ +/** Returns a compact date label, e.g. "Sep 15, ’26", or "Sep 15" when the year is left out. */ function getShortFormattedDayForSearch(day: string, dateFnsLocale: DateFnsLocale | undefined, shouldShowYear = true): string { return format(parse(day, 'yyyy-MM-dd', new Date()), shouldShowYear ? 'MMM d, ’yy' : 'MMM d', {locale: dateFnsLocale}); } @@ -1367,6 +1367,7 @@ const DateUtils = { formatViolationSnapshotStartedAtDate, normalizeDateToStartOfDay, normalizeDateToEndOfDay, + formatDBTimeWithoutMilliseconds, getMonthDateRange, getWeekDateRange, isDateStringInMonth, diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 8c8361be9712..7766e9c90254 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -2,7 +2,7 @@ /* eslint-disable max-classes-per-file */ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import type {Report, ReportAction, ReportActions, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {Report, ReportAction, ReportActions, ReportMetadata, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type {Comment} from '@src/types/onyx/Transaction'; @@ -493,6 +493,7 @@ function validateReportDraftProperty(key: keyof Report | keyof ReportNameValuePa case 'isDeletedParentAction': case 'isWaitingOnBankAccount': case 'canCancelReimbursement': + case 'isWaitingForCancelledReimbursement': case 'hasReportBeenRetracted': case 'isCancelledIOU': case 'hasReportBeenReopened': @@ -678,6 +679,7 @@ function validateReportDraftProperty(key: keyof Report | keyof ReportNameValuePa unheldReimbursableTotal: CONST.RED_BRICK_ROAD_PENDING_ACTION, isWaitingOnBankAccount: CONST.RED_BRICK_ROAD_PENDING_ACTION, canCancelReimbursement: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isWaitingForCancelledReimbursement: CONST.RED_BRICK_ROAD_PENDING_ACTION, isCancelledIOU: CONST.RED_BRICK_ROAD_PENDING_ACTION, hasReportBeenRetracted: CONST.RED_BRICK_ROAD_PENDING_ACTION, hasReportBeenReopened: CONST.RED_BRICK_ROAD_PENDING_ACTION, @@ -1458,7 +1460,6 @@ function validateTransactionViolationDraftProperty(key: keyof TransactionViolati comment: 'string', cardID: 'number', missingFields: 'array', - isSupplierViolation: 'boolean', startDate: 'string', endDate: 'string', nights: 'number', @@ -1609,6 +1610,7 @@ function getReasonAndReportActionForGBRInLHNRow( report: OnyxEntry, currentUserLogin: string, currentUserAccountID: number, + reportMetadata: OnyxEntry, isReportArchived = false, transactionViolations?: OnyxCollection, ): GBRReasonAndReportAction | null { @@ -1617,7 +1619,7 @@ function getReasonAndReportActionForGBRInLHNRow( } const {reason, reportAction} = - getReasonAndReportActionThatRequiresAttention({optionOrReport: report, currentUserLogin, currentUserAccountID, transactionViolations, isReportArchived}) ?? {}; + getReasonAndReportActionThatRequiresAttention({optionOrReport: report, currentUserLogin, currentUserAccountID, transactionViolations, isReportArchived, reportMetadata}) ?? {}; if (reason) { return {reason: `debug.reasonGBR.${reason}`, reportAction}; diff --git a/src/libs/DeviceCapabilities/canUseCssPositionSticky/index.native.ts b/src/libs/DeviceCapabilities/canUseCssPositionSticky/index.native.ts new file mode 100644 index 000000000000..0bc45b64daea --- /dev/null +++ b/src/libs/DeviceCapabilities/canUseCssPositionSticky/index.native.ts @@ -0,0 +1,8 @@ +import type CanUseCssPositionSticky from './types'; + +/** + * Allows us to identify whether the platform can pin an element inside its scroller with `position: sticky`. + */ +const canUseCssPositionSticky: CanUseCssPositionSticky = () => false; + +export default canUseCssPositionSticky; diff --git a/src/libs/DeviceCapabilities/canUseCssPositionSticky/index.ts b/src/libs/DeviceCapabilities/canUseCssPositionSticky/index.ts new file mode 100644 index 000000000000..5d723ee0011a --- /dev/null +++ b/src/libs/DeviceCapabilities/canUseCssPositionSticky/index.ts @@ -0,0 +1,8 @@ +import type CanUseCssPositionSticky from './types'; + +/** + * Allows us to identify whether the platform can pin an element inside its scroller with `position: sticky`. + */ +const canUseCssPositionSticky: CanUseCssPositionSticky = () => true; + +export default canUseCssPositionSticky; diff --git a/src/libs/DeviceCapabilities/canUseCssPositionSticky/types.ts b/src/libs/DeviceCapabilities/canUseCssPositionSticky/types.ts new file mode 100644 index 000000000000..e8b44aa67c72 --- /dev/null +++ b/src/libs/DeviceCapabilities/canUseCssPositionSticky/types.ts @@ -0,0 +1,3 @@ +type CanUseCssPositionSticky = () => boolean; + +export default CanUseCssPositionSticky; diff --git a/src/libs/DeviceCapabilities/index.ts b/src/libs/DeviceCapabilities/index.ts index 3759e4ade730..b5ae49ccb861 100644 --- a/src/libs/DeviceCapabilities/index.ts +++ b/src/libs/DeviceCapabilities/index.ts @@ -1,4 +1,5 @@ +import canUseCssPositionSticky from './canUseCssPositionSticky'; import canUseTouchScreen from './canUseTouchScreen'; import hasHoverSupport from './hasHoverSupport'; -export {canUseTouchScreen, hasHoverSupport}; +export {canUseCssPositionSticky, canUseTouchScreen, hasHoverSupport}; diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index ff71b2447623..dc2cf14333b2 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -5,7 +5,7 @@ import CONST from '@src/CONST'; import type {IOURequestType} from '@src/CONST'; import type {LastSelectedDistanceRates, OnyxInputOrEntry, Transaction} from '@src/types/onyx'; import type DefaultP2PMileageRate from '@src/types/onyx/DefaultP2PMileageRate'; -import type {Unit} from '@src/types/onyx/Policy'; +import type {RateAttributes, Unit} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; import type {TransactionCustomUnit} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -17,6 +17,7 @@ import {format, parseISO} from 'date-fns'; import DateUtils from './DateUtils'; import {getDistanceUnitLabel, getFormattedDistanceInUnits} from './DistanceDisplayUtils'; +import {convertDistanceUnit, convertToDistanceInMeters} from './DistanceUnitConversionUtils'; import getStoredDefaultP2PMileageRate from './getStoredDefaultP2PMileageRate'; import {getDistanceRateCustomUnit, getDistanceRateCustomUnitRate, getUnitRateValue} from './PolicyUtils'; import replaceAllDigits from './replaceAllDigits'; @@ -32,12 +33,11 @@ type MileageRate = { index?: number; startDate?: string | null; endDate?: string | null; + attributes?: RateAttributes; }; type CommuterExclusionData = Required>; -const METERS_TO_KM = 0.001; // 1 kilometer is 1000 meters -const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 miles in a meter const DEFAULT_P2P_RATE_CENTS_PER_MILE = 67; function getMileageRates(policy: OnyxInputOrEntry, includeDisabledRates = false, selectedRateID?: string): Record { @@ -71,6 +71,7 @@ function getMileageRates(policy: OnyxInputOrEntry, includeDisabledRates index: rate.index, startDate: rate.startDate, endDate: rate.endDate, + attributes: rate.attributes, }; } @@ -116,25 +117,6 @@ function getDefaultMileageRate(policy: OnyxInputOrEntry): MileageRate | }; } -/** - * Converts a given distance in meters to the specified unit (kilometers or miles). - * - * @param distanceInMeters - The distance in meters to be converted. - * @param unit - The desired unit of conversion, either 'km' for kilometers or 'mi' for miles. - * - * @returns The converted distance in the specified unit. - */ -function convertDistanceUnit(distanceInMeters: number, unit: Unit): number { - switch (unit) { - case CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS: - return distanceInMeters * METERS_TO_KM; - case CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES: - return distanceInMeters * METERS_TO_MILES; - default: - throw new Error('Unsupported unit. Supported units are "mi" or "km".'); - } -} - /** * Converts a distance into the given unit at the two decimal places the backend stores distances with. The * commuter exclusion arithmetic has to use this: the backend rounds both the trip and the commute before @@ -183,7 +165,6 @@ function getFormattedRateValue( const singularDistanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer'); const formattedRate = getUnitRateValue(toLocaleDigit, {rate}, useShortFormUnit); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const currencySymbol = getCurrencySymbol(currency) || `${currency} `; return `${currencySymbol}${formattedRate} / ${useShortFormUnit ? unit : singularDistanceUnit}`; @@ -549,20 +530,6 @@ function getDistanceDisplayDetailsWithCommuter( }; } -/** - * Converts the distance from kilometers or miles to meters. - * - * @param distance - The distance to be converted. - * @param unit - The unit of measurement for the distance. - * @returns The distance in meters. - */ -function convertToDistanceInMeters(distance: number, unit: Unit): number { - if (unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS) { - return distance / METERS_TO_KM; - } - return distance / METERS_TO_MILES; -} - /** * Returns whether the distance custom unit rate ID is unset or represents a non-workspace rate (P2P or placeholder). */ @@ -1045,6 +1012,67 @@ function getRateDateLabel(rate: MileageRate, translate: LocaleContextProps['tran return ''; } +type RateNameFields = Pick; + +/** Matches a trailing ` - M/D/YY - M/D/YY` or ` - From M/D/YY` suffix that the backend appends to keep dated rate names unique */ +const RATE_NAME_DATE_SUFFIX_REGEX = / - (?:\d{1,2}\/\d{1,2}\/\d{2}(?:\d{2})? - \d{1,2}\/\d{1,2}\/\d{2}(?:\d{2})?|From \d{1,2}\/\d{1,2}\/\d{2}(?:\d{2})?)$/; + +/** + * Returns the trailing date suffix that getRateDisplayName hides, or an empty string when nothing is hidden. + * Only rates copied from the government rate table get the suffix from the backend, so a custom rate that was named with a date on purpose keeps it. + */ +function getRateNameDateSuffix(rate: RateNameFields): string { + if (!rate.name || !rate.attributes?.governmentRate) { + return ''; + } + + const suffix = RATE_NAME_DATE_SUFFIX_REGEX.exec(rate.name)?.at(0) ?? ''; + + // Keep the full name when stripping would leave nothing + return suffix === rate.name ? '' : suffix; +} + +/** + * Returns the rate name without its trailing date suffix, since the date range is already shown next to it. + * Editors prefill this name and save it through getRateNameWithDateSuffix, so the raw name keeps its suffix. + */ +function getRateDisplayName(rate: RateNameFields): string | undefined { + const suffix = getRateNameDateSuffix(rate); + return suffix ? rate.name?.slice(0, -suffix.length) : rate.name; +} + +/** + * Re-appends the date suffix hidden by getRateDisplayName to a name edited from the display name, so Onyx and the API keep the full raw name. + * Pass a sanitized name. An empty name stays empty so it still fails the required-name check. + */ +function getRateNameWithDateSuffix(rate: RateNameFields, editedDisplayName: string): string { + if (!editedDisplayName) { + return editedDisplayName; + } + + return `${editedDisplayName}${getRateNameDateSuffix(rate)}`; +} + +/** + * Sorts rates by display name. Rates that share a display name are ordered newest start date first, so the + * current band sits above its ended bands and a rate without a start date sorts last. The raw name keeps the order stable. + * displayNameOrderMultiplier only flips the display name order, so the tie-break stays the same in both directions. + */ +function compareRatesByDisplayName(a: RateNameFields, b: RateNameFields, localeCompare: LocaleContextProps['localeCompare'], displayNameOrderMultiplier = 1): number { + const displayNameDiff = localeCompare(getRateDisplayName(a) ?? '', getRateDisplayName(b) ?? ''); + if (displayNameDiff !== 0) { + return displayNameDiff * displayNameOrderMultiplier; + } + + const aStart = a.startDate ?? ''; + const bStart = b.startDate ?? ''; + if (aStart !== bStart) { + return aStart < bStart ? 1 : -1; + } + + return localeCompare(a.name ?? '', b.name ?? ''); +} + export default { getDefaultMileageRate, getDistanceMerchant, @@ -1084,6 +1112,9 @@ export default { getBestEligibleRate, getRateForPolicyChange, getRateDateLabel, + getRateDisplayName, + getRateNameWithDateSuffix, + compareRatesByDisplayName, }; export type {MileageRate, CommuterExclusionData}; diff --git a/src/libs/DistanceUnitConversionUtils.ts b/src/libs/DistanceUnitConversionUtils.ts new file mode 100644 index 000000000000..28c1e08902c5 --- /dev/null +++ b/src/libs/DistanceUnitConversionUtils.ts @@ -0,0 +1,40 @@ +import CONST from '@src/CONST'; +import type {Unit} from '@src/types/onyx/Policy'; + +const METERS_TO_KM = 0.001; // 1 kilometer is 1000 meters +const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 miles in a meter + +/** + * Converts a given distance in meters to the specified unit (kilometers or miles). + * + * @param distanceInMeters - The distance in meters to be converted. + * @param unit - The desired unit of conversion, either 'km' for kilometers or 'mi' for miles. + * + * @returns The converted distance in the specified unit. + */ +function convertDistanceUnit(distanceInMeters: number, unit: Unit): number { + switch (unit) { + case CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS: + return distanceInMeters * METERS_TO_KM; + case CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES: + return distanceInMeters * METERS_TO_MILES; + default: + throw new Error('Unsupported unit. Supported units are "mi" or "km".'); + } +} + +/** + * Converts the distance from kilometers or miles to meters. + * + * @param distance - The distance to be converted. + * @param unit - The unit of measurement for the distance. + * @returns The distance in meters. + */ +function convertToDistanceInMeters(distance: number, unit: Unit): number { + if (unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS) { + return distance / METERS_TO_KM; + } + return distance / METERS_TO_MILES; +} + +export {convertDistanceUnit, convertToDistanceInMeters}; diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index c1cee4959ef9..981963f838d5 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -5,6 +5,7 @@ */ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {MerchantRuleForm} from '@src/types/form'; import type {Policy, Rule} from '@src/types/onyx'; import type {ApprovalWorkflowAction} from '@src/types/onyx/ApprovalWorkflowRules'; import type { @@ -15,6 +16,7 @@ import type { ExpenseDefaultTaxValue, ExpenseDefaultTriggers, } from '@src/types/onyx/ExpenseDefaultRules'; +import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type {RuleFilterComparison, RuleFilterNode} from '@src/types/onyx/RuleFilters'; import type {OnyxCollection} from 'react-native-onyx'; @@ -222,8 +224,8 @@ function buildMerchantRuleActions(formValues: Partial, p } /** - * Builds the rules engine body for a merchant rule. It is what the `rules_` collection stores, and what the - * legacy `codingRuleValue` is derived from until writes move to `SetRule`. + * Builds the rules engine body for a merchant rule. It is what the `rules_` collection stores and what is sent + * as the `value` param of `SetRule`. * Returns undefined when the form has nothing to match on or nothing to set, which the API rejects. */ function buildMerchantRule(formValues: Partial, policy: Policy | undefined): BuiltMerchantRule | undefined { @@ -451,13 +453,19 @@ function canEditMerchantRule(rule: Rule | undefined, policyID: string | undefine return isPolicyScopedRule(rule, policyID) && isEditableMerchantRule(rule); } -export type {BuiltMerchantRule, MerchantRuleFormValues}; +/** Returns the merchant rule draft for generated values */ +function getMerchantRuleDraftFromGeneratedValues(values: GeneratedRuleValues): Partial { + return {...values, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}; +} + +export type {MerchantRuleFormValues}; export { buildCopiedExpenseDefaultRules, buildMerchantRule, canEditMerchantRule, getExpenseDefaultRuleCount, getExpenseDefaultRuleSummaryFields, + getMerchantRuleDraftFromGeneratedValues, getMerchantRuleFormValues, getPolicyExpenseDefaultRules, getRuleMerchantMatchSummary, diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index 71e5804d8e5a..0acce1213bed 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -24,7 +24,10 @@ const onyxKeysToRemove = new Set | ValueOf = { allowList: ['externalID', 'enabled', 'origin'], maskList: ['name'], }, + // These are other members' bank details, so mask them in exports. + [ONYXKEYS.COLLECTION.POLICY_MEMBER_BANK_ACCOUNTS]: { + allowList: [], + maskList: ['bankName', 'bankAccountLastFour'], + }, [ONYXKEYS.USER_WALLET]: { allowList: ['currentBalance', 'availableBalance', 'tierName'], maskList: [], @@ -176,6 +184,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.ACTIVE_SERVER, ONYXKEYS.AGENT_RULE_SUGGESTIONS, ONYXKEYS.AGENT_TEMPLATES, + ONYXKEYS.APP_REVIEW_LAST_PROMPT, ONYXKEYS.ARE_AGENTS_LOADED, ONYXKEYS.ATTACHMENT_RECORD_PATHS_MIGRATED, ONYXKEYS.BETAS, @@ -287,6 +296,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION, ONYXKEYS.NVP_DISMISSED_TRIAL_PAYMENT_REMINDER, ONYXKEYS.NVP_EMPTY_REPORTS_CONFIRMATION_DISMISSED, + ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, ONYXKEYS.NVP_HAS_SEEDED_MY_EXPENSES_SEARCH, ONYXKEYS.NVP_HAS_SEEN_FOR_YOU_TODO, ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING, @@ -442,6 +452,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT, ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT, ONYXKEYS.COLLECTION.WORKSPACE_INVITE_ROLE_DRAFT, + ONYXKEYS.COLLECTION.WORKSPACE_INVITE_WORK_ARRANGEMENT_DRAFT, ONYXKEYS.CONCIERGE_PROMPT_DRAFT, ONYXKEYS.CORPAY_FIELDS, ONYXKEYS.CORPAY_ONBOARDING_FIELDS, @@ -503,6 +514,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.ONYX_UPDATES_FROM_SERVER, ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, ONYXKEYS.PENDING_CONTACT_ACTION, + ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL, ONYXKEYS.PERSISTED_ONGOING_REQUESTS, ONYXKEYS.PERSISTED_REQUESTS, ONYXKEYS.PERSONAL_BANK_ACCOUNT, @@ -517,6 +529,8 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.RAM_ONLY_HAS_FRESH_WALLET_DATA, ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA, ONYXKEYS.RAM_ONLY_WALLET_ONFIDO, + ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, + ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, ONYXKEYS.RECENTLY_USED_CURRENCIES, ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, ONYXKEYS.RECENT_SEARCHES, diff --git a/src/libs/Formula.ts b/src/libs/Formula.ts index d214500907c4..3e8974351028 100644 --- a/src/libs/Formula.ts +++ b/src/libs/Formula.ts @@ -971,13 +971,10 @@ function computePersonalDetailsField(path: string[], personalDetails: PersonalDe switch (field.toLowerCase()) { case 'firstname': - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return personalDetails.firstName || personalDetails.login || ''; case 'lastname': - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return personalDetails.lastName || personalDetails.login || ''; case 'fullname': - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return personalDetails.displayName || personalDetails.login || ''; case 'email': return personalDetails.login ?? ''; diff --git a/src/libs/Fullstory/utils.ts b/src/libs/Fullstory/utils.ts index aa8c3581876e..1ffe106b7268 100644 --- a/src/libs/Fullstory/utils.ts +++ b/src/libs/Fullstory/utils.ts @@ -1,6 +1,5 @@ import {filterObject} from '@libs/ObjectUtils'; -import {getActivePolicies, isControlPolicy} from '@libs/PolicyUtils'; -import getHighestPolicyRole from '@libs/PolicyUtils/getHighestPolicyRole'; +import {getActivePolicies, getHighestPolicyRole, isControlPolicy} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; diff --git a/src/libs/Growl.ts b/src/libs/Growl.ts index d1a3066f0171..c5942f68c546 100644 --- a/src/libs/Growl.ts +++ b/src/libs/Growl.ts @@ -1,5 +1,7 @@ import CONST from '@src/CONST'; +import type {ValueOf} from 'type-fest'; + import React from 'react'; type GrowlAction = { @@ -10,8 +12,17 @@ type GrowlAction = { /** The set of growl variants the notification UI knows how to render. */ type GrowlType = typeof CONST.GROWL.SUCCESS | typeof CONST.GROWL.ERROR | typeof CONST.GROWL.WARNING; +/** Wide-screen anchor. Narrow layouts keep the top anchor. */ +type GrowlPosition = ValueOf; + +type GrowlOptions = { + duration?: number; + action?: GrowlAction; + position?: GrowlPosition; +}; + type GrowlRef = { - show?: (bodyText: string, type: GrowlType, duration: number, action?: GrowlAction) => void; + show?: (bodyText: string, type: GrowlType, duration: number, action?: GrowlAction, position?: GrowlPosition) => void; }; const growlRef = React.createRef(); @@ -30,22 +41,22 @@ function setIsReady() { /** * Show the growl notification */ -function show(bodyText: string, type: GrowlType, duration?: number, action?: GrowlAction) { +function show(bodyText: string, type: GrowlType, duration?: number, action?: GrowlAction, position?: GrowlPosition) { // Default to a longer duration when there's an action button so users have time to tap it. const resolvedDuration = duration ?? (action ? CONST.GROWL.DURATION_WITH_ACTION : CONST.GROWL.DURATION); isReadyPromise.then(() => { if (!growlRef?.current?.show) { return; } - growlRef.current.show(bodyText, type, resolvedDuration, action); + growlRef.current.show(bodyText, type, resolvedDuration, action, position); }); } /** - * Show error growl + * Show error growl. */ -function error(bodyText: string, duration?: number, action?: GrowlAction) { - show(bodyText, CONST.GROWL.ERROR, duration, action); +function error(bodyText: string, options?: GrowlOptions) { + show(bodyText, CONST.GROWL.ERROR, options?.duration, options?.action, options?.position); } /** @@ -61,6 +72,6 @@ export default { success, }; -export type {GrowlRef, GrowlAction, GrowlType}; +export type {GrowlRef, GrowlAction, GrowlType, GrowlPosition}; export {growlRef, setIsReady}; diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts index d4ad52b448b9..254a41a14e44 100644 --- a/src/libs/HttpUtils.ts +++ b/src/libs/HttpUtils.ts @@ -84,12 +84,14 @@ function processHTTPRequest( method: RequestType = 'get', body: FormData | null = null, abortSignal: AbortSignal | undefined = undefined, + shouldSkipPrefetch = false, ): Promise> { const startTime = new Date().valueOf(); const command = url.match(APICommandRegex)?.[1]; - const {prefetchKey, prefetchHeaders} = preparePrefetchRequest(command); + const {prefetchKey, prefetchHeaders} = shouldSkipPrefetch ? {prefetchKey: undefined, prefetchHeaders: undefined} : preparePrefetchRequest(command); + let isPrefetchedResponse = false; const fetchParams: NonNullable[1]> = { // We hook requests to the same Controller signal, so we can cancel them all at once @@ -125,6 +127,7 @@ function processHTTPRequest( } if (response.headers) { setLoadTestParameters(response.headers.get('X-Load-Test')); + isPrefetchedResponse = response.headers.get('nitroPrefetched') === 'true'; } // We are calculating the skew to minimize the delay when posting the messages @@ -240,6 +243,13 @@ function processHTTPRequest( alert('Too many auth writes', message); } if (response.jsonCode === CONST.JSON_CODE.UPDATE_REQUIRED) { + // A prefetched response can come from a body saved by the previous build, so it carries that build's appversion. + // https://github.com/Expensify/App/pull/103842 leaves appversion out of saved bodies, but bodies saved by older builds still have it. + // Retry without the prefetchKey, because nitro-fetch can serve the same cached response again for a few seconds. + if (isPrefetchedResponse) { + return processHTTPRequest(url, method, body, abortSignal, true); + } + // Trigger a modal and disable the app as the user needs to upgrade to the latest minimum version to continue alertUser(); } diff --git a/src/libs/IOUAmountSubmission.ts b/src/libs/IOUAmountSubmission.ts index 8e0b8e6eedcc..b4476bf79aab 100644 --- a/src/libs/IOUAmountSubmission.ts +++ b/src/libs/IOUAmountSubmission.ts @@ -272,6 +272,7 @@ function buildReportParticipants(args: SubmitAmountArgs) { translate, dateFnsLocale, convertToDisplayString, + formatPhoneNumber, rules, } = args; const selectedParticipants = getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID); @@ -296,6 +297,7 @@ function buildReportParticipants(args: SubmitAmountArgs) { translate, dateFnsLocale, convertToDisplayString, + formatPhoneNumber, }, rules, // Passing pendingDeleteMemberAccountIDs as undefined is intentional, getMoneyRequestParticipantsFromReport only report-backs policy expense chats, self DMs and invoice rooms. diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 78da75b43b03..ea555045903e 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -419,16 +419,7 @@ function navigateToConfirmationPage( Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.SUBMIT, transactionID, reportID, backToReport)); break; case CONST.IOU.TYPE.CREATE: - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute( - CONST.IOU.ACTION.CREATE, - CONST.IOU.TYPE.SUBMIT, - transactionID, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - reportIDParam || reportID, - backToReport, - ), - ); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.SUBMIT, transactionID, reportIDParam || reportID, backToReport)); break; case CONST.IOU.TYPE.SEND: if (fromManualDistanceRequest) { @@ -443,7 +434,6 @@ function navigateToConfirmationPage( CONST.IOU.ACTION.CREATE, shouldNavigateToSubmit ? CONST.IOU.TYPE.SUBMIT : iouType, transactionID, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing reportIDParam || reportID, backToReport, ), @@ -462,6 +452,27 @@ function getExistingTransactionID(linkedTrackedExpenseReportAction: ReportAction return getOriginalMessage(linkedTrackedExpenseReportAction)?.IOUTransactionID; } +/** + * Resolve the `reimbursable` value a brand new expense should be created with. + * + * `defaultReimbursable` is optional on the policy blob and an absent value means "reimbursable", + * which is what the backend assumes too. The policy default, locked or not, only applies when the policy + * is where the expense goes (its policy expense chat, or a track expense). `policy` can be an unrelated + * workspace - for example the policy for moving expenses when an unreported expense is submitted to a + * P2P participant - and that workspace's rules must not leak into the P2P expense. + */ +function getDefaultReimbursableForPolicy({ + policy, + isPolicyExpenseChat, + isCreatingTrackExpense, +}: { + policy: OnyxEntry; + isPolicyExpenseChat: boolean; + isCreatingTrackExpense: boolean; +}): boolean { + return (isPolicyExpenseChat && isGroupPolicy(policy)) || isCreatingTrackExpense ? (policy?.defaultReimbursable ?? true) : true; +} + function calculateDefaultReimbursable({ iouType, policy, @@ -479,7 +490,7 @@ function calculateDefaultReimbursable({ const isUnreported = transactionReportID === CONST.REPORT.UNREPORTED_REPORT_ID; const isPolicyExpenseChat = !!participant?.isPolicyExpenseChat; const reportPolicy = isCreatingTrackExpense || isUnreported ? policyForMovingExpenses : policy; - return (isPolicyExpenseChat && isGroupPolicy(reportPolicy)) || isCreatingTrackExpense ? (reportPolicy?.defaultReimbursable ?? true) : true; + return getDefaultReimbursableForPolicy({policy: reportPolicy, isPolicyExpenseChat, isCreatingTrackExpense}); } function getInitialPerDiemTargetReport( @@ -635,6 +646,15 @@ function isSelfDMParticipant(participant: Participant | undefined, currentUserAc return participant.isSelfDM === true || (!!currentUserAccountID && participant.accountID === currentUserAccountID); } +/** + * Whether the expense is addressed 1:1 to another user (a P2P IOU). P2P chats don't support negative amounts, so the + * amount input and the confirmation validation both rely on this. Unlike `isParticipantP2P`, it also excludes a raw + * current-user participant (self-DM seeded before the flag is set) and an invoice sender. + */ +function isP2PRecipient(participant: Participant | undefined, currentUserAccountID: number | undefined): boolean { + return isParticipantP2P(participant) && !isSelfDMParticipant(participant, currentUserAccountID); +} + /** * An expense targets the current user's self-DM (and therefore must be tracked rather than requested) when it has a * single selected participant that resolves to the current user's self-DM. SPLIT/INVOICE/PAY are never self-DM @@ -694,10 +714,12 @@ export { shouldShowPerDiemTabOption, navigateToConfirmationPage, calculateDefaultReimbursable, + getDefaultReimbursableForPolicy, getInitialPerDiemTargetReport, getIsWorkspacesOnlyForTransaction, getReusableP2PReportID, isParticipantP2P, + isP2PRecipient, isSelfDMSoleDestination, isLookingAroundSearchRoutingActive, resolveOptimisticChatReportID, diff --git a/src/libs/InputUtils/index.ts b/src/libs/InputUtils/index.ts index b6a0a91be289..3f5c49e3cc18 100644 --- a/src/libs/InputUtils/index.ts +++ b/src/libs/InputUtils/index.ts @@ -1,20 +1,21 @@ import type {MoveSelectionToEnd, ScrollInput} from './types'; const scrollToBottom: ScrollInput = (input) => { - if (!('scrollTop' in input)) { + // Native inputs also expose scroll offsets, but only DOM offsets are writable. + if (!(input instanceof HTMLElement)) { return; } // eslint-disable-next-line no-param-reassign - (input as HTMLInputElement).scrollTop = input.scrollHeight; + input.scrollTop = input.scrollHeight; }; const scrollToRight: ScrollInput = (input) => { - if (!('scrollLeft' in input)) { + if (!(input instanceof HTMLElement)) { return; } // Scroll to the far right // eslint-disable-next-line no-param-reassign - (input as HTMLInputElement).scrollLeft = input.scrollWidth; + input.scrollLeft = input.scrollWidth; }; const moveSelectionToEnd: MoveSelectionToEnd = (input) => { diff --git a/src/libs/InputUtils/types.ts b/src/libs/InputUtils/types.ts index 684d2790efef..494133ffe4d4 100644 --- a/src/libs/InputUtils/types.ts +++ b/src/libs/InputUtils/types.ts @@ -1,7 +1,7 @@ import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; -type ScrollInput = (input: HTMLInputElement | ComponentRef) => void; +type ScrollInput = (input: HTMLInputElement | HTMLTextAreaElement | HTMLDivElement | ComponentRef) => void; type MoveSelectionToEnd = (input: HTMLInputElement | ComponentRef) => void; export type {ScrollInput, MoveSelectionToEnd}; diff --git a/src/libs/ListUtils.tsx b/src/libs/ListUtils.tsx index 6d5fbbe1a03d..502912e28024 100644 --- a/src/libs/ListUtils.tsx +++ b/src/libs/ListUtils.tsx @@ -21,5 +21,20 @@ function sortDefaultToTop(items: T[], isDefault: (item: T) = })); } -// eslint-disable-next-line import/prefer-default-export -export {sortDefaultToTop}; +/** + * Places the clear row below a pinned selection, otherwise at the top. + */ +function withClearRow(items: T[], clearRow: T, isSelectionPinnedToTop: boolean): T[] { + return isSelectionPinnedToTop ? [...items.slice(0, 1), clearRow, ...items.slice(1)] : [clearRow, ...items]; +} + +/** Removes the default row's separator when it is the last item in the list. */ +function clearTrailingDefaultSeparator(items: T[], isDefault: (item: T) => boolean): T[] { + const lastItem = items.at(-1); + if (!lastItem?.footerContent || !isDefault(lastItem)) { + return items; + } + return [...items.slice(0, -1), {...lastItem, footerContent: undefined}]; +} + +export {sortDefaultToTop, withClearRow, clearTrailingDefaultSeparator}; diff --git a/src/libs/MerchantTypeRulesUtils.ts b/src/libs/MerchantTypeRulesUtils.ts index 9104d6e88044..f069592a4f14 100644 --- a/src/libs/MerchantTypeRulesUtils.ts +++ b/src/libs/MerchantTypeRulesUtils.ts @@ -17,7 +17,7 @@ import {getCategoryTaxRulesTableData, getTaxRateDisplayName} from './CategoryTax import {getDecodedCategoryName} from './CategoryUtils'; import {getExpenseDefaultRuleSummaryFields, getPolicyExpenseDefaultRules, getRuleMerchantMatchSummary, isEditableMerchantRule, isExpenseDefaultTaxValue} from './ExpenseDefaultRuleUtils'; import {getMccGroupDisplayName} from './PolicyRulesUtils'; -import {getCommaSeparatedTagNameWithSanitizedColons, getVendorRuleDisplayValue, isTagInPolicy, isXeroActiveMatchingSource} from './PolicyUtils'; +import {getCommaSeparatedTagNameWithSanitizedColons, getVendorRuleDisplayValue, isTagInPolicy} from './PolicyUtils'; const MERCHANT_TYPE_RULE_KEY_PREFIX = 'mcc-group:'; @@ -121,13 +121,12 @@ function getMerchantRulesTableData({ return []; } - const isOnXero = isXeroActiveMatchingSource(policy); const fieldLabels = { category: translate('common.category').toLowerCase(), tag: translate('common.tag').toLowerCase(), description: translate('common.description').toLowerCase(), tax: translate('common.tax').toLowerCase(), - vendor: translate(isOnXero ? 'common.supplier' : 'common.vendor').toLowerCase(), + vendor: translate('common.vendor').toLowerCase(), }; const {FIELD} = CONST.RULES.EXPENSE_DEFAULT; @@ -177,7 +176,7 @@ function getMerchantRulesTableData({ ), ); } else if (field === FIELD.VENDOR_ID && typeof value === 'string') { - const unavailableLabel = translate(isOnXero ? 'workspace.rules.merchantRules.supplierUnavailable' : 'workspace.rules.merchantRules.vendorUnavailable'); + const unavailableLabel = translate('workspace.rules.merchantRules.vendorUnavailable'); actions.push( translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', fieldLabels.vendor, getVendorRuleDisplayValue(policy, value, unavailableLabel)), ); diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index fbad088f740a..3be94bd870b5 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -232,19 +232,16 @@ function getMergeableDataAndConflictFields( searchReports: Array> = [], targetTransactionPolicy?: OnyxEntry, sourceTransactionPolicy?: OnyxEntry, - targetReportOwnerAsAttendee?: Attendee, - sourceReportOwnerAsAttendee?: Attendee, ) { const conflictFields: string[] = []; const mergeableData: Record = {}; // The same object, typed for the field builders that read the values merged so far const mergedSoFar = mergeableData as MergeTransaction; - // Resolve the report-owner fallback the same way the display path (buildMergeFieldsData) does, so an expense - // with no stored attendee is compared as [owner] instead of [] and doesn't produce a false attendee conflict - // against an expense whose attendee is that same owner. - const targetTransactionDetails = getTransactionDetails(targetTransaction, undefined, undefined, undefined, undefined, targetReportOwnerAsAttendee); - const sourceTransactionDetails = getTransactionDetails(sourceTransaction, undefined, undefined, undefined, undefined, sourceReportOwnerAsAttendee); + // Attendees are compared as stored, without the report-owner display fallback. The owner can only be resolved for + // reports loaded locally, so applying it would give the two expenses different values when they sit on different reports. + const targetTransactionDetails = getTransactionDetails(targetTransaction); + const sourceTransactionDetails = getTransactionDetails(sourceTransaction); for (const field of getMergeFields(targetTransaction)) { const targetValue = getMergeFieldValue(targetTransactionDetails, targetTransaction, field); @@ -319,7 +316,8 @@ function getMergeableDataAndConflictFields( const targetAttendeeLogins = ((targetValue as Attendee[] | undefined)?.map((attendee) => attendee.email) ?? []).filter((login): login is string => !!login).sort(localeCompare); const sourceAttendeeLogins = ((sourceValue as Attendee[] | undefined)?.map((attendee) => attendee.email) ?? []).filter((login): login is string => !!login).sort(localeCompare); - if (deepEqual(targetAttendeeLogins, sourceAttendeeLogins)) { + // An expense with no attendees has nothing to choose between, so the other side's attendees are kept + if (isTargetValueEmpty || isSourceValueEmpty || deepEqual(targetAttendeeLogins, sourceAttendeeLogins)) { mergeableData[field] = isTargetValueEmpty ? sourceValue : targetValue; } else { conflictFields.push(field); @@ -579,6 +577,7 @@ function getDisplayValue( translate: LocaleContextProps['translate'], convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'], localeCompare: LocaleContextProps['localeCompare'], + formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], reports?: Array>, ): string { const fieldValue = getMergeFieldValue(transactionDetails, transaction, field); @@ -610,7 +609,7 @@ function getDisplayValue( return transaction?.reportName ?? getReportName(getReportOrDraftReport(SafeString(fieldValue), reports)); } if (field === 'attendees') { - return Array.isArray(fieldValue) ? getAttendeesListDisplayString(fieldValue, localeCompare) : ''; + return Array.isArray(fieldValue) ? getAttendeesListDisplayString(fieldValue, formatPhoneNumber, localeCompare) : ''; } if (field === 'taxValue') { @@ -644,6 +643,7 @@ function buildMergeFieldsData({ translate, convertToDisplayString, localeCompare, + formatPhoneNumber, reports, }: { conflictFields: MergeFieldKey[]; @@ -657,6 +657,7 @@ function buildMergeFieldsData({ translate: LocaleContextProps['translate']; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; localeCompare: LocaleContextProps['localeCompare']; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; reports: Array>; }): MergeFieldData[] { const targetTransactionDetails = getTransactionDetails(targetTransaction, undefined, undefined, undefined, undefined, targetReportOwnerAsAttendee); @@ -675,13 +676,33 @@ function buildMergeFieldsData({ { transaction: targetTransaction, transactionDetails: targetTransactionDetails, - displayValue: getDisplayValue(field, targetTransaction, targetTransactionDetails, targetTransactionPolicy, translate, convertToDisplayString, localeCompare, reports), + displayValue: getDisplayValue( + field, + targetTransaction, + targetTransactionDetails, + targetTransactionPolicy, + translate, + convertToDisplayString, + localeCompare, + formatPhoneNumber, + reports, + ), isSelected: selectedTransactionId === targetTransaction.transactionID, }, { transaction: sourceTransaction, transactionDetails: sourceTransactionDetails, - displayValue: getDisplayValue(field, sourceTransaction, sourceTransactionDetails, sourceTransactionPolicy, translate, convertToDisplayString, localeCompare, reports), + displayValue: getDisplayValue( + field, + sourceTransaction, + sourceTransactionDetails, + sourceTransactionPolicy, + translate, + convertToDisplayString, + localeCompare, + formatPhoneNumber, + reports, + ), isSelected: selectedTransactionId === sourceTransaction.transactionID, }, ]; diff --git a/src/libs/Middleware/SaveResponseInOnyx.ts b/src/libs/Middleware/SaveResponseInOnyx.ts index 1a423540de20..fc5b2f96e884 100644 --- a/src/libs/Middleware/SaveResponseInOnyx.ts +++ b/src/libs/Middleware/SaveResponseInOnyx.ts @@ -1,5 +1,7 @@ import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import Log from '@libs/Log'; +import {reconnectApp} from '@userActions/App'; import * as OnyxUpdates from '@userActions/OnyxUpdates'; import CONST from '@src/CONST'; @@ -21,6 +23,15 @@ const requestsToIgnoreLastUpdateID = new Set([ SIDE_EFFECT_REQUEST_COMMANDS.GET_MISSING_ONYX_MESSAGES, ]); +// These requests already bring the client up to date (or end the session), so they never need a full ReconnectApp to cover a gap in update IDs. +// This is separate from requestsToIgnoreLastUpdateID because requests like DeleteMoneyRequest skip the OnyxUpdates flow but still move lastUpdateID forward. +const requestsExemptFromFullReconnect = new Set([ + WRITE_COMMANDS.OPEN_APP, + SIDE_EFFECT_REQUEST_COMMANDS.RECONNECT_APP, + SIDE_EFFECT_REQUEST_COMMANDS.GET_MISSING_ONYX_MESSAGES, + WRITE_COMMANDS.CLOSE_ACCOUNT, +]); + // A request belongs here when its successData/finallyData is what unblocks authentication, because parking that leaves the client unable to reauthenticate. const requestsToApplyWithoutAdvancingLastUpdateID = new Set([READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, READ_COMMANDS.SIGN_IN_WITH_SUPPORT_AUTH_TOKEN]); @@ -44,6 +55,31 @@ const SaveResponseInOnyx: Middleware = (requestResponse: P const shouldApplyWithoutAdvancingLastUpdateID = requestsToApplyWithoutAdvancingLastUpdateID.has(request.command); + // The server only keeps recent update IDs. When a client comes back after a long time, the server no longer knows its + // lastUpdateIDAppliedToClient and answers a request with a lastUpdateID but no previousUpdateID. Applying that response moves + // the client's lastUpdateID forward past updates it never received, so an incremental ReconnectApp can't recover them. + // Run a full ReconnectApp after applying so the client gets everything in the gap. + const clientLastUpdateID = OnyxUpdates.getPersistedLastUpdateID(); + const responseLastUpdateID = Number(response?.lastUpdateID ?? CONST.DEFAULT_NUMBER_ID); + const shouldFullReconnectAfterApply = + !shouldApplyWithoutAdvancingLastUpdateID && + !requestsExemptFromFullReconnect.has(request.command) && + !!clientLastUpdateID && + responseLastUpdateID > clientLastUpdateID && + !Number(response?.previousUpdateID ?? CONST.DEFAULT_NUMBER_ID); + + if (shouldFullReconnectAfterApply) { + Log.info('[SaveResponseInOnyx] Response has a lastUpdateID but no previousUpdateID, running a full ReconnectApp after applying it', false, { + command: request.command, + clientLastUpdateID, + responseLastUpdateID, + }); + return OnyxUpdates.apply(responseToApply).then((appliedResponse) => { + reconnectApp(); + return appliedResponse; + }); + } + if ( shouldApplyWithoutAdvancingLastUpdateID || requestsToIgnoreLastUpdateID.has(request.command) || diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index bbfd8995beb3..29cf4093a114 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -18,15 +18,7 @@ import DateUtils from './DateUtils'; import {getEnvironmentURL} from './Environment/Environment'; import {formatList} from './Localize'; import Log from './Log'; -import { - arePolicyRulesEnabled, - findVendorByID, - getCleanedTagName, - getCommaSeparatedTagNameWithSanitizedColons, - getSortedTagKeys, - isPolicyAdmin, - isXeroActiveMatchingSource, -} from './PolicyUtils'; +import {arePolicyRulesEnabled, findVendorByID, getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, getSortedTagKeys, isPolicyAdmin} from './PolicyUtils'; import {getOriginalMessage} from './ReportActionMessageUtils'; import {isModifiedExpenseAction} from './ReportActionTypeGuards'; // This cycle import is safe because ReportNameUtils was extracted from ReportUtils to separate report name computation logic. @@ -537,7 +529,7 @@ function getForReportAction({ translate, resolveVendorName(reportActionOriginalMessage?.vendor), resolveVendorName(reportActionOriginalMessage?.oldVendor), - isXeroActiveMatchingSource(policy) ? translate('common.supplier') : translate('common.vendor'), + translate('common.vendor'), true, setFragments, removalFragments, @@ -547,7 +539,7 @@ function getForReportAction({ const hasModifiedAttendees = isReportActionOriginalMessageAnObject && 'oldAttendees' in reportActionOriginalMessage && 'newAttendees' in reportActionOriginalMessage; if (hasModifiedAttendees) { - const [oldAttendees, newAttendees] = getFormattedAttendees(reportActionOriginalMessage.oldAttendees, reportActionOriginalMessage.newAttendees); + const [oldAttendees, newAttendees] = getFormattedAttendees(formatPhoneNumber, reportActionOriginalMessage.oldAttendees, reportActionOriginalMessage.newAttendees); buildMessageFragmentForValue(translate, newAttendees, oldAttendees, translate('iou.attendees'), false, setFragments, removalFragments, changeFragments); } diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index dc4dd597cade..77f239de3062 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -157,6 +157,7 @@ const API_RESPONSE_MAP = { /** No custom error handling — all responses rely on category fallback reasons. */ CHANGE_CARD_PIN: {}, + CREATE_DIGITAL_WALLET: {}, } as const; type DeepLeafValues = T extends Record ? DeepLeafValues : T; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 08834700ef63..21ee4725483c 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -65,7 +65,6 @@ import DelegatorConnectGuard from './DelegatorConnectGate'; import hideKeyboardOnSwipe from './hideKeyboardOnSwipe'; import KeyboardShortcutsHandler from './KeyboardShortcutsHandler'; import {ShareModalStackNavigator} from './ModalStackNavigators'; -import AIFeaturesPromoModalNavigator from './Navigators/AIFeaturesPromoModalNavigator'; import FeatureTrainingModalNavigator from './Navigators/FeatureTrainingModalNavigator'; import MigratedUserWelcomeModalNavigator from './Navigators/MigratedUserWelcomeModalNavigator'; import MultifactorAuthenticationModalNavigator from './Navigators/MultifactorAuthenticationModalNavigator'; @@ -278,6 +277,12 @@ function AuthScreens() { getComponent={loadAttachmentModalScreen} listeners={modalScreenListeners} /> + - require('../../../../pages/DynamicReportDetailsPage').default, [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_CODE]: () => require('../../../../pages/inbox/report/DynamicReportDetailsShareCodePage').default, [SCREENS.REPORT_DETAILS.DYNAMIC_EXPORT]: () => require('../../../../pages/inbox/report/DynamicReportDetailsExportPage').default, + [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_REPORT]: () => require('../../../../pages/inbox/report/DynamicReportDetailsShareReportPage').default, }); const ReportCardActivateStackNavigator = createModalStackNavigator({ @@ -344,8 +345,6 @@ const ChronosScheduleOOOModalStackNavigator = createModalStackNavigator - require('../../../../pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage').default, [SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_CREATE]: () => require('../../../../pages/workspace/categories/DynamicCreateCategoryPage').default, [SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_EDIT]: () => require('../../../../pages/workspace/categories/DynamicEditCategoryPage').default, [SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS]: () => require('../../../../pages/workspace/categories/CategorySettingsPage').default, @@ -356,7 +355,6 @@ const CategoriesModalStackNavigator = createModalStackNavigator({ }); const TagsModalStackNavigator = createModalStackNavigator({ - [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAGS_SETTINGS]: () => require('../../../../pages/workspace/tags/WorkspaceTagsSettingsPage').default, [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAGS_EDIT]: () => require('../../../../pages/workspace/tags/DynamicWorkspaceEditTagsPage').default, [SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_IMPORT]: () => require('../../../../pages/workspace/tags/ImportTagsPage').default, [SCREENS.WORKSPACE.TAGS_IMPORT_OPTIONS]: () => require('../../../../pages/workspace/tags/ImportTagsOptionsPage').default, @@ -500,6 +498,7 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsSignPage').default, [SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT]: () => require('../../../../pages/settings/Wallet/ShareBankAccount/ShareBankAccount').default, [SCREENS.SETTINGS.WALLET.EDIT_BANK_ACCOUNT_NICKNAME]: () => require('../../../../pages/settings/Wallet/EditBankAccountNicknamePage').default, + [SCREENS.SETTINGS.WALLET.DYNAMIC_WISE_GLOBAL_REIMBURSEMENTS_INTRO]: () => require('../../../../pages/settings/Wallet/WiseGlobalReimbursementsIntroPage').default, [SCREENS.SETTINGS.WALLET.TRAVEL_CVV]: () => require('../../../../pages/settings/Wallet/TravelCVVPage/TravelCVVPage').default, [SCREENS.SETTINGS.WALLET.TRAVEL_CVV_VERIFY_ACCOUNT]: () => require('../../../../pages/settings/Wallet/TravelCVVPage/TravelCVVVerifyAccountPage').default, [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_FIX_CONNECTION]: () => require('@pages/settings/Wallet/PersonalCards/FixPersonalCardConnectionPage').default, @@ -508,20 +507,20 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Wallet/PersonalCards/upgrade/PersonalCardUpgradePage').default, [SCREENS.SETTINGS.ADD_DEBIT_CARD]: () => require('../../../../pages/settings/Wallet/AddDebitCardPage').default, [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: () => require('../../../../pages/settings/Wallet/InternationalDepositAccount').default, - [SCREENS.SETTINGS.COLLECT_DEPOSIT_ACCOUNT]: () => require('../../../../pages/settings/Wallet/CollectDepositAccount').default, + [SCREENS.SETTINGS.DYNAMIC_COLLECT_DEPOSIT_ACCOUNT]: () => require('../../../../pages/settings/Wallet/CollectDepositAccount').default, [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT]: () => require('../../../../pages/AddPersonalBankAccountPage').default, [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT_ENTRY_POINT]: () => require('../../../../pages/settings/Wallet/InternationalDepositAccount/subPages/AccountFlowEntryPoint').default, [SCREENS.SETTINGS.UPDATE_PERSONAL_BANK_ACCOUNT]: () => require('../../../../pages/settings/Wallet/UpdatePersonalBankAccountPage').default, [SCREENS.SETTINGS.BANK_ACCOUNT_PURPOSE]: () => require('../../../../pages/settings/Wallet/BankAccountPurposePage').default, [SCREENS.SETTINGS.RULES.ROOT]: () => require('../../../../pages/settings/Rules/ExpenseRulesPage').default, - [SCREENS.SETTINGS.AGENTS.NEW]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/NewAgentPage').default), - [SCREENS.SETTINGS.AGENTS.ADD]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/AddAgentPage').default), - [SCREENS.SETTINGS.AGENTS.ADD_AVATAR]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/AddAgentAvatarPage').default), - [SCREENS.SETTINGS.AGENTS.EDIT]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/EditAgentPage').default), - [SCREENS.SETTINGS.AGENTS.EDIT_NAME]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/EditNamePage').default), - [SCREENS.SETTINGS.AGENTS.EDIT_PROMPT]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/EditPromptPage').default), - [SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/EditAgentAvatarPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_NEW]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/DynamicNewAgentPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/DynamicAddAgentPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD_AVATAR]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/DynamicAddAgentAvatarPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/DynamicEditAgentPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_NAME]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/DynamicEditNamePage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_PROMPT]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/DynamicEditPromptPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_AVATAR]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/DynamicEditAgentAvatarPage').default), [SCREENS.SETTINGS.RULES.ADD]: () => require('../../../../pages/settings/Rules/AddRulePage').default, [SCREENS.SETTINGS.RULES.ADD_MERCHANT]: () => require('../../../../pages/settings/Rules/Fields/AddMerchantPage').default, [SCREENS.SETTINGS.RULES.ADD_RENAME_MERCHANT]: () => require('../../../../pages/settings/Rules/Fields/AddRenameMerchantPage').default, @@ -546,7 +545,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, @@ -560,6 +560,8 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/settings/Subscription/SubscriptionPlan/DynamicSubscriptionPlanDowngradeBlockedPage').default, [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_INVITE]: () => require('../../../../pages/workspace/DynamicWorkspaceInvitePage').default, [SCREENS.WORKSPACE.MEMBERS_IMPORT]: () => require('../../../../pages/workspace/members/ImportMembersPage').default, + [SCREENS.WORKSPACE.MEMBERS_CHANGE_OFFICE_LOCATION]: () => require('../../../../pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage').default, + [SCREENS.WORKSPACE.MEMBERS_CHANGE_WORK_ARRANGEMENT]: () => require('../../../../pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage').default, [SCREENS.WORKSPACE.WORKFLOWS_IMPORT]: () => require('../../../../pages/workspace/workflows/ImportWorkflowsPage').default, [SCREENS.WORKSPACE.MEMBERS_IMPORTED]: () => require('../../../../pages/workspace/members/ImportedMembersPage').default, [SCREENS.WORKSPACE.MEMBERS_IMPORTED_CONFIRMATION]: () => require('../../../../pages/workspace/members/ImportedMembersConfirmationPage').default, @@ -581,6 +583,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/WorkspaceInviteMessageApproverPage').default, [SCREENS.WORKSPACE.WORKFLOWS_PAYER]: () => require('../../../../pages/workspace/workflows/WorkspaceWorkflowsPayerPage').default, [SCREENS.WORKSPACE.NAME]: () => require('../../../../pages/workspace/WorkspaceNamePage').default, + [SCREENS.WORKSPACE.OFFICE_LOCATION_ADD]: () => require('../../../../pages/workspace/WorkspaceOfficeLocationAddPage').default, + [SCREENS.WORKSPACE.OFFICE_LOCATION_EDIT]: () => require('../../../../pages/workspace/WorkspaceOfficeLocationEditPage').default, [SCREENS.WORKSPACE.DESCRIPTION]: () => require('../../../../pages/workspace/WorkspaceOverviewDescriptionPage').default, [SCREENS.WORKSPACE.CLIENT_ID]: () => require('../../../../pages/workspace/WorkspaceOverviewClientIDPage').default, [SCREENS.WORKSPACE.SHARE]: () => require('../../../../pages/workspace/WorkspaceOverviewSharePage').default, @@ -603,10 +607,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage').default, [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_OVERVIEW_ADDRESS]: () => require('../../../../pages/workspace/WorkspaceOverviewAddressPage').default, [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_OVERVIEW_PLAN]: () => require('../../../../pages/workspace/DynamicWorkspaceOverviewPlanTypePage').default, - [SCREENS.WORKSPACE.DYNAMIC_CATEGORIES_SETTINGS]: () => require('../../../../pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage').default, [SCREENS.WORKSPACE.DYNAMIC_CATEGORIES_IMPORT]: () => require('../../../../pages/workspace/categories/ImportCategoriesPage').default, [SCREENS.WORKSPACE.DYNAMIC_CATEGORIES_IMPORTED]: () => require('../../../../pages/workspace/categories/ImportedCategoriesPage').default, - [SCREENS.WORKSPACE.DYNAMIC_SPEND_CATEGORY_SELECTOR]: () => require('../../../../pages/workspace/categories/DynamicSpendCategorySelectorPage').default, [SCREENS.WORKSPACE.DYNAMIC_DEFAULT_CATEGORY_SELECTOR]: () => require('../../../../pages/workspace/categories/DynamicDefaultCategorySelectorPage').default, [SCREENS.WORKSPACE.UPGRADE]: () => require('../../../../pages/workspace/upgrade/WorkspaceUpgradePage').default, [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_DOWNGRADE]: () => require('../../../../pages/workspace/downgrade/DynamicWorkspaceDowngradePage').default, @@ -614,7 +616,10 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/members/WorkspaceMemberDetailsPage').default, [SCREENS.WORKSPACE.ROOM_CREATE]: () => require('../../../../pages/workspace/rooms/WorkspaceRoomCreatePage').default, [SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE]: () => require('../../../../pages/workspace/members/WorkspaceMemberDetailsRolePage').default, + [SCREENS.WORKSPACE.MEMBERS_ROLE]: () => require('../../../../pages/workspace/members/WorkspaceMembersRolePage').default, [SCREENS.WORKSPACE.MEMBER_WORK_ARRANGEMENT]: () => require('../../../../pages/workspace/members/WorkArrangementPage').default, + [SCREENS.WORKSPACE.MEMBER_OFFICE_LOCATION]: () => require('../../../../pages/workspace/members/WorkspaceMemberOfficeLocationPage').default, + [SCREENS.WORKSPACE.INVITE_WORK_ARRANGEMENT]: () => require('../../../../pages/workspace/members/WorkArrangementPage').default, [SCREENS.WORKSPACE.MEMBER_CUSTOM_FIELD]: () => require('../../../../pages/workspace/members/WorkspaceMemberCustomFieldPage').default, [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_CHECK]: () => require('@pages/workspace/members/DynamicWorkspaceOwnerChangeWrapperPage').default, [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_SUCCESS]: () => require('../../../../pages/workspace/members/DynamicWorkspaceOwnerChangeSuccessPage').default, @@ -653,7 +658,6 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/tags/ImportMultiLevelTagsSettingsPage').default, [SCREENS.WORKSPACE.DYNAMIC_TAGS_IMPORTED]: () => require('../../../../pages/workspace/tags/ImportedTagsPage').default, [SCREENS.WORKSPACE.TAGS_IMPORTED_MULTI_LEVEL]: () => require('../../../../pages/workspace/tags/ImportedMultiLevelTagsPage').default, - [SCREENS.WORKSPACE.DYNAMIC_TAGS_SETTINGS]: () => require('../../../../pages/workspace/tags/WorkspaceTagsSettingsPage').default, [SCREENS.WORKSPACE.DYNAMIC_TAG_SETTINGS]: () => require('../../../../pages/workspace/tags/DynamicTagSettingsPage').default, [SCREENS.WORKSPACE.DYNAMIC_TAG_LIST_VIEW]: () => require('../../../../pages/workspace/tags/DynamicWorkspaceViewTagsPage').default, [SCREENS.WORKSPACE.DYNAMIC_TAGS_EDIT]: () => require('../../../../pages/workspace/tags/DynamicWorkspaceEditTagsPage').default, @@ -797,6 +801,7 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/qbo/advanced/QuickbooksFxExpenseAccountSelectPage').default, [SCREENS.WORKSPACE.ACCOUNTING.XERO_SETUP]: () => require('../../../../pages/workspace/accounting/xero/XeroSetupPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.ZOHO_BOOKS_OAUTH]: () => require('../../../../pages/workspace/accounting/zohoBooks/ZohoBooksOAuthPage').default, [SCREENS.WORKSPACE.ACCOUNTING.XERO_IMPORT]: () => require('../../../../pages/workspace/accounting/xero/XeroImportPage').default, [SCREENS.WORKSPACE.ACCOUNTING.CLAIM_OFFER]: () => require('../../../../pages/workspace/accounting/ClaimOfferPage').default, [SCREENS.WORKSPACE.ACCOUNTING.XERO_ORGANIZATION]: () => require('../../../../pages/workspace/accounting/xero/XeroOrganizationConfigurationPage').default, @@ -1070,6 +1075,14 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/businessCentral/export/BusinessCentralDefaultVendorSelectPage').default, [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_PAYMENT_METHOD]: () => require('../../../../pages/workspace/accounting/businessCentral/export/BusinessCentralPaymentMethodSelectPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_ADVANCED]: () => + require('../../../../pages/workspace/accounting/businessCentral/advanced/BusinessCentralAdvancedPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_ACCOUNTING_METHOD]: () => + require('../../../../pages/workspace/accounting/businessCentral/advanced/BusinessCentralAccountingMethodSelectPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_ADVANCED_POSTING_MODE]: () => + require('../../../../pages/workspace/accounting/businessCentral/advanced/BusinessCentralPostingModeSelectPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_BANK_ACCOUNT_SELECTOR]: () => + require('../../../../pages/workspace/accounting/businessCentral/advanced/BusinessCentralBankAccountSelectPage').default, [SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION]: () => require('../../../../pages/workspace/accounting/reconciliation/CardReconciliationPage').default, [SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION_SAGE_INTACCT_AUTO_SYNC]: () => require('../../../../pages/workspace/accounting/reconciliation/CardReconciliationSageIntacctAutoSyncPage').default, @@ -1315,7 +1328,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, @@ -1440,6 +1454,7 @@ const MergeATSApprovalModalStackNavigator = createModalStackNavigator require('../../../../pages/Search/SearchAdvancedFiltersPage').default, [SCREENS.SEARCH.ADVANCED_FILTERS_CONTENT_RHP]: () => require('../../../../pages/Search/SearchAdvancedFiltersContentPage').default, + [SCREENS.SEARCH.NL_FILTER_RHP]: () => require('../../../../pages/Search/SearchNLFilterPage').default, }); const SearchSavedSearchModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx deleted file mode 100644 index 07793b4004f2..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/AIFeaturesPromoModalNavigator.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import AIFeaturesPromoModal from '@components/AIFeaturesPromoModal'; -import NoDropZone from '@components/DragAndDrop/NoDropZone'; - -import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; -import type {AIFeaturesPromoModalNavigatorParamList} from '@libs/Navigation/types'; - -import SCREENS from '@src/SCREENS'; - -import React from 'react'; - -const Stack = createPlatformStackNavigator(); - -function AIFeaturesPromoModalNavigator() { - return ( - - - - - - ); -} - -export default AIFeaturesPromoModalNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts new file mode 100644 index 000000000000..495f8dfcdf42 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts @@ -0,0 +1,84 @@ +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +type NativeTabGlyphPath = { + d: string; + isEvenOdd?: boolean; +}; + +type NativeTabGlyph = { + /** Side of the square viewBox the paths are drawn in. */ + viewBoxSize: number; + paths: NativeTabGlyphPath[]; +}; + +/** + * Vector copies of the tab bar SVGs in `assets/images`, so Skia draws each glyph at the bar's size and the device's + * density on both platforms. They have to be kept in sync with those files. + */ +const NATIVE_TAB_GLYPHS = { + // home.svg + [SCREENS.HOME]: { + viewBoxSize: 20, + paths: [ + { + d: 'M9.386 1.477a1 1 0 0 1 1.229 0l7.613 5.922c.487.379.772.962.772 1.579V18a1 1 0 0 1-1 1h-5v-6a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v6H2a1 1 0 0 1-1-1V8.978c0-.617.285-1.2.772-1.579z', + }, + ], + }, + // inbox.svg + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + { + d: 'M4.5 1.5a3 3 0 0 0-3 3v11a3 3 0 0 0 3 3h11a3 3 0 0 0 3-3v-11a3 3 0 0 0-3-3zm-1 3a1 1 0 0 1 1-1h11a1 1 0 0 1 1 1V12H13c-.276 0-.495.226-.549.497a2.5 2.5 0 0 1-4.902 0C7.494 12.226 7.276 12 7 12H3.5z', + isEvenOdd: true, + }, + ], + }, + // receipt-multiple.svg + [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + { + d: 'M9.72 1.24c.12-.18.36-.21.52-.07l1.15 1c.16.14.41.1.52-.07l.54-.83a.35.35 0 0 1 .64.14l2.18 15.49c.04.32-.33.52-.58.31l-.75-.65a.35.35 0 0 0-.52.07l-.84 1.28c-.12.18-.36.21-.52.07l-1.16-1a.35.35 0 0 0-.52.07l-.84 1.28c-.12.18-.36.21-.52.07l-1.15-1a.35.35 0 0 0-.52.07l-.83 1.28c-.12.18-.36.21-.52.07l-1.16-1a.35.35 0 0 0-.52.07l-.54.83a.35.35 0 0 1-.64-.14L.97 3.11c-.04-.32.33-.52.58-.31l.75.65c.16.14.41.1.52-.07l.83-1.28c.12-.18.36-.21.52-.07l1.15 1c.16.14.41.1.52-.07l.83-1.28c.12-.18.36-.21.52-.07l1.15 1c.16.14.41.1.52-.07l.83-1.28Zm1.37 10.99-5.2.73a.87.87 0 0 0-.74.99c.07.48.51.81.99.75l5.2-.73c.48-.07.81-.51.75-.99a.886.886 0 0 0-.99-.75ZM8.87 9.01 5.4 9.5c-.48.07-.81.51-.75.99.07.48.51.81.99.75l3.47-.49c.48-.07.81-.51.75-.99a.87.87 0 0 0-.99-.74Zm1.24-3.71-5.2.73c-.48.07-.81.51-.75.99.07.48.51.81.99.75l5.2-.73a.87.87 0 0 0 .74-.99.886.886 0 0 0-.99-.75Z', + isEvenOdd: true, + }, + { + d: 'M15.15 3.09c.1.16.32.19.46.07l1.03-.89c.14-.12.36-.09.46.06l.74 1.14c.1.16.32.19.46.07l.67-.58c.22-.19.55 0 .51.28l-1.94 13.79c-.04.28-.41.37-.57.13l-.49-.74s-.02-.03-.04-.04L14.42 2.03l.7 1.07Z', + }, + ], + }, + // pie-chart.svg + [SCREENS.INSIGHTS]: { + viewBoxSize: 24, + paths: [ + {d: 'M15.598.074C14.94-.008 14.4.537 14.4 1.2v8.4h8.4c.663 0 1.208-.54 1.126-1.198A9.605 9.605 0 0 0 15.598.074'}, + {d: 'M22.734 13.198C22.138 18.6 17.56 22.8 12 22.8 6.035 22.8 1.2 17.965 1.2 12c0-5.56 4.2-10.138 9.602-10.734C11.46 1.193 12 1.737 12 2.4V12h9.6c.663 0 1.207.54 1.134 1.198'}, + ], + }, + // buildings.svg + [NAVIGATORS.WORKSPACE_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + { + d: 'M1 3a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8v-3a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v3H3a2 2 0 0 1-2-2zm2 1a1 1 0 1 1 2 0 1 1 0 0 1-2 0m0 4a1 1 0 1 1 2 0 1 1 0 0 1-2 0m1 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2m3-7a1 1 0 1 1 2 0 1 1 0 0 1-2 0m1 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-1 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0m5-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2m3 1a1 1 0 1 1 2 0 1 1 0 0 1-2 0m1 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-1 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0m-3-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-1 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0', + isEvenOdd: true, + }, + ], + }, + // profile.svg, with its circle written as a path + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + {d: 'M13 7a3 3 0 1 1-6 0 3 3 0 0 1 6 0z'}, + {d: 'M10 1c-5 0-9 4-9 9s4 9 9 9 9-4 9-9-4-9-9-9m5.9 12.7C14.6 12.1 12.4 11 10 11s-4.6 1.1-5.9 2.7C3.4 12.6 3 11.4 3 10c0-3.9 3.1-7 7-7s7 3.1 7 7c0 1.4-.4 2.6-1.1 3.7'}, + ], + }, +} as const satisfies Record; + +/** A tab of the native tab bar, by its route name. */ +type NativeTabName = keyof typeof NATIVE_TAB_GLYPHS; + +export default NATIVE_TAB_GLYPHS; +export type {NativeTabGlyph, NativeTabName}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx new file mode 100644 index 000000000000..1328b8270df7 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx @@ -0,0 +1,74 @@ +import FloatingCameraButton from '@components/FloatingCameraButton'; +import FloatingGPSButton from '@components/FloatingGPSButton'; +import DebugTabView from '@components/Navigation/DebugTabView'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; + +import useOnyx from '@hooks/useOnyx'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import cancelTabNavigationSpans, {NAVIGATION_TAB_TO_SPANS} from '@libs/telemetry/cancelTabNavigationSpans'; + +import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import React, {useEffect} from 'react'; +import {StyleSheet, View} from 'react-native'; +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; + +import type {NativeTabLayoutProps} from './NativeTabLayout'; + +import NativeTabBarShadow from './NativeTabBarShadow'; +import {getFloatingButtonsBottom} from './useNativeTabBarOptions'; + +/** What a narrow layout draws over the tabs along with the native bar, kept apart so wide layouts skip its hooks. */ +function NativeTabBarOverlay({state, descriptors}: Pick) { + const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); + const styles = useThemeStyles(); + const {bottom: bottomInset} = useSafeAreaInsets(); + const activeRoute = state.routes[state.index]; + const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; + + // Cancel any in-flight tab-navigation span that doesn't match the new focused tab. The new tab's span is started at + // the tap, before navigation, so it is kept. On wide layouts the JS side bar does this. + useEffect(() => { + cancelTabNavigationSpans(NAVIGATION_TAB_TO_SPANS[selectedTab]); + }, [selectedTab]); + + // The buttons follow whatever the navigator decided for the bar itself. + const shouldShowNativeTabBar = !!activeRoute && descriptors[activeRoute.key]?.options.tabBarStyle?.display !== 'none'; + + return ( + <> + {!!isDebugModeEnabled && shouldShowNativeTabBar && } + {shouldShowNativeTabBar && ( + // The shadow and the buttons belong to the bar, so they fade with it rather than appearing in place. + // They leave faster than they come in, so they stop covering a bar that is still sliding out. + + + + + + + + + + + )} + + ); +} + +export default NativeTabBarOverlay; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.ios.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.ios.tsx new file mode 100644 index 000000000000..d3f248535c5b --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.ios.tsx @@ -0,0 +1,6 @@ +/** UITabBar draws its own glass edge over the content. */ +function NativeTabBarShadow() { + return null; +} + +export default NativeTabBarShadow; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx new file mode 100644 index 000000000000..2ad21697e685 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx @@ -0,0 +1,20 @@ +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +/** Material's bar shares the screen's background, so a shadow along its top edge marks where the content ends. */ +function NativeTabBarShadow() { + const styles = useThemeStyles(); + const {bottom: bottomInset} = useSafeAreaInsets(); + + return ( + + ); +} + +export default NativeTabBarShadow; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx new file mode 100644 index 000000000000..6ba91ad61c5a --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx @@ -0,0 +1,52 @@ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import TabNavigatorBar from '@libs/Navigation/AppNavigator/Navigators/TabNavigatorBar'; + +import type {NativeBottomTabNavigatorProps} from '@react-navigation/bottom-tabs/unstable'; + +import React from 'react'; +import {View} from 'react-native'; + +import NativeTabBarOverlay from './NativeTabBarOverlay'; +import TabPressListeners from './TabPressListeners'; + +type NativeTabLayoutProps = Parameters>[0]; + +function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const styles = useThemeStyles(); + // The native tab host can't drop a tab it already shows: on Android the removed tab keeps its bar item and keeps + // firing selection events. Remounting the host when the set of tabs without a bar item changes (the Insights beta + // loading after launch or being toggled) gives it only the tabs it should show. + const tabsWithoutBarItemKey = state.routes + .filter((route) => descriptors[route.key]?.options.tabBarItemHidden) + .map((route) => route.name) + .join(); + + // The tabs stay at the same place in the tree in both layouts, so crossing the breakpoint does not remount them. + return ( + + {!shouldUseNarrowLayout && } + + {children} + + + {shouldUseNarrowLayout && ( + + )} + + ); +} + +export default NativeTabLayout; +export type {NativeTabLayoutProps}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx new file mode 100644 index 000000000000..7e61362931a5 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx @@ -0,0 +1,62 @@ +import {getStoredSearchTabParams} from '@components/Navigation/NavigationTabBar/getSearchTabRoute'; + +import useHasTabBeenShown from '@hooks/useHasTabBeenShown'; +import useOnyx from '@hooks/useOnyx'; +import useRestoreWorkspacesTabOnNavigate from '@hooks/useRestoreWorkspacesTabOnNavigate'; + +import navigationRef from '@libs/Navigation/navigationRef'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; + +import {CommonActions} from '@react-navigation/native'; +import {useEffect} from 'react'; + +import type {NativeTabLayoutProps} from './NativeTabLayout'; + +/** + * The native bar's Spend and Workspaces handling that needs Onyx data, so the data re-renders only this component. + * Spend's first visit opens the latest search kept in Onyx, so the tab mounted at startup with the default search gets + * it before it is first shown, both for the native bar and for the side bar of a wide layout. Workspaces restores the + * last workspace, or the list for one that is gone, whenever the tab does not already show it. + */ +function TabPressListeners({state, descriptors}: Pick) { + const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); + const [lastExpensesSearchQuery] = useOnyx(ONYXKEYS.SEARCH_FILTERS, {selector: lastExpensesSearchQuerySelector}); + const hasSpendBeenShown = useHasTabBeenShown(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + const navigateToWorkspaces = useRestoreWorkspacesTabOnNavigate(); + const getTabRoute = (routeName: string) => state.routes.find((tabRoute) => tabRoute.name === routeName); + const spendNavigatorState = getTabRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)?.state; + const spendNavigatorKey = spendNavigatorState?.key; + const spendRootRoute = spendNavigatorState?.routes.at(0); + const spendRootRouteKey = spendRootRoute?.key; + const spendRootQuery = spendRootRoute?.params && 'q' in spendRootRoute.params ? spendRootRoute.params.q : undefined; + const {query: storedQuery, searchKey: storedSearchKey} = getStoredSearchTabParams(lastSearchParams, lastExpensesSearchQuery); + const workspacesDescriptor = descriptors[getTabRoute(NAVIGATORS.WORKSPACE_NAVIGATOR)?.key ?? '']; + const workspacesNavigation = workspacesDescriptor?.navigation; + const isWorkspacesSwitchedByJS = workspacesDescriptor?.options.tabBarSelectionEnabled === false; + + useEffect(() => { + if (hasSpendBeenShown || !spendNavigatorKey || !spendRootRouteKey || spendRootQuery === storedQuery) { + return; + } + navigationRef.dispatch({...CommonActions.setParams({q: storedQuery, searchKey: storedSearchKey}), source: spendRootRouteKey, target: spendNavigatorKey}); + }, [hasSpendBeenShown, spendNavigatorKey, spendRootRouteKey, spendRootQuery, storedQuery, storedSearchKey]); + + // Runs on the focused tab too, where it returns from a workspace to the list. + useEffect( + () => + workspacesNavigation?.addListener('tabPress', () => { + if (!workspacesNavigation.isFocused() && !isWorkspacesSwitchedByJS) { + return; + } + navigateToWorkspaces(); + }), + [workspacesNavigation, isWorkspacesSwitchedByJS, navigateToWorkspaces], + ); + + return null; +} + +export default TabPressListeners; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getActiveTabRoute.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getActiveTabRoute.ts new file mode 100644 index 000000000000..4ca09937d47e --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getActiveTabRoute.ts @@ -0,0 +1,11 @@ +import type {NavigationState, PartialState} from '@react-navigation/native'; + +/** + * The focused TAB_NAVIGATOR route, read from the partial cold-start state too so a restored non-root screen doesn't flash the bar. + * A partial state without an index focuses the first tab, as in TabRouter. + */ +function getActiveTabRoute(tabState: NavigationState | PartialState | undefined) { + return tabState?.routes.at(tabState.index ?? 0); +} + +export default getActiveTabRoute; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem.ts new file mode 100644 index 000000000000..502b8506ea4e --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem.ts @@ -0,0 +1,12 @@ +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +/** + * The native bar shows at most five of the six tabs. With the Insights beta, Insights takes the Account tab's place + * and Account moves to the top bar; without it, Insights has no item. + */ +function getTabWithoutBarItem(isInsightsBetaEnabled: boolean) { + return isInsightsBetaEnabled ? NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR : SCREENS.INSIGHTS; +} + +export default getTabWithoutBarItem; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts new file mode 100644 index 000000000000..0eb625077854 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts @@ -0,0 +1,252 @@ +import FontUtils from '@styles/utils/FontUtils'; +import variables from '@styles/variables'; + +import type {NativeBottomTabIcon, NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; +import type {SkCanvas, SkImage, SkParagraph, SkPath} from '@shopify/react-native-skia'; + +import {BlendMode, ClipOp, FillType, FilterMode, FontWeight, ImageFormat, MipmapMode, Skia, TextAlign} from '@shopify/react-native-skia'; +import {PixelRatio} from 'react-native'; + +import type {NativeTabGlyph, NativeTabName} from './NATIVE_TAB_GLYPHS'; +import type {TabAvatarImage} from './useTabAvatarImage'; + +import NATIVE_TAB_GLYPHS from './NATIVE_TAB_GLYPHS'; + +type TabIconLayout = { + /** Side of a glyph's box. */ + glyphSize: number; + /** Side of the account avatar's box. */ + avatarSize: number; + /** Whether the label is drawn under the glyph or beside it, as UIKit lays out a tab in landscape. */ + direction: 'stacked' | 'inline'; +}; + +type TabIconLabel = { + text: string; + color: string; + isBold: boolean; + fontSize: number; + /** Gap between the glyph box and the label. */ + gap: number; + /** Width of the tab's slot in the bar, past which the label is truncated with an ellipsis. */ + maxWidth: number; +}; + +type TabIconParams = { + /** The glyph drawn unless an avatar is given. */ + name: NativeTabName; + color: string; + avatar?: TabAvatarImage; + dotColor?: string; + label?: TabIconLabel; +}; + +/** Upper bound on cached icons, enough for every tab in both selection states across a few status and theme changes. */ +const MAX_CACHED_ICONS = 64; + +const iconCache = new Map(); +const glyphPathCache = new Map(); + +function getGlyphPaths(name: NativeTabName): SkPath[] { + const cachedPaths = glyphPathCache.get(name); + if (cachedPaths) { + return cachedPaths; + } + const paths: SkPath[] = []; + const glyph: NativeTabGlyph = NATIVE_TAB_GLYPHS[name]; + for (const {d, isEvenOdd} of glyph.paths) { + const path = Skia.Path.MakeFromSVGString(d); + if (!path) { + continue; + } + if (isEvenOdd) { + path.setFillType(FillType.EvenOdd); + } + paths.push(path); + } + glyphPathCache.set(name, paths); + return paths; +} + +/** A paragraph rather than a single font, because only a paragraph falls back to a system font for scripts Expensify Neue lacks. */ +function makeLabelParagraph({text, color, isBold, fontSize, maxWidth}: TabIconLabel, scale: number): SkParagraph { + const paragraph = Skia.ParagraphBuilder.Make({maxLines: 1, ellipsis: '…', textAlign: TextAlign.Left}) + .pushStyle({ + color: Skia.Color(color), + fontFamilies: [FontUtils.fontFamily.single.EXP_NEUE.fontFamily], + fontSize: fontSize * scale, + fontStyle: {weight: isBold ? FontWeight.Bold : FontWeight.Normal}, + }) + .addText(text) + .build(); + paragraph.layout(maxWidth * scale); + return paragraph; +} + +function drawGlyph(canvas: SkCanvas, name: NativeTabName, left: number, top: number, size: number, color: string) { + const paint = Skia.Paint(); + paint.setColor(Skia.Color(color)); + paint.setAntiAlias(true); + const viewBoxScale = size / NATIVE_TAB_GLYPHS[name].viewBoxSize; + + canvas.save(); + canvas.translate(left, top); + canvas.scale(viewBoxScale, viewBoxScale); + for (const path of getGlyphPaths(name)) { + canvas.drawPath(path, paint); + } + canvas.restore(); + paint.dispose(); +} + +function drawCircularImage(canvas: SkCanvas, image: SkImage, left: number, top: number, size: number) { + const sourceSize = Math.min(image.width(), image.height()); + const circle = Skia.Path.Make(); + circle.addCircle(left + size / 2, top + size / 2, size / 2); + const paint = Skia.Paint(); + + canvas.save(); + canvas.clipPath(circle, ClipOp.Intersect, true); + canvas.drawImageRectOptions( + image, + Skia.XYWHRect((image.width() - sourceSize) / 2, (image.height() - sourceSize) / 2, sourceSize, sourceSize), + Skia.XYWHRect(left, top, size, size), + FilterMode.Linear, + MipmapMode.Linear, + paint, + ); + canvas.restore(); + paint.dispose(); + circle.dispose(); +} + +/** A cutout rather than a border in the bar's color, because the iOS 26 bar is glass and has no single color. */ +function drawStatusDot(canvas: SkCanvas, right: number, top: number, scale: number, color: string) { + const radius = variables.nativeTabIconDotRadius * scale; + const centerX = right - radius; + const centerY = top + radius; + const paint = Skia.Paint(); + paint.setAntiAlias(true); + paint.setBlendMode(BlendMode.Clear); + canvas.drawCircle(centerX, centerY, radius + variables.nativeTabIconDotCutout * scale, paint); + paint.setBlendMode(BlendMode.SrcOver); + paint.setColor(Skia.Color(color)); + canvas.drawCircle(centerX, centerY, radius, paint); + paint.dispose(); +} + +type TabIconFrame = { + canvasWidth: number; + canvasHeight: number; + contentLeft: number; + contentTop: number; + labelLeft: number; + labelTop: number; +}; + +/** + * With a label, every tab gives its glyph or avatar a box as big as the avatar, so glyphs and the avatar share a centre + * and every label starts at the same place: under that box, or beside it in landscape. + */ +function getTabIconFrame(layout: TabIconLayout, contentSize: number, labelGap: number, labelWidth: number, labelHeight: number, hasLabel: boolean, scale: number): TabIconFrame { + const boxSize = hasLabel ? Math.max(layout.glyphSize, layout.avatarSize) * scale : contentSize; + + if (layout.direction === 'inline') { + const canvasHeight = Math.ceil(Math.max(boxSize, labelHeight)); + return { + canvasWidth: Math.ceil(boxSize + (hasLabel ? labelGap + labelWidth : 0)), + canvasHeight, + contentLeft: (boxSize - contentSize) / 2, + contentTop: (canvasHeight - contentSize) / 2, + labelLeft: boxSize + labelGap, + labelTop: (canvasHeight - labelHeight) / 2, + }; + } + + const canvasWidth = Math.ceil(Math.max(contentSize, labelWidth)); + const labelTop = (boxSize + layout.glyphSize * scale) / 2 + labelGap; + return { + canvasWidth, + canvasHeight: Math.ceil(hasLabel ? labelTop + labelHeight : boxSize), + contentLeft: (canvasWidth - contentSize) / 2, + contentTop: (boxSize - contentSize) / 2, + labelLeft: (canvasWidth - labelWidth) / 2, + labelTop, + }; +} + +function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, label}: TabIconParams): NativeBottomTabIcon | undefined { + const scale = PixelRatio.get(); + const contentSize = (avatar ? layout.avatarSize : layout.glyphSize) * scale; + const paragraph = label ? makeLabelParagraph(label, scale) : undefined; + const {canvasWidth, canvasHeight, contentLeft, contentTop, labelLeft, labelTop} = getTabIconFrame( + layout, + contentSize, + (label?.gap ?? 0) * scale, + paragraph?.getLongestLine() ?? 0, + paragraph?.getHeight() ?? 0, + !!label, + scale, + ); + + const surface = Skia.Surface.Make(canvasWidth, canvasHeight); + if (!surface) { + paragraph?.dispose(); + return undefined; + } + + const canvas = surface.getCanvas(); + if (avatar) { + drawCircularImage(canvas, avatar.image, contentLeft, contentTop, contentSize); + } else { + drawGlyph(canvas, name, contentLeft, contentTop, contentSize, color); + } + if (dotColor) { + drawStatusDot(canvas, contentLeft + contentSize, contentTop, scale, dotColor); + } + if (paragraph) { + paragraph.paint(canvas, labelLeft, labelTop); + paragraph.dispose(); + } + + surface.flush(); + const snapshot = surface.makeImageSnapshot(); + const base64 = snapshot.encodeToBase64(ImageFormat.PNG, 100); + snapshot.dispose(); + surface.dispose(); + + return { + type: 'image', + source: {uri: `data:image/png;base64,${base64}`, width: canvasWidth / scale, height: canvasHeight / scale, scale}, + tinted: false, + }; +} + +/** + * Draws a tab icon synchronously, so the bar never waits for it, and reuses it for as long as everything drawn into it + * stays the same. A status, theme or language change redraws only the icons it reaches. + */ +function getTabIcon(layout: TabIconLayout, params: TabIconParams): NativeBottomTabIcon | undefined { + const {name, color, avatar, dotColor, label} = params; + // The label's font is fixed per platform, so it is left out of the key. + const key = [layout.direction, name, avatar?.uri ?? color, dotColor, label?.text, label?.color, label?.isBold, label?.maxWidth].join('|'); + if (iconCache.has(key)) { + return iconCache.get(key); + } + if (iconCache.size >= MAX_CACHED_ICONS) { + iconCache.clear(); + } + const icon = drawTabIcon(layout, params); + iconCache.set(key, icon); + return icon; +} + +/** Both selection states as images, given as a function, because React Navigation derives the selected icon only from a function. */ +function getTabBarIcon(layout: TabIconLayout, getParams: (isSelected: boolean) => TabIconParams): NativeBottomTabNavigationOptions['tabBarIcon'] { + const inactiveIcon = getTabIcon(layout, getParams(false)); + const activeIcon = getTabIcon(layout, getParams(true)); + return inactiveIcon && activeIcon ? ({focused}) => (focused ? activeIcon : inactiveIcon) : undefined; +} + +export default getTabBarIcon; +export type {TabIconLayout}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts new file mode 100644 index 000000000000..50b556171740 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts @@ -0,0 +1,87 @@ +import navigateToInboxTab from '@components/Navigation/NavigationTabBar/navigateToInboxTab'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; + +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import getFocusedLeafScreenName from '@libs/Navigation/helpers/getFocusedLeafScreenName'; +import {startNavigateToInboxTabSpan, startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; + +import SCREENS from '@src/SCREENS'; + +import type {NavigationState, PartialState} from '@react-navigation/native'; +import type {ValueOf} from 'type-fest'; + +type NavigationTab = ValueOf; + +/** Tabs the JS tab buttons keep from anonymous users, who get the sign-in modal instead. */ +const ANONYMOUS_GATED_TABS = new Set([NAVIGATION_TABS.SEARCH, NAVIGATION_TABS.INSIGHTS, NAVIGATION_TABS.WORKSPACES, NAVIGATION_TABS.SETTINGS]); + +type TabRoute = { + name: string; + state?: NavigationState | PartialState; +}; + +/** Whether Inbox shows its chat list, which is where its JS tab button opens it. */ +function isInboxTabAtChatList(inboxRoute: TabRoute | undefined) { + return getFocusedLeafScreenName(inboxRoute?.state) === SCREENS.INBOX; +} + +/** + * What a tap on the native bar does on top of the tab switch, mirroring the JS tab buttons the native bar replaces. + * Workspaces is handled by TabPressListeners, which needs Onyx data. + */ +const NAVIGATION_TAB_PRESS_HANDLERS: Partial void>> = { + [NAVIGATION_TABS.INBOX]: (route) => (isInboxTabAtChatList(route) ? startNavigateToInboxTabSpan({isWideLayout: false}) : navigateToInboxTab()), + [NAVIGATION_TABS.SEARCH]: () => interceptAnonymousUser(startNavigateToReportsTabSpans), + [NAVIGATION_TABS.INSIGHTS]: () => interceptAnonymousUser(() => {}), + [NAVIGATION_TABS.SETTINGS]: () => interceptAnonymousUser(() => {}), +}; + +type NativeTabSelectionParams = { + isAnonymousUser: boolean; + isInboxAtChatList: boolean; + /** Whether Workspaces shows what its JS tab button restores, see useIsWorkspacesTabRestored. */ + isWorkspacesTabRestored: boolean; +}; + +/** + * Whether the native bar switches to the tab itself, or leaves the switch to the tab's press handler. The native switch + * is kept whenever the tab already shows what its JS tab button opens, because a prevented selection makes the iOS bar + * slide back to the current tab before JS moves it to the new one. + */ +function isNativeTabSelectionEnabled(routeName: string, {isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored}: NativeTabSelectionParams) { + const tab = ROUTE_TO_NAVIGATION_TAB[routeName]; + if (isAnonymousUser && ANONYMOUS_GATED_TABS.has(tab)) { + return false; + } + if (tab === NAVIGATION_TABS.INBOX) { + return isInboxAtChatList; + } + if (tab === NAVIGATION_TABS.WORKSPACES) { + return isWorkspacesTabRestored; + } + return true; +} + +type TabScreenListenerProps = { + route: TabRoute; + navigation: {isFocused: () => boolean}; +}; + +/** + * Native tabs emit `tabPress` when a user tap reaches JS, both for a tab the bar switched to and for one whose + * selection is disabled. A tap on the already focused tab does nothing, the same as a JS tab button pressed on its own tab. + */ +function tabScreenListeners({route, navigation}: TabScreenListenerProps) { + return { + tabPress: () => { + if (navigation.isFocused()) { + return; + } + NAVIGATION_TAB_PRESS_HANDLERS[ROUTE_TO_NAVIGATION_TAB[route.name]]?.(route); + }, + }; +} + +export default tabScreenListeners; +export {isInboxTabAtChatList, isNativeTabSelectionEnabled}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIsWorkspacesTabRestored.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIsWorkspacesTabRestored.ts new file mode 100644 index 000000000000..928dd7accded --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIsWorkspacesTabRestored.ts @@ -0,0 +1,50 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; + +import getFocusedLeafScreenName from '@libs/Navigation/helpers/getFocusedLeafScreenName'; +import {isPendingDeletePolicy, shouldShowPolicy} from '@libs/PolicyUtils'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Domain, Policy} from '@src/types/onyx'; + +import type {NavigationState, PartialState} from '@react-navigation/native'; +import type {OnyxEntry} from 'react-native-onyx'; + +type WorkspacesTabRoute = { + state?: NavigationState | PartialState; +}; + +const domainExistsSelector = (domain: OnyxEntry) => !!domain?.accountID; + +/** + * Whether the Workspaces tab shows what its JS tab button restores on narrow layouts, so a plain switch to the tab + * lands on the same page: the workspaces list, the initial page of a workspace that is still shown, or a domain that + * still exists. Any other page is replaced by the tab button, see navigateToWorkspacesPage. + */ +function useIsWorkspacesTabRestored(workspacesTabRoute: WorkspacesTabRoute | undefined) { + const {login: currentUserLogin} = useCurrentUserPersonalDetails(); + const navigatorState = workspacesTabRoute?.state; + const topRoute = navigatorState?.routes.at(navigatorState.index ?? -1); + const splitRootParams = topRoute?.state?.routes.at(0)?.params; + const policyID = topRoute?.name === NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR && splitRootParams && 'policyID' in splitRootParams ? String(splitRootParams.policyID) : undefined; + const domainAccountID = + topRoute?.name === NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR && splitRootParams && 'domainAccountID' in splitRootParams ? String(splitRootParams.domainAccountID) : undefined; + const isPolicyShownSelector = (policy: OnyxEntry) => shouldShowPolicy(policy, false, currentUserLogin) && !isPendingDeletePolicy(policy); + const [isPolicyShown = false] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: isPolicyShownSelector}); + const [doesDomainExist = false] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {selector: domainExistsSelector}); + + switch (topRoute?.name) { + case SCREENS.WORKSPACES_LIST: + return true; + case NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR: + return !!policyID && isPolicyShown && getFocusedLeafScreenName(topRoute.state) === SCREENS.WORKSPACE.INITIAL; + case NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR: + return !!domainAccountID && doesDomainExist; + default: + return false; + } +} + +export default useIsWorkspacesTabRestored; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts new file mode 100644 index 000000000000..d439553f17ae --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts @@ -0,0 +1,61 @@ +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; +import getTabBarIcon from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import type {TabIconLayout} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import useTabAvatarImage from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage'; + +import variables from '@styles/variables'; + +import NAVIGATORS from '@src/NAVIGATORS'; + +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; + +import type NativeTabBarOptionsParams from './types'; + +/** Material's bar sits above the system gesture inset, so the floating buttons clear both. */ +const getFloatingButtonsBottom = (bottomInset: number) => variables.androidNativeTabBarFloatingButtonsBottom + bottomInset; + +/** + * Material draws each icon in its fixed icon slot, with a label of its own. The status dot is drawn into the icon rather + * than as a Material badge, because RNScreens paints every badge in the badge color of the selected tab. + */ +const TAB_ICON_LAYOUT: TabIconLayout = { + glyphSize: variables.iconBottomBar, + avatarSize: variables.iconBottomBar, + direction: 'stacked', +}; + +function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { + const theme = useTheme(); + const styles = useThemeStyles(); + const avatar = useTabAvatarImage(isAccountAvatarShown); + + // The tint only reaches the labels, since the icons arrive already recolored (react-native-screens patch 004). + const screenOptions: NativeBottomTabNavigationOptions = { + tabBarActiveTintColor: theme.text, + tabBarInactiveTintColor: theme.textSupporting, + tabBarLabelStyle: {fontFamily: styles.textSmall.fontFamily, fontSize: styles.textSmall.fontSize}, + tabBarActiveIndicatorColor: theme.androidTabBarActiveIndicatorBG, + tabBarLabelVisibilityMode: 'labeled', + // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. + tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.appBG}, + }; + + const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => ({ + tabBarLabel: tabLabels[name], + // The active indicator pill marks the selected tab, so the avatar is the same image in both selection states. + tabBarIcon: getTabBarIcon(TAB_ICON_LAYOUT, (isSelected) => ({ + name, + color: isSelected ? theme.iconMenu : theme.icon, + avatar: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined, + dotColor: dotColors[name], + })), + }); + + return {screenOptions, getTabOptions}; +} + +export default useNativeTabBarOptions; +export {getFloatingButtonsBottom}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts new file mode 100644 index 000000000000..81627df46fd6 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -0,0 +1,89 @@ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useWindowDimensions from '@hooks/useWindowDimensions'; + +import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; +import getTabBarIcon from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import type {TabIconLayout} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import useTabAvatarImage from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage'; + +import {fontScale} from '@styles/typography'; +import variables from '@styles/variables'; + +import NAVIGATORS from '@src/NAVIGATORS'; + +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; + +import type NativeTabBarOptionsParams from './types'; + +/** UITabBar sits above the home indicator inset, so the floating buttons clear both. */ +const getFloatingButtonsBottom = (bottomInset: number) => variables.iosNativeTabBarFloatingButtonsBottom + bottomInset; + +const TAB_ICON_LAYOUT: TabIconLayout = { + glyphSize: variables.iconNativeTabBarIOS, + // A circle reads smaller than a glyph of the same box, so the avatar is drawn a little larger than the glyphs. + avatarSize: variables.avatarNativeTabBarIOS, + direction: 'stacked', +}; + +/** A phone in landscape gives the bar too little height for a stacked tab, so UIKit lays its own tabs out this way. */ +const LANDSCAPE_TAB_ICON_LAYOUT: TabIconLayout = { + glyphSize: variables.iconNativeTabBarIOSLandscape, + avatarSize: variables.avatarNativeTabBarIOSLandscape, + direction: 'inline', +}; + +/** + * iOS 26 never applies the inactive icon color or the title color from `UITabBarItemAppearance`, and paints every badge + * in the color of the selected tab, so each icon is drawn with its own colors, its status dot and its label. Both + * selection states are images, because RNScreens rejects a tab whose icon and selectedIcon differ in type. + */ +function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { + const theme = useTheme(); + const avatar = useTabAvatarImage(isAccountAvatarShown); + const {windowWidth} = useWindowDimensions(); + const {isInLandscapeMode} = useResponsiveLayout(); + const tabIconLayout = isInLandscapeMode ? LANDSCAPE_TAB_ICON_LAYOUT : TAB_ICON_LAYOUT; + // One of the tabs never has a bar item, so the bar splits its width between the others. + const barItemCount = Object.keys(tabLabels).length - 1; + const itemWidth = (windowWidth - 2 * variables.iosNativeTabBarHorizontalInset) / barItemCount - variables.iosNativeTabBarLabelInset; + // In landscape the label shares the item's width with the glyph's box beside it. + const labelMaxWidth = isInLandscapeMode ? itemWidth - tabIconLayout.avatarSize - variables.nativeTabIconLabelGap : itemWidth; + + const screenOptions: NativeBottomTabNavigationOptions = { + // The labels are drawn into the icons. + tabBarLabel: '', + tabBarActiveTintColor: theme.iconMenu, + tabBarInactiveTintColor: theme.icon, + // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. + // The background only lands on iOS 18 and below; iOS 26 keeps its own glass material. + tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.appBG}, + tabBarControllerMode: 'tabBar', + // The bar stays put while the content scrolls, instead of collapsing the way iOS 26 does by default. + tabBarMinimizeBehavior: 'none', + }; + + const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => ({ + tabBarIcon: getTabBarIcon(tabIconLayout, (isSelected) => ({ + name, + color: isSelected ? theme.iconMenu : theme.icon, + avatar: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined, + dotColor: dotColors[name], + label: { + text: tabLabels[name], + color: isSelected ? theme.text : theme.textSupporting, + isBold: isSelected, + fontSize: fontScale.micro, + gap: variables.nativeTabIconLabelGap, + maxWidth: labelMaxWidth, + }, + })), + // The label is drawn into the icon, so VoiceOver reads it from here. + tabBarAccessibilityLabel: tabLabels[name], + }); + + return {screenOptions, getTabOptions}; +} + +export default useNativeTabBarOptions; +export {getFloatingButtonsBottom}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts new file mode 100644 index 000000000000..2401d88a0b31 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts @@ -0,0 +1,18 @@ +import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; + +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; + +import type NativeTabBarOptionsParams from './types'; + +type UseNativeTabBarOptions = (params: NativeTabBarOptionsParams) => { + screenOptions: NativeBottomTabNavigationOptions; + getTabOptions: (name: NativeTabName) => NativeBottomTabNavigationOptions; +}; + +/** Only iOS and Android have a native tab bar, so other platforms get no options. */ +const useNativeTabBarOptions: UseNativeTabBarOptions = () => ({screenOptions: {}, getTabOptions: () => ({})}); + +const getFloatingButtonsBottom = (bottomInset: number) => bottomInset; + +export default useNativeTabBarOptions; +export {getFloatingButtonsBottom}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts new file mode 100644 index 000000000000..4091ff2f6522 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts @@ -0,0 +1,9 @@ +type NativeTabBarOptionsParams = { + shouldShowNativeTabBar: boolean; + /** Whether the account tab has an item in a narrow layout's bar, so its avatar is drawn. */ + isAccountAvatarShown: boolean; + dotColors: Record; + tabLabels: Record; +}; + +export default NativeTabBarOptionsParams; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts new file mode 100644 index 000000000000..1cd5ff3dcc68 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -0,0 +1,131 @@ +import {useFullScreenBlockingViewState} from '@components/FullScreenBlockingViewContextProvider'; + +import useAccountTabIndicatorStatus from '@hooks/useAccountTabIndicatorStatus'; +import useInboxTabIndicatorStatus from '@hooks/useInboxTabIndicatorStatus'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; + +import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; +import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; +import Navigation from '@libs/Navigation/Navigation'; +import type {TabNavigatorParamList} from '@libs/Navigation/types'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationAction, NavigationState, PartialState, Router, TabNavigationState} from '@react-navigation/native'; + +import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; +import {isAnonymousSessionSelector} from '@selectors/Session'; +import {useEffect} from 'react'; + +import getActiveTabRoute from './getActiveTabRoute'; +import getTabWithoutBarItem from './getTabWithoutBarItem'; +import {isInboxTabAtChatList, isNativeTabSelectionEnabled} from './tabScreenListeners'; +import useIsWorkspacesTabRestored from './useIsWorkspacesTabRestored'; + +/** + * Root-level tab screens where the swipe-back gesture should be disabled. + * Swiping from these screens would pop the entire TAB_NAVIGATOR, which feels wrong. + * WORKSPACE.INITIAL is intentionally excluded, since swiping back from it returns to the workspace list. + */ +const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.INSIGHTS, SCREENS.SETTINGS.ROOT]); + +/** stale === false distinguishes a fully realized NavigationState from a PartialState. */ +function isRealizedNavigationState(state: NavigationState | PartialState | undefined): state is NavigationState { + return state?.stale === false; +} + +function useNativeTabNavigator() { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBlockingViewVisible} = useFullScreenBlockingViewState(); + const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); + const {translate} = useLocalize(); + const [isAnonymousUser = false] = useOnyx(ONYXKEYS.SESSION, {selector: isAnonymousSessionSelector}); + const isInsightsTabVisible = isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE); + const tabWithoutBarItem = getTabWithoutBarItem(isInsightsTabVisible); + const {indicatorColor: inboxDotColor} = useInboxTabIndicatorStatus(); + const {indicatorColor: workspacesIndicatorColor, status: workspacesIndicatorStatus} = useWorkspacesTabIndicatorStatus(); + const {indicatorColor: accountIndicatorColor, status: accountIndicatorStatus} = useAccountTabIndicatorStatus(); + const navigation = useNavigation(); + const parentNavigation = navigation.getParent(); + const isFocusedRouteTabRoot = useNavigationState((state) => TAB_ROOT_SCREENS_WITHOUT_GESTURE.has(findFocusedRoute(state)?.name ?? '')); + const route = useRoute(); + // The Tab.Navigator's own state lives at `parentState.routes[i].state`. We can't read it via + // `useNavigationState((s) => s)` here because the navigator's body runs before + // mounts, so the nearest navigation listener context is still the parent stack's. + const tabState = useNavigationState((parentState) => parentState.routes.find((parentRoute) => parentRoute.key === route.key)?.state); + const activeTabRoute = getActiveTabRoute(tabState); + // A tab with no item in the bar is drawn over the other tabs as a full screen, so the bar hides while it is focused. + const isActiveTabWithoutBarItem = activeTabRoute?.name === tabWithoutBarItem; + const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible && !isActiveTabWithoutBarItem; + const getTabRoute = (tabName: string) => (isRealizedNavigationState(tabState) ? tabState.routes.find((tabRoute) => tabRoute.name === tabName) : undefined); + const isInboxAtChatList = isInboxTabAtChatList(getTabRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR)); + const isWorkspacesTabRestored = useIsWorkspacesTabRestored(getTabRoute(NAVIGATORS.WORKSPACE_NAVIGATOR)); + + const dotColors: Record = { + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: inboxDotColor, + [NAVIGATORS.WORKSPACE_NAVIGATOR]: workspacesIndicatorStatus ? workspacesIndicatorColor : undefined, + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: accountIndicatorStatus ? accountIndicatorColor : undefined, + }; + const tabLabels: Record = { + [SCREENS.HOME]: translate('common.home'), + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: translate('common.inbox'), + [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: translate('common.spend'), + [SCREENS.INSIGHTS]: translate('common.insights'), + [NAVIGATORS.WORKSPACE_NAVIGATOR]: translate('common.workspacesTabTitle'), + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: translate('initialSettingsPage.account'), + }; + + useEffect(() => { + if (!shouldUseNarrowLayout || !parentNavigation) { + return; + } + parentNavigation.setOptions({gestureEnabled: !isFocusedRouteTabRoot}); + }, [isFocusedRouteTabRoot, shouldUseNarrowLayout, parentNavigation]); + + useEffect(() => { + if (!isRealizedNavigationState(tabState)) { + return; + } + setPreservedNavigatorState(route.key, tabState); + }, [tabState, route.key]); + + // Without the beta, Insights has no bar item and its page is not found, so a restored or deep-linked Insights tab + // would be a full screen with no way out. Home replaces it, so going back does not return to it. Betas that are + // still loading are not a missing beta. + const isInsightsTabFocusedWithoutBeta = isBetaEnabledOrUnknown(CONST.BETAS.INSIGHTS_PAGE) === false && activeTabRoute?.name === SCREENS.INSIGHTS; + useEffect(() => { + if (!isInsightsTabFocusedWithoutBeta) { + return; + } + Navigation.navigate(ROUTES.HOME, {forceReplace: true}); + }, [isInsightsTabFocusedWithoutBeta]); + + // The slicing optimization in useCustomRootStackNavigatorState can unmount and later remount + // this TAB_NAVIGATOR. Without restoration it would default to index 0. We restore the saved + // state by overriding the bottom-tab router's getInitialState, the same pattern SplitRouter + // uses for its split navigators. + const tabRouterOverride = ( + originalRouter: Router, Action>, + ): Partial, Action>> => ({ + getInitialState: (configOptions) => { + const preserved = getPreservedNavigatorState>(route.key); + return preserved ? originalRouter.getRehydratedState(preserved, configOptions) : originalRouter.getInitialState(configOptions); + }, + }); + + const isAccountAvatarShown = shouldUseNarrowLayout && tabWithoutBarItem !== NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR; + + const isTabSelectionEnabled = (routeName: string) => isNativeTabSelectionEnabled(routeName, {isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored}); + + return {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isTabSelectionEnabled}; +} + +export default useNativeTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts new file mode 100644 index 000000000000..b38d210f3cd0 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts @@ -0,0 +1,54 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; + +import {getAvatarURL} from '@libs/UserAvatarUtils'; + +import type {SkImage} from '@shopify/react-native-skia'; + +import {Skia} from '@shopify/react-native-skia'; +import {useEffect, useState} from 'react'; + +type TabAvatarImage = {uri: string; image: SkImage}; + +/** Held outside any component, so a status or theme redraw and a remounted navigator reuse the decoded avatar. */ +let lastDecodedAvatar: TabAvatarImage | undefined; + +/** + * The current user's avatar, decoded once per URI for the account tab icon while that icon is shown. Until it is + * decoded, and for avatars that are not a remote image, the account glyph stands in. + */ +function useTabAvatarImage(isShown: boolean): TabAvatarImage | undefined { + const {avatar, accountID} = useCurrentUserPersonalDetails(); + const avatarSource = getAvatarURL({avatarSource: avatar, accountID}); + const uri = typeof avatarSource === 'string' ? avatarSource : undefined; + const [decodedAvatar, setDecodedAvatar] = useState(lastDecodedAvatar); + const decodedUri = decodedAvatar?.uri; + + useEffect(() => { + if (!isShown || !uri || decodedUri === uri) { + return; + } + let isActive = true; + Skia.Data.fromURI(uri) + .then((data) => { + const image = Skia.Image.MakeImageFromEncoded(data); + data.dispose(); + if (!image) { + return; + } + lastDecodedAvatar = {uri, image}; + if (isActive) { + setDecodedAvatar(lastDecodedAvatar); + } + }) + // A failed decode keeps the account glyph. + .catch(() => undefined); + return () => { + isActive = false; + }; + }, [isShown, uri, decodedUri]); + + return decodedAvatar?.uri === uri ? decodedAvatar : undefined; +} + +export default useTabAvatarImage; +export type {TabAvatarImage}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts index 84825c7babaa..cbb02a5c88d8 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts @@ -1,6 +1,7 @@ /** * Ordered list of screen names registered inside TabNavigator. - * This must match the Tab.Screen order in TabNavigator.tsx. + * This must match the Tab.Screen order in TabNavigator.tsx and TabNavigator.native.tsx, where it is also the order + * the native tab bar draws its items in. * Used by getAdaptedStateFromPath to build complete tab navigator state for deep-links. */ import NAVIGATORS from '@src/NAVIGATORS'; @@ -11,8 +12,8 @@ const TAB_SCREENS = [ NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.INSIGHTS, - NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, NAVIGATORS.WORKSPACE_NAVIGATOR, + NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, ] as const; export default TAB_SCREENS; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index 8b0b7c7e1856..6c4b97d1fedc 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -1,104 +1,60 @@ -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; - -import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import {bottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; import HomePage from '@pages/home/HomePage'; import InsightsPage from '@pages/Insights/InsightsPage'; +import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; -/** - * Tab Navigator containing Home, Inbox (Reports), Search, Insights, Settings, and Workspaces pages. - */ -import type {BottomTabBarProps} from '@react-navigation/bottom-tabs'; -import type {NavigationAction, NavigationState, Router, TabNavigationState} from '@react-navigation/native'; +import {createNativeBottomTabNavigator} from '@react-navigation/bottom-tabs/unstable'; +import React from 'react'; -import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; -import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; -import React, {useEffect} from 'react'; +import type {NativeTabName} from './NativeTabNavigator/NATIVE_TAB_GLYPHS'; +import type {NativeTabLayoutProps} from './NativeTabNavigator/NativeTabLayout'; +import NativeTabLayout from './NativeTabNavigator/NativeTabLayout'; +import tabScreenListeners from './NativeTabNavigator/tabScreenListeners'; +import useNativeTabBarOptions from './NativeTabNavigator/useNativeTabBarOptions'; +import useNativeTabNavigator from './NativeTabNavigator/useNativeTabNavigator'; import ReportsSplitNavigator from './ReportsSplitNavigator'; import SearchFullscreenNavigator from './SearchFullscreenNavigator'; import SettingsSplitNavigator from './SettingsSplitNavigator'; -import TabNavigatorBar from './TabNavigatorBar'; import WorkspaceNavigator from './WorkspaceNavigator'; -const renderTabBar = ({state}: BottomTabBarProps) => ; - -const Tab = createBottomTabNavigator(); - /** - * Root-level tab screens where the swipe-back gesture should be disabled. - * Swiping from these screens would pop the entire TAB_NAVIGATOR, which feels wrong. - * WORKSPACE.INITIAL is intentionally excluded — swiping back from it returns to the workspace list. + * Tab Navigator backed by the platform's own tab bar: UITabBar with the liquid glass material on iOS 26, and + * Material's BottomNavigationView on Android. Wide layouts keep the JS side bar, since neither native bar can be + * moved to the side of the screen. */ -const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.INSIGHTS, SCREENS.SETTINGS.ROOT]); - -const TAB_SCREEN_OPTIONS_BASE = { - headerShown: false, - lazy: true, - animation: 'none' as const, - freezeOnBlur: true, - tabBarPosition: 'bottom' as const, -} as const; - -function TabNavigator() { - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const theme = useTheme(); - const navigation = useNavigation(); - const parentNavigation = navigation.getParent(); - const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); - const route = useRoute(); - // The Tab.Navigator's own state lives at `parentState.routes[i].state`. We can't read it via - // `useNavigationState((s) => s)` here because TabNavigator's body runs before - // mounts, so the nearest navigation listener context is still the parent stack's. - const tabState = useNavigationState((parentState) => parentState.routes.find((r) => r.key === route.key)?.state as NavigationState | undefined); - - useEffect(() => { - if (!shouldUseNarrowLayout || !parentNavigation) { - return; - } - const isRootScreen = TAB_ROOT_SCREENS_WITHOUT_GESTURE.has(focusedRouteName ?? ''); - parentNavigation.setOptions({gestureEnabled: !isRootScreen}); - }, [focusedRouteName, shouldUseNarrowLayout, parentNavigation]); +const Tab = createNativeBottomTabNavigator(); - useEffect(() => { - // stale === false distinguishes a fully realized NavigationState from a PartialState. - if (!tabState || tabState.stale !== false) { - return; - } - setPreservedNavigatorState(route.key, tabState); - }, [tabState, route.key]); +const HIDDEN_TAB_OPTIONS = {tabBarItemHidden: true}; - // The slicing optimization in useCustomRootStackNavigatorState can unmount and later remount - // this TAB_NAVIGATOR. Without restoration it would default to index 0. We restore the saved - // state by overriding the bottom-tab router's getInitialState — the same pattern SplitRouter - // uses for its split navigators. - const tabRouterOverride = ( - originalRouter: Router, Action>, - ): Partial, Action>> => ({ - getInitialState: (configOptions) => { - const preserved = getPreservedNavigatorState>(route.key); - return preserved ? originalRouter.getRehydratedState(preserved, configOptions) : originalRouter.getInitialState(configOptions); - }, - }); +const renderNativeTabLayout = (props: NativeTabLayoutProps) => ; - const screenOptions = { - ...TAB_SCREEN_OPTIONS_BASE, - sceneStyle: {flex: 1, backgroundColor: theme.appBG}, - }; +function TabNavigator() { + const {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isTabSelectionEnabled} = useNativeTabNavigator(); + const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}); + // A tab with no bar item draws no icon. + const getScreenOptions = ({route}: {route: {name: NativeTabName}}) => + route.name === tabWithoutBarItem + ? {...screenOptions, ...HIDDEN_TAB_OPTIONS} + : { + ...screenOptions, + ...getTabOptions(route.name), + tabBarSelectionEnabled: isTabSelectionEnabled(route.name), + }; return ( - + ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx index d77a12be149d..7d7745a49188 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx @@ -8,6 +8,8 @@ import {bottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNaviga import type {TabNavigatorParamList} from '@libs/Navigation/types'; import {getSpan} from '@libs/telemetry/activeSpans'; +import FreezeWrapper from '@navigation/AppNavigator/FreezeWrapper'; + import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; @@ -66,7 +68,16 @@ function withSuspense

>(LazyComponent: React.La return SuspenseWrapper; } -const HomePageScreen = withSuspense(LazyHomePage); +const HomePageWithSuspense = withSuspense(LazyHomePage); + +// Outside the Suspense so the lazy chunk load still shows LazyFallback instead of suspending into Freeze. +function HomePageScreen() { + return ( + + + + ); +} const ReportsSplitNavigatorScreen = withSuspense(LazyReportsSplitNavigator, CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB); const SettingsSplitNavigatorScreen = withSuspense(LazySettingsSplitNavigator); const WorkspaceNavigatorScreen = withSuspense(LazyWorkspaceNavigator); @@ -133,14 +144,14 @@ function TabNavigator() { name={SCREENS.INSIGHTS} component={InsightsPageScreen} /> - + ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx index 9b46ffd6e246..327776eaa50b 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx @@ -10,21 +10,15 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; -import cancelTabNavigationSpans, {INBOX_TAB_SPAN_IDS, REPORTS_TAB_SPAN_IDS} from '@libs/telemetry/cancelTabNavigationSpans'; +import cancelTabNavigationSpans, {NAVIGATION_TAB_TO_SPANS} from '@libs/telemetry/cancelTabNavigationSpans'; import SCREENS from '@src/SCREENS'; import type {BottomTabBarProps} from '@react-navigation/bottom-tabs'; -import type {ValueOf} from 'type-fest'; import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; -const NAVIGATION_TAB_TO_SPANS: Partial, readonly string[]>> = { - [NAVIGATION_TABS.INBOX]: INBOX_TAB_SPAN_IDS, - [NAVIGATION_TABS.SEARCH]: REPORTS_TAB_SPAN_IDS, -}; - /** * Custom tab bar rendered by the BottomTabNavigator. Only receives `state` (not the * full BottomTabBarProps) to avoid `descriptors` thrashing memoization. @@ -40,7 +34,7 @@ function TabNavigatorBar({state}: Pick) { const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; const isAtRoot = isTabRouteAtRoot(activeRoute); // --- Narrow-only animation logic (hooks must run unconditionally per Rules of Hooks) --- - // On native, screens also render the tab bar via bottomContent for swipe-back animations. + // Screens also render the tab bar via bottomContent for swipe-back animations. // Delay showing this navigator's tab bar only when navigating back from a deeper screen // (where the tab bar was hidden). Keep it visible during tab switches so it doesn't flash. // Guard with shouldUseNarrowLayout so prevShouldHide stays false in wide layout, diff --git a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx index c2cc3fe466ff..e219d7e024f5 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx @@ -4,6 +4,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import createSplitNavigator from '@libs/Navigation/AppNavigator/createSplitNavigator'; import useSplitNavigatorScreenOptions from '@libs/Navigation/AppNavigator/useSplitNavigatorScreenOptions'; +import withAgentAccessDenied from '@libs/Navigation/AppNavigator/withAgentAccessDenied'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceNavigatorParamList, WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; @@ -23,6 +24,7 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.WORKFLOWS]: () => require('../../../../pages/workspace/workflows/WorkspaceWorkflowsPageRevamp').default, [SCREENS.WORKSPACE.INVOICES]: () => require('../../../../pages/workspace/invoices/WorkspaceInvoicesPage').default, [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../../pages/workspace/WorkspaceMembersPage').default, + [SCREENS.WORKSPACE.AGENTS]: withAgentAccessDenied(() => require('../../../../pages/workspace/WorkspaceAgentsPage').default), [SCREENS.WORKSPACE.ROOMS]: () => require('../../../../pages/workspace/rooms/WorkspaceRoomsPage').default, [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default, [SCREENS.WORKSPACE.HR]: () => require('../../../../pages/workspace/hr/WorkspaceHRPage').default, diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts index 30a79116a813..3a5e4b405355 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts @@ -1,3 +1,4 @@ +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; import TAB_SCREENS from '@libs/Navigation/AppNavigator/Navigators/TAB_SCREENS'; @@ -5,6 +6,7 @@ import buildTabNavigatorNestedState from '@libs/Navigation/helpers/buildTabNavig import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; import hasNativeSwipeBackGesture from '@libs/Navigation/helpers/hasNativeSwipeBackGesture'; import {isFullScreenName, isPreMountBufferHostName} from '@libs/Navigation/helpers/isNavigatorName'; +import {isStaleWideTabPreMountPreloadedRouteKey, isStaleWideTabPreMountRouteKey} from '@libs/Navigation/helpers/wideTabPreMountRouteKey'; import {SIDEBAR_TO_SPLIT, SPLIT_TO_SIDEBAR} from '@libs/Navigation/linkingConfig/RELATIONS'; import type {NavigationPartialRoute, ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; import {isRecord} from '@libs/ObjectUtils'; @@ -23,6 +25,7 @@ import type { RemoveFullscreenUnderRHPActionType, ReplaceActionType, ReplaceFullscreenUnderRHPActionType, + RootStackNavigatorAction, ToggleMfaModalNavigatorWithHistoryActionType, ToggleModalWithHistoryActionType, ToggleSidePanelWithHistoryActionType, @@ -54,6 +57,7 @@ const MODAL_ROUTES_TO_DISMISS = new Set([ SCREENS.DYNAMIC_PROFILE_AVATAR, SCREENS.WORKSPACE_AVATAR, SCREENS.WORKSPACE_DOCUMENT, + SCREENS.BILLING_RECEIPT, SCREENS.REPORT_AVATAR, SCREENS.CONCIERGE, SCREENS.SEARCH_ROUTER.ROOT, @@ -119,10 +123,10 @@ type TabNavigatorPushPayloadParams = { }; type TabRouteForReplacement = NavigationState['routes'][number] | NavigationPartialRoute; -type TabStateForReplacement = Omit & {routes: TabRouteForReplacement[]; stale?: true | false}; +type TabStateForReplacement = Omit & {routes: TabRouteForReplacement[]; stale?: true | false; preloadedRouteKeys?: string[]}; type StaleTabStateOverrides = {routes: TabRouteForReplacement[]; index: number; routeNames?: string[]}; -function toStaleTabState(existingTabState: NavigationState | undefined, overrides: StaleTabStateOverrides): TabStateForReplacement { +function toStaleTabState(existingTabState: NavigationState | PartialState | undefined, overrides: StaleTabStateOverrides): TabStateForReplacement { return { type: existingTabState?.type ?? 'tab', key: existingTabState?.key ?? '', @@ -208,6 +212,10 @@ function isNavigationPartialRoute(route: unknown): route is NavigationPartialRou return typeof route === 'object' && route !== null && 'name' in route && typeof route.name === 'string'; } +function isRealizedNavigationState(state: unknown): state is NavigationState { + return isRecord(state) && state.stale === false && typeof state.key === 'string' && Array.isArray(state.routes); +} + function isNavigationStateWithRoutes(state: unknown): state is PartialState { return typeof state === 'object' && state !== null && 'routes' in state && Array.isArray(state.routes); } @@ -353,6 +361,14 @@ function getTabStateWithFocusedTarget(existingTabState: NavigationState | undefi } } + return getTabStateWithFreshTarget(existingTabState, focusedTargetTab); +} + +/** Builds the target tab with fresh keys, keeping the other tabs and tab history from `existingTabState`. */ +function getTabStateWithFreshTarget( + existingTabState: NavigationState | PartialState | undefined, + focusedTargetTab: NavigationPartialRoute, +): TabStateForReplacement | undefined { const completeTabState = buildTabNavigatorNestedState(focusedTargetTab); const completeTargetTabIndex = completeTabState.routes.findIndex((route) => route.name === focusedTargetTab.name); if (completeTargetTabIndex < 0) { @@ -363,6 +379,7 @@ function getTabStateWithFocusedTarget(existingTabState: NavigationState | undefi if (route.name === focusedTargetTab.name) { return getTargetTabRoute(undefined, focusedTargetTab); } + // Keys are kept on purpose: tab history references them, so fresh keys would drop it after the reveal. return existingTabState?.routes.find((r) => r.name === route.name) ?? route; }); @@ -464,6 +481,68 @@ function markFocusedTabRouteForRemount(tabState: TabStateForReplacement, existin }); } +/** + * Adds the focused tab to the end of the tab history the way the tab router does when switching tabs (fullHistory). + * A realized state is not rehydrated, so without this the history would still end on the tab the user came from. + */ +function withFocusedTabInHistory(tabState: TabStateForReplacement): TabStateForReplacement { + const focusedRoute = tabState.routes[tabState.index]; + if (tabState.stale === true || !tabState.history || !focusedRoute || !('key' in focusedRoute) || typeof focusedRoute.key !== 'string') { + return tabState; + } + const lastRouteEntry = tabState.history.findLast((entry) => isRecord(entry) && entry.type === 'route'); + if (isRecord(lastRouteEntry) && lastRouteEntry.key === focusedRoute.key) { + return tabState; + } + return {...tabState, history: [...tabState.history, {type: 'route', key: focusedRoute.key, params: focusedRoute.params}]}; +} + +/** + * Puts the wide pre-mounted destination screen in place of the freshly built one, so the reveal shows the mounted instance. + * Everything else comes from the regular replace, so the stack, tab history and back navigation end up the same as without it. + */ +function withPreMountedDestination(builtTabState: TabStateForReplacement, existingTabState: NavigationState | undefined, preMountedRouteKey: string): TabStateForReplacement { + const targetTabRoute = builtTabState.routes[builtTabState.index]; + const existingTargetTabRoute = existingTabState?.routes.find((route) => route.name === targetTabRoute?.name); + const existingNestedState = isNavigationStateWithRoutes(existingTargetTabRoute?.state) ? existingTargetTabRoute.state : undefined; + const preMountedRoute = existingNestedState?.routes.find((route) => route.key === preMountedRouteKey); + const builtNestedState = isNavigationStateWithRoutes(targetTabRoute?.state) ? targetTabRoute.state : undefined; + const builtDestination = builtNestedState?.routes.at(-1); + if (!targetTabRoute || !preMountedRoute || !builtNestedState || builtDestination?.name !== preMountedRoute.name) { + Log.hmmm('[Navigation] Wide pre-mount does not match the revealed destination, replacing without it', {preMountedRouteKey}); + return withoutFocusedTabPreload(builtTabState); + } + + // Native keeps the focused report's history, which holds the pre-mount under it, so that copy is dropped to keep keys unique. + const backRoutes = builtNestedState.routes.slice(0, -1).filter((route) => !('key' in route) || route.key !== preMountedRouteKey); + const nestedRoutes = [...backRoutes, {...builtDestination, key: preMountedRouteKey}]; + // When every screen of the built stack is already mounted, the mounted stack state is kept with only its routes changed. + // A partial state would be rehydrated under a new key, which makes the navigator re-render all of its screens. + const mountedNestedState = isRealizedNavigationState(existingTargetTabRoute?.state) ? existingTargetTabRoute.state : undefined; + // Params still come from the built routes, so both branches reveal the destination the action asked for. + const mountedRoutes = nestedRoutes.map((route) => { + const existing = mountedNestedState?.routes.find((mountedRoute) => 'key' in route && mountedRoute.key === route.key); + return existing ? {...existing, params: route.params} : undefined; + }); + const routes = [...builtTabState.routes]; + if (mountedNestedState && mountedRoutes.every((route): route is NonNullable => !!route) && 'key' in targetTabRoute && typeof targetTabRoute.key === 'string') { + // Route snapshots added by addPushParamsRouterExtension are rebuilt from the routes, the same as a rehydrated stack gets them. + const history = mountedNestedState.history ? {history: mountedRoutes.map((route) => ({...route}))} : {}; + routes[builtTabState.index] = {...targetTabRoute, key: targetTabRoute.key, state: {...mountedNestedState, routes: mountedRoutes, index: mountedRoutes.length - 1, ...history}}; + } else { + routes[builtTabState.index] = {...targetTabRoute, state: {...builtNestedState, routes: nestedRoutes, index: nestedRoutes.length - 1}}; + } + return withoutFocusedTabPreload({...builtTabState, routes}); +} + +/** The pre-mount kept a covered tab unfrozen. It is focused from here, so it no longer needs that. */ +function withoutFocusedTabPreload(tabState: TabStateForReplacement): TabStateForReplacement { + const {preloadedRouteKeys} = tabState; + const focusedTabRoute = tabState.routes[tabState.index]; + const focusedTabRouteKey = focusedTabRoute && 'key' in focusedTabRoute ? focusedTabRoute.key : undefined; + return {...tabState, ...(preloadedRouteKeys ? {preloadedRouteKeys: preloadedRouteKeys.filter((key) => key !== focusedTabRouteKey)} : {})}; +} + /** * Handles the REPLACE_FULLSCREEN_UNDER_RHP action. * @@ -530,14 +609,24 @@ function handleReplaceFullscreenUnderRHP( if (!focusedTargetTab) { return null; } - const updatedTabState = getTabStateWithFocusedTarget(existingTabState, focusedTargetTab); - if (!updatedTabState) { + + const builtTabState = getTabStateWithFocusedTarget(existingTabState, focusedTargetTab); + if (!builtTabState) { return null; } - const staleTabState = existingTabState ? markFocusedTabRouteForRemount(updatedTabState, existingTabState) : updatedTabState; + const {preMountedRouteKey} = action.payload; + const updatedTabState = preMountedRouteKey ? withPreMountedDestination(builtTabState, existingTabState, preMountedRouteKey) : builtTabState; + // The remount guards against a push-transition flash on narrow layout (#90985). A wide pre-mount reveal keeps the key, + // because a remount would drop the pre-mounted screen. Wide renders split screens without a push animation. + let tabStateForReplace = updatedTabState; + if (preMountedRouteKey && !getIsNarrowLayout()) { + tabStateForReplace = withFocusedTabInHistory(updatedTabState); + } else if (existingTabState) { + tabStateForReplace = markFocusedTabRouteForRemount(updatedTabState, existingTabState); + } // Drop consumed deep-link hints before remounting, or React Navigation can replay the old target over the new state. - const updatedTabRoute = {...withSanitizedDeepLinkParams(existingTabRoute, undefined), state: staleTabState} as StackNavigationState['routes'][number]; + const updatedTabRoute = {...withSanitizedDeepLinkParams(existingTabRoute, undefined), state: tabStateForReplace} as StackNavigationState['routes'][number]; // Save original route so handleRemoveFullscreenUnderRHP can fully restore it on cancel. // In the cold-start fallback the tab navigator has no nested state yet, so saving the raw // route would leave it stateless and the dismiss-restore path (removePreInsertedFullscreenIfNeeded) @@ -755,12 +844,56 @@ function handleToggleModalWithHistoryAction(state: StackNavigationState; index?: number; preloadedRouteKeys?: string[]}; + +function isResetPayloadState(state: unknown): state is ResetPayloadState { + return isRecord(state) && Array.isArray(state.routes); +} + +/** Drops stale wide pre-mounts from a (nested) state, returning the same object when there is none. */ +function removeStalePreMountsFromState(state: S): S { + let hasChangedNestedState = false; + const routes = state.routes.map((route) => { + if (!isResetPayloadState(route.state)) { + return route; + } + const nestedState = removeStalePreMountsFromState(route.state); + if (nestedState === route.state) { + return route; + } + hasChangedNestedState = true; + // A never-visited tab can hold only the pre-mount. Without a state, the navigator builds its initial route again. + return {...route, state: nestedState.routes.length ? nestedState : undefined}; + }); + const hasStalePreload = !!state.preloadedRouteKeys?.some(isStaleWideTabPreMountPreloadedRouteKey); + const preloads = hasStalePreload ? {preloadedRouteKeys: state.preloadedRouteKeys?.filter((key) => !isStaleWideTabPreMountPreloadedRouteKey(key))} : {}; + const isStale = (route: {key?: string}) => isStaleWideTabPreMountRouteKey(route.key); + if (!routes.some(isStale)) { + return hasChangedNestedState || hasStalePreload ? {...state, routes, ...preloads} : state; + } + const keptRoutes = routes.filter((route) => !isStale(route)); + const focusedIndex = state.index ?? routes.length - 1; + // A stale pre-mount on top of a covered stack was its focused route, so focus falls back to the route under it. + const index = Math.max(0, Math.min(focusedIndex - routes.slice(0, focusedIndex).filter(isStale).length, keptRoutes.length - 1)); + return {...state, routes: keptRoutes, index, ...preloads}; +} + +/** Drops pre-mounts nobody owns from a RESET, e.g. restored by browser back/forward from a saved history entry. */ +function removeStalePreMountsFromResetAction(action: RootStackNavigatorAction): RootStackNavigatorAction { + if (action.type !== CONST.NAVIGATION.ACTION_TYPE.RESET || !action.payload || !isResetPayloadState(action.payload)) { + return action; + } + const payload = removeStalePreMountsFromState(action.payload); + return payload === action.payload ? action : ({...action, payload} as RootStackNavigatorAction); +} + export { handleDismissModalAction, handleNavigatingToModalFromModal, handlePushFullscreenAction, handleReplaceFullscreenUnderRHP, handleRemoveFullscreenUnderRHP, + removeStalePreMountsFromResetAction, handleReplaceReportsSplitNavigatorAction, screensWithEnteringAnimation, handleToggleSidePanelWithHistoryAction, @@ -770,6 +903,7 @@ export { clearPreInsertedOriginalTabRoute, MODAL_ROUTES_TO_DISMISS, // Exported for unit-test access; not used outside of testing. + getTabStateWithFreshTarget, withSanitizedDeepLinkParams, getTabStateWithFocusedTarget, markFocusedTabRouteForRemount, diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts index b59a0154e6e9..2d70b852def2 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts @@ -38,6 +38,7 @@ import { handleToggleModalWithHistoryAction, handleToggleSidePanelWithHistoryAction, MODAL_ROUTES_TO_DISMISS, + removeStalePreMountsFromResetAction, } from './GetStateForActionHandlers'; import syncBrowserHistory from './syncBrowserHistory'; @@ -189,7 +190,9 @@ function RootStackRouter(options: RootStackNavigatorRouterOptions) { return { ...stackRouter, - getStateForAction(state: StackNavigationState, action: RootStackNavigatorAction, configOptions: RouterConfigOptions) { + getStateForAction(state: StackNavigationState, rawAction: RootStackNavigatorAction, configOptions: RouterConfigOptions) { + const action = removeStalePreMountsFromResetAction(rawAction); + // Evaluate navigation guards FIRST const guardState = handleNavigationGuards(state, action, configOptions, stackRouter); if (guardState) { diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts index 2371a29b3680..a40348629bfd 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts @@ -28,7 +28,7 @@ type RootStackNavigatorActionType = } | { type: typeof CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP; - payload: {route: Route; shouldInsertPreMountBuffer?: boolean}; + payload: {route: Route; shouldInsertPreMountBuffer?: boolean; preMountedRouteKey?: string}; } | { type: typeof CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP; @@ -69,7 +69,7 @@ type DismissModalActionType = RootStackNavigatorActionType & { type ReplaceFullscreenUnderRHPActionType = RootStackNavigatorActionType & { type: typeof CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP; - payload: {route: Route; shouldInsertPreMountBuffer?: boolean}; + payload: {route: Route; shouldInsertPreMountBuffer?: boolean; preMountedRouteKey?: string}; }; type RemoveFullscreenUnderRHPActionType = RootStackNavigatorActionType & { diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts index 3af6e3f40087..0af832696e4d 100644 --- a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts @@ -168,11 +168,13 @@ function SplitRouter(options: SplitNavigatorRouterOptions) { return result ? getRehydratedState(result, configOptions) : result; } if (isPushingSidebarOnCentralPane(state, action, options)) { - if (getIsNarrowLayout()) { + if (getIsNarrowLayout() && state.index > 0) { const newAction = StackActions.popToTop(); - return stackRouter.getStateForAction(state, newAction, configOptions); + // popToTop returns null when the sidebar is already the only screen, so there is nothing to do. + return stackRouter.getStateForAction(state, newAction, configOptions) ?? state; } // On wide screen do nothing as we want to keep the central pane screen and the sidebar is visible. + // On narrow screen the sidebar is already on top, and the stack router reports popToTop with nothing to pop as unhandled. return state; } diff --git a/src/libs/Navigation/AppNavigator/withAgentAccessDenied.tsx b/src/libs/Navigation/AppNavigator/withAgentAccessDenied.tsx index 2904b812cb42..d3f77bdacffc 100644 --- a/src/libs/Navigation/AppNavigator/withAgentAccessDenied.tsx +++ b/src/libs/Navigation/AppNavigator/withAgentAccessDenied.tsx @@ -3,8 +3,10 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import useIsAgentAccount from '@hooks/useIsAgentAccount'; import useOnyx from '@hooks/useOnyx'; +import getTopmostFullScreenRoute from '@libs/Navigation/helpers/getTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; +import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -46,6 +48,13 @@ function withAgentAccessDenied(getComponent: () => React.ComponentType): () => R forceReplace: true, }); }; + const resetWorkspaceTabAndNavigateToHome = () => { + Navigation.popRootToTop(); + Navigation.navigate(ROUTES.WORKSPACES_LIST.getRoute(), { + forceReplace: true, + afterTransition: () => Navigation.navigate(ROUTES.HOME), + }); + }; // The guarded screen can be open inside a modal/RHP (e.g. the agent-edit page the owner was // on when they tapped "Copilot into account"), or an unguarded RHP (e.g. the agent DM) can be @@ -59,6 +68,12 @@ function withAgentAccessDenied(getComponent: () => React.ComponentType): () => R return; } + const topmostFullScreenRoute = getTopmostFullScreenRoute(); + if (topmostFullScreenRoute?.name === NAVIGATORS.WORKSPACE_NAVIGATOR || topmostFullScreenRoute?.name === NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR) { + resetWorkspaceTabAndNavigateToHome(); + return; + } + redirectToProfile(); }); }, [isAgent]); diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 91c270ac7e56..b8d057f4969e 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -63,7 +63,7 @@ import { } from './helpers/preMountBuffer'; import replaceWithSplitNavigator from './helpers/replaceWithSplitNavigator'; import setNavigationActionToMicrotaskQueue from './helpers/setNavigationActionToMicrotaskQueue'; -import {linkingConfig} from './linkingConfig'; +import {config as linkingConfig} from './linkingConfig/config'; import {SPLIT_TO_SIDEBAR} from './linkingConfig/RELATIONS'; import navigationRef from './navigationRef'; import TransitionTracker from './TransitionTracker'; @@ -515,7 +515,7 @@ function goUp(backToRoute: Route, options?: GoBackOptions): boolean { const stateFromPath = getStateFromPath(backToRoute); - const action = getActionFromState(stateFromPath, linkingConfig.config); + const action = getActionFromState(stateFromPath, linkingConfig); if (!action) { Log.hmmm(`[Navigation] Unable to go up. Action is undefined.`); diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index da6ab94cb1af..543b69655ea0 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -144,7 +144,6 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N const {updateCurrentReportID} = useCurrentReportIDActions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [isOnboardingCompleted = true] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { selector: hasCompletedGuidedSetupFlowSelector, }); @@ -166,10 +165,6 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N return getAdaptedStateFromPath(lastVisitedPath); } - if (!account || account.isFromPublicDomain) { - return; - } - const isTransitioning = path?.includes(ROUTES.TRANSITION_BETWEEN_APPS); // If we have a transition URL, don't restore last visited path - let React Navigation handle it diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index 181e9a3170d4..b8eae5c610ad 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -18,13 +18,12 @@ import Tab from '@userActions/Tab'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {SelectedTabRequest} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import KeyboardUtils from '@src/utils/keyboard'; import type {MaterialTopTabNavigationEventMap} from '@react-navigation/material-top-tabs'; -import type {EventArg, EventMapCore, NavigationProp, NavigationState, ParamListBase, ScreenListeners} from '@react-navigation/native'; +import type {EventArg, NavigationProp, ParamListBase, ScreenListeners, TabNavigationState} from '@react-navigation/native'; import {createMaterialTopTabNavigator} from '@react-navigation/material-top-tabs'; import {TabActions, useRoute} from '@react-navigation/native'; @@ -36,15 +35,17 @@ import type {RegisterTabSwitchGuard, TabSwitchGuard} from './TabSwitchGuardConte import {backBehavior, defaultScreenOptions, pagerContainerStyle} from './OnyxTabNavigatorConfig'; import TabSwitchGuardContext from './TabSwitchGuardContext'; -type OnyxTabNavigatorProps = ChildrenProps & { +type OnyxTabParamList = Record; + +type OnyxTabNavigatorProps = ChildrenProps & { /** ID of the tab component to be saved in onyx */ id: string; - defaultSelectedTab?: TTabName; + defaultSelectedTab?: Extract; onTabSelected?: (newTabName: TTabName) => void; tabBar: (props: TabSelectorProps) => React.ReactNode; - screenListeners?: ScreenListeners; + screenListeners?: ScreenListeners>, MaterialTopTabNavigationEventMap>; /** Callback to register the focus trap container elements of the current active tab. * Use this in the parent component to get the focus trap container element of the active tab, @@ -83,8 +84,6 @@ type OnyxTabNavigatorProps = Child shouldReapplyInterruptedTabPress?: boolean; }; -const TopTab = createMaterialTopTabNavigator(); - const DISMISS_KEYBOARD_BEFORE_TAB_SWITCH_TIMEOUT_MS = CONST.MAX_TRANSITION_DURATION_MS; /** @@ -132,10 +131,9 @@ const getTabNames = (children: React.ReactNode): string[] => { return; } - const element = child as React.ReactElement<{name?: string}>; - - if (typeof element.props.name === 'string') { - result.push(element.props.name); + const props = child.props; + if (props && typeof props === 'object' && 'name' in props && typeof props.name === 'string') { + result.push(props.name); } }); @@ -145,294 +143,305 @@ const getTabNames = (children: React.ReactNode): string[] => { // This takes all the same props as MaterialTopTabsNavigator: https://reactnavigation.org/docs/material-top-tab-navigator/#props, // except ID is now required, and it gets a `selectedTab` from Onyx // It also takes 2 more optional callbacks to manage the focus trap container elements of the tab bar and the active tab -function OnyxTabNavigator({ - id, - defaultSelectedTab, - tabBar: TabBar, - children, - onTabBarFocusTrapContainerElementChanged, - onActiveTabFocusTrapContainerElementChanged, - onTabSelected = () => {}, - screenListeners, - shouldShowLabelWhenInactive = true, - lazyLoadEnabled = false, - onTabSelect, - equalWidth = false, - shouldDismissKeyboardBeforeTabSwitch = false, - shouldReapplyInterruptedTabPress = false, - ...rest -}: OnyxTabNavigatorProps) { - const styles = useThemeStyles(); - const isFirstMountRef = useRef(true); - // Mapping of tab name to focus trap container element - const [focusTrapContainerElementMapping, setFocusTrapContainerElementMapping] = useState>({}); - const [selectedTab, selectedTabResult] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${id}`); - - const pressedTabRef = useRef<{from: string | undefined; to: string; armedAt: number} | undefined>(undefined); - // Frame handle of a queued re-apply jump, so it can be dropped when it is superseded by a newer press or by unmount. - const reapplyFrameRef = useRef(undefined); +function createOnyxTabNavigator(names: ReadonlyArray>) { + const TopTab = createMaterialTopTabNavigator, string>(); + + function OnyxTabNavigator({ + id, + defaultSelectedTab, + tabBar: TabBar, + children, + onTabBarFocusTrapContainerElementChanged, + onActiveTabFocusTrapContainerElementChanged, + onTabSelected = () => {}, + screenListeners, + shouldShowLabelWhenInactive = true, + lazyLoadEnabled = false, + onTabSelect, + equalWidth = false, + shouldDismissKeyboardBeforeTabSwitch = false, + shouldReapplyInterruptedTabPress = false, + ...rest + }: OnyxTabNavigatorProps) { + const styles = useThemeStyles(); + const isFirstMountRef = useRef(true); + // Mapping of tab name to focus trap container element + const [focusTrapContainerElementMapping, setFocusTrapContainerElementMapping] = useState>({}); + const [selectedTab, selectedTabResult] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${id}`); + + const pressedTabRef = useRef<{from: string | undefined; to: string; armedAt: number} | undefined>(undefined); + // Frame handle of a queued re-apply jump, so it can be dropped when it is superseded by a newer press or by unmount. + const reapplyFrameRef = useRef(undefined); + + const tabNames = getTabNames(children); + + const storedTabName = names.find((name) => name === selectedTab); + const validInitialTab = storedTabName && tabNames.includes(storedTabName) ? storedTabName : defaultSelectedTab; + + const LazyPlaceholder = useCallback(() => { + return ( + + + + ); + }, [styles.fullScreenLoading, styles.w100]); + + // This callback is used to register the focus trap container element of each available tab screen + const setTabFocusTrapContainerElement = (tabName: string, containerElement: HTMLElement | null) => { + setFocusTrapContainerElementMapping((prevMapping) => { + const resultMapping = {...prevMapping}; + if (containerElement) { + resultMapping[tabName] = containerElement; + } else { + delete resultMapping[tabName]; + } + return resultMapping; + }); + }; - const tabNames = getTabNames(children); + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + // Tab-switch discard guards, keyed by tab name. Tab screens register via `useDiscardChangesConfirmation`. + const guardsRef = useRef>(new Map()); + const isDiscardModalOpenRef = useRef(false); + // Set while a tab switch is waiting on the keyboard, so repeated taps can't queue competing jumps. + const isTabSwitchPendingRef = useRef(false); + + const registerTabGuard: RegisterTabSwitchGuard = (guard) => { + guardsRef.current.set(guard.tabName, guard); + return () => { + // Only clear if this exact guard is still registered, so a re-registration from another mount isn't wiped. + if (guardsRef.current.get(guard.tabName) !== guard) { + return; + } + guardsRef.current.delete(guard.tabName); + }; + }; - const validInitialTab = selectedTab && tabNames.includes(selectedTab) ? selectedTab : defaultSelectedTab; + // A function, not a cached value: each call site checks the keyboard's current state, not one captured earlier. + // Gated on Keyboard.isVisible() rather than platform, so an opted-in caller also defers on iOS, not just Android. + const hasKeyboardToDismiss = () => shouldDismissKeyboardBeforeTabSwitch && Keyboard.isVisible(); - const LazyPlaceholder = useCallback(() => { - return ( - - - - ); - }, [styles.fullScreenLoading, styles.w100]); - - // This callback is used to register the focus trap container element of each available tab screen - const setTabFocusTrapContainerElement = (tabName: string, containerElement: HTMLElement | null) => { - setFocusTrapContainerElementMapping((prevMapping) => { - const resultMapping = {...prevMapping}; - if (containerElement) { - resultMapping[tabName] = containerElement; - } else { - delete resultMapping[tabName]; + const runAfterKeyboardDismiss = async (callback: () => void) => { + if (!hasKeyboardToDismiss()) { + callback(); + return; } - return resultMapping; - }); - }; - const {translate} = useLocalize(); - const {showConfirmModal} = useConfirmModal(); - // Tab-switch discard guards, keyed by tab name. Tab screens register via `useDiscardChangesConfirmation`. - const guardsRef = useRef>(new Map()); - const isDiscardModalOpenRef = useRef(false); - // Set while a tab switch is waiting on the keyboard, so repeated taps can't queue competing jumps. - const isTabSwitchPendingRef = useRef(false); - - const registerTabGuard: RegisterTabSwitchGuard = (guard) => { - guardsRef.current.set(guard.tabName, guard); - return () => { - // Only clear if this exact guard is still registered, so a re-registration from another mount isn't wiped. - if (guardsRef.current.get(guard.tabName) !== guard) { - return; + isTabSwitchPendingRef.current = true; + + // Shared try/catch so neither branch can leave `isTabSwitchPendingRef` stuck `true` if the jump callback + // throws. No `finally`: the React Compiler (Babel) can't lower a `try` with a `finally` clause. + try { + // dismissKeyboardAndExecute only actually waits for the keyboard to close on Android; elsewhere it + // runs the callback immediately, so iOS/web use the dismiss()-based wait instead. + if (getPlatform() === CONST.PLATFORM.ANDROID) { + await KeyboardUtils.dismissKeyboardAndExecute(callback); + } else { + await dismissKeyboardBeforeTabSwitch(); + callback(); + } + } catch (error: unknown) { + Log.warn('[OnyxTabNavigator] Failed to switch tabs after dismissing the keyboard', {error}); } - guardsRef.current.delete(guard.tabName); + isTabSwitchPendingRef.current = false; }; - }; - - // A function, not a cached value: each call site checks the keyboard's current state, not one captured earlier. - // Gated on Keyboard.isVisible() rather than platform, so an opted-in caller also defers on iOS, not just Android. - const hasKeyboardToDismiss = () => shouldDismissKeyboardBeforeTabSwitch && Keyboard.isVisible(); - const runAfterKeyboardDismiss = async (callback: () => void) => { - if (!hasKeyboardToDismiss()) { - callback(); - return; - } - - isTabSwitchPendingRef.current = true; - - // Shared try/catch so neither branch can leave `isTabSwitchPendingRef` stuck `true` if the jump callback - // throws. No `finally`: the React Compiler (Babel) can't lower a `try` with a `finally` clause. - try { - // dismissKeyboardAndExecute only actually waits for the keyboard to close on Android; elsewhere it - // runs the callback immediately, so iOS/web use the dismiss()-based wait instead. - if (getPlatform() === CONST.PLATFORM.ANDROID) { - await KeyboardUtils.dismissKeyboardAndExecute(callback); - } else { - await dismissKeyboardBeforeTabSwitch(); - callback(); + // Records a tab switch that is about to be dispatched, so the `state` listener can re-apply it if it gets bounced back. + const armTabSwitch = (from: string | undefined, to: string | undefined) => { + if (!shouldReapplyInterruptedTabPress) { + return; } - } catch (error: unknown) { - Log.warn('[OnyxTabNavigator] Failed to switch tabs after dismissing the keyboard', {error}); - } - isTabSwitchPendingRef.current = false; - }; - - // Records a tab switch that is about to be dispatched, so the `state` listener can re-apply it if it gets bounced back. - const armTabSwitch = (from: string | undefined, to: string | undefined) => { - if (!shouldReapplyInterruptedTabPress) { - return; - } - // A newer switch supersedes a re-apply that hasn't been dispatched yet, otherwise the queued jump would override it. - cancelQueuedTabSwitch(reapplyFrameRef.current); - pressedTabRef.current = to && to !== from ? {from, to, armedAt: Date.now()} : undefined; - }; - - const reapplyTabSwitchIfInterrupted = (navigation: NavigationProp, newSelectedTab: string | undefined) => { - const pressedTab = pressedTabRef.current; - if (!pressedTab || !newSelectedTab) { - return; - } - const isSwitchInFlight = Date.now() - pressedTab.armedAt <= CONST.ANIMATED_TRANSITION; - if (!isSwitchInFlight || newSelectedTab !== pressedTab.to) { - pressedTabRef.current = undefined; - } - if (isSwitchInFlight && newSelectedTab === pressedTab.from) { + // A newer switch supersedes a re-apply that hasn't been dispatched yet, otherwise the queued jump would override it. cancelQueuedTabSwitch(reapplyFrameRef.current); - reapplyFrameRef.current = requestAnimationFrame(() => { - reapplyFrameRef.current = undefined; - navigation.dispatch(TabActions.jumpTo(pressedTab.to)); - }); - } - }; + pressedTabRef.current = to && to !== from ? {from, to, armedAt: Date.now()} : undefined; + }; - const handleTabPress = (navigation: NavigationProp, event: EventArg<'tabPress', true, undefined>) => { - if (isDiscardModalOpenRef.current || isTabSwitchPendingRef.current) { - event.preventDefault(); - return; - } - const navState = navigation.getState(); - const currentRouteName = navState.routes.at(navState.index)?.name; - const guard = currentRouteName ? guardsRef.current.get(currentRouteName) : undefined; - const targetRoute = navState.routes.find((tabRoute) => tabRoute.key === event.target); - if (!targetRoute || targetRoute.name === currentRouteName) { - return; - } - // The tab only changes once the keyboard is fully gone. A fire-and-forget dismiss loses the race: the tab - // switches mid hide-animation and the incoming tab lays out while the keyboard still occupies the screen. - const jumpToTargetTab = () => { - runAfterKeyboardDismiss(() => { - // This jump replaces the press we prevented above (both callers below call `preventDefault`), so it - // needs the same bounce-back protection the outer `tabPress` listener applies to a normal press. - armTabSwitch(currentRouteName, targetRoute.name); - navigation.dispatch(TabActions.jumpTo(targetRoute.name)); - }); + const reapplyTabSwitchIfInterrupted = (navigation: NavigationProp, newSelectedTab: string | undefined) => { + const pressedTab = pressedTabRef.current; + if (!pressedTab || !newSelectedTab) { + return; + } + const isSwitchInFlight = Date.now() - pressedTab.armedAt <= CONST.ANIMATED_TRANSITION; + if (!isSwitchInFlight || newSelectedTab !== pressedTab.to) { + pressedTabRef.current = undefined; + } + if (isSwitchInFlight && newSelectedTab === pressedTab.from) { + cancelQueuedTabSwitch(reapplyFrameRef.current); + reapplyFrameRef.current = requestAnimationFrame(() => { + reapplyFrameRef.current = undefined; + navigation.dispatch(TabActions.jumpTo(pressedTab.to)); + }); + } }; - if (!guard || !guard.getHasUnsavedChanges()) { - if (!hasKeyboardToDismiss()) { - // Nothing to wait for, so leave the jump to react-navigation and keep it synchronous. + + const handleTabPress = (navigation: NavigationProp, event: EventArg<'tabPress', true, undefined>) => { + if (isDiscardModalOpenRef.current || isTabSwitchPendingRef.current) { + event.preventDefault(); return; } - // No unsaved changes means no modal is shown, but the input can still be focused with the keyboard up. Take - // the jump over from react-navigation so it waits for the keyboard instead of switching straight away. - event.preventDefault(); - jumpToTargetTab(); - return; - } - event.preventDefault(); - isDiscardModalOpenRef.current = true; - ComposerFocusManager.blurActiveInput(); - showConfirmModal({ - ...getDiscardChangesModalConfig(translate), - shouldIgnoreBackHandlerDuringTransition: true, - }).then((result) => { - isDiscardModalOpenRef.current = false; - if (result.action !== ModalActions.CONFIRM) { - guard.onCancel?.(); + const navState = navigation.getState(); + const currentRouteName = navState.routes.at(navState.index)?.name; + const guard = currentRouteName ? guardsRef.current.get(currentRouteName) : undefined; + const targetRoute = navState.routes.find((tabRoute) => tabRoute.key === event.target); + if (!targetRoute || targetRoute.name === currentRouteName) { return; } - // User confirmed: always jump to the target tab, even if onDiscard fails, rather than stranding them with no feedback. - Promise.resolve() - .then(() => guard.onDiscard()) - .catch((error: unknown) => { - Log.warn('[OnyxTabNavigator] Failed to run tab-switch onDiscard callback', {error}); - Growl.error(translate('common.genericErrorMessage')); - }) - .then(() => { - jumpToTargetTab(); + // The tab only changes once the keyboard is fully gone. A fire-and-forget dismiss loses the race: the tab + // switches mid hide-animation and the incoming tab lays out while the keyboard still occupies the screen. + const jumpToTargetTab = () => { + runAfterKeyboardDismiss(() => { + // This jump replaces the press we prevented above (both callers below call `preventDefault`), so it + // needs the same bounce-back protection the outer `tabPress` listener applies to a normal press. + armTabSwitch(currentRouteName, targetRoute.name); + navigation.dispatch(TabActions.jumpTo(targetRoute.name)); }); - }); - }; + }; + if (!guard || !guard.getHasUnsavedChanges()) { + if (!hasKeyboardToDismiss()) { + // Nothing to wait for, so leave the jump to react-navigation and keep it synchronous. + return; + } + // No unsaved changes means no modal is shown, but the input can still be focused with the keyboard up. Take + // the jump over from react-navigation so it waits for the keyboard instead of switching straight away. + event.preventDefault(); + jumpToTargetTab(); + return; + } + event.preventDefault(); + isDiscardModalOpenRef.current = true; + ComposerFocusManager.blurActiveInput(); + showConfirmModal({ + ...getDiscardChangesModalConfig(translate), + shouldIgnoreBackHandlerDuringTransition: true, + }).then((result) => { + isDiscardModalOpenRef.current = false; + if (result.action !== ModalActions.CONFIRM) { + guard.onCancel?.(); + return; + } + // User confirmed: always jump to the target tab, even if onDiscard fails, rather than stranding them with no feedback. + Promise.resolve() + .then(() => guard.onDiscard()) + .catch((error: unknown) => { + Log.warn('[OnyxTabNavigator] Failed to run tab-switch onDiscard callback', {error}); + Growl.error(translate('common.genericErrorMessage')); + }) + .then(() => { + jumpToTargetTab(); + }); + }); + }; - /** - * This is a TabBar wrapper component that includes the focus trap container element callback. - * In `TabSelector.tsx` component, the callback prop to register focus trap container element is supported out of the box - */ - const TabBarWithFocusTrapInclusion = useCallback( - (props: TabSelectorProps) => { - return ( - - ); - }, - [TabBar, onTabBarFocusTrapContainerElementChanged, shouldShowLabelWhenInactive, equalWidth], - ); + /** + * This is a TabBar wrapper component that includes the focus trap container element callback. + * In `TabSelector.tsx` component, the callback prop to register focus trap container element is supported out of the box + */ + const TabBarWithFocusTrapInclusion = useCallback( + (props: TabSelectorProps) => { + return ( + + ); + }, + [TabBar, onTabBarFocusTrapContainerElementChanged, shouldShowLabelWhenInactive, equalWidth], + ); - // Keep the generic type casts outside the nested screenListeners callback because OXC cannot hoist - // type-parameter references while outlining that callback. - const persistSelectedTab = Tab.setSelectedTab as (tabID: string, tabName: string) => void; - const notifyTabSelected = onTabSelected as (newTabName: string | undefined) => void; + // Drop a queued re-apply jump on unmount so it isn't dispatched into a navigator that no longer exists. + useEffect(() => () => cancelQueuedTabSwitch(reapplyFrameRef.current), []); - // Drop a queued re-apply jump on unmount so it isn't dispatched into a navigator that no longer exists. - useEffect(() => () => cancelQueuedTabSwitch(reapplyFrameRef.current), []); + // If the selected tab changes, we need to update the focus trap container element of the active tab + useEffect(() => { + onActiveTabFocusTrapContainerElementChanged?.(selectedTab ? focusTrapContainerElementMapping[selectedTab] : null); + }, [selectedTab, focusTrapContainerElementMapping, onActiveTabFocusTrapContainerElementChanged]); - // If the selected tab changes, we need to update the focus trap container element of the active tab - useEffect(() => { - onActiveTabFocusTrapContainerElementChanged?.(selectedTab ? focusTrapContainerElementMapping[selectedTab] : null); - }, [selectedTab, focusTrapContainerElementMapping, onActiveTabFocusTrapContainerElementChanged]); + if (isLoadingOnyxValue(selectedTabResult)) { + return null; + } - if (isLoadingOnyxValue(selectedTabResult)) { - return null; + return ( + + + }) => { + const callerListeners = screenListeners ?? {}; + return { + ...callerListeners, + state: (e) => { + callerListeners.state?.(e); + const state = e.data.state; + const index = state.index; + const routeNames = state.routeNames; + if (isFirstMountRef.current) { + onTabSelect?.({index}); + isFirstMountRef.current = false; + } + const newSelectedTab = routeNames.at(index); + reapplyTabSwitchIfInterrupted(navigation, newSelectedTab); + if (!newSelectedTab || selectedTab === newSelectedTab) { + return; + } + Tab.setSelectedTab(id, newSelectedTab); + onTabSelected(newSelectedTab); + }, + tabPress: (e) => { + // Let a caller's own tabPress run first; if it blocked the switch, don't also run the guard. + callerListeners.tabPress?.(e); + if (e.defaultPrevented) { + return; + } + handleTabPress(navigation, e); + if (e.defaultPrevented) { + return; + } + const navState = navigation.getState(); + const pressedTabName = navState.routes.find((tabRoute) => tabRoute.key === e.target)?.name; + const currentTabName = navState.routes.at(navState.index)?.name; + armTabSwitch(currentTabName, pressedTabName); + }, + }; + }} + screenOptions={{ + ...defaultScreenOptions, + swipeEnabled: false, + lazy: lazyLoadEnabled, + lazyPlaceholder: LazyPlaceholder, + }} + > + {children} + + + + ); } - return ( - - - }) => { - const callerListeners = screenListeners ?? {}; - return { - ...callerListeners, - state: (e) => { - callerListeners.state?.(e); - const event = e as unknown as EventMapCore['state']; - const state = event.data.state; - const index = state.index; - const routeNames = state.routeNames; - if (isFirstMountRef.current) { - onTabSelect?.({index}); - isFirstMountRef.current = false; - } - const newSelectedTab = routeNames.at(index); - reapplyTabSwitchIfInterrupted(navigation, newSelectedTab); - if (selectedTab === newSelectedTab) { - return; - } - if (newSelectedTab) { - persistSelectedTab(id, newSelectedTab); - } - notifyTabSelected(newSelectedTab); - }, - tabPress: (e) => { - // Let a caller's own tabPress run first; if it blocked the switch, don't also run the guard. - callerListeners.tabPress?.(e); - if (e.defaultPrevented) { - return; - } - handleTabPress(navigation, e); - if (e.defaultPrevented) { - return; - } - const navState = navigation.getState(); - const pressedTabName = navState.routes.find((tabRoute) => tabRoute.key === e.target)?.name; - const currentTabName = navState.routes.at(navState.index)?.name; - armTabSwitch(currentTabName, pressedTabName); - }, - }; - }} - screenOptions={{ - ...defaultScreenOptions, - swipeEnabled: false, - lazy: lazyLoadEnabled, - lazyPlaceholder: LazyPlaceholder, - }} - > - {children} - - - - ); + return {OnyxTabNavigator, TopTab}; } +const {OnyxTabNavigator, TopTab} = createOnyxTabNavigator([ + ...Object.values(CONST.TAB_REQUEST), + CONST.TAB.NEW_CHAT, + CONST.TAB.NEW_ROOM, + ...Object.values(CONST.TAB.SPLIT), + CONST.TAB.SHARE.SHARE, + CONST.TAB.SHARE.SUBMIT, + CONST.TAB.RECEIPT_PARTNERS.ALL, + CONST.TAB.RECEIPT_PARTNERS.LINKED, + CONST.TAB.RECEIPT_PARTNERS.OUTSTANDING, +]); + /** * We should use this wrapper for each tab screen. This will help register the focus trap container element of each tab screen. * In the OnyxTabNavigator component, depending on the selected tab, we will further register the correct container element of the current active tab to the parent focus trap. @@ -470,4 +479,6 @@ function TabScreenWithFocusTrapWrapper({children}: {children?: React.ReactNode}) export default OnyxTabNavigator; -export {TabScreenWithFocusTrapWrapper, TopTab}; +export {createOnyxTabNavigator, TabScreenWithFocusTrapWrapper, TopTab}; + +export type {OnyxTabNavigatorProps}; diff --git a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx index 821007eb556a..c50b7a92b33a 100644 --- a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx @@ -1,7 +1,8 @@ import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {TransitionHandle} from '@libs/Navigation/TransitionTracker'; -import type {BottomTabNavigationOptions, BottomTabNavigationProp} from '@react-navigation/bottom-tabs'; +import type {BottomTabNavigationOptions} from '@react-navigation/bottom-tabs'; +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; import type {ParamListBase, ScreenLayoutArgs} from '@react-navigation/native'; import React, {useLayoutEffect, useRef} from 'react'; @@ -26,18 +27,18 @@ function screenLayoutWrapper({navigation, ...rest}: ScreenLayoutArgs>) { - return ( - - ); +// Same as screenLayoutWrapper above, but for bottom-tab navigators, JS and native. No cast needed here - their +// `navigation` props' `addListener` structurally satisfies TransitionAwareNavigation. +function bottomTabScreenLayoutWrapper(props: ScreenLayoutProps) { + return ; } -type ScreenLayoutProps = ScreenLayoutArgs; +type ScreenLayoutProps = ScreenLayoutArgs< + ParamListBase, + string, + PlatformSpecificNavigationOptions | PlatformStackNavigationOptions | BottomTabNavigationOptions | NativeBottomTabNavigationOptions, + TransitionAwareNavigation +>; function ScreenLayout({children, navigation}: ScreenLayoutProps) { const transitionHandleRef = useRef(null); diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/wrapDescriptorsWithNonTopScreensBehavior.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/wrapDescriptorsWithNonTopScreensBehavior.tsx index 386d78d00c47..74bb12405f96 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/wrapDescriptorsWithNonTopScreensBehavior.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/wrapDescriptorsWithNonTopScreensBehavior.tsx @@ -51,6 +51,7 @@ function wrapDescriptorsWithNonTopScreensBehavior( const NonTopScreenWrapper = WRAPPER_FOR_BEHAVIOR[behavior]; // The state always carries a top route, but a missing key must leave every screen visible instead of blurring the whole stack. const isCovered = topRouteKey !== undefined && key !== topRouteKey; + // A wide submit pre-mount keeps its wrapper, so the reveal does not remount it, but must render while covered. const isLivePreMount = isLiveWideTabPreMountRouteKey(key); const isScreenBlurred = isCovered && !isLivePreMount; diff --git a/src/libs/Navigation/guards/OnboardingGuard.ts b/src/libs/Navigation/guards/OnboardingGuard.ts index 7ea044ebcd20..1e0910101436 100644 --- a/src/libs/Navigation/guards/OnboardingGuard.ts +++ b/src/libs/Navigation/guards/OnboardingGuard.ts @@ -241,7 +241,6 @@ const OnboardingGuard: NavigationGuard = { const isMigratedUser = tryNewDot?.hasBeenAddedToNudgeMigration ?? false; const isSingleEntry = hybridApp?.isSingleNewDotEntry ?? false; const isFirstTimeHybridAppTransition = (CONFIG.IS_HYBRID_APP && tryNewDot?.isHybridAppOnboardingCompleted !== true) ?? false; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const isInvitedOrGroupMember = (hasNonPersonalPolicy || wasInvitedToNewDot) ?? false; const isNavigatingToJoinWorkspaceTaskRoute = isNavigatingToJoinWorkspaceTask(action); diff --git a/src/libs/Navigation/helpers/createNormalizedConfigs.ts b/src/libs/Navigation/helpers/createNormalizedConfigs.ts index ecf996fbda1a..f995760d98aa 100644 --- a/src/libs/Navigation/helpers/createNormalizedConfigs.ts +++ b/src/libs/Navigation/helpers/createNormalizedConfigs.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/default-param-last */ -/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ /* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ diff --git a/src/libs/Navigation/helpers/dynamicRoutesUtils/stripDynamicRouteSuffixFromPath.ts b/src/libs/Navigation/helpers/dynamicRoutesUtils/stripDynamicRouteSuffixFromPath.ts new file mode 100644 index 000000000000..40695c97ceee --- /dev/null +++ b/src/libs/Navigation/helpers/dynamicRoutesUtils/stripDynamicRouteSuffixFromPath.ts @@ -0,0 +1,28 @@ +import type {DynamicRouteSuffix, Route} from '@src/ROUTES'; + +import findAllMatchingDynamicSuffixes from './findAllMatchingDynamicSuffixes'; +import getPathWithoutDynamicSuffix from './getPathWithoutDynamicSuffix'; + +/** + * Removes the given dynamic suffix from the end of a path, if present. + * + * Supported suffix types: static (`/edit`), parametric (`/:reportID`), + * and parametric with optional params (`/:reportID/:reportActionID?`). + * + * If the suffix doesn't match the tail of the path, returns the path as-is. + * + * @param path - The full path, with or without a leading slash. + * @param dynamicRouteSuffix - The dynamic route pattern to remove from the end of the path. + * @returns The path without the given dynamic suffix. + */ +function stripDynamicRouteSuffixFromPath(path: string, dynamicRouteSuffix: DynamicRouteSuffix): Route { + const pathWithoutLeadingSlash = path.replaceAll(/^\/+/g, ''); + const match = findAllMatchingDynamicSuffixes(pathWithoutLeadingSlash).find((m) => m.pattern === dynamicRouteSuffix); + if (match) { + return getPathWithoutDynamicSuffix(match.pathUsedForMatching, match.actualSuffix, match.pattern); + } + + return pathWithoutLeadingSlash as Route; +} + +export default stripDynamicRouteSuffixFromPath; diff --git a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts index 2c77dccd00b0..a82e4d144761 100644 --- a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts @@ -1,38 +1,20 @@ import getInitialSplitNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; import TAB_SCREENS from '@libs/Navigation/AppNavigator/Navigators/TAB_SCREENS'; -import { - RHP_TO_DOMAIN, - RHP_TO_HOME, - RHP_TO_SEARCH, - RHP_TO_SEARCH_DEEPLINK, - RHP_TO_SETTINGS, - RHP_TO_SIDEBAR, - RHP_TO_WORKSPACE, - RHP_TO_WORKSPACES_LIST, -} from '@libs/Navigation/linkingConfig/RELATIONS'; import type {NavigationPartialRoute, NavigationRoute, RootNavigatorParamList} from '@libs/Navigation/types'; import {getReportOrDraftReport} from '@libs/ReportUtils'; -import {getSearchParamFromPath} from '@libs/Url'; import NAVIGATORS from '@src/NAVIGATORS'; import type {Route as RoutePath} from '@src/ROUTES'; -import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Screen} from '@src/SCREENS'; import type {NavigationState, PartialState, getStateFromPath as RNGetStateFromPath, Route} from '@react-navigation/native'; -import pick from 'lodash/pick'; - -import buildTabNavigatorNestedState from './buildTabNavigatorNestedState'; -import findAllMatchingDynamicSuffixes from './dynamicRoutesUtils/findAllMatchingDynamicSuffixes'; import getDynamicRouteAdaptedState from './dynamicRoutesUtils/getDynamicRouteAdaptedState'; -import getPathWithoutDynamicSuffix from './dynamicRoutesUtils/getPathWithoutDynamicSuffix'; import isDynamicRouteScreen from './dynamicRoutesUtils/isDynamicRouteScreen'; import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard'; -import getDefaultDeeplinkSearchQuery from './getDefaultDeeplinkSearchQuery'; +import getMatchingFullScreenRoute, {getRoutesWithIndex, getTabNavigatorState} from './getMatchingFullScreenRoute'; import getMatchingNewRoute from './getMatchingNewRoute'; -import getParamsFromRoute from './getParamsFromRoute'; import getStateFromPath from './getStateFromPath'; import {isFullScreenName} from './isNavigatorName'; import normalizePath from './normalizePath'; @@ -42,9 +24,6 @@ type GetAdaptedStateReturnType = ReturnType; type GetAdaptedStateFromPath = (...args: [...Parameters, shouldReplacePathInNestedState?: boolean]) => GetAdaptedStateReturnType; -// The function getPathFromState that we are using in some places isn't working correctly without defined index. -const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState => ({routes, index: routes.length - 1}); - /** * Standalone full-screen public pages registered in PublicScreens (unauthenticated navigator) that * should NOT have TabNavigator prepended — they render on their own, with no tab navigator underneath. @@ -61,221 +40,10 @@ const PUBLIC_SCREENS = new Set([ SCREENS.SAML_SIGN_IN, ]); -/** - * Builds TabNavigator state with all tabs and the correct selected tab. - * Tab navigators require all routes in the state for proper rendering. - */ -function getTabNavigatorState(selectedTabRoute: NavigationPartialRoute): NavigationPartialRoute { - return { - name: NAVIGATORS.TAB_NAVIGATOR, - state: buildTabNavigatorNestedState(selectedTabRoute), - }; -} - -function isRouteWithBackToParam(route: NavigationRoute): route is Route { - return route.params !== undefined && 'backTo' in route.params && typeof route.params.backTo === 'string'; -} - function isRouteWithReportID(route: NavigationRoute): route is Route { return route.params !== undefined && 'reportID' in route.params && typeof route.params.reportID === 'string'; } -/** - * @param route - The (focused) route to find a full screen route for. - * @param isDeeplink - Whether the state is being built from a path (deeplink / browser refresh / cold - * load) as opposed to in-app navigation. When true, deeplink-only relations (e.g. RHP_TO_SEARCH_DEEPLINK) - * are also considered so an RHP can get a sensible default fullscreen underneath it. In-app callers - * (linkTo, swapBackgroundTabForRHPTarget) leave this false so the same RHP can open over any fullscreen - * without changing the page currently underneath. - */ -function getMatchingFullScreenRoute(route: NavigationRoute, isDeeplink = false) { - const isDynamicScreen = isDynamicRouteScreen(route.name as Screen); - - // Check for backTo param. One screen with different backTo value may need different screens visible under the overlay. - // Dynamic screens are skipped here because they never carry their own backTo - they only - // inherit it from the screen underneath. Letting backTo dictate the full-screen route for - // a dynamic screen would resolve the wrong page. - if (isRouteWithBackToParam(route) && !isDynamicScreen) { - const stateForBackTo = getStateFromPath(route.params.backTo as RoutePath); - - // This may happen if the backTo url is invalid. - const lastRoute = stateForBackTo?.routes.at(-1); - if (!stateForBackTo || !lastRoute || lastRoute.name === SCREENS.NOT_FOUND) { - return undefined; - } - - const isLastRouteFullScreen = isFullScreenName(lastRoute.name); - - // If the state for back to last route is a full screen route, we can use it - if (isLastRouteFullScreen) { - return lastRoute; - } - - const focusedStateForBackToRoute = findFocusedRouteWithOnyxTabGuard(stateForBackTo); - - if (!focusedStateForBackToRoute) { - return undefined; - } - // If not, get the matching full screen route for the back to state. - return getMatchingFullScreenRoute(focusedStateForBackToRoute, isDeeplink); - } - - // Deeplink-only relations provide a default search screen underneath the RHP when the state is - // built from a path. They are ignored for in-app navigation so the RHP can open over any fullscreen. - const matchingSearchScreen = RHP_TO_SEARCH[route.name]; - const matchingDeeplinkSearchScreen = isDeeplink ? RHP_TO_SEARCH_DEEPLINK[route.name] : undefined; - const resolvedSearchScreen = matchingSearchScreen ?? matchingDeeplinkSearchScreen; - if (resolvedSearchScreen) { - const paramsFromRoute = getParamsFromRoute(resolvedSearchScreen); - const copiedParams = paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : {}; - let queryParam: Record = {}; - if (route.path) { - const query = getSearchParamFromPath(route.path, 'q'); - if (query) { - queryParam = {q: query}; - } - } - - // Deeplink-only defaults (e.g. create flows) land on SCREENS.SEARCH.ROOT, which carries no path - // params and no `q`. Without an explicit query, SearchQueryProvider falls back to the previous - // defined query and can reveal a stale search when the RHP closes. Seed the canned expenses query - // so these deeplinks resolve to Spend > Expenses. - if (!queryParam.q && !matchingSearchScreen && matchingDeeplinkSearchScreen) { - queryParam = {q: getDefaultDeeplinkSearchQuery()}; - } - - const searchRoute = { - name: resolvedSearchScreen, - params: Object.keys({...copiedParams, ...queryParam}).length > 0 ? {...copiedParams, ...queryParam} : undefined, - }; - const searchState = getRoutesWithIndex([searchRoute]); - return getTabNavigatorState({name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, state: searchState}); - } - - if (RHP_TO_HOME[route.name]) { - return { - ...getTabNavigatorState({name: SCREENS.HOME}), - path: normalizePath(ROUTES.HOME), - }; - } - - if (RHP_TO_WORKSPACES_LIST[route.name]) { - return getTabNavigatorState({ - name: NAVIGATORS.WORKSPACE_NAVIGATOR, - state: getRoutesWithIndex([ - { - name: SCREENS.WORKSPACES_LIST, - // prepending a slash to ensure closing the RHP after refreshing the page - // replaces the whole path with "/workspaces", instead of just replacing the last url segment ("/x/y/workspaces") - path: normalizePath(ROUTES.WORKSPACES_LIST.route), - }, - ]), - }); - } - - if (RHP_TO_WORKSPACE[route.name]) { - const paramsFromRoute = getParamsFromRoute(RHP_TO_WORKSPACE[route.name]); - - const workspaceSplitRoute = getInitialSplitNavigatorState( - { - name: SCREENS.WORKSPACE.INITIAL, - params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, - }, - { - name: RHP_TO_WORKSPACE[route.name], - params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, - }, - ); - - return getTabNavigatorState({ - name: NAVIGATORS.WORKSPACE_NAVIGATOR, - state: getRoutesWithIndex([{name: SCREENS.WORKSPACES_LIST}, workspaceSplitRoute]), - }); - } - - if (RHP_TO_SETTINGS[route.name]) { - const paramsFromRoute = getParamsFromRoute(RHP_TO_SETTINGS[route.name]); - - const settingsState = getInitialSplitNavigatorState( - {name: SCREENS.SETTINGS.ROOT}, - { - name: RHP_TO_SETTINGS[route.name], - params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, - }, - ); - return getTabNavigatorState(settingsState); - } - - if (RHP_TO_DOMAIN[route.name]) { - const paramsFromRoute = getParamsFromRoute(RHP_TO_DOMAIN[route.name]); - - const domainSplitRoute = getInitialSplitNavigatorState( - { - name: SCREENS.DOMAIN.INITIAL, - params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, - }, - { - name: RHP_TO_DOMAIN[route.name], - params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, - }, - ); - - return getTabNavigatorState({ - name: NAVIGATORS.WORKSPACE_NAVIGATOR, - state: getRoutesWithIndex([{name: SCREENS.WORKSPACES_LIST}, domainSplitRoute]), - }); - } - - // Fallback: if no specific central screen RELATION matched, check if the RHP screen - // maps to a sidebar. This shows the split navigator with just the sidebar (no central screen). - if (RHP_TO_SIDEBAR[route.name]) { - const splitState = getInitialSplitNavigatorState({ - name: RHP_TO_SIDEBAR[route.name], - }); - return getTabNavigatorState(splitState); - } - - // Handle dynamic routes: find the appropriate full screen route. - // Iterate all candidates so that a false-positive first match (e.g. a tag named "gl-code" - // colliding with the registered static suffix) does not produce a NOT_FOUND state. - if (route.path) { - const allSuffixMatches = findAllMatchingDynamicSuffixes(route.path); - for (const suffixMatch of allSuffixMatches) { - const pathWithoutDynamicSuffix = getPathWithoutDynamicSuffix(suffixMatch.pathUsedForMatching, suffixMatch.actualSuffix, suffixMatch.pattern); - - if (!pathWithoutDynamicSuffix) { - continue; - } - - // Parse the base path (without dynamic suffix) into a navigation state - // to determine which full-screen route should be visible underneath the overlay. - const stateUnderDynamicRoute = getStateFromPath(pathWithoutDynamicSuffix); - const lastRoute = stateUnderDynamicRoute?.routes.at(-1); - - if (!stateUnderDynamicRoute || !lastRoute || lastRoute.name === SCREENS.NOT_FOUND) { - continue; - } - - const isLastRouteFullScreen = isFullScreenName(lastRoute.name); - - if (isLastRouteFullScreen) { - return lastRoute; - } - - const focusedRouteUnderDynamicRoute = findFocusedRouteWithOnyxTabGuard(stateUnderDynamicRoute); - - if (!focusedRouteUnderDynamicRoute) { - continue; - } - - // Recursively find the matching full screen route for the focused dynamic route - return getMatchingFullScreenRoute(focusedRouteUnderDynamicRoute, isDeeplink); - } - } - - return undefined; -} - // If there is no particular matching route defined, we want to get the default route. // It is the reports split navigator with report. If the reportID is defined in the focused route, we want to use it for the default report. // This is separated from getMatchingFullScreenRoute because we want to use it only for the initial state. @@ -445,4 +213,3 @@ const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldR }; export default getAdaptedStateFromPath; -export {getMatchingFullScreenRoute, isFullScreenName}; diff --git a/src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts b/src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts new file mode 100644 index 000000000000..10ee6caa4cad --- /dev/null +++ b/src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts @@ -0,0 +1,258 @@ +/** + * Finds the full screen route to render under a given route. + * Split from getAdaptedStateFromPath so linkTo and Navigation can import it without pulling in ReportUtils. + * Do not import ReportUtils or anything that imports Navigation here, or Navigation rejoins the import/no-cycle graph. + */ +import getInitialSplitNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; +import { + RHP_TO_DOMAIN, + RHP_TO_HOME, + RHP_TO_SEARCH, + RHP_TO_SEARCH_DEEPLINK, + RHP_TO_SETTINGS, + RHP_TO_SIDEBAR, + RHP_TO_WORKSPACE, + RHP_TO_WORKSPACES_LIST, +} from '@libs/Navigation/linkingConfig/RELATIONS'; +import type {NavigationPartialRoute, NavigationRoute} from '@libs/Navigation/types'; +import {getSearchParamFromPath} from '@libs/Url'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import type {Route as RoutePath} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type {Screen} from '@src/SCREENS'; + +import type {NavigationState, PartialState, Route} from '@react-navigation/native'; + +import pick from 'lodash/pick'; + +import buildTabNavigatorNestedState from './buildTabNavigatorNestedState'; +import findAllMatchingDynamicSuffixes from './dynamicRoutesUtils/findAllMatchingDynamicSuffixes'; +import getPathWithoutDynamicSuffix from './dynamicRoutesUtils/getPathWithoutDynamicSuffix'; +import isDynamicRouteScreen from './dynamicRoutesUtils/isDynamicRouteScreen'; +import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard'; +import getDefaultDeeplinkSearchQuery from './getDefaultDeeplinkSearchQuery'; +import getParamsFromRoute from './getParamsFromRoute'; +import getStateFromPath from './getStateFromPath'; +import {isFullScreenName} from './isNavigatorName'; +import normalizePath from './normalizePath'; + +// The function getPathFromState that we are using in some places isn't working correctly without defined index. +const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState => ({routes, index: routes.length - 1}); + +/** + * Builds TabNavigator state with all tabs and the correct selected tab. + * Tab navigators require all routes in the state for proper rendering. + */ +function getTabNavigatorState(selectedTabRoute: NavigationPartialRoute): NavigationPartialRoute { + return { + name: NAVIGATORS.TAB_NAVIGATOR, + state: buildTabNavigatorNestedState(selectedTabRoute), + }; +} + +function isRouteWithBackToParam(route: NavigationRoute): route is Route { + return route.params !== undefined && 'backTo' in route.params && typeof route.params.backTo === 'string'; +} + +/** + * @param route - The (focused) route to find a full screen route for. + * @param isDeeplink - Whether the state is being built from a path (deeplink / browser refresh / cold + * load) as opposed to in-app navigation. When true, deeplink-only relations (e.g. RHP_TO_SEARCH_DEEPLINK) + * are also considered so an RHP can get a sensible default fullscreen underneath it. In-app callers + * (linkTo, swapBackgroundTabForRHPTarget) leave this false so the same RHP can open over any fullscreen + * without changing the page currently underneath. + */ +function getMatchingFullScreenRoute(route: NavigationRoute, isDeeplink = false) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- route names in navigation state are typed as string + const isDynamicScreen = isDynamicRouteScreen(route.name as Screen); + + // Check for backTo param. One screen with different backTo value may need different screens visible under the overlay. + // Dynamic screens are skipped here because they never carry their own backTo - they only + // inherit it from the screen underneath. Letting backTo dictate the full-screen route for + // a dynamic screen would resolve the wrong page. + if (isRouteWithBackToParam(route) && !isDynamicScreen) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- backTo is a path string read from route params + const stateForBackTo = getStateFromPath(route.params.backTo as RoutePath); + + // This may happen if the backTo url is invalid. + const lastRoute = stateForBackTo?.routes.at(-1); + if (!stateForBackTo || !lastRoute || lastRoute.name === SCREENS.NOT_FOUND) { + return undefined; + } + + const isLastRouteFullScreen = isFullScreenName(lastRoute.name); + + // If the state for back to last route is a full screen route, we can use it + if (isLastRouteFullScreen) { + return lastRoute; + } + + const focusedStateForBackToRoute = findFocusedRouteWithOnyxTabGuard(stateForBackTo); + + if (!focusedStateForBackToRoute) { + return undefined; + } + // If not, get the matching full screen route for the back to state. + return getMatchingFullScreenRoute(focusedStateForBackToRoute, isDeeplink); + } + + // Deeplink-only relations provide a default search screen underneath the RHP when the state is + // built from a path. They are ignored for in-app navigation so the RHP can open over any fullscreen. + const matchingSearchScreen = RHP_TO_SEARCH[route.name]; + const matchingDeeplinkSearchScreen = isDeeplink ? RHP_TO_SEARCH_DEEPLINK[route.name] : undefined; + const resolvedSearchScreen = matchingSearchScreen ?? matchingDeeplinkSearchScreen; + if (resolvedSearchScreen) { + const paramsFromRoute = getParamsFromRoute(resolvedSearchScreen); + const copiedParams = paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : {}; + let queryParam: Record = {}; + if (route.path) { + const query = getSearchParamFromPath(route.path, 'q'); + if (query) { + queryParam = {q: query}; + } + } + + // Deeplink-only defaults (e.g. create flows) land on SCREENS.SEARCH.ROOT, which carries no path + // params and no `q`. Without an explicit query, SearchQueryProvider falls back to the previous + // defined query and can reveal a stale search when the RHP closes. Seed the canned expenses query + // so these deeplinks resolve to Spend > Expenses. + if (!queryParam.q && !matchingSearchScreen && matchingDeeplinkSearchScreen) { + queryParam = {q: getDefaultDeeplinkSearchQuery()}; + } + + const searchRoute = { + name: resolvedSearchScreen, + params: Object.keys({...copiedParams, ...queryParam}).length > 0 ? {...copiedParams, ...queryParam} : undefined, + }; + const searchState = getRoutesWithIndex([searchRoute]); + return getTabNavigatorState({name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, state: searchState}); + } + + if (RHP_TO_HOME[route.name]) { + return { + ...getTabNavigatorState({name: SCREENS.HOME}), + path: normalizePath(ROUTES.HOME), + }; + } + + if (RHP_TO_WORKSPACES_LIST[route.name]) { + return getTabNavigatorState({ + name: NAVIGATORS.WORKSPACE_NAVIGATOR, + state: getRoutesWithIndex([ + { + name: SCREENS.WORKSPACES_LIST, + // prepending a slash to ensure closing the RHP after refreshing the page + // replaces the whole path with "/workspaces", instead of just replacing the last url segment ("/x/y/workspaces") + path: normalizePath(ROUTES.WORKSPACES_LIST.route), + }, + ]), + }); + } + + if (RHP_TO_WORKSPACE[route.name]) { + const paramsFromRoute = getParamsFromRoute(RHP_TO_WORKSPACE[route.name]); + + const workspaceSplitRoute = getInitialSplitNavigatorState( + { + name: SCREENS.WORKSPACE.INITIAL, + params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, + }, + { + name: RHP_TO_WORKSPACE[route.name], + params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, + }, + ); + + return getTabNavigatorState({ + name: NAVIGATORS.WORKSPACE_NAVIGATOR, + state: getRoutesWithIndex([{name: SCREENS.WORKSPACES_LIST}, workspaceSplitRoute]), + }); + } + + if (RHP_TO_SETTINGS[route.name]) { + const paramsFromRoute = getParamsFromRoute(RHP_TO_SETTINGS[route.name]); + + const settingsState = getInitialSplitNavigatorState( + {name: SCREENS.SETTINGS.ROOT}, + { + name: RHP_TO_SETTINGS[route.name], + params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, + }, + ); + return getTabNavigatorState(settingsState); + } + + if (RHP_TO_DOMAIN[route.name]) { + const paramsFromRoute = getParamsFromRoute(RHP_TO_DOMAIN[route.name]); + + const domainSplitRoute = getInitialSplitNavigatorState( + { + name: SCREENS.DOMAIN.INITIAL, + params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, + }, + { + name: RHP_TO_DOMAIN[route.name], + params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, + }, + ); + + return getTabNavigatorState({ + name: NAVIGATORS.WORKSPACE_NAVIGATOR, + state: getRoutesWithIndex([{name: SCREENS.WORKSPACES_LIST}, domainSplitRoute]), + }); + } + + // Fallback: if no specific central screen RELATION matched, check if the RHP screen + // maps to a sidebar. This shows the split navigator with just the sidebar (no central screen). + if (RHP_TO_SIDEBAR[route.name]) { + const splitState = getInitialSplitNavigatorState({ + name: RHP_TO_SIDEBAR[route.name], + }); + return getTabNavigatorState(splitState); + } + + // Handle dynamic routes: find the appropriate full screen route. + // Iterate all candidates so that a false-positive first match (e.g. a tag named "gl-code" + // colliding with the registered static suffix) does not produce a NOT_FOUND state. + if (route.path) { + const allSuffixMatches = findAllMatchingDynamicSuffixes(route.path); + for (const suffixMatch of allSuffixMatches) { + const pathWithoutDynamicSuffix = getPathWithoutDynamicSuffix(suffixMatch.pathUsedForMatching, suffixMatch.actualSuffix, suffixMatch.pattern); + + if (!pathWithoutDynamicSuffix) { + continue; + } + + // Parse the base path (without dynamic suffix) into a navigation state + // to determine which full-screen route should be visible underneath the overlay. + const stateUnderDynamicRoute = getStateFromPath(pathWithoutDynamicSuffix); + const lastRoute = stateUnderDynamicRoute?.routes.at(-1); + + if (!stateUnderDynamicRoute || !lastRoute || lastRoute.name === SCREENS.NOT_FOUND) { + continue; + } + + const isLastRouteFullScreen = isFullScreenName(lastRoute.name); + + if (isLastRouteFullScreen) { + return lastRoute; + } + + const focusedRouteUnderDynamicRoute = findFocusedRouteWithOnyxTabGuard(stateUnderDynamicRoute); + + if (!focusedRouteUnderDynamicRoute) { + continue; + } + + // Recursively find the matching full screen route for the focused dynamic route + return getMatchingFullScreenRoute(focusedRouteUnderDynamicRoute, isDeeplink); + } + } + + return undefined; +} + +export default getMatchingFullScreenRoute; +export {getRoutesWithIndex, getTabNavigatorState}; diff --git a/src/libs/Navigation/helpers/getReportURLForCurrentContext.ts b/src/libs/Navigation/helpers/getReportURLForCurrentContext.ts index 45679b29acb8..ec2670b08a09 100644 --- a/src/libs/Navigation/helpers/getReportURLForCurrentContext.ts +++ b/src/libs/Navigation/helpers/getReportURLForCurrentContext.ts @@ -8,6 +8,9 @@ import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute'; let environmentURL: string; getEnvironmentURL().then((url: string) => (environmentURL = url)); +// Static part of the search report path (everything before the first route param), used to detect sibling search-report routes. +const SEARCH_REPORT_ROUTE_PREFIX = ROUTES.SEARCH_REPORT.route.slice(0, ROUTES.SEARCH_REPORT.route.indexOf(':')); + /** * Generates a URL for a report that respects the current navigation context. * When in a search context, includes backTo parameter to preserve search state. @@ -50,6 +53,12 @@ function getReportURLForCurrentContext(reportID: string | undefined): string { } } + // When paging between sibling expenses in the RHP, the stored backTo points at the previous expense. + // Return to the expense currently shown instead, so going back reopens the same report rather than a sibling. + if (backToRoute?.startsWith(SEARCH_REPORT_ROUTE_PREFIX) && activeRoute.startsWith(SEARCH_REPORT_ROUTE_PREFIX)) { + backToRoute = activeRoute; + } + if (!backToRoute && activeRoute.startsWith(ROUTES.SEARCH_ROOT.route)) { // Otherwise keep the current search route (preserves tab + filters) as the return target. backToRoute = activeRoute; diff --git a/src/libs/Navigation/helpers/goBackFromExportConnection.ts b/src/libs/Navigation/helpers/goBackFromExportConnection.ts index 3ff96abf65d0..3196f9fe68bf 100644 --- a/src/libs/Navigation/helpers/goBackFromExportConnection.ts +++ b/src/libs/Navigation/helpers/goBackFromExportConnection.ts @@ -1,22 +1,19 @@ import Navigation from '@libs/Navigation/Navigation'; -import CONST from '@src/CONST'; import type {Route} from '@src/ROUTES'; import replaceCompanyCardsRoute from './replaceCompanyCardsRoute'; /** - * If export company card value is changed to unsupported - we should redirect user directly to card details view + * If the card export value is changed to an unsupported type - we should redirect user directly to card details view * If not, just regular go back */ function goBackFromExportConnection(shouldGoBackToSpecificRoute: boolean, dynamicBackPath?: Route) { - const feature = CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards; - - if (!(shouldGoBackToSpecificRoute && dynamicBackPath?.includes(feature.alias))) { + if (!shouldGoBackToSpecificRoute || !dynamicBackPath) { return Navigation.goBack(dynamicBackPath); } - const companyCardDetailsPage = replaceCompanyCardsRoute(dynamicBackPath); - return Navigation.goBack(companyCardDetailsPage, {compareParams: false}); + const cardDetailsPage = replaceCompanyCardsRoute(dynamicBackPath); + return Navigation.goBack(cardDetailsPage, {compareParams: false}); } export default goBackFromExportConnection; diff --git a/src/libs/Navigation/helpers/isNavigatorName.ts b/src/libs/Navigation/helpers/isNavigatorName.ts index 80914b3a5d22..fd119bca8414 100644 --- a/src/libs/Navigation/helpers/isNavigatorName.ts +++ b/src/libs/Navigation/helpers/isNavigatorName.ts @@ -20,7 +20,6 @@ const MODAL_NAVIGATORS_SET = new Set([ NAVIGATORS.FEATURE_TRAINING_MODAL_NAVIGATOR, NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR, NAVIGATORS.SUBMIT_PLAN_MODAL_NAVIGATOR, - NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR, NAVIGATORS.TEST_DRIVE_DEMO_NAVIGATOR, NAVIGATORS.SHARE_MODAL_NAVIGATOR, NAVIGATORS.TEST_TOOLS_MODAL_NAVIGATOR, diff --git a/src/libs/Navigation/helpers/linkTo/index.ts b/src/libs/Navigation/helpers/linkTo/index.ts index 7bafdef8d723..398a68898e57 100644 --- a/src/libs/Navigation/helpers/linkTo/index.ts +++ b/src/libs/Navigation/helpers/linkTo/index.ts @@ -1,8 +1,9 @@ -import {getMatchingFullScreenRoute, isFullScreenName} from '@libs/Navigation/helpers/getAdaptedStateFromPath'; +import getMatchingFullScreenRoute from '@libs/Navigation/helpers/getMatchingFullScreenRoute'; import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; +import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import normalizePath from '@libs/Navigation/helpers/normalizePath'; import {getTabState} from '@libs/Navigation/helpers/tabNavigatorUtils'; -import {linkingConfig} from '@libs/Navigation/linkingConfig'; +import {config as linkingConfig} from '@libs/Navigation/linkingConfig/config'; import type {PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types'; import {shallowCompare} from '@libs/ObjectUtils'; @@ -204,7 +205,7 @@ export default function linkTo(navigation: NavigationContainerRef { - return route?.replaceAll(/\/edit\/export$/g, '') as Routes; + const strippedPath = stripDynamicRouteSuffixFromPath(route, DYNAMIC_ROUTES.WORKSPACE_COMPANY_CARD_EXPORT.path); + return strippedPath.replaceAll(/\/edit\/export$/g, '') as Routes; }; + export default replaceCompanyCardsRoute; diff --git a/src/libs/Navigation/helpers/swapBackgroundTabForRHPTarget.ts b/src/libs/Navigation/helpers/swapBackgroundTabForRHPTarget.ts index 3ba025bb76e3..8acde0b1567e 100644 --- a/src/libs/Navigation/helpers/swapBackgroundTabForRHPTarget.ts +++ b/src/libs/Navigation/helpers/swapBackgroundTabForRHPTarget.ts @@ -10,7 +10,7 @@ import type {NavigationState} from '@react-navigation/native'; import {findFocusedRoute} from '@react-navigation/native'; -import {getMatchingFullScreenRoute} from './getAdaptedStateFromPath'; +import getMatchingFullScreenRoute from './getMatchingFullScreenRoute'; import getStateFromPath from './getStateFromPath'; import {getMatchingFullScreenRouteParams, shouldChangeToMatchingFullScreen} from './linkTo'; import {getTabState} from './tabNavigatorUtils'; @@ -38,7 +38,7 @@ function getCrossTabContext(currentState: NavigationState | undefined, route: Ro } const matchingTabNavigatorRoute = getMatchingFullScreenRoute(targetFocusedRoute); - const matchingTabState = matchingTabNavigatorRoute ? getTabState(matchingTabNavigatorRoute as NavigationPartialRoute) : undefined; + const matchingTabState = matchingTabNavigatorRoute ? getTabState(matchingTabNavigatorRoute) : undefined; const matchingFullScreenRoute = matchingTabState?.routes?.at(matchingTabState.index ?? 0) as NavigationPartialRoute | undefined; const tabRoute = currentState.routes.findLast((r) => r.name === NAVIGATORS.TAB_NAVIGATOR); diff --git a/src/libs/Navigation/helpers/tabNavigatorUtils.ts b/src/libs/Navigation/helpers/tabNavigatorUtils.ts index ce0b0e5702ff..9ea91afa468e 100644 --- a/src/libs/Navigation/helpers/tabNavigatorUtils.ts +++ b/src/libs/Navigation/helpers/tabNavigatorUtils.ts @@ -46,11 +46,16 @@ function isReportsTabPreloaded(rootState: RootNavigationState): boolean { return !!reportsSplitRouteKey && !!tabState?.preloadedRouteKeys?.includes(reportsSplitRouteKey); } +function getFocusedTabRouteName(rootState: RootNavigationState): string | undefined { + const tabState = getTabNavigatorState(rootState); + return tabState?.index === undefined ? undefined : tabState.routes.at(tabState.index)?.name; +} + /** Undefined when preloading would be pure cost: a preloaded key drops `shouldFreeze`, with nothing to warm in return. */ function getReportsTabPreloadTarget(rootState: RootNavigationState): string | undefined { const tabState = getTabNavigatorState(rootState); const reportsSplitRoute = tabState?.routes.findLast((route) => route.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR); - const focusedRouteName = tabState?.index === undefined ? undefined : tabState.routes.at(tabState.index)?.name; + const focusedRouteName = getFocusedTabRouteName(rootState); if (!tabState?.key || !reportsSplitRoute || reportsSplitRoute.state || focusedRouteName === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR) { return undefined; @@ -70,4 +75,14 @@ function getTabScreenParam(route: NavigationPartialRoute | {name: string; params return undefined; } -export {getTabState, getTabScreenParam, getTabNavigatorRoute, getTabNavigatorState, getTabNavigatorStateKey, getReportsTabPreloadTarget, isReportsTabPreloaded, isTabRoutePreloaded}; +export { + getFocusedTabRouteName, + getTabState, + getTabScreenParam, + getTabNavigatorRoute, + getTabNavigatorState, + getTabNavigatorStateKey, + getReportsTabPreloadTarget, + isReportsTabPreloaded, + isTabRoutePreloaded, +}; diff --git a/src/libs/Navigation/helpers/wideTabPreMountRouteKey.ts b/src/libs/Navigation/helpers/wideTabPreMountRouteKey.ts index a9fde0861e07..2a72c3cf1553 100644 --- a/src/libs/Navigation/helpers/wideTabPreMountRouteKey.ts +++ b/src/libs/Navigation/helpers/wideTabPreMountRouteKey.ts @@ -5,6 +5,15 @@ let liveRouteKey: string | undefined; // Tab route holding the live pre-mount. Background tabs are frozen on web, so this one must stay rendered to mount the screen. let liveTabRouteKey: string | undefined; +const ROUTE_KEY_INFIX = '-wide-pre-mount-'; + +// A revealed pre-mount becomes a regular screen but keeps its key, so it must never count as stale. +const revealedRouteKeys = new Set(); + +// Tab route keys a pre-mount marked preloaded, so a saved browser history entry cannot keep a covered tab unfrozen forever. +const preloadedRouteKeysAddedByPreMount = new Set(); +let livePreloadedRouteKey: string | undefined; + function setLiveWideTabPreMountRouteKey(key: string | undefined) { liveRouteKey = key; } @@ -21,4 +30,26 @@ function isLiveWideTabPreMountTabRouteKey(key: string | undefined): boolean { return !!key && key === liveTabRouteKey; } -export {setLiveWideTabPreMountRouteKey, isLiveWideTabPreMountRouteKey, setLiveWideTabPreMountTabRouteKey, isLiveWideTabPreMountTabRouteKey}; +function markWideTabPreMountRouteKeyRevealed(key: string) { + revealedRouteKeys.add(key); +} + +/** True for a pre-mount that was neither revealed nor still live, e.g. restored from a saved browser history entry. */ +function isStaleWideTabPreMountRouteKey(key: string | undefined): boolean { + return !!key && key.includes(ROUTE_KEY_INFIX) && key !== liveRouteKey && !revealedRouteKeys.has(key); +} + +/** True for a tab preload that an earlier pre-mount added and the live one does not own. */ +function isStaleWideTabPreMountPreloadedRouteKey(key: string): boolean { + return preloadedRouteKeysAddedByPreMount.has(key) && key !== livePreloadedRouteKey; +} + +export { + setLiveWideTabPreMountRouteKey, + isLiveWideTabPreMountRouteKey, + setLiveWideTabPreMountTabRouteKey, + isLiveWideTabPreMountTabRouteKey, + markWideTabPreMountRouteKeyRevealed, + isStaleWideTabPreMountRouteKey, + isStaleWideTabPreMountPreloadedRouteKey, +}; diff --git a/src/libs/Navigation/linkingConfig/OldRoutes.ts b/src/libs/Navigation/linkingConfig/OldRoutes.ts index c3911599856f..3eda85c9d008 100644 --- a/src/libs/Navigation/linkingConfig/OldRoutes.ts +++ b/src/libs/Navigation/linkingConfig/OldRoutes.ts @@ -10,8 +10,10 @@ const oldRoutes: Record = { '/settings/*/category/*/edit': '/settings/$1/categories/category-settings/$2/category-edit', '/settings/*/category/*': '/settings/$1/categories/category-settings/$2', '/settings/*/tags/*/edit': '/settings/$1/tags/settings-tags-edit/$2', - '/settings/*/tags/settings/edit/*': '/settings/$1/tags/settings-tags-settings/settings-tags-edit/$2', - '/settings/*/tags/settings': '/settings/$1/tags/settings-tags-settings', + '/settings/*/tags/settings/edit/*': '/settings/$1/tags/settings-tags-edit/$2', + '/settings/*/tags/settings': '/settings/$1/tags', + '/settings/*/tags/settings-tags-settings': '/settings/$1/tags', + '/settings/*/tags/settings-tags-settings/*': '/settings/$1/tags', '/settings/*/tags/tag-list/*/edit/*': '/settings/$1/tags/tag-list/$2/settings-tags-edit/$3', '/settings/*/tags/new': '/settings/$1/tags/tag-new', '/settings/*/tag/*/*/edit': '/settings/$1/tags/tag-settings/$2/$3/tag-edit/$2/$3', @@ -53,15 +55,21 @@ const oldRoutes: Record = { '/r/*/trip/*/*/*': '/r/$1/trip-details/$1/$2/$3/$4', '/r/*/trip/*': '/r/$1/trip-summary/$1/$2', '/workspaces/*/overview/address': '/workspaces/$1/overview/workspace-address', - '/workspaces/*/categories/settings': '/workspaces/$1/categories/categories-settings', + '/workspaces/*/categories/settings': '/workspaces/$1/categories', + '/workspaces/*/categories/categories-settings': '/workspaces/$1/categories', + // Nested sub-pages of the removed Categories Settings page (e.g. spend-category-selector) also redirect to the list. + '/workspaces/*/categories/categories-settings/*': '/workspaces/$1/categories', '/workspaces/*/categories/new': '/workspaces/$1/categories/category-new', - '/workspaces/*/tags/settings': '/workspaces/$1/tags/tags-settings', + '/workspaces/*/tags/settings': '/workspaces/$1/tags', + '/workspaces/*/tags/tags-settings': '/workspaces/$1/tags', + // Nested sub-pages of the removed Tags Settings page redirect to the list (the specific tag-edit redirect below wins by length). + '/workspaces/*/tags/tags-settings/*': '/workspaces/$1/tags', '/workspaces/*/tags/new': '/workspaces/$1/tags/tag-create', '/workspaces/*/tag-list/*': '/workspaces/$1/tags/workspace-tag-list/$2', '/workspaces/*/tags/import': '/workspaces/$1/tags/workspace-tags-import', '/workspaces/*/tags/imported': '/workspaces/$1/tags/workspace-tags-imported', - '/workspaces/*/tags/*/edit': '/workspaces/$1/tags/tags-settings/workspace-edit-tags/$2', - '/workspaces/*/tags/tags-settings/edit/*': '/workspaces/$1/tags/tags-settings/workspace-edit-tags/$2', + '/workspaces/*/tags/*/edit': '/workspaces/$1/tags/workspace-edit-tags/$2', + '/workspaces/*/tags/tags-settings/edit/*': '/workspaces/$1/tags/workspace-edit-tags/$2', '/workspaces/*/tag/*/*/edit': '/workspaces/$1/tags/workspace-tag-settings/$2/$3/workspace-tag-edit', '/workspaces/*/tag/*/*/gl-code': '/workspaces/$1/tags/workspace-tag-settings/$2/$3/workspace-tag-gl-code', '/workspaces/*/tag/*/*/approver': '/workspaces/$1/tags/workspace-tag-settings/$2/$3/workspace-tag-approver', @@ -69,7 +77,9 @@ const oldRoutes: Record = { '/workspaces/*/tag/*/*/workspace-tag-gl-code': '/workspaces/$1/tags/workspace-tag-settings/$2/$3/workspace-tag-gl-code', '/workspaces/*/tag/*/*/workspace-tag-approver': '/workspaces/$1/tags/workspace-tag-settings/$2/$3/workspace-tag-approver', '/workspaces/*/tag/*/*': '/workspaces/$1/tags/workspace-tag-settings/$2/$3', - '/settings/*/categories/settings': '/settings/$1/categories/manage-settings', + '/settings/*/categories/settings': '/settings/$1/categories', + '/settings/*/categories/manage-settings': '/settings/$1/categories', + '/settings/*/categories/manage-settings/*': '/settings/$1/categories', '/settings/*/categories/new': '/settings/$1/categories/category-create', '/workspaces/*/accounting/*/card-reconciliation/account': '/workspaces/$1/accounting/$2/card-reconciliation/account-reconciliation-settings', '/workspaces/*/connections/netsuite/export/invoice-item-preference/invoice-item/select': '/workspaces/$1/connections/netsuite/export/invoice-item-preference/select/invoice-item/select', @@ -133,6 +143,7 @@ const oldRoutes: Record = { '/workspaces/*/expensify-card/*': '/workspaces/$1/expensify-card/card-details/$2', '/workspace/confirmation/currency': '/workspaces/workspace/confirmation/currency', '/workspace/confirmation': '/workspaces/workspace/confirmation', + '/settings/wallet/*/global-reimbursements': '/settings/wallet/global-reimbursements/$1', '/settings/wallet/enable-payments/plaid*': '/settings/wallet/enable-payments/add-bank-account/plaid$1', '/settings/wallet/enable-payments/bank-account-confirmation*': '/settings/wallet/enable-payments/add-bank-account/confirmation$1', '/settings/wallet/enable-payments/legal-name*': '/settings/wallet/enable-payments/personal-info/legal-name$1', 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'] = { [SCREENS.DYNAMIC_CARD_AUTHENTICATION]: DYNAMIC_ROUTES.CARD_AUTHENTICATION.path, [SCREENS.WORKSPACE_AVATAR]: ROUTES.WORKSPACE_AVATAR.route, [SCREENS.WORKSPACE_DOCUMENT]: ROUTES.WORKSPACE_DOCUMENT.route, + [SCREENS.BILLING_RECEIPT]: ROUTES.BILLING_RECEIPT.route, [SCREENS.REPORT_AVATAR]: ROUTES.REPORT_AVATAR.route, [SCREENS.TRANSACTION_RECEIPT]: ROUTES.TRANSACTION_RECEIPT.route, [SCREENS.MONEY_REQUEST.RECEIPT_PREVIEW]: ROUTES.MONEY_REQUEST_RECEIPT_PREVIEW.route, @@ -59,11 +60,6 @@ const config: LinkingOptions['config'] = { [SCREENS.SUBMIT_PLAN_WELCOME_MODAL.DYNAMIC_ROOT]: DYNAMIC_ROUTES.SUBMIT_PLAN_WELCOME.path, }, }, - [NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR]: { - screens: { - [SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT]: DYNAMIC_ROUTES.AI_FEATURES_PROMO.path, - }, - }, [NAVIGATORS.TEST_DRIVE_DEMO_NAVIGATOR]: { screens: { @@ -264,6 +260,7 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_WALLET_SHARE_BANK_ACCOUNT.route, exact: true, }, + [SCREENS.SETTINGS.WALLET.DYNAMIC_WISE_GLOBAL_REIMBURSEMENTS_INTRO]: DYNAMIC_ROUTES.WISE_GLOBAL_REIMBURSEMENTS_INTRO.path, [SCREENS.SETTINGS.WALLET.EDIT_BANK_ACCOUNT_NICKNAME]: { path: ROUTES.SETTINGS_WALLET_EDIT_BANK_ACCOUNT_NICKNAME.route, exact: true, @@ -360,10 +357,7 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_ADD_BANK_ACCOUNT.route, exact: true, }, - [SCREENS.SETTINGS.COLLECT_DEPOSIT_ACCOUNT]: { - path: ROUTES.SETTINGS_COLLECT_DEPOSIT_ACCOUNT.route, - exact: true, - }, + [SCREENS.SETTINGS.DYNAMIC_COLLECT_DEPOSIT_ACCOUNT]: DYNAMIC_ROUTES.COLLECT_DEPOSIT_ACCOUNT.path, [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT]: { path: ROUTES.SETTINGS_ADD_US_BANK_ACCOUNT.route, exact: true, @@ -379,37 +373,37 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_BANK_ACCOUNT_PURPOSE, exact: true, }, - [SCREENS.SETTINGS.AGENTS.NEW]: { - path: ROUTES.SETTINGS_AGENTS_NEW.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_NEW]: { + path: DYNAMIC_ROUTES.AGENT_NEW.path, exact: true, }, - [SCREENS.SETTINGS.AGENTS.ADD]: { - path: ROUTES.SETTINGS_AGENTS_ADD.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD]: { + path: DYNAMIC_ROUTES.AGENT_ADD.path, }, - [SCREENS.SETTINGS.AGENTS.ADD_AVATAR]: { - path: ROUTES.SETTINGS_AGENTS_ADD_AVATAR, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD_AVATAR]: { + path: DYNAMIC_ROUTES.AGENT_ADD_AVATAR.path, exact: true, }, - [SCREENS.SETTINGS.AGENTS.EDIT]: { - path: ROUTES.SETTINGS_AGENTS_EDIT.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT]: { + path: DYNAMIC_ROUTES.AGENT_EDIT.path, parse: { accountID: Number, }, }, - [SCREENS.SETTINGS.AGENTS.EDIT_NAME]: { - path: ROUTES.SETTINGS_AGENTS_EDIT_NAME.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_NAME]: { + path: DYNAMIC_ROUTES.AGENT_EDIT_NAME.path, parse: { accountID: Number, }, }, - [SCREENS.SETTINGS.AGENTS.EDIT_PROMPT]: { - path: ROUTES.SETTINGS_AGENTS_EDIT_PROMPT.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_PROMPT]: { + path: DYNAMIC_ROUTES.AGENT_EDIT_PROMPT.path, parse: { accountID: Number, }, }, - [SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]: { - path: ROUTES.SETTINGS_AGENTS_EDIT_AVATAR.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_AVATAR]: { + path: DYNAMIC_ROUTES.AGENT_EDIT_AVATAR.path, parse: { accountID: Number, }, @@ -541,6 +535,10 @@ const config: LinkingOptions['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, @@ -706,6 +704,7 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS.route}, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_ITEMS]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_ITEMS.route}, [SCREENS.WORKSPACE.ACCOUNTING.XERO_SETUP]: {path: ROUTES.POLICY_ACCOUNTING_XERO_SETUP.route}, + [SCREENS.WORKSPACE.ACCOUNTING.ZOHO_BOOKS_OAUTH]: {path: ROUTES.POLICY_ACCOUNTING_ZOHO_BOOKS_OAUTH.route}, [SCREENS.WORKSPACE.ACCOUNTING.XERO_IMPORT]: {path: ROUTES.POLICY_ACCOUNTING_XERO_IMPORT.route}, [SCREENS.WORKSPACE.ACCOUNTING.XERO_CHART_OF_ACCOUNTS]: {path: ROUTES.POLICY_ACCOUNTING_XERO_CHART_OF_ACCOUNTS.route}, [SCREENS.WORKSPACE.ACCOUNTING.XERO_ORGANIZATION]: {path: ROUTES.POLICY_ACCOUNTING_XERO_ORGANIZATION.route}, @@ -920,6 +919,10 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_COMPANY_CARD_ACCOUNT]: {path: ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_COMPANY_CARD_ACCOUNT.route}, [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_DEFAULT_VENDOR]: {path: ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_DEFAULT_VENDOR.route}, [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_PAYMENT_METHOD]: {path: ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT_PAYMENT_METHOD.route}, + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_ADVANCED]: {path: ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_ADVANCED.route}, + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_ACCOUNTING_METHOD]: {path: ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_ACCOUNTING_METHOD.route}, + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_ADVANCED_POSTING_MODE]: {path: ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_ADVANCED_POSTING_MODE.route}, + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_BANK_ACCOUNT_SELECTOR]: {path: ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_BANK_ACCOUNT_SELECTOR.route}, [SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION]: {path: ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.route}, [SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION_SAGE_INTACCT_AUTO_SYNC]: { path: ROUTES.POLICY_ACCOUNTING_CARD_RECONCILIATION_SAGE_INTACCT_AUTO_SYNC.route, @@ -1090,6 +1093,12 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.MEMBERS_IMPORT]: { path: ROUTES.WORKSPACE_MEMBERS_IMPORT.route, }, + [SCREENS.WORKSPACE.MEMBERS_CHANGE_OFFICE_LOCATION]: { + path: ROUTES.WORKSPACE_MEMBERS_CHANGE_OFFICE_LOCATION.route, + }, + [SCREENS.WORKSPACE.MEMBERS_CHANGE_WORK_ARRANGEMENT]: { + path: ROUTES.WORKSPACE_MEMBERS_CHANGE_WORK_ARRANGEMENT.route, + }, [SCREENS.WORKSPACE.WORKFLOWS_IMPORT]: { path: ROUTES.WORKSPACE_WORKFLOWS_IMPORT.route, }, @@ -1156,12 +1165,9 @@ const config: LinkingOptions['config'] = { }, [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_DOWNGRADE]: DYNAMIC_ROUTES.WORKSPACE_DOWNGRADE.path, [SCREENS.WORKSPACE.DYNAMIC_PAY_AND_DOWNGRADE]: DYNAMIC_ROUTES.WORKSPACE_PAY_AND_DOWNGRADE.path, - [SCREENS.WORKSPACE.DYNAMIC_CATEGORIES_SETTINGS]: DYNAMIC_ROUTES.WORKSPACE_CATEGORIES_SETTINGS.path, - [SCREENS.WORKSPACE.DYNAMIC_TAGS_SETTINGS]: DYNAMIC_ROUTES.WORKSPACE_TAGS_SETTINGS.path, [SCREENS.WORKSPACE.DYNAMIC_TAG_CREATE]: DYNAMIC_ROUTES.WORKSPACE_TAG_CREATE.path, [SCREENS.WORKSPACE.DYNAMIC_CATEGORIES_IMPORT]: DYNAMIC_ROUTES.WORKSPACE_CATEGORIES_IMPORT.path, [SCREENS.WORKSPACE.DYNAMIC_CATEGORIES_IMPORTED]: DYNAMIC_ROUTES.WORKSPACE_CATEGORIES_IMPORTED.path, - [SCREENS.WORKSPACE.DYNAMIC_SPEND_CATEGORY_SELECTOR]: DYNAMIC_ROUTES.SPEND_CATEGORY_SELECTOR.path, [SCREENS.WORKSPACE.DYNAMIC_DEFAULT_CATEGORY_SELECTOR]: DYNAMIC_ROUTES.DEFAULT_CATEGORY_SELECTOR.path, [SCREENS.WORKSPACE.WORKFLOWS_PAYER]: { path: ROUTES.WORKSPACE_WORKFLOWS_PAYER.route, @@ -1175,9 +1181,18 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE]: { path: ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.route, }, + [SCREENS.WORKSPACE.MEMBERS_ROLE]: { + path: ROUTES.WORKSPACE_MEMBERS_ROLE.route, + }, [SCREENS.WORKSPACE.MEMBER_WORK_ARRANGEMENT]: { path: ROUTES.WORKSPACE_MEMBER_WORK_ARRANGEMENT.route, }, + [SCREENS.WORKSPACE.MEMBER_OFFICE_LOCATION]: { + path: ROUTES.WORKSPACE_MEMBER_OFFICE_LOCATION.route, + }, + [SCREENS.WORKSPACE.INVITE_WORK_ARRANGEMENT]: { + path: DYNAMIC_ROUTES.WORKSPACE_INVITE_WORK_ARRANGEMENT.path, + }, [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_SUCCESS]: DYNAMIC_ROUTES.WORKSPACE_OWNER_CHANGE_SUCCESS.path, [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_ERROR]: DYNAMIC_ROUTES.WORKSPACE_OWNER_CHANGE_ERROR.path, [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_CHECK]: DYNAMIC_ROUTES.WORKSPACE_OWNER_CHANGE_CHECK.path, @@ -1322,6 +1337,8 @@ const config: LinkingOptions['config'] = { [SCREENS.SETTINGS.DYNAMIC_KEYBOARD_SHORTCUTS]: DYNAMIC_ROUTES.KEYBOARD_SHORTCUTS.path, [SCREENS.SETTINGS.DYNAMIC_BETA_OVERRIDES]: DYNAMIC_ROUTES.BETA_OVERRIDES.path, [SCREENS.WORKSPACE.NAME]: ROUTES.WORKSPACE_OVERVIEW_NAME.route, + [SCREENS.WORKSPACE.OFFICE_LOCATION_ADD]: ROUTES.WORKSPACE_OVERVIEW_OFFICE_LOCATION_ADD.route, + [SCREENS.WORKSPACE.OFFICE_LOCATION_EDIT]: ROUTES.WORKSPACE_OVERVIEW_OFFICE_LOCATION_EDIT.route, [SCREENS.SETTINGS.SHARE_CODE]: { path: ROUTES.SETTINGS_SHARE_CODE, }, @@ -1618,6 +1635,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, }, @@ -1707,6 +1727,7 @@ const config: LinkingOptions['config'] = { [SCREENS.REPORT_DETAILS.DYNAMIC_ROOT]: DYNAMIC_ROUTES.REPORT_DETAILS.path, [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_CODE]: DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_CODE.path, [SCREENS.REPORT_DETAILS.DYNAMIC_EXPORT]: DYNAMIC_ROUTES.REPORT_DETAILS_EXPORT.path, + [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_REPORT]: DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_REPORT.path, }, }, [SCREENS.RIGHT_MODAL.REPORT_CARD_ACTIVATE]: { @@ -1739,7 +1760,6 @@ const config: LinkingOptions['config'] = { [SCREENS.RIGHT_MODAL.SETTINGS_CATEGORIES]: { screens: { [SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS]: DYNAMIC_ROUTES.SETTINGS_CATEGORY_SETTINGS.path, - [SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORIES_SETTINGS]: DYNAMIC_ROUTES.SETTINGS_CATEGORIES_SETTINGS.path, [SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_CREATE]: DYNAMIC_ROUTES.SETTINGS_CATEGORY_CREATE.path, [SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_EDIT]: DYNAMIC_ROUTES.SETTINGS_CATEGORY_EDIT.path, [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_IMPORT]: { @@ -1758,7 +1778,6 @@ const config: LinkingOptions['config'] = { }, [SCREENS.RIGHT_MODAL.SETTINGS_TAGS]: { screens: { - [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAGS_SETTINGS]: DYNAMIC_ROUTES.SETTINGS_TAGS_SETTINGS.path, [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAGS_EDIT]: DYNAMIC_ROUTES.SETTINGS_TAGS_EDIT.path, [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_CREATE]: DYNAMIC_ROUTES.SETTINGS_TAG_CREATE.path, [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_EDIT]: DYNAMIC_ROUTES.SETTINGS_TAG_EDIT.path, @@ -2170,6 +2189,7 @@ const config: LinkingOptions['config'] = { screens: { [SCREENS.SEARCH.ADVANCED_FILTERS_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS, [SCREENS.SEARCH.ADVANCED_FILTERS_CONTENT_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_CONTENT.route, + [SCREENS.SEARCH.NL_FILTER_RHP]: ROUTES.SEARCH_NL_FILTER, }, }, [SCREENS.RIGHT_MODAL.SEARCH_SAVE]: ROUTES.SEARCH_SAVE, @@ -2514,6 +2534,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.MEMBERS]: { path: ROUTES.WORKSPACE_MEMBERS.route, }, + [SCREENS.WORKSPACE.AGENTS]: { + path: ROUTES.WORKSPACE_AGENTS.route, + }, [SCREENS.WORKSPACE.ROOMS]: { path: ROUTES.WORKSPACE_ROOMS.route, }, diff --git a/src/libs/Navigation/paramLists/AccountSettingsParamList.ts b/src/libs/Navigation/paramLists/AccountSettingsParamList.ts new file mode 100644 index 000000000000..6def8fbda1c3 --- /dev/null +++ b/src/libs/Navigation/paramLists/AccountSettingsParamList.ts @@ -0,0 +1,351 @@ +import type {ReplacementReason} from '@libs/actions/Card'; + +import type {Country, EnablePaymentsPageType, EnablePaymentsSubPageType} from '@src/CONST'; +import type CONST from '@src/CONST'; +import type {Route as Routes} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {ExpenseRuleFormFieldID} from '@src/types/form/ExpenseRuleForm'; + +import type {ValueOf} from 'type-fest'; + +type AccountSettingsParamList = { + [SCREENS.SETTINGS.SHARE_CODE]: undefined; + [SCREENS.SETTINGS.PROFILE.PRONOUNS]: undefined; + [SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: undefined; + [SCREENS.SETTINGS.PROFILE.TIMEZONE]: undefined; + [SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT]: undefined; + [SCREENS.SETTINGS.PROFILE.PRIVATE_PERSONAL_DETAILS]: { + fieldToFocus?: string; + }; + [SCREENS.SETTINGS.PROFILE.PRIVATE_PERSONAL_DETAILS_CONFIRM_VALIDATE_CODE]: undefined; + [SCREENS.SETTINGS.PROFILE.ADDRESS]: { + country?: Country | ''; + }; + [SCREENS.SETTINGS.PROFILE.DYNAMIC_ADDRESS_COUNTRY]: { + country?: string; + }; + [SCREENS.SETTINGS.PROFILE.DYNAMIC_ADDRESS_STATE]: { + state?: string; + label?: string; + }; + [SCREENS.SETTINGS.PROFILE.DYNAMIC_CONTACT_METHODS]: undefined; + [SCREENS.SETTINGS.PROFILE.DYNAMIC_CONTACT_METHOD_DETAILS]: { + contactMethod: string; + shouldSkipInitialValidation?: string; + }; + [SCREENS.SETTINGS.PROFILE.DYNAMIC_NEW_CONTACT_METHOD]: undefined; + [SCREENS.SETTINGS.PROFILE.DYNAMIC_NEW_CONTACT_METHOD_CONFIRM_VALIDATE_CODE]: undefined; + [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_SET_DEFAULT_CONFIRM]: { + contactMethod: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: undefined; + [SCREENS.SETTINGS.PREFERENCES.PAYMENT_CURRENCY]: undefined; + [SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: undefined; + [SCREENS.SETTINGS.PREFERENCES.THEME]: undefined; + [SCREENS.SETTINGS.TROUBLESHOOT_SERVER]: undefined; + [SCREENS.SETTINGS.CLOSE]: undefined; + [SCREENS.SETTINGS.CLOSE_ACCOUNT_CONFIRM_VALIDATE_CODE]: undefined; + [SCREENS.SETTINGS.MERGE_ACCOUNTS.ACCOUNT_DETAILS]: { + email?: string; + }; + [SCREENS.SETTINGS.MERGE_ACCOUNTS.ACCOUNT_VALIDATE]: { + login: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + forwardTo?: Routes; + }; + [SCREENS.SETTINGS.MERGE_ACCOUNTS.MERGE_RESULT]: { + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + result: ValueOf; + login: string; + }; + [SCREENS.SETTINGS.LOCK.LOCK_ACCOUNT]: undefined; + [SCREENS.SETTINGS.LOCK.UNLOCK_ACCOUNT]: undefined; + [SCREENS.SETTINGS.LOCK.FAILED_TO_LOCK_ACCOUNT]: undefined; + [SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: { + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + /** Whether the validation was opened from the join-workspace Concierge task. */ + isJoinWorkspaceTask?: 'true'; + }; + [SCREENS.SETTINGS.DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT]: + | { + /** Whether the bank account purpose screen should be skipped after the account is validated. */ + shouldSkipPurposeSelection?: 'true'; + /** Whether the US bank account flow should open after the account is validated. */ + shouldSetUpUSBankAccount?: 'true'; + } + | undefined; + [SCREENS.SETTINGS.DYNAMIC_EXIT_SURVEY_REASON]: undefined; + [SCREENS.SETTINGS.DYNAMIC_EXIT_SURVEY_CONFIRM]: undefined; + [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: undefined; + [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: { + cardID: string; + }; + [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_DETAILS]: { + cardID: string; + }; + [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_EDIT_NAME]: { + cardID: string; + }; + [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_EDIT_TRANSACTION_START_DATE]: { + cardID: string; + }; + [SCREENS.SETTINGS.WALLET.DOMAIN_CARD_CONFIRM_VALIDATE_CODE]: { + cardID: string; + }; + [SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD]: { + cardID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD_CONFIRMATION]: { + cardID: string; + }; + [SCREENS.SETTINGS.WALLET.CARD_ADD_TO_DIGITAL_WALLET]: { + /** cardID of the card waiting to be added to a digital wallet */ + cardID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.SETTINGS.WALLET.CARD_ACTIVATE]: { + cardID: string; + /** Whether the flow was launched from the top-level DomainCard route (deep-linked from OldDot) rather than the Settings wallet card route */ + isFromDomainCardDetail?: string; + }; + [SCREENS.SETTINGS.WALLET.CARD_ADDED_TO_WALLET]: { + /** cardID of selected card */ + cardID: string; + }; + [SCREENS.SETTINGS.WALLET.CARD_CHANGE_PIN]: { + cardID: string; + }; + [SCREENS.SETTINGS.WALLET.CARD_CHANGE_PIN_ATM]: { + cardID: string; + }; + [SCREENS.SETTINGS.WALLET.EXPENSIFY_CARD_SPEND_RULES]: { + policyID: string; + ruleID: string; + }; + [SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE]: undefined; + [SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT]: undefined; + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS]: undefined; + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS_CARD_NAME]: undefined; + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS_CURRENCY]: undefined; + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS_SPREADSHEET]: {cardID?: string}; + [SCREENS.SETTINGS.WALLET.TRANSACTIONS_IMPORTED]: {cardID?: string}; + [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS]: { + page?: EnablePaymentsPageType; + subPage?: EnablePaymentsSubPageType; + action?: 'edit'; + }; + [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS_CONFIRM_VALIDATE_CODE]: undefined; + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_PAYMENTS_CONFIRM_VALIDATE_CODE]: undefined; + [SCREENS.SETTINGS.WALLET.UNSHARE_BANK_ACCOUNT]: { + bankAccountID: string; + }; + [SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS]: { + bankAccountID: string; + subPage: string; + action?: 'edit'; + bankCountry?: string; + bankCurrency?: string; + }; + [SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS]: { + bankAccountID: string; + bankCountry?: string; + bankCurrency?: string; + }; + [SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_SIGN]: { + bankAccountID: string; + bankCountry?: string; + bankCurrency?: string; + }; + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS]: { + bankAccountID: string; + subPage: string; + action?: 'edit'; + bankCountry?: string; + bankCurrency?: string; + }; + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS]: { + bankAccountID: string; + bankCountry?: string; + bankCurrency?: string; + }; + [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN]: { + bankAccountID: string; + bankCountry?: string; + bankCurrency?: string; + }; + [SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT]: { + bankAccountID: string; + }; + [SCREENS.SETTINGS.WALLET.DYNAMIC_WISE_GLOBAL_REIMBURSEMENTS_INTRO]: { + bankAccountID: string; + }; + [SCREENS.SETTINGS.WALLET.EDIT_BANK_ACCOUNT_NICKNAME]: { + bankAccountID: string; + }; + [SCREENS.SETTINGS.WALLET.TRAVEL_CVV]: undefined; + [SCREENS.SETTINGS.WALLET.TRAVEL_CVV_VERIFY_ACCOUNT]: undefined; + [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_ADD_NEW]: undefined; + [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_FIX_CONNECTION]: { + cardID: string; + }; + [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_WARNING]: undefined; + [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_UPGRADE]: undefined; + [SCREENS.SETTINGS.ADD_DEBIT_CARD]: undefined; + [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: { + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + subPage?: string; + action?: 'edit'; + }; + [SCREENS.SETTINGS.DYNAMIC_COLLECT_DEPOSIT_ACCOUNT]: { + subPage?: string; + action?: 'edit'; + }; + [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT]: { + subPage?: string; + /** Whether the user is editing one of the sub pages */ + action?: 'edit'; + }; + [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT_ENTRY_POINT]: undefined; + [SCREENS.SETTINGS.UPDATE_PERSONAL_BANK_ACCOUNT]: { + subPage?: string; + }; + [SCREENS.SETTINGS.BANK_ACCOUNT_PURPOSE]: undefined; + [SCREENS.SETTINGS.AGENTS.DYNAMIC_NEW]: { + policyID?: string; + }; + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD]: { + policyID?: string; + }; + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD_AVATAR]: undefined; + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT]: { + accountID: number; + }; + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_NAME]: { + accountID: number; + }; + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_PROMPT]: { + accountID: number; + }; + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_AVATAR]: { + accountID: number; + }; + [SCREENS.SETTINGS.RULES.ADD]: undefined; + [SCREENS.SETTINGS.RULES.ADD_MERCHANT]: undefined; + [SCREENS.SETTINGS.RULES.ADD_RENAME_MERCHANT]: undefined; + [SCREENS.SETTINGS.RULES.ADD_CATEGORY]: undefined; + [SCREENS.SETTINGS.RULES.ADD_TAG]: { + index: number; + }; + [SCREENS.SETTINGS.RULES.ADD_TAX]: undefined; + [SCREENS.SETTINGS.RULES.ADD_DESCRIPTION]: undefined; + [SCREENS.SETTINGS.RULES.ADD_REIMBURSABLE]: undefined; + [SCREENS.SETTINGS.RULES.ADD_BILLABLE]: undefined; + [SCREENS.SETTINGS.RULES.ADD_REPORT]: undefined; + [SCREENS.SETTINGS.RULES.EDIT]: { + hash: string; + field?: ExpenseRuleFormFieldID; + }; + [SCREENS.SETTINGS.RULES.EDIT_MERCHANT]: { + hash: string; + }; + [SCREENS.SETTINGS.RULES.EDIT_RENAME_MERCHANT]: { + hash: string; + }; + [SCREENS.SETTINGS.RULES.EDIT_CATEGORY]: { + hash: string; + }; + [SCREENS.SETTINGS.RULES.EDIT_TAG]: { + hash: string; + index: number; + }; + [SCREENS.SETTINGS.RULES.EDIT_TAX]: { + hash: string; + }; + [SCREENS.SETTINGS.RULES.EDIT_DESCRIPTION]: { + hash: string; + }; + [SCREENS.SETTINGS.RULES.EDIT_REIMBURSABLE]: { + hash: string; + }; + [SCREENS.SETTINGS.RULES.EDIT_BILLABLE]: { + hash: string; + }; + [SCREENS.SETTINGS.RULES.EDIT_REPORT]: { + hash: string; + }; + [SCREENS.SETTINGS.PROFILE.STATUS]: undefined; + [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER]: undefined; + [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.SETTINGS.SUBSCRIPTION.SIZE]: { + canChangeSize: 0 | 1; + subPage: string; + }; + [SCREENS.SETTINGS.SUBSCRIPTION.EXPENSIFY_CODE]: undefined; + [SCREENS.SETTINGS.SUBSCRIPTION.PAYMENT_HISTORY]: undefined; + [SCREENS.SETTINGS.SUBSCRIPTION.PAYMENT_DETAILS]: { + purchaseID: string; + }; + [SCREENS.SETTINGS.SUBSCRIPTION.SETTINGS_DETAILS]: undefined; + [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD]: undefined; + [SCREENS.SETTINGS.SUBSCRIPTION.EARLY_RENEWAL]: undefined; + [SCREENS.SETTINGS.EMAIL_ISSUE]: undefined; + [SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_BILLING_CURRENCY]: undefined; + [SCREENS.SETTINGS.SUBSCRIPTION.DYNAMIC_PAYMENT_CARD_CURRENCY_SELECTOR]: undefined; + [SCREENS.SETTINGS.SUBSCRIPTION.DYNAMIC_SUBSCRIPTION_DOWNGRADE_BLOCKED]: undefined; + [SCREENS.TWO_FACTOR_AUTH.DISABLED]: undefined; + [SCREENS.TWO_FACTOR_AUTH.DISABLE]: undefined; + [SCREENS.SETTINGS.DELEGATE.VERIFY_ACCOUNT]: undefined; + [SCREENS.SETTINGS.DELEGATE.ADD_DELEGATE]: undefined; + [SCREENS.SETTINGS.DELEGATE.DELEGATE_ROLE]: { + login: string; + role?: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE]: { + login: string; + currentRole: string; + }; + [SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE_CONFIRM_VALIDATE_CODE]: { + login: string; + currentRole: string; + newRole: string; + }; + [SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM]: { + login: string; + role: string; + }; + [SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM_VALIDATE_CODE]: { + login: string; + role: string; + }; + [SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: { + cardID: string; + /** Whether the flow was launched from the top-level DomainCard route (deep-linked from OldDot) rather than the Settings wallet card route */ + isFromDomainCardDetail?: string; + }; + [SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED_CONFIRM_VALIDATE_CODE]: { + cardID: string; + /** Reason for replacing the card */ + reason: ReplacementReason; + /** Whether the flow was launched from the top-level DomainCard route (deep-linked from OldDot) rather than the Settings wallet card route */ + isFromDomainCardDetail?: string; + }; + [SCREENS.SETTINGS.DYNAMIC_KEYBOARD_SHORTCUTS]: undefined; + [SCREENS.SETTINGS.DYNAMIC_BETA_OVERRIDES]: undefined; +}; + +export default AccountSettingsParamList; diff --git a/src/libs/Navigation/paramLists/DomainSettingsParamList.ts b/src/libs/Navigation/paramLists/DomainSettingsParamList.ts new file mode 100644 index 000000000000..39c2947c23fc --- /dev/null +++ b/src/libs/Navigation/paramLists/DomainSettingsParamList.ts @@ -0,0 +1,89 @@ +import type SCREENS from '@src/SCREENS'; + +type DomainSettingsParamList = { + [SCREENS.DOMAIN.SAML_VERIFY]: { + domainAccountID: number; + }; + [SCREENS.DOMAIN.SAML_VERIFIED]: { + domainAccountID: number; + }; + [SCREENS.DOMAIN.MEMBERS_VERIFY]: { + domainAccountID: number; + }; + [SCREENS.DOMAIN.MEMBERS_VERIFIED]: { + domainAccountID: number; + }; + [SCREENS.DOMAIN.ADMIN_DETAILS]: { + domainAccountID: number; + accountID: number; + }; + [SCREENS.DOMAIN.ADMINS_SETTINGS]: { + domainAccountID: number; + }; + [SCREENS.DOMAIN.ADD_PRIMARY_CONTACT]: { + domainAccountID: number; + }; + [SCREENS.DOMAIN.ADD_ADMIN]: { + domainAccountID: number; + }; + [SCREENS.DOMAIN.MEMBER_DETAILS]: { + domainAccountID: number; + accountID: number; + }; + [SCREENS.DOMAIN.ADD_MEMBER]: { + domainAccountID: number; + }; + [SCREENS.DOMAIN.MEMBERS_SETTINGS]: { + domainAccountID: number; + }; + [SCREENS.DOMAIN.MEMBERS_SETTINGS_TWO_FACTOR_AUTH]: { + domainAccountID: number; + }; + [SCREENS.DOMAIN.VACATION_DELEGATE]: { + domainAccountID: number; + accountID: number; + }; + [SCREENS.DOMAIN.VACATION_DELEGATE_SELECT]: { + domainAccountID: number; + accountID: number; + }; + [SCREENS.DOMAIN.MEMBER_FORCE_TWO_FACTOR_AUTH]: { + domainAccountID: number; + accountID: number; + }; + [SCREENS.DOMAIN.MEMBER_RESET_TWO_FACTOR_AUTH]: { + domainAccountID: number; + accountID: number; + }; + [SCREENS.DOMAIN.MEMBER_LOCK_ACCOUNT]: { + domainAccountID: number; + accountID: number; + }; + [SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP]: { + domainAccountID: number; + }; + [SCREENS.DOMAIN.MEMBER_MOVE_TO_GROUP]: { + domainAccountID: number; + accountID: number; + }; + [SCREENS.DOMAIN.GROUP_DETAILS]: { + domainAccountID: number; + groupID: string; + }; + [SCREENS.DOMAIN.GROUP_EDIT_NAME]: { + domainAccountID: number; + groupID: string; + }; + [SCREENS.DOMAIN.SECURITY_GROUPS_PREFERRED_WORKSPACE]: { + domainAccountID: number; + groupID: string; + }; + [SCREENS.DOMAIN.GROUP_CREATE]: { + domainAccountID: number; + }; + [SCREENS.DOMAIN.GROUP_CREATE_PREFERRED_WORKSPACE]: { + domainAccountID: number; + }; +}; + +export default DomainSettingsParamList; diff --git a/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts b/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts new file mode 100644 index 000000000000..dc38564d79a3 --- /dev/null +++ b/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts @@ -0,0 +1,1802 @@ +import type RECONCILIATION_ACCOUNT_SETTINGS_TYPE from '@pages/workspace/accounting/reconciliation/constants'; + +import type {Country, IOUAction, IOUType} from '@src/CONST'; +import type CONST from '@src/CONST'; +import type {Route as Routes} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {CardFeedWithDomainID, CompanyCardFeedWithDomainID} from '@src/types/onyx'; +import type {BusinessCentralBankAccountSettingName, PolicyReportFieldType, SageIntacctMappingName} from '@src/types/onyx/Policy'; +import type {CustomFieldType} from '@src/types/onyx/PolicyEmployee'; + +import type {TupleToUnion, ValueOf} from 'type-fest'; + +type WorkspaceSettingsParamList = { + [SCREENS.WORKSPACE.WORKFLOWS_PAYER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.CURRENCY]: { + isForcedToChangeCurrency?: boolean; + shouldStartExpensifyCardEnrollment?: boolean; + }; + [SCREENS.WORKSPACE.CURRENCY_GOVERNMENT_RATE_COUNTRY]: { + policyID: string; + currencyCode: string; + isForcedToChangeCurrency?: boolean; + shouldStartExpensifyCardEnrollment?: boolean; + }; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_OVERVIEW_ADDRESS]: { + policyID: string; + country?: Country | ''; + }; + [SCREENS.WORKSPACE.NAME]: undefined; + [SCREENS.WORKSPACE.OFFICE_LOCATION_ADD]: { + policyID: string; + country?: Country | ''; + }; + [SCREENS.WORKSPACE.OFFICE_LOCATION_EDIT]: { + policyID: string; + officeID: string; + country?: Country | ''; + }; + [SCREENS.WORKSPACE.DESCRIPTION]: undefined; + [SCREENS.WORKSPACE.CLIENT_ID]: undefined; + [SCREENS.WORKSPACE.SHARE]: undefined; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_INVITE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.MEMBERS_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.MEMBERS_CHANGE_OFFICE_LOCATION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.MEMBERS_CHANGE_WORK_ARRANGEMENT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.WORKFLOWS_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.WORKFLOWS_IMPORTED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.WORKFLOWS_IMPORTED_CONFIRMATION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.MEMBERS_IMPORTED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.MEMBERS_IMPORTED_CONFIRMATION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_INVITE_MESSAGE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_INVITE_MESSAGE_ROLE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.INVITE_MESSAGE_APPROVER]: { + policyID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_CREATE]: { + policyID: string; + }; + [SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_CREATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_EDIT]: { + policyID: string; + categoryName: string; + }; + [SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_EDIT]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_PAYROLL_CODE]: { + policyID: string; + categoryName: string; + }; + [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORY_PAYROLL_CODE]: { + policyID: string; + categoryName: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_GL_CODE]: { + policyID: string; + categoryName: string; + }; + [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORY_GL_CODE]: { + policyID: string; + categoryName: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_DEFAULT_TAX_RATE]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_AMOUNTS_OVER]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_EXPENSE_LIMIT_TYPE_SELECTOR]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_IMPORTED_MEMBERS_ROLE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_DESCRIPTION_HINT]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_APPROVER]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_REQUIRE_RECEIPTS_OVER]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_REQUIRE_ITEMIZED_RECEIPTS_OVER]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_SETTINGS]: { + policyID: string; + categoryName: string; + }; + [SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_REQUIRED_FIELDS]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_RULES_NEW]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_FOR_REVIEW_RULE_NEW]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_FOR_REVIEW_RULE_EDIT]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_FOR_REVIEW_RULE_AMOUNT]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_REQUIRE_FIELDS_RULE_NEW]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_REQUIRE_FIELDS_RULE_EDIT]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_NEW]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_EDIT]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.UPGRADE]: { + policyID?: string; + featureName?: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + categoryId?: string; + reportID?: string; + upgradePlanType?: ValueOf; + }; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_DOWNGRADE]: { + policyID?: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_PAY_AND_DOWNGRADE]: { + policyID?: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_DEFAULT_CATEGORY_SELECTOR]: { + policyID: string; + customUnitID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORIES_IMPORT]: { + policyID: string; + }; + [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_IMPORT]: { + policyID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.DYNAMIC_CATEGORIES_IMPORTED]: { + policyID: string; + }; + [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_IMPORTED]: { + policyID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.DYNAMIC_TAG_CREATE]: { + policyID: string; + }; + [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_CREATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS]: { + policyID: string; + rateID: string; + }; + [SCREENS.WORKSPACE.DISTANCE_RATE_EDIT]: { + policyID: string; + rateID: string; + }; + [SCREENS.WORKSPACE.DISTANCE_RATE_NAME_EDIT]: { + policyID: string; + rateID: string; + }; + [SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT]: { + policyID: string; + rateID: string; + }; + [SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RATE_EDIT]: { + policyID: string; + rateID: string; + }; + [SCREENS.WORKSPACE.DISTANCE_RATE_START_DATE_EDIT]: { + policyID: string; + rateID: string; + }; + [SCREENS.WORKSPACE.DISTANCE_RATE_END_DATE_EDIT]: { + policyID: string; + rateID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_TAGS_IMPORT]: { + policyID: string; + }; + [SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_IMPORT]: { + policyID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.TAGS_IMPORT_OPTIONS]: { + policyID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.TAGS_IMPORT_MULTI_LEVEL_SETTINGS]: { + policyID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.DYNAMIC_TAGS_IMPORTED]: { + policyID: string; + }; + [SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_IMPORTED]: { + policyID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.TAGS_IMPORTED_MULTI_LEVEL]: { + policyID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.DYNAMIC_TAG_SETTINGS]: { + policyID: string; + orderWeight: number; + tagName: string; + parentTagsFilter?: string; + }; + [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_SETTINGS]: { + policyID: string; + orderWeight: number; + tagName: string; + parentTagsFilter?: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_TAG_LIST_VIEW]: { + policyID: string; + orderWeight: number | string; + }; + [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_LIST_VIEW]: { + policyID: string; + orderWeight: number | string; + }; + [SCREENS.WORKSPACE.DYNAMIC_TAGS_EDIT]: { + policyID: string; + orderWeight: number; + }; + [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAGS_EDIT]: { + policyID: string; + orderWeight: number; + }; + [SCREENS.WORKSPACE.DYNAMIC_TAG_EDIT]: { + policyID: string; + orderWeight: number; + tagName: string; + }; + [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_EDIT]: { + policyID: string; + orderWeight: number; + tagName: string; + }; + [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_GL_CODE]: { + policyID: string; + orderWeight: number; + tagName: string; + parentTagsFilter?: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_TAG_APPROVER]: { + policyID: string; + orderWeight: number; + tagName: string; + }; + [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_APPROVER]: { + policyID: string; + orderWeight: number; + tagName: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_TAG_GL_CODE]: { + policyID: string; + orderWeight: number; + tagName: string; + parentTagsFilter?: string; + }; + [SCREENS.WORKSPACE.TAXES_SETTINGS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME]: { + policyID: string; + }; + [SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.INVOICE_FIELDS_CREATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_REPORT_FIELDS_INITIAL_LIST_VALUE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_INVOICE_FIELDS_INITIAL_LIST_VALUE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: { + policyID: string; + reportFieldID?: string; + }; + [SCREENS.WORKSPACE.INVOICE_FIELDS_LIST_VALUES]: { + policyID: string; + reportFieldID?: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: { + policyID: string; + reportFieldID?: string; + }; + [SCREENS.WORKSPACE.INVOICE_FIELDS_ADD_VALUE]: { + policyID: string; + reportFieldID?: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: { + policyID: string; + valueIndex: number; + reportFieldID?: string; + }; + [SCREENS.WORKSPACE.INVOICE_FIELDS_VALUE_SETTINGS]: { + policyID: string; + valueIndex: number; + reportFieldID?: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: { + policyID: string; + valueIndex: number; + }; + [SCREENS.WORKSPACE.INVOICE_FIELDS_EDIT_VALUE]: { + policyID: string; + valueIndex: number; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS]: { + policyID: string; + reportFieldID: string; + }; + [SCREENS.WORKSPACE.INVOICE_FIELDS_SETTINGS]: { + policyID: string; + reportFieldID: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: { + policyID: string; + reportFieldID: string; + }; + [SCREENS.WORKSPACE.INVOICE_FIELDS_EDIT_INITIAL_VALUE]: { + policyID: string; + reportFieldID: string; + }; + [SCREENS.WORKSPACE.REPORT_FIELDS_TYPE_SELECTOR]: { + policyID: string; + currentType?: PolicyReportFieldType; + }; + [SCREENS.WORKSPACE.INVOICE_FIELDS_TYPE_SELECTOR]: { + policyID: string; + currentType?: PolicyReportFieldType; + }; + [SCREENS.WORKSPACE.MEMBER_DETAILS]: { + policyID: string; + accountID: string; + }; + [SCREENS.WORKSPACE.ROOM_CREATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE]: { + policyID: string; + accountID: string; + }; + [SCREENS.WORKSPACE.MEMBERS_ROLE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.MEMBER_WORK_ARRANGEMENT]: { + policyID: string; + accountID: string; + }; + [SCREENS.WORKSPACE.MEMBER_OFFICE_LOCATION]: { + policyID: string; + accountID: string; + }; + [SCREENS.WORKSPACE.INVITE_WORK_ARRANGEMENT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.MEMBER_CUSTOM_FIELD]: { + policyID: string; + accountID: string; + customFieldType: CustomFieldType; + }; + [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_SUCCESS]: { + policyID: string; + accountID: number; + }; + [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_ERROR]: { + policyID: string; + accountID: number; + }; + [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_CHECK]: { + policyID: string; + accountID: number; + error: ValueOf; + }; + [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: { + policyID: string; + transactionID?: string; + reportID?: string; + action?: IOUAction; + iouType?: IOUType; + }; + [SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DISTANCE_RATES_UNIT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DISTANCE_RATES_GOVERNMENT_RATE_COUNTRY]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DISTANCE_RATES_COMMUTER_EXCLUSIONS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING]: { + policyID: string; + }; + [SCREENS.WORKSPACE.CONNECTIONS_HR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.CONNECTIONS_RECRUITING]: { + policyID: string; + }; + [SCREENS.WORKSPACE.CONNECTIONS_RECEIPT_PARTNERS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DISTANCE_RATES_WORK_ARRANGEMENT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_SETUP]: { + policyID: string; + isIntuitEnterpriseSuite?: string; + isSandbox?: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.INTUIT_ENTERPRISE_SUITE_ENTITY_SELECTOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CHART_OF_ACCOUNTS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_LOCATIONS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_ITEMS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CLASSES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CUSTOM_DIMENSION]: { + policyID: string; + dimensionID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CUSTOMERS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_TAXES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_DATE_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_INVOICE_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_PREFERRED_EXPORTER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_TRAVEL_BILLING_CONFIGURATION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_TRAVEL_BILLING_VENDOR_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_CONFIGURATION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TRAVEL_BILLING_CONFIGURATION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TRAVEL_BILLING_JOURNAL_POSTING_PREFERENCE_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_ADVANCED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_FX_EXPENSE_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_EXPORT_PREFERRED_EXPORTER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_OUT_OF_POCKET_EXPENSE_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_OUT_OF_POCKET_EXPENSE_ENTITY_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_EXPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_REUSE_EXISTING_CONNECTIONS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_SETUP_MODAL]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_SETUP_REQUIRED_DEVICE_MODAL]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CHART_OF_ACCOUNTS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CLASSES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_ITEMS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.XERO_SETUP]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.ZOHO_BOOKS_OAUTH]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.XERO_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.XERO_CHART_OF_ACCOUNTS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.XERO_CUSTOMER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.XERO_ORGANIZATION]: { + policyID: string; + organizationID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.XERO_TAXES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.XERO_TRACKING_CATEGORIES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.XERO_MAP_TRACKING_CATEGORY]: { + policyID: string; + categoryId: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT_PURCHASE_BILL_DATE_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_TRAVEL_BILLING_CONFIGURATION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.XERO_ADVANCED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CLAIM_OFFER]: { + policyID: string; + integration: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_AUTO_SYNC]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_BILL_STATUS_SELECTOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_ACCOUNTING_METHOD]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_AUTO_SYNC]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_ACCOUNTING_METHOD]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT_BANK_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.XERO_INVOICE_ACCOUNT_SELECTOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT_PREFERRED_EXPORTER_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.XERO_BILL_PAYMENT_ACCOUNT_SELECTOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.XERO_FX_EXPENSE_ACCOUNT_SELECTOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_PREREQUISITES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.ENTER_SAGE_INTACCT_CREDENTIALS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ENTITY]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_REUSE_EXISTING_CONNECTIONS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TOKEN_INPUT]: { + policyID: string; + subPage?: string; + authType?: string; + isMigration?: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SETUP]: { + policyID: string; + accountID?: string; + isMigration?: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_MAPPING]: { + policyID: string; + importField: TupleToUnion; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOM_FIELD]: { + policyID: string; + importCustomField: TupleToUnion; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOM_FIELD_VIEW]: { + policyID: string; + importCustomField: TupleToUnion; + internalID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOM_FIELD_EDIT]: { + policyID: string; + importCustomField: TupleToUnion; + internalID: string; + fieldName: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOM_LIST_ADD]: { + policyID: string; + subPage?: string; + action?: 'edit'; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOM_LIST_SELECTOR]: { + policyID: string; + action?: 'edit'; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOM_SEGMENT_ADD]: { + policyID: string; + subPage?: string; + action?: 'edit'; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_PREFERRED_EXPORTER_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_DATE_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT_EXPENSES]: { + policyID: string; + expenseType: ValueOf; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT_EXPENSES_DESTINATION_SELECT]: { + policyID: string; + expenseType: ValueOf; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT_EXPENSES_VENDOR_SELECT]: { + policyID: string; + expenseType: ValueOf; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT_EXPENSES_PAYABLE_ACCOUNT_SELECT]: { + policyID: string; + expenseType: ValueOf; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT_EXPENSES_JOURNAL_POSTING_PREFERENCE_SELECT]: { + policyID: string; + expenseType: ValueOf; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_RECEIVABLE_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_INVOICE_ITEM_PREFERENCE_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_INVOICE_ITEM_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TAX_POSTING_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_PROVINCIAL_TAX_POSTING_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_ADVANCED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_REIMBURSEMENT_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_COLLECTION_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPENSE_REPORT_APPROVAL_LEVEL_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_VENDOR_BILL_APPROVAL_LEVEL_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_JOURNAL_ENTRY_APPROVAL_LEVEL_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_APPROVAL_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_FX_EXPENSE_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_CUSTOM_FORM_ID]: { + policyID: string; + expenseType: ValueOf; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_TOGGLE_MAPPING]: { + policyID: string; + mapping: SageIntacctMappingName; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_MAPPING_TYPE]: { + policyID: string; + mapping: SageIntacctMappingName; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_IMPORT_TAX]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADD_USER_DIMENSION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_USER_DIMENSIONS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_EDIT_USER_DIMENSION]: { + policyID: string; + dimensionName: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_EXPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREFERRED_EXPORTER]: { + policyID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_EXPORT_DATE]: { + policyID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_REIMBURSABLE_EXPENSES]: { + policyID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES]: { + policyID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_REIMBURSABLE_DESTINATION]: { + policyID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_NON_REIMBURSABLE_DESTINATION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_DEFAULT_VENDOR]: { + policyID: string; + reimbursable: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_TRAVEL_BILLING_CONFIGURATION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADVANCED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_AUTO_SYNC]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_ACCOUNTING_METHOD]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_FX_EXPENSE_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_PREREQUISITES]: { + policyID: string; + isSandbox?: string; + subPage?: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_EXISTING_CONNECTIONS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_DIMENSION_MAPPING]: { + policyID: string; + dimension: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_EXPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_PREFERRED_EXPORTER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_EXPORT_STATUS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_EXPORT_DATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_DEFAULT_VENDOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_ADVANCED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_FX_EXPENSE_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_TAGS_MAPPING]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_REPORT_EXPORT_STATUS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_COMPANY_SELECTOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_SETUP]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_EXISTING_CONNECTIONS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_SUBSIDIARY_SELECTOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_EXPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_PREFERRED_EXPORTER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_VENDOR_BILL_DATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_COMPANY_CARD_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_DEFAULT_COMPANY_CARD_VENDOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_CARD_PROGRAM_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_CARD_PROGRAM_ACCOUNT_SELECTOR]: { + policyID: string; + feed: CardFeedWithDomainID; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_CARD_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_CARD_ACCOUNT_CARD_LIST]: { + policyID: string; + feed: CardFeedWithDomainID; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_ADVANCED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_EXPORT_METHOD]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_BILL_PAYMENT_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_FX_EXPENSE_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_EXPENSIFY_CARD_SETTLEMENT_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.RILLET_TRAVEL_BILLING_SETTLEMENT_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_SETUP]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_EXISTING_CONNECTIONS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_SUBSIDIARY_SELECTOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_EXPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_PREFERRED_EXPORTER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_VENDOR_BILL_DATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_COMPANY_CARD_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_DEFAULT_COMPANY_CARD_VENDOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_CARD_PROGRAM_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_CARD_PROGRAM_ACCOUNT_SELECTOR]: { + policyID: string; + feed: CardFeedWithDomainID; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_CARD_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_CARD_ACCOUNT_CARD_LIST]: { + policyID: string; + feed: CardFeedWithDomainID; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_ADVANCED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_EXPORT_METHOD]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_BILL_PAYMENT_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_FX_EXPENSE_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_EXPENSIFY_CARD_SETTLEMENT_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_TRAVEL_BILLING_SETTLEMENT_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_TRAVEL_BILLING_PAYABLE_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_SETUP]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_EXISTING_CONNECTIONS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_SUBSIDIARY_SELECTOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_EXPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_PREFERRED_EXPORTER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_VENDOR_BILL_DATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_DEFAULT_COMPANY_CARD_VENDOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_COMPANY_CARD_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_CARD_PROGRAM_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_CARD_PROGRAM_ACCOUNT_SELECTOR]: { + policyID: string; + feed: CardFeedWithDomainID; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_CARD_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_CARD_ACCOUNT_CARD_LIST]: { + policyID: string; + feed: CardFeedWithDomainID; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_ADVANCED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_EXPORT_METHOD]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_BILL_PAYMENT_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_EXPENSIFY_CARD_SETTLEMENT_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_TRAVEL_BILLING_SETTLEMENT_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_TRAVEL_BILLING_PAYABLE_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_PREREQUISITES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_SETUP]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_COMPANY_SELECTOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_PREFERRED_EXPORTER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_DATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_REIMBURSABLE_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_NONREIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_COMPANY_CARD_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_DEFAULT_VENDOR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_PAYMENT_METHOD]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_ADVANCED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_ACCOUNTING_METHOD]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_ADVANCED_POSTING_MODE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_BANK_ACCOUNT_SELECTOR]: { + policyID: string; + settingName: BusinessCentralBankAccountSettingName; + }; + [SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION]: { + policyID: string; + connection: ValueOf; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_RECONCILIATION_ACCOUNT_SETTINGS]: { + policyID: string; + connection: ValueOf; + reconciliationAccountSettingsType?: ValueOf; + fundID?: string; + }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_RECONCILIATION_SELECT_FEED]: { + policyID: string; + connection: ValueOf; + fundID?: string; + }; + [SCREENS.WORKSPACE.TAX_CREATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.TAX_CREATE_VALUE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.TAX_EDIT]: { + policyID: string; + taxID: string; + }; + [SCREENS.WORKSPACE.TAX_NAME]: { + policyID: string; + taxID: string; + }; + [SCREENS.WORKSPACE.TAX_VALUE]: { + policyID: string; + taxID: string; + }; + [SCREENS.WORKSPACE.TAX_CODE]: { + policyID: string; + taxID: string; + }; + [SCREENS.WORKSPACE.INVOICES_COMPANY_NAME]: { + policyID: string; + }; + [SCREENS.WORKSPACE.INVOICES_COMPANY_WEBSITE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.COMPANY_CARDS_SELECT_FEED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_DETAILS]: { + policyID: string; + feed: CompanyCardFeedWithDomainID; + cardID: string; + }; + [SCREENS.WORKSPACE.COMPANY_CARD_EDIT_CARD_NAME]: { + policyID: string; + feed: CompanyCardFeedWithDomainID; + cardID: string; + }; + [SCREENS.WORKSPACE.COMPANY_CARD_EDIT_TRANSACTION_START_DATE]: { + policyID: string; + feed: string; + cardID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT]: { + policyID: string; + cardID: string; + feed: CompanyCardFeedWithDomainID; + }; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_CONFIRM_VALIDATE_CODE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.EXPENSIFY_CARD_BANK_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.EXPENSIFY_CARD_SETTINGS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_SETTINGS_ACCOUNT]: { + policyID: string; + fundID?: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_SELECT_FEED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_TRAVEL_BILLING_SELECT_FEED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.EXPENSIFY_CARD_SETTINGS_FREQUENCY]: { + policyID: string; + }; + [SCREENS.WORKSPACE.EXPENSIFY_CARD_ADD_WORK_EMAIL]: { + policyID: string; + fundID: number; + }; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_SELECTION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_MERCHANTS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_MERCHANT_EDIT]: { + policyID: string; + merchantIndex: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_CATEGORY]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_CURRENCY]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_MAX_AMOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.EXPENSIFY_CARD_VERIFY_WORK_EMAIL]: { + policyID: string; + fundID: number; + }; + [SCREENS.WORKSPACE.TRAVEL_SETTINGS_ACCOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.TRAVEL_SETTINGS_FREQUENCY]: { + policyID: string; + }; + [SCREENS.WORKSPACE.TRAVEL_SETTINGS_MONTHLY_LIMIT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.TRAVEL_EXPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.TRAVEL_MISSING_PERSONAL_DETAILS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.COMPANY_CARDS_BROKEN_CARD_FEED_CONNECTION]: { + policyID: string; + feed: CompanyCardFeedWithDomainID; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.WORKSPACE.COMPANY_CARDS_REFRESH_CARD_FEED_CONNECTION]: { + policyID: string; + feed: CompanyCardFeedWithDomainID; + }; + [SCREENS.WORKSPACE.COMPANY_CARD_ADD_WORK_EMAIL]: { + policyID: string; + feed: CompanyCardFeedWithDomainID; + }; + [SCREENS.WORKSPACE.COMPANY_CARD_VERIFY_WORK_EMAIL]: { + policyID: string; + feed: CompanyCardFeedWithDomainID; + }; + [SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARDS_ASSIGN_CARD_ASSIGNEE]: { + policyID: string; + feed: CompanyCardFeedWithDomainID; + cardID: string; + }; + [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD_CARD_SELECTION]: { + policyID: string; + feed: CompanyCardFeedWithDomainID; + cardID: string; + }; + [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD_TRANSACTION_START_DATE]: { + policyID: string; + feed: CompanyCardFeedWithDomainID; + cardID: string; + }; + [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD_CARD_NAME]: { + policyID: string; + feed: CompanyCardFeedWithDomainID; + cardID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARDS_ASSIGN_CARD_CONFIRMATION]: { + policyID: string; + feed: CompanyCardFeedWithDomainID; + cardID: string; + }; + [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD_INVITE_NEW_MEMBER]: { + policyID: string; + feed: CompanyCardFeedWithDomainID; + cardID: string; + }; + [SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS_FEED_NAME]: { + policyID: string; + }; + [SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS_STATEMENT_CLOSE_DATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_EXPENSIFY_CARD_DETAILS]: { + policyID: string; + cardID: string; + }; + [SCREENS.EXPENSIFY_CARD.DYNAMIC_EXPENSIFY_CARD_DETAILS]: { + policyID: string; + cardID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo?: Routes; + }; + [SCREENS.EXPENSIFY_CARD.DYNAMIC_EXPENSIFY_CARD_NAME]: { + policyID: string; + cardID: string; + }; + [SCREENS.EXPENSIFY_CARD.DYNAMIC_EXPENSIFY_CARD_LIMIT]: { + policyID: string; + cardID: string; + }; + [SCREENS.EXPENSIFY_CARD.DYNAMIC_EXPENSIFY_CARD_LIMIT_TYPE]: { + policyID: string; + cardID: string; + }; + [SCREENS.WORKSPACE.REPORTS_DEFAULT_TITLE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_AUTO_APPROVE_REPORTS_UNDER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_RANDOM_REPORT_AUDIT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_AUTO_PAY_REPORTS_UNDER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_RECEIPT_REQUIRED_AMOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_ITEMIZED_RECEIPT_REQUIRED_AMOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_REQUIRE_RECEIPTS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AMOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_REIMBURSABLE_DEFAULT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_HR_SYNC_RESULTS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_RECRUITING_SYNC_RESULTS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.HR_GUSTO_APPROVAL_MODE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.HR_GUSTO_FINAL_APPROVER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.HR_ZENEFITS_APPROVAL_MODE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.HR_ZENEFITS_FINAL_APPROVER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.HR_MERGE_APPROVAL_MODE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.HR_MERGE_FINAL_APPROVER]: { + policyID: string; + }; + [SCREENS.WORKSPACE.HR_MERGE_GROUPS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_CUSTOM]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_NEW]: { + policyID: string; + categoryName?: string; + }; + [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { + policyID: string; + categoryName?: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW]: { + policyID: string; + categoryName?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MATCH_TYPE]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_CATEGORY]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAG]: { + policyID: string; + orderWeight: number | string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAX]: { + policyID: string; + ruleID?: undefined; + /** Set when opened from a category tax default in the category details RHP, absent from the callout flow */ + categoryName?: string; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_VENDOR]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_DESCRIPTION]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_REIMBURSABLE]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_BILLABLE]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORTED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_SPEND_NEW]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_NEW]: { + policyID: string; + categoryName?: string; + }; + [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_EDIT]: { + policyID: string; + categoryName: string; + /** When 'true', category field is locked (e.g. opened from category details RHP). */ + isCategoryLocked?: string; + }; + [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_CATEGORY]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_CATEGORY_EDIT]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_NEW]: { + policyID: string; + categoryName?: string; + }; + [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_EDIT]: { + policyID: string; + categoryName: string; + /** When 'true', category field is locked (e.g. opened from category details RHP). */ + isCategoryLocked?: string; + }; + [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_CATEGORY]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_CATEGORY_EDIT]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_AMOUNT]: { + policyID: string; + /** When 'true', return to create with ?categoryName= so the locked category create entry matches. */ + isCategoryLocked?: string; + }; + [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_AMOUNT_EDIT]: { + policyID: string; + categoryName: string; + /** When 'true', preserve locked category on return to the edit screen. */ + isCategoryLocked?: string; + }; + [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_EXPENSE_LIMIT_TYPE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_EXPENSE_LIMIT_TYPE_EDIT]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_TYPE_EDIT]: { + policyID: string; + groupID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_TYPE_CATEGORY]: { + policyID: string; + groupID: string; + }; + [SCREENS.WORKSPACE.RULES_SPEND_EDIT]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_SPEND_CARD]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_SPEND_CATEGORY]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_SPEND_MAX_AMOUNT]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_SPEND_CURRENCIES]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_SPEND_MERCHANTS]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_SPEND_MERCHANT_EDIT]: { + policyID: string; + ruleID: string; + merchantIndex: string; + }; + [SCREENS.WORKSPACE.RULES_AGENT_NEW]: { + policyID: string; + generationID?: string; + }; + [SCREENS.WORKSPACE.RULES_AGENT_EDIT]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_MATCH_TYPE]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_CATEGORY]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_TAG]: { + policyID: string; + ruleID: string; + orderWeight: number; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_TAX]: { + policyID: string; + ruleID: string; + /** Set when editing a category tax default, so the picker returns to that editor. */ + categoryName?: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_VENDOR]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_DESCRIPTION]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_REIMBURSABLE]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_EDIT]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_PREVIEW_MATCHES]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_CATEGORY_TO_MATCH]: { + policyID: string; + ruleID: string; + categoryName?: string; + }; + [SCREENS.WORKSPACE.RULES_CATEGORY_TAX_EDIT]: { + policyID: string; + categoryName: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_IMPORTED]: { + policyID: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_DETAILS]: { + policyID: string; + rateID: string; + subRateID: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_EDIT_DESTINATION]: { + policyID: string; + rateID: string; + subRateID: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_EDIT_SUBRATE]: { + policyID: string; + rateID: string; + subRateID: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_EDIT_AMOUNT]: { + policyID: string; + rateID: string; + subRateID: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_EDIT_CURRENCY]: { + policyID: string; + rateID: string; + subRateID: string; + }; + [SCREENS.WORKSPACE.TIME_TRACKING_DEFAULT_RATE]: { + policyID: string; + }; +}; + +export default WorkspaceSettingsParamList; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index d48e1c39b4fd..3b0c214c2848 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1,30 +1,28 @@ import type {MultifactorAuthenticationPromptType} from '@components/MultifactorAuthentication/config/types'; import type {SearchQueryString} from '@components/Search/types'; -import type {ReplacementReason} from '@libs/actions/Card'; import type {ReimbursementAccountStepToOpen} from '@libs/ReimbursementAccountUtils'; import type {SearchKey} from '@libs/SearchKeyUtils'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import type {AttachmentModalContainerModalProps} from '@pages/media/AttachmentModalScreen/types'; -import type RECONCILIATION_ACCOUNT_SETTINGS_TYPE from '@pages/workspace/accounting/reconciliation/constants'; -import type {Country, EnablePaymentsPageType, EnablePaymentsSubPageType, IOUAction, IOURequestType, IOUType, OdometerImageType} from '@src/CONST'; +import type {IOUAction, IOURequestType, IOUType, OdometerImageType} from '@src/CONST'; import type CONST from '@src/CONST'; import type NAVIGATORS from '@src/NAVIGATORS'; import type {Route as ExpensifyRoute, Route as Routes} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {ExpenseRuleFormFieldID} from '@src/types/form/ExpenseRuleForm'; -import type {CardFeedWithDomainID, CompanyCardFeedWithDomainID} from '@src/types/onyx'; -import type {ConnectionName, PolicyReportFieldType, SageIntacctMappingName} from '@src/types/onyx/Policy'; -import type {CustomFieldType} from '@src/types/onyx/PolicyEmployee'; +import type {ConnectionName} from '@src/types/onyx/Policy'; import type {FileObject} from '@src/types/utils/Attachment'; import type {CommonActions, NavigationContainerRefWithCurrent, NavigationState, NavigatorScreenParams, ParamListBase, PartialRoute, PartialState, Route} from '@react-navigation/native'; -import type {TupleToUnion, ValueOf} from 'type-fest'; +import type {ValueOf} from 'type-fest'; import type {UpperCaseCharacters} from 'type-fest/source/internal'; import type {SIDEBAR_TO_SPLIT} from './linkingConfig/RELATIONS'; +import type AccountSettingsParamList from './paramLists/AccountSettingsParamList'; +import type DomainSettingsParamList from './paramLists/DomainSettingsParamList'; +import type WorkspaceSettingsParamList from './paramLists/WorkspaceSettingsParamList'; type NavigationRef = NavigationContainerRefWithCurrent; @@ -70,2188 +68,7 @@ type BackToParams = { backTo?: Routes; }; -type SettingsNavigatorParamList = { - [SCREENS.SETTINGS.SHARE_CODE]: undefined; - [SCREENS.SETTINGS.PROFILE.PRONOUNS]: undefined; - [SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: undefined; - [SCREENS.SETTINGS.PROFILE.TIMEZONE]: undefined; - [SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT]: undefined; - [SCREENS.SETTINGS.PROFILE.PRIVATE_PERSONAL_DETAILS]: { - fieldToFocus?: string; - }; - [SCREENS.SETTINGS.PROFILE.PRIVATE_PERSONAL_DETAILS_CONFIRM_VALIDATE_CODE]: undefined; - [SCREENS.SETTINGS.PROFILE.ADDRESS]: { - country?: Country | ''; - }; - [SCREENS.SETTINGS.PROFILE.DYNAMIC_ADDRESS_COUNTRY]: { - country?: string; - }; - [SCREENS.SETTINGS.PROFILE.DYNAMIC_ADDRESS_STATE]: { - state?: string; - label?: string; - }; - [SCREENS.SETTINGS.PROFILE.DYNAMIC_CONTACT_METHODS]: undefined; - [SCREENS.SETTINGS.PROFILE.DYNAMIC_CONTACT_METHOD_DETAILS]: { - contactMethod: string; - shouldSkipInitialValidation?: string; - }; - [SCREENS.SETTINGS.PROFILE.DYNAMIC_NEW_CONTACT_METHOD]: undefined; - [SCREENS.SETTINGS.PROFILE.DYNAMIC_NEW_CONTACT_METHOD_CONFIRM_VALIDATE_CODE]: undefined; - [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_SET_DEFAULT_CONFIRM]: { - contactMethod: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: undefined; - [SCREENS.SETTINGS.PREFERENCES.PAYMENT_CURRENCY]: undefined; - [SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: undefined; - [SCREENS.SETTINGS.PREFERENCES.THEME]: undefined; - [SCREENS.SETTINGS.TROUBLESHOOT_SERVER]: undefined; - [SCREENS.SETTINGS.CLOSE]: undefined; - [SCREENS.SETTINGS.CLOSE_ACCOUNT_CONFIRM_VALIDATE_CODE]: undefined; - [SCREENS.SETTINGS.MERGE_ACCOUNTS.ACCOUNT_DETAILS]: { - email?: string; - }; - [SCREENS.SETTINGS.MERGE_ACCOUNTS.ACCOUNT_VALIDATE]: { - login: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - forwardTo?: Routes; - }; - [SCREENS.SETTINGS.MERGE_ACCOUNTS.MERGE_RESULT]: { - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - result: ValueOf; - login: string; - }; - [SCREENS.SETTINGS.LOCK.LOCK_ACCOUNT]: undefined; - [SCREENS.SETTINGS.LOCK.UNLOCK_ACCOUNT]: undefined; - [SCREENS.SETTINGS.LOCK.FAILED_TO_LOCK_ACCOUNT]: undefined; - [SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: { - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - - /** Whether the validation was opened from the join-workspace Concierge task. */ - isJoinWorkspaceTask?: 'true'; - }; - [SCREENS.SETTINGS.DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT]: - | { - /** Whether the bank account purpose screen should be skipped after the account is validated. */ - shouldSkipPurposeSelection?: 'true'; - - /** Whether the US bank account flow should open after the account is validated. */ - shouldSetUpUSBankAccount?: 'true'; - } - | undefined; - [SCREENS.SETTINGS.DYNAMIC_EXIT_SURVEY_REASON]: undefined; - [SCREENS.SETTINGS.DYNAMIC_EXIT_SURVEY_CONFIRM]: undefined; - [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: undefined; - [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: { - cardID: string; - }; - [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_DETAILS]: { - cardID: string; - }; - [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_EDIT_NAME]: { - cardID: string; - }; - [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_EDIT_TRANSACTION_START_DATE]: { - cardID: string; - }; - [SCREENS.SETTINGS.WALLET.DOMAIN_CARD_CONFIRM_VALIDATE_CODE]: { - cardID: string; - }; - [SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD]: { - cardID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD_CONFIRMATION]: { - cardID: string; - }; - [SCREENS.SETTINGS.WALLET.CARD_ADD_TO_DIGITAL_WALLET]: { - /** cardID of the card waiting to be added to a digital wallet */ - cardID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.SETTINGS.WALLET.CARD_ACTIVATE]: { - cardID: string; - - /** Whether the flow was launched from the top-level DomainCard route (deep-linked from OldDot) rather than the Settings wallet card route */ - isFromDomainCardDetail?: string; - }; - [SCREENS.SETTINGS.WALLET.CARD_ADDED_TO_WALLET]: { - /** cardID of selected card */ - cardID: string; - }; - [SCREENS.SETTINGS.WALLET.CARD_CHANGE_PIN]: { - cardID: string; - }; - [SCREENS.SETTINGS.WALLET.CARD_CHANGE_PIN_ATM]: { - cardID: string; - }; - [SCREENS.SETTINGS.WALLET.EXPENSIFY_CARD_SPEND_RULES]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.WORKFLOWS_PAYER]: { - policyID: string; - }; - [SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE]: undefined; - [SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT]: undefined; - [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS]: undefined; - [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS_CARD_NAME]: undefined; - [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS_CURRENCY]: undefined; - [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS_SPREADSHEET]: {cardID?: string}; - [SCREENS.SETTINGS.WALLET.TRANSACTIONS_IMPORTED]: {cardID?: string}; - [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS]: { - page?: EnablePaymentsPageType; - subPage?: EnablePaymentsSubPageType; - action?: 'edit'; - }; - [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS_CONFIRM_VALIDATE_CODE]: undefined; - [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_PAYMENTS_CONFIRM_VALIDATE_CODE]: undefined; - [SCREENS.SETTINGS.WALLET.UNSHARE_BANK_ACCOUNT]: { - bankAccountID: string; - }; - [SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS]: { - bankAccountID: string; - subPage: string; - action?: 'edit'; - bankCountry?: string; - bankCurrency?: string; - }; - [SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS]: { - bankAccountID: string; - bankCountry?: string; - bankCurrency?: string; - }; - [SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_SIGN]: { - bankAccountID: string; - bankCountry?: string; - bankCurrency?: string; - }; - [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS]: { - bankAccountID: string; - subPage: string; - action?: 'edit'; - bankCountry?: string; - bankCurrency?: string; - }; - [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS]: { - bankAccountID: string; - bankCountry?: string; - bankCurrency?: string; - }; - [SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN]: { - bankAccountID: string; - bankCountry?: string; - bankCurrency?: string; - }; - [SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT]: { - bankAccountID: string; - }; - [SCREENS.SETTINGS.WALLET.EDIT_BANK_ACCOUNT_NICKNAME]: { - bankAccountID: string; - }; - [SCREENS.SETTINGS.WALLET.TRAVEL_CVV]: undefined; - [SCREENS.SETTINGS.WALLET.TRAVEL_CVV_VERIFY_ACCOUNT]: undefined; - [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_ADD_NEW]: undefined; - [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_FIX_CONNECTION]: { - cardID: string; - }; - [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_WARNING]: undefined; - [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_UPGRADE]: undefined; - [SCREENS.SETTINGS.ADD_DEBIT_CARD]: undefined; - [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: { - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - subPage?: string; - action?: 'edit'; - }; - [SCREENS.SETTINGS.COLLECT_DEPOSIT_ACCOUNT]: { - subPage?: string; - action?: 'edit'; - }; - [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT]: { - subPage?: string; - - /** Whether the user is editing one of the sub pages */ - action?: 'edit'; - }; - [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT_ENTRY_POINT]: undefined; - [SCREENS.SETTINGS.UPDATE_PERSONAL_BANK_ACCOUNT]: { - subPage?: string; - }; - [SCREENS.SETTINGS.BANK_ACCOUNT_PURPOSE]: undefined; - [SCREENS.SETTINGS.AGENTS.NEW]: { - policyID?: string; - }; - [SCREENS.SETTINGS.AGENTS.ADD]: { - policyID?: string; - }; - [SCREENS.SETTINGS.AGENTS.ADD_AVATAR]: undefined; - [SCREENS.SETTINGS.AGENTS.EDIT]: { - accountID: number; - }; - [SCREENS.SETTINGS.AGENTS.EDIT_NAME]: { - accountID: number; - }; - [SCREENS.SETTINGS.AGENTS.EDIT_PROMPT]: { - accountID: number; - }; - [SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]: { - accountID: number; - }; - [SCREENS.SETTINGS.RULES.ADD]: undefined; - [SCREENS.SETTINGS.RULES.ADD_MERCHANT]: undefined; - [SCREENS.SETTINGS.RULES.ADD_RENAME_MERCHANT]: undefined; - [SCREENS.SETTINGS.RULES.ADD_CATEGORY]: undefined; - [SCREENS.SETTINGS.RULES.ADD_TAG]: { - index: number; - }; - [SCREENS.SETTINGS.RULES.ADD_TAX]: undefined; - [SCREENS.SETTINGS.RULES.ADD_DESCRIPTION]: undefined; - [SCREENS.SETTINGS.RULES.ADD_REIMBURSABLE]: undefined; - [SCREENS.SETTINGS.RULES.ADD_BILLABLE]: undefined; - [SCREENS.SETTINGS.RULES.ADD_REPORT]: undefined; - [SCREENS.SETTINGS.RULES.EDIT]: { - hash: string; - field?: ExpenseRuleFormFieldID; - }; - [SCREENS.SETTINGS.RULES.EDIT_MERCHANT]: { - hash: string; - }; - [SCREENS.SETTINGS.RULES.EDIT_RENAME_MERCHANT]: { - hash: string; - }; - [SCREENS.SETTINGS.RULES.EDIT_CATEGORY]: { - hash: string; - }; - [SCREENS.SETTINGS.RULES.EDIT_TAG]: { - hash: string; - index: number; - }; - [SCREENS.SETTINGS.RULES.EDIT_TAX]: { - hash: string; - }; - [SCREENS.SETTINGS.RULES.EDIT_DESCRIPTION]: { - hash: string; - }; - [SCREENS.SETTINGS.RULES.EDIT_REIMBURSABLE]: { - hash: string; - }; - [SCREENS.SETTINGS.RULES.EDIT_BILLABLE]: { - hash: string; - }; - [SCREENS.SETTINGS.RULES.EDIT_REPORT]: { - hash: string; - }; - [SCREENS.SETTINGS.PROFILE.STATUS]: undefined; - [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER]: undefined; - [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_MISSING_WORKSPACES]: undefined; - [SCREENS.WORKSPACE.CURRENCY]: { - isForcedToChangeCurrency?: boolean; - shouldStartExpensifyCardEnrollment?: boolean; - }; - [SCREENS.WORKSPACE.CURRENCY_GOVERNMENT_RATE_COUNTRY]: { - policyID: string; - currencyCode: string; - isForcedToChangeCurrency?: boolean; - shouldStartExpensifyCardEnrollment?: boolean; - }; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_OVERVIEW_ADDRESS]: { - policyID: string; - country?: Country | ''; - }; - [SCREENS.WORKSPACE.NAME]: undefined; - [SCREENS.WORKSPACE.DESCRIPTION]: undefined; - [SCREENS.WORKSPACE.CLIENT_ID]: undefined; - [SCREENS.WORKSPACE.SHARE]: undefined; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_INVITE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.MEMBERS_IMPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.WORKFLOWS_IMPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.WORKFLOWS_IMPORTED]: { - policyID: string; - }; - [SCREENS.WORKSPACE.WORKFLOWS_IMPORTED_CONFIRMATION]: { - policyID: string; - }; - [SCREENS.WORKSPACE.MEMBERS_IMPORTED]: { - policyID: string; - }; - [SCREENS.WORKSPACE.MEMBERS_IMPORTED_CONFIRMATION]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_INVITE_MESSAGE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_INVITE_MESSAGE_ROLE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.INVITE_MESSAGE_APPROVER]: { - policyID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_CREATE]: { - policyID: string; - }; - [SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_CREATE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_EDIT]: { - policyID: string; - categoryName: string; - }; - [SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_EDIT]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_PAYROLL_CODE]: { - policyID: string; - categoryName: string; - }; - [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORY_PAYROLL_CODE]: { - policyID: string; - categoryName: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_GL_CODE]: { - policyID: string; - categoryName: string; - }; - [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORY_GL_CODE]: { - policyID: string; - categoryName: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_DEFAULT_TAX_RATE]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_AMOUNTS_OVER]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_EXPENSE_LIMIT_TYPE_SELECTOR]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_IMPORTED_MEMBERS_ROLE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_DESCRIPTION_HINT]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_APPROVER]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_REQUIRE_RECEIPTS_OVER]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_REQUIRE_ITEMIZED_RECEIPTS_OVER]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_SETTINGS]: { - policyID: string; - categoryName: string; - }; - [SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_REQUIRED_FIELDS]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_RULES_NEW]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_FOR_REVIEW_RULE_NEW]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_FOR_REVIEW_RULE_EDIT]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_FOR_REVIEW_RULE_AMOUNT]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_REQUIRE_FIELDS_RULE_NEW]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_REQUIRE_FIELDS_RULE_EDIT]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_NEW]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_EDIT]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.UPGRADE]: { - policyID?: string; - featureName?: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - categoryId?: string; - reportID?: string; - upgradePlanType?: ValueOf; - }; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_DOWNGRADE]: { - policyID?: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_PAY_AND_DOWNGRADE]: { - policyID?: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORIES_SETTINGS]: { - policyID: string; - }; - [SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORIES_SETTINGS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_SPEND_CATEGORY_SELECTOR]: { - policyID: string; - groupID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_DEFAULT_CATEGORY_SELECTOR]: { - policyID: string; - customUnitID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORIES_IMPORT]: { - policyID: string; - }; - [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_IMPORT]: { - policyID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORIES_IMPORTED]: { - policyID: string; - }; - [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_IMPORTED]: { - policyID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.DYNAMIC_TAG_CREATE]: { - policyID: string; - }; - [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_CREATE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS]: { - policyID: string; - rateID: string; - }; - [SCREENS.WORKSPACE.DISTANCE_RATE_EDIT]: { - policyID: string; - rateID: string; - }; - [SCREENS.WORKSPACE.DISTANCE_RATE_NAME_EDIT]: { - policyID: string; - rateID: string; - }; - [SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT]: { - policyID: string; - rateID: string; - }; - [SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RATE_EDIT]: { - policyID: string; - rateID: string; - }; - [SCREENS.WORKSPACE.DISTANCE_RATE_START_DATE_EDIT]: { - policyID: string; - rateID: string; - }; - [SCREENS.WORKSPACE.DISTANCE_RATE_END_DATE_EDIT]: { - policyID: string; - rateID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_TAGS_SETTINGS]: { - policyID: string; - }; - [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAGS_SETTINGS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_TAGS_IMPORT]: { - policyID: string; - }; - [SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_IMPORT]: { - policyID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.TAGS_IMPORT_OPTIONS]: { - policyID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.TAGS_IMPORT_MULTI_LEVEL_SETTINGS]: { - policyID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.DYNAMIC_TAGS_IMPORTED]: { - policyID: string; - }; - [SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_IMPORTED]: { - policyID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.TAGS_IMPORTED_MULTI_LEVEL]: { - policyID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.DYNAMIC_TAG_SETTINGS]: { - policyID: string; - orderWeight: number; - tagName: string; - parentTagsFilter?: string; - }; - [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_SETTINGS]: { - policyID: string; - orderWeight: number; - tagName: string; - parentTagsFilter?: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_TAG_LIST_VIEW]: { - policyID: string; - orderWeight: number | string; - }; - [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_LIST_VIEW]: { - policyID: string; - orderWeight: number | string; - }; - [SCREENS.WORKSPACE.DYNAMIC_TAGS_EDIT]: { - policyID: string; - orderWeight: number; - }; - [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAGS_EDIT]: { - policyID: string; - orderWeight: number; - }; - [SCREENS.WORKSPACE.DYNAMIC_TAG_EDIT]: { - policyID: string; - orderWeight: number; - tagName: string; - }; - [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_EDIT]: { - policyID: string; - orderWeight: number; - tagName: string; - }; - [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_GL_CODE]: { - policyID: string; - orderWeight: number; - tagName: string; - parentTagsFilter?: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_TAG_APPROVER]: { - policyID: string; - orderWeight: number; - tagName: string; - }; - [SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_APPROVER]: { - policyID: string; - orderWeight: number; - tagName: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_TAG_GL_CODE]: { - policyID: string; - orderWeight: number; - tagName: string; - parentTagsFilter?: string; - }; - [SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: { - canChangeSize: 0 | 1; - subPage: string; - }; - [SCREENS.SETTINGS.SUBSCRIPTION.EXPENSIFY_CODE]: undefined; - [SCREENS.SETTINGS.SUBSCRIPTION.PAYMENT_HISTORY]: undefined; - [SCREENS.SETTINGS.SUBSCRIPTION.PAYMENT_DETAILS]: { - purchaseID: string; - }; - [SCREENS.SETTINGS.SUBSCRIPTION.SETTINGS_DETAILS]: undefined; - [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD]: undefined; - [SCREENS.SETTINGS.SUBSCRIPTION.EARLY_RENEWAL]: undefined; - [SCREENS.SETTINGS.EMAIL_ISSUE]: undefined; - [SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_BILLING_CURRENCY]: undefined; - [SCREENS.SETTINGS.SUBSCRIPTION.DYNAMIC_PAYMENT_CARD_CURRENCY_SELECTOR]: undefined; - [SCREENS.SETTINGS.SUBSCRIPTION.DYNAMIC_SUBSCRIPTION_DOWNGRADE_BLOCKED]: undefined; - [SCREENS.WORKSPACE.TAXES_SETTINGS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME]: { - policyID: string; - }; - [SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.INVOICE_FIELDS_CREATE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_REPORT_FIELDS_INITIAL_LIST_VALUE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_INVOICE_FIELDS_INITIAL_LIST_VALUE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: { - policyID: string; - reportFieldID?: string; - }; - [SCREENS.WORKSPACE.INVOICE_FIELDS_LIST_VALUES]: { - policyID: string; - reportFieldID?: string; - }; - [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: { - policyID: string; - reportFieldID?: string; - }; - [SCREENS.WORKSPACE.INVOICE_FIELDS_ADD_VALUE]: { - policyID: string; - reportFieldID?: string; - }; - [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: { - policyID: string; - valueIndex: number; - reportFieldID?: string; - }; - [SCREENS.WORKSPACE.INVOICE_FIELDS_VALUE_SETTINGS]: { - policyID: string; - valueIndex: number; - reportFieldID?: string; - }; - [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: { - policyID: string; - valueIndex: number; - }; - [SCREENS.WORKSPACE.INVOICE_FIELDS_EDIT_VALUE]: { - policyID: string; - valueIndex: number; - }; - [SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS]: { - policyID: string; - reportFieldID: string; - }; - [SCREENS.WORKSPACE.INVOICE_FIELDS_SETTINGS]: { - policyID: string; - reportFieldID: string; - }; - [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: { - policyID: string; - reportFieldID: string; - }; - [SCREENS.WORKSPACE.INVOICE_FIELDS_EDIT_INITIAL_VALUE]: { - policyID: string; - reportFieldID: string; - }; - [SCREENS.WORKSPACE.REPORT_FIELDS_TYPE_SELECTOR]: { - policyID: string; - currentType?: PolicyReportFieldType; - }; - [SCREENS.WORKSPACE.INVOICE_FIELDS_TYPE_SELECTOR]: { - policyID: string; - currentType?: PolicyReportFieldType; - }; - [SCREENS.WORKSPACE.MEMBER_DETAILS]: { - policyID: string; - accountID: string; - }; - [SCREENS.WORKSPACE.ROOM_CREATE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE]: { - policyID: string; - accountID: string; - }; - [SCREENS.WORKSPACE.MEMBER_WORK_ARRANGEMENT]: { - policyID: string; - accountID: string; - }; - [SCREENS.WORKSPACE.MEMBER_CUSTOM_FIELD]: { - policyID: string; - accountID: string; - customFieldType: CustomFieldType; - }; - [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_SUCCESS]: { - policyID: string; - accountID: number; - }; - [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_ERROR]: { - policyID: string; - accountID: number; - }; - [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_CHECK]: { - policyID: string; - accountID: number; - error: ValueOf; - }; - [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: { - policyID: string; - transactionID?: string; - reportID?: string; - action?: IOUAction; - iouType?: IOUType; - }; - [SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DISTANCE_RATES_UNIT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DISTANCE_RATES_GOVERNMENT_RATE_COUNTRY]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DISTANCE_RATES_COMMUTER_EXCLUSIONS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING]: { - policyID: string; - }; - [SCREENS.WORKSPACE.CONNECTIONS_HR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.CONNECTIONS_RECRUITING]: { - policyID: string; - }; - [SCREENS.WORKSPACE.CONNECTIONS_RECEIPT_PARTNERS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DISTANCE_RATES_WORK_ARRANGEMENT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_SETUP]: { - policyID: string; - isIntuitEnterpriseSuite?: string; - isSandbox?: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.INTUIT_ENTERPRISE_SUITE_ENTITY_SELECTOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_IMPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CHART_OF_ACCOUNTS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_LOCATIONS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_ITEMS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CLASSES]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CUSTOM_DIMENSION]: { - policyID: string; - dimensionID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CUSTOMERS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_TAXES]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_DATE_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_INVOICE_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_PREFERRED_EXPORTER]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_TRAVEL_BILLING_CONFIGURATION]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_TRAVEL_BILLING_VENDOR_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_CONFIGURATION]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TRAVEL_BILLING_CONFIGURATION]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TRAVEL_BILLING_JOURNAL_POSTING_PREFERENCE_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_ADVANCED]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_FX_EXPENSE_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_EXPORT_PREFERRED_EXPORTER]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_OUT_OF_POCKET_EXPENSE_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_OUT_OF_POCKET_EXPENSE_ENTITY_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_DESKTOP_EXPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_REUSE_EXISTING_CONNECTIONS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_SETUP_MODAL]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_SETUP_REQUIRED_DEVICE_MODAL]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_IMPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CHART_OF_ACCOUNTS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CLASSES]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_ITEMS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.XERO_SETUP]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.XERO_IMPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.XERO_CHART_OF_ACCOUNTS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.XERO_CUSTOMER]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.XERO_ORGANIZATION]: { - policyID: string; - organizationID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.XERO_TAXES]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.XERO_TRACKING_CATEGORIES]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.XERO_MAP_TRACKING_CATEGORY]: { - policyID: string; - categoryId: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT_PURCHASE_BILL_DATE_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_TRAVEL_BILLING_CONFIGURATION]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.XERO_ADVANCED]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CLAIM_OFFER]: { - policyID: string; - integration: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_AUTO_SYNC]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_BILL_STATUS_SELECTOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_ACCOUNTING_METHOD]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_AUTO_SYNC]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_ACCOUNTING_METHOD]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT_BANK_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.XERO_INVOICE_ACCOUNT_SELECTOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT_PREFERRED_EXPORTER_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.XERO_BILL_PAYMENT_ACCOUNT_SELECTOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.XERO_FX_EXPENSE_ACCOUNT_SELECTOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_PREREQUISITES]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.ENTER_SAGE_INTACCT_CREDENTIALS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ENTITY]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_REUSE_EXISTING_CONNECTIONS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TOKEN_INPUT]: { - policyID: string; - subPage?: string; - authType?: string; - isMigration?: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SETUP]: { - policyID: string; - accountID?: string; - isMigration?: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_MAPPING]: { - policyID: string; - importField: TupleToUnion; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOM_FIELD]: { - policyID: string; - importCustomField: TupleToUnion; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOM_FIELD_VIEW]: { - policyID: string; - importCustomField: TupleToUnion; - internalID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOM_FIELD_EDIT]: { - policyID: string; - importCustomField: TupleToUnion; - internalID: string; - fieldName: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOM_LIST_ADD]: { - policyID: string; - subPage?: string; - action?: 'edit'; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOM_LIST_SELECTOR]: { - policyID: string; - action?: 'edit'; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_IMPORT_CUSTOM_SEGMENT_ADD]: { - policyID: string; - subPage?: string; - action?: 'edit'; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_PREFERRED_EXPORTER_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_DATE_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT_EXPENSES]: { - policyID: string; - expenseType: ValueOf; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT_EXPENSES_DESTINATION_SELECT]: { - policyID: string; - expenseType: ValueOf; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT_EXPENSES_VENDOR_SELECT]: { - policyID: string; - expenseType: ValueOf; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT_EXPENSES_PAYABLE_ACCOUNT_SELECT]: { - policyID: string; - expenseType: ValueOf; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT_EXPENSES_JOURNAL_POSTING_PREFERENCE_SELECT]: { - policyID: string; - expenseType: ValueOf; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_RECEIVABLE_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_INVOICE_ITEM_PREFERENCE_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_INVOICE_ITEM_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TAX_POSTING_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_PROVINCIAL_TAX_POSTING_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_ADVANCED]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_REIMBURSEMENT_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_COLLECTION_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPENSE_REPORT_APPROVAL_LEVEL_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_VENDOR_BILL_APPROVAL_LEVEL_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_JOURNAL_ENTRY_APPROVAL_LEVEL_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_APPROVAL_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_FX_EXPENSE_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_CUSTOM_FORM_ID]: { - policyID: string; - expenseType: ValueOf; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_IMPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_TOGGLE_MAPPING]: { - policyID: string; - mapping: SageIntacctMappingName; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_MAPPING_TYPE]: { - policyID: string; - mapping: SageIntacctMappingName; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_IMPORT_TAX]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADD_USER_DIMENSION]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_USER_DIMENSIONS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_EDIT_USER_DIMENSION]: { - policyID: string; - dimensionName: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_EXPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREFERRED_EXPORTER]: { - policyID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_EXPORT_DATE]: { - policyID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_REIMBURSABLE_EXPENSES]: { - policyID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES]: { - policyID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_REIMBURSABLE_DESTINATION]: { - policyID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_NON_REIMBURSABLE_DESTINATION]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_DEFAULT_VENDOR]: { - policyID: string; - reimbursable: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_TRAVEL_BILLING_CONFIGURATION]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADVANCED]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_AUTO_SYNC]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_ACCOUNTING_METHOD]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_FX_EXPENSE_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_PREREQUISITES]: { - policyID: string; - isSandbox?: string; - subPage?: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_EXISTING_CONNECTIONS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_IMPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_DIMENSION_MAPPING]: { - policyID: string; - dimension: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_EXPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_PREFERRED_EXPORTER]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_EXPORT_STATUS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_EXPORT_DATE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_DEFAULT_VENDOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_ADVANCED]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_FX_EXPENSE_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_TAGS_MAPPING]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_REPORT_EXPORT_STATUS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CERTINIA_COMPANY_SELECTOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_SETUP]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_EXISTING_CONNECTIONS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_SUBSIDIARY_SELECTOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_IMPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_EXPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_PREFERRED_EXPORTER]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_VENDOR_BILL_DATE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_COMPANY_CARD_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_DEFAULT_COMPANY_CARD_VENDOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_CARD_PROGRAM_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_CARD_PROGRAM_ACCOUNT_SELECTOR]: { - policyID: string; - feed: CardFeedWithDomainID; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_CARD_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_CARD_ACCOUNT_CARD_LIST]: { - policyID: string; - feed: CardFeedWithDomainID; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_ADVANCED]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_EXPORT_METHOD]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_BILL_PAYMENT_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_FX_EXPENSE_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_EXPENSIFY_CARD_SETTLEMENT_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.RILLET_TRAVEL_BILLING_SETTLEMENT_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_SETUP]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_EXISTING_CONNECTIONS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_SUBSIDIARY_SELECTOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_IMPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_EXPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_PREFERRED_EXPORTER]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_VENDOR_BILL_DATE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_COMPANY_CARD_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_DEFAULT_COMPANY_CARD_VENDOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_CARD_PROGRAM_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_CARD_PROGRAM_ACCOUNT_SELECTOR]: { - policyID: string; - feed: CardFeedWithDomainID; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_CARD_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_CARD_ACCOUNT_CARD_LIST]: { - policyID: string; - feed: CardFeedWithDomainID; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_ADVANCED]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_EXPORT_METHOD]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_BILL_PAYMENT_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_FX_EXPENSE_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_EXPENSIFY_CARD_SETTLEMENT_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_TRAVEL_BILLING_SETTLEMENT_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_TRAVEL_BILLING_PAYABLE_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_SETUP]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_EXISTING_CONNECTIONS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_SUBSIDIARY_SELECTOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_IMPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_EXPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_PREFERRED_EXPORTER]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_VENDOR_BILL_DATE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_DEFAULT_COMPANY_CARD_VENDOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_COMPANY_CARD_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_CARD_PROGRAM_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_CARD_PROGRAM_ACCOUNT_SELECTOR]: { - policyID: string; - feed: CardFeedWithDomainID; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_CARD_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_CARD_ACCOUNT_CARD_LIST]: { - policyID: string; - feed: CardFeedWithDomainID; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_ADVANCED]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_EXPORT_METHOD]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_BILL_PAYMENT_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_EXPENSIFY_CARD_SETTLEMENT_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_TRAVEL_BILLING_SETTLEMENT_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_TRAVEL_BILLING_PAYABLE_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_PREREQUISITES]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_SETUP]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_COMPANY_SELECTOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_IMPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_PREFERRED_EXPORTER]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_DATE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_REIMBURSABLE_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_NONREIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_COMPANY_CARD_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_DEFAULT_VENDOR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_EXPORT_PAYMENT_METHOD]: { - policyID: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION]: { - policyID: string; - connection: ValueOf; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_RECONCILIATION_ACCOUNT_SETTINGS]: { - policyID: string; - connection: ValueOf; - reconciliationAccountSettingsType?: ValueOf; - fundID?: string; - }; - [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_RECONCILIATION_SELECT_FEED]: { - policyID: string; - connection: ValueOf; - fundID?: string; - }; - [SCREENS.TWO_FACTOR_AUTH.DISABLED]: undefined; - [SCREENS.TWO_FACTOR_AUTH.DISABLE]: undefined; - [SCREENS.SETTINGS.DELEGATE.VERIFY_ACCOUNT]: undefined; - [SCREENS.SETTINGS.DELEGATE.ADD_DELEGATE]: undefined; - [SCREENS.SETTINGS.DELEGATE.DELEGATE_ROLE]: { - login: string; - role?: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE]: { - login: string; - currentRole: string; - }; - [SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE_CONFIRM_VALIDATE_CODE]: { - login: string; - currentRole: string; - newRole: string; - }; - [SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM]: { - login: string; - role: string; - }; - [SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM_VALIDATE_CODE]: { - login: string; - role: string; - }; - [SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: { - cardID: string; - - /** Whether the flow was launched from the top-level DomainCard route (deep-linked from OldDot) rather than the Settings wallet card route */ - isFromDomainCardDetail?: string; - }; - [SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED_CONFIRM_VALIDATE_CODE]: { - cardID: string; - - /** Reason for replacing the card */ - reason: ReplacementReason; - - /** Whether the flow was launched from the top-level DomainCard route (deep-linked from OldDot) rather than the Settings wallet card route */ - isFromDomainCardDetail?: string; - }; - [SCREENS.SETTINGS.DYNAMIC_KEYBOARD_SHORTCUTS]: undefined; - [SCREENS.SETTINGS.DYNAMIC_BETA_OVERRIDES]: undefined; - [SCREENS.WORKSPACE.TAX_CREATE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.TAX_CREATE_VALUE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.TAX_EDIT]: { - policyID: string; - taxID: string; - }; - [SCREENS.WORKSPACE.TAX_NAME]: { - policyID: string; - taxID: string; - }; - [SCREENS.WORKSPACE.TAX_VALUE]: { - policyID: string; - taxID: string; - }; - [SCREENS.WORKSPACE.TAX_CODE]: { - policyID: string; - taxID: string; - }; - [SCREENS.WORKSPACE.INVOICES_COMPANY_NAME]: { - policyID: string; - }; - [SCREENS.WORKSPACE.INVOICES_COMPANY_WEBSITE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.COMPANY_CARDS_SELECT_FEED]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_DETAILS]: { - policyID: string; - feed: CompanyCardFeedWithDomainID; - cardID: string; - }; - [SCREENS.WORKSPACE.COMPANY_CARD_EDIT_CARD_NAME]: { - policyID: string; - feed: CompanyCardFeedWithDomainID; - cardID: string; - }; - [SCREENS.WORKSPACE.COMPANY_CARD_EDIT_TRANSACTION_START_DATE]: { - policyID: string; - feed: string; - cardID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT]: { - policyID: string; - cardID: string; - feed: CompanyCardFeedWithDomainID; - }; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_CONFIRM_VALIDATE_CODE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.EXPENSIFY_CARD_BANK_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.EXPENSIFY_CARD_SETTINGS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_SETTINGS_ACCOUNT]: { - policyID: string; - fundID?: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_SELECT_FEED]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_TRAVEL_BILLING_SELECT_FEED]: { - policyID: string; - }; - [SCREENS.WORKSPACE.EXPENSIFY_CARD_SETTINGS_FREQUENCY]: { - policyID: string; - }; - [SCREENS.WORKSPACE.EXPENSIFY_CARD_ADD_WORK_EMAIL]: { - policyID: string; - fundID: number; - }; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_SELECTION]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_MERCHANTS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_MERCHANT_EDIT]: { - policyID: string; - merchantIndex: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_CATEGORY]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_CURRENCY]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_MAX_AMOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.EXPENSIFY_CARD_VERIFY_WORK_EMAIL]: { - policyID: string; - fundID: number; - }; - [SCREENS.WORKSPACE.TRAVEL_SETTINGS_ACCOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.TRAVEL_SETTINGS_FREQUENCY]: { - policyID: string; - }; - [SCREENS.WORKSPACE.TRAVEL_SETTINGS_MONTHLY_LIMIT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.TRAVEL_EXPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.TRAVEL_MISSING_PERSONAL_DETAILS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.COMPANY_CARDS_BROKEN_CARD_FEED_CONNECTION]: { - policyID: string; - feed: CompanyCardFeedWithDomainID; - - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.WORKSPACE.COMPANY_CARDS_REFRESH_CARD_FEED_CONNECTION]: { - policyID: string; - feed: CompanyCardFeedWithDomainID; - }; - [SCREENS.WORKSPACE.COMPANY_CARD_ADD_WORK_EMAIL]: { - policyID: string; - feed: CompanyCardFeedWithDomainID; - }; - [SCREENS.WORKSPACE.COMPANY_CARD_VERIFY_WORK_EMAIL]: { - policyID: string; - feed: CompanyCardFeedWithDomainID; - }; - [SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARDS_ASSIGN_CARD_ASSIGNEE]: { - policyID: string; - feed: CompanyCardFeedWithDomainID; - cardID: string; - }; - [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD_CARD_SELECTION]: { - policyID: string; - feed: CompanyCardFeedWithDomainID; - cardID: string; - }; - [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD_TRANSACTION_START_DATE]: { - policyID: string; - feed: CompanyCardFeedWithDomainID; - cardID: string; - }; - [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD_CARD_NAME]: { - policyID: string; - feed: CompanyCardFeedWithDomainID; - cardID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARDS_ASSIGN_CARD_CONFIRMATION]: { - policyID: string; - feed: CompanyCardFeedWithDomainID; - cardID: string; - }; - [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD_INVITE_NEW_MEMBER]: { - policyID: string; - feed: CompanyCardFeedWithDomainID; - cardID: string; - }; - [SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS_FEED_NAME]: { - policyID: string; - }; - [SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS_STATEMENT_CLOSE_DATE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_EXPENSIFY_CARD_DETAILS]: { - policyID: string; - cardID: string; - }; - [SCREENS.EXPENSIFY_CARD.DYNAMIC_EXPENSIFY_CARD_DETAILS]: { - policyID: string; - cardID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; - [SCREENS.EXPENSIFY_CARD.DYNAMIC_EXPENSIFY_CARD_NAME]: { - policyID: string; - cardID: string; - }; - [SCREENS.EXPENSIFY_CARD.DYNAMIC_EXPENSIFY_CARD_LIMIT]: { - policyID: string; - cardID: string; - }; - [SCREENS.EXPENSIFY_CARD.DYNAMIC_EXPENSIFY_CARD_LIMIT_TYPE]: { - policyID: string; - cardID: string; - }; - [SCREENS.WORKSPACE.REPORTS_DEFAULT_TITLE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_AUTO_APPROVE_REPORTS_UNDER]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_RANDOM_REPORT_AUDIT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_AUTO_PAY_REPORTS_UNDER]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_RECEIPT_REQUIRED_AMOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_ITEMIZED_RECEIPT_REQUIRED_AMOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_REQUIRE_RECEIPTS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AMOUNT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_REIMBURSABLE_DEFAULT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_HR_SYNC_RESULTS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_RECRUITING_SYNC_RESULTS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.HR_GUSTO_APPROVAL_MODE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.HR_GUSTO_FINAL_APPROVER]: { - policyID: string; - }; - [SCREENS.WORKSPACE.HR_ZENEFITS_APPROVAL_MODE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.HR_ZENEFITS_FINAL_APPROVER]: { - policyID: string; - }; - [SCREENS.WORKSPACE.HR_MERGE_APPROVAL_MODE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.HR_MERGE_FINAL_APPROVER]: { - policyID: string; - }; - [SCREENS.WORKSPACE.HR_MERGE_GROUPS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_CUSTOM]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_NEW]: { - policyID: string; - categoryName?: string; - }; - [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { - policyID: string; - categoryName?: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW]: { - policyID: string; - categoryName?: undefined; - }; - [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH]: { - policyID: string; - ruleID?: undefined; - }; - [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MATCH_TYPE]: { - policyID: string; - ruleID?: undefined; - }; - [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT]: { - policyID: string; - ruleID?: undefined; - }; - [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_CATEGORY]: { - policyID: string; - ruleID?: undefined; - }; - [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAG]: { - policyID: string; - orderWeight: number | string; - ruleID?: undefined; - }; - [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAX]: { - policyID: string; - ruleID?: undefined; - /** Set when opened from a category tax default in the category details RHP, absent from the callout flow */ - categoryName?: string; - }; - [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_VENDOR]: { - policyID: string; - ruleID?: undefined; - }; - [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_DESCRIPTION]: { - policyID: string; - ruleID?: undefined; - }; - [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_REIMBURSABLE]: { - policyID: string; - ruleID?: undefined; - }; - [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_BILLABLE]: { - policyID: string; - ruleID?: undefined; - }; - [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: { - policyID: string; - ruleID?: undefined; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORTED]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_SPEND_NEW]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_NEW]: { - policyID: string; - categoryName?: string; - }; - [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_EDIT]: { - policyID: string; - categoryName: string; - /** When 'true', category field is locked (e.g. opened from category details RHP). */ - isCategoryLocked?: string; - }; - [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_CATEGORY]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_CATEGORY_EDIT]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_NEW]: { - policyID: string; - categoryName?: string; - }; - [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_EDIT]: { - policyID: string; - categoryName: string; - /** When 'true', category field is locked (e.g. opened from category details RHP). */ - isCategoryLocked?: string; - }; - [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_CATEGORY]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_CATEGORY_EDIT]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_AMOUNT]: { - policyID: string; - /** When 'true', return to create with ?categoryName= so the locked category create entry matches. */ - isCategoryLocked?: string; - }; - [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_AMOUNT_EDIT]: { - policyID: string; - categoryName: string; - /** When 'true', preserve locked category on return to the edit screen. */ - isCategoryLocked?: string; - }; - [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_EXPENSE_LIMIT_TYPE]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_FLAG_FOR_REVIEW_RULE_EXPENSE_LIMIT_TYPE_EDIT]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_TYPE_EDIT]: { - policyID: string; - groupID: string; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_TYPE_CATEGORY]: { - policyID: string; - groupID: string; - }; - [SCREENS.WORKSPACE.RULES_SPEND_EDIT]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_SPEND_CARD]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_SPEND_CATEGORY]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_SPEND_MAX_AMOUNT]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_SPEND_CURRENCIES]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_SPEND_MERCHANTS]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_SPEND_MERCHANT_EDIT]: { - policyID: string; - ruleID: string; - merchantIndex: string; - }; - [SCREENS.WORKSPACE.RULES_AGENT_NEW]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RULES_AGENT_EDIT]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_MATCH_TYPE]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_CATEGORY]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_TAG]: { - policyID: string; - ruleID: string; - orderWeight: number; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_TAX]: { - policyID: string; - ruleID: string; - /** Set when editing a category tax default, so the picker returns to that editor. */ - categoryName?: string; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_VENDOR]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_DESCRIPTION]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_REIMBURSABLE]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_EDIT]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_MERCHANT_PREVIEW_MATCHES]: { - policyID: string; - ruleID: string; - }; - [SCREENS.WORKSPACE.RULES_CATEGORY_TO_MATCH]: { - policyID: string; - ruleID: string; - categoryName?: string; - }; - [SCREENS.WORKSPACE.RULES_CATEGORY_TAX_EDIT]: { - policyID: string; - categoryName: string; - }; - [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: { - policyID: string; - }; - [SCREENS.WORKSPACE.PER_DIEM_IMPORTED]: { - policyID: string; - }; - [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.PER_DIEM_DETAILS]: { - policyID: string; - rateID: string; - subRateID: string; - }; - [SCREENS.WORKSPACE.PER_DIEM_EDIT_DESTINATION]: { - policyID: string; - rateID: string; - subRateID: string; - }; - [SCREENS.WORKSPACE.PER_DIEM_EDIT_SUBRATE]: { - policyID: string; - rateID: string; - subRateID: string; - }; - [SCREENS.WORKSPACE.PER_DIEM_EDIT_AMOUNT]: { - policyID: string; - rateID: string; - subRateID: string; - }; - [SCREENS.WORKSPACE.PER_DIEM_EDIT_CURRENCY]: { - policyID: string; - rateID: string; - subRateID: string; - }; - [SCREENS.WORKSPACE.TIME_TRACKING_DEFAULT_RATE]: { - policyID: string; - }; - [SCREENS.DOMAIN.SAML_VERIFY]: { - domainAccountID: number; - }; - [SCREENS.DOMAIN.SAML_VERIFIED]: { - domainAccountID: number; - }; - [SCREENS.DOMAIN.MEMBERS_VERIFY]: { - domainAccountID: number; - }; - [SCREENS.DOMAIN.MEMBERS_VERIFIED]: { - domainAccountID: number; - }; - [SCREENS.DOMAIN.ADMIN_DETAILS]: { - domainAccountID: number; - accountID: number; - }; - [SCREENS.DOMAIN.ADMINS_SETTINGS]: { - domainAccountID: number; - }; - [SCREENS.DOMAIN.ADD_PRIMARY_CONTACT]: { - domainAccountID: number; - }; - [SCREENS.DOMAIN.ADD_ADMIN]: { - domainAccountID: number; - }; - [SCREENS.DOMAIN.MEMBER_DETAILS]: { - domainAccountID: number; - accountID: number; - }; - [SCREENS.DOMAIN.ADD_MEMBER]: { - domainAccountID: number; - }; - [SCREENS.DOMAIN.MEMBERS_SETTINGS]: { - domainAccountID: number; - }; - [SCREENS.DOMAIN.MEMBERS_SETTINGS_TWO_FACTOR_AUTH]: { - domainAccountID: number; - }; - [SCREENS.DOMAIN.VACATION_DELEGATE]: { - domainAccountID: number; - accountID: number; - }; - [SCREENS.DOMAIN.MEMBER_FORCE_TWO_FACTOR_AUTH]: { - domainAccountID: number; - accountID: number; - }; - [SCREENS.DOMAIN.MEMBER_RESET_TWO_FACTOR_AUTH]: { - domainAccountID: number; - accountID: number; - }; - [SCREENS.DOMAIN.MEMBER_LOCK_ACCOUNT]: { - domainAccountID: number; - accountID: number; - }; - [SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP]: { - domainAccountID: number; - }; - [SCREENS.DOMAIN.MEMBER_MOVE_TO_GROUP]: { - domainAccountID: number; - accountID: number; - }; - [SCREENS.DOMAIN.GROUP_DETAILS]: { - domainAccountID: number; - groupID: string; - }; - [SCREENS.DOMAIN.GROUP_EDIT_NAME]: { - domainAccountID: number; - groupID: string; - }; - [SCREENS.DOMAIN.SECURITY_GROUPS_PREFERRED_WORKSPACE]: { - domainAccountID: number; - groupID: string; - }; - [SCREENS.DOMAIN.GROUP_CREATE]: { - domainAccountID: number; - }; - [SCREENS.DOMAIN.GROUP_CREATE_PREFERRED_WORKSPACE]: { - domainAccountID: number; - }; -} & ReimbursementAccountNavigatorParamList; +type SettingsNavigatorParamList = AccountSettingsParamList & WorkspaceSettingsParamList & DomainSettingsParamList & ReimbursementAccountNavigatorParamList; type DomainCardNavigatorParamList = { [SCREENS.DOMAIN_CARD.DOMAIN_CARD_DETAIL]: { @@ -2314,12 +131,16 @@ type ReportDetailsNavigatorParamList = { }; [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_CODE]: { reportID: string; + shouldShowQRCodeOnly?: string; }; [SCREENS.REPORT_DETAILS.DYNAMIC_EXPORT]: { reportID: string; policyID: string; connectionName: ConnectionName; }; + [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_REPORT]: { + reportID: string; + }; }; type ReportCardActivateNavigatorParamList = { @@ -2472,6 +293,7 @@ type MoneyRequestNavigatorParamList = { iouType: Exclude; transactionID: string; reportID: string; + backToReport?: string; }; [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_CATEGORY_CREATE]: { action: IOUAction; @@ -3337,6 +1159,9 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.MEMBERS]: { policyID: string; }; + [SCREENS.WORKSPACE.AGENTS]: { + policyID: string; + }; [SCREENS.WORKSPACE.ROOMS]: { policyID: string; }; @@ -3484,10 +1309,6 @@ type SubmitPlanModalNavigatorParamList = { [SCREENS.SUBMIT_PLAN_WELCOME_MODAL.DYNAMIC_ROOT]: undefined; }; -type AIFeaturesPromoModalNavigatorParamList = { - [SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT]: undefined; -}; - type TestDriveDemoNavigatorParamList = { [SCREENS.TEST_DRIVE_DEMO.ROOT]: undefined; }; @@ -3613,6 +1434,7 @@ type AttachmentModalScreensParamList = { [SCREENS.WORKSPACE_DOCUMENT]: AttachmentModalContainerModalProps & { policyID: string; }; + [SCREENS.BILLING_RECEIPT]: AttachmentModalContainerModalProps & {purchaseID: string}; [SCREENS.REPORT_AVATAR]: AttachmentModalContainerModalProps & { reportID: string; policyID?: string; @@ -3671,7 +1493,6 @@ type AuthScreensParamList = SharedScreensParamList & [NAVIGATORS.FEATURE_TRAINING_MODAL_NAVIGATOR]: NavigatorScreenParams; [NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR]: NavigatorScreenParams; [NAVIGATORS.SUBMIT_PLAN_MODAL_NAVIGATOR]: NavigatorScreenParams; - [NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR]: NavigatorScreenParams; [NAVIGATORS.TEST_DRIVE_DEMO_NAVIGATOR]: NavigatorScreenParams; [SCREENS.CONNECTION_COMPLETE]: undefined; [NAVIGATORS.SHARE_MODAL_NAVIGATOR]: NavigatorScreenParams; @@ -3750,6 +1571,7 @@ type SearchAdvancedFiltersParamList = { filterKey: string; applyDirectly?: string; }; + [SCREENS.SEARCH.NL_FILTER_RHP]: Record; }; type SearchSavedSearchParamList = { @@ -3962,7 +1784,6 @@ export type { WorkspaceNavigatorParamList, MigratedUserModalNavigatorParamList, SubmitPlanModalNavigatorParamList, - AIFeaturesPromoModalNavigatorParamList, WorkspaceConfirmationNavigatorParamList, WorkspaceDuplicateNavigatorParamList, PolicyCopySettingsNavigatorParamList, diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 7db71f9dd4e5..28d41d2f79dd 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -329,6 +329,10 @@ function shouldSkipAutoFocusDueToExistingFocus(): boolean { return false; } +/** Web only. Native restores to a registered pressable rather than to a DOM element, so there is nothing to nominate. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function nominateTriggerFallback(_element: HTMLElement | null): void {} + function resetForTests(): void { teardownNavigationFocusReturn(); } @@ -352,6 +356,7 @@ export { teardownNavigationFocusReturn, handleStateChange, notifyPressedTrigger, + nominateTriggerFallback, registerPressable, notifyPushParamsForward, notifyPushParamsBackward, diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts index ba7b9046d8dc..5ee9e6ba979e 100644 --- a/src/libs/NavigationFocusReturn/index.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -38,6 +38,7 @@ let lastMouseTriggerAt = 0; let lastKeyboardTriggerElement: HTMLElement | null = null; let lastKeyboardTriggerTime = 0; let pendingActivationKey: 'Enter' | 'Space' | null = null; +let nominatedFallback: HTMLElement | null = null; function setTriggerEntry(routeKey: string, entry: TriggerEntry): void { setFifoEntry(triggerMap, routeKey, entry, TRIGGER_MAP_MAX); @@ -121,9 +122,23 @@ function captureTriggerForRoute(routeKey: string): void { if (!inner) { return; } + if (nominatedFallback && nominatedFallback !== inner) { + setTriggerEntry(routeKey, {primary: inner, fallback: nominatedFallback}); + return; + } setTriggerEntry(routeKey, {primary: inner}); } +/** + * Nominates a second choice to return to for the next route opened from here, for callers whose controls are removed + * while that route is open. A bulk action bar is the case this exists for. Its buttons go away with the selection the + * route cleared, so the captured trigger is detached by the time the route pops and focus falls to the document. + * Passing null withdraws the nomination, which callers do when they unmount. + */ +function nominateTriggerFallback(element: HTMLElement | null): void { + nominatedFallback = element; +} + /** Single-site latch reset so the three fields always clear together and future additions get one call site. */ function clearKeyboardLatch(): void { lastKeyboardTriggerElement = null; @@ -599,6 +614,7 @@ function resetForTests(): void { clearTransientCaptures(); lastRestoreTarget = null; skipNextRestore = false; + nominatedFallback = null; } function setLastInteractiveElementForTests(element: HTMLElement | null): void { @@ -690,6 +706,7 @@ export { teardownNavigationFocusReturn, handleStateChange, captureTriggerForRoute, + nominateTriggerFallback, restoreTriggerForRoute, notifyPushParamsForward, notifyPushParamsBackward, diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.ts b/src/libs/Notification/LocalNotification/BrowserNotifications.ts index 0ecf70c7a456..08ac564a4274 100644 --- a/src/libs/Notification/LocalNotification/BrowserNotifications.ts +++ b/src/libs/Notification/LocalNotification/BrowserNotifications.ts @@ -1,6 +1,7 @@ import EXPENSIFY_ICON_URL from '@assets/images/expensify-logo-round-clearspace.png'; import type {CurrencyListActionsContextType} from '@components/CurrencyListContextProvider/types'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import {convertToFrontendAmountAsInteger, sanitizeCurrencyCode} from '@libs/CurrencyUtils'; import {translateLocal} from '@libs/Localize'; @@ -142,7 +143,14 @@ export default { * * @param usesIcon true if notification uses right circular icon */ - pushReportCommentNotification(report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler, derivedReportName: string | undefined, usesIcon = false) { + pushReportCommentNotification( + report: Report, + reportAction: ReportAction, + onClick: LocalNotificationClickHandler, + derivedReportName: string | undefined, + formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], + usesIcon = false, + ) { let title; let body; const icon = usesIcon ? EXPENSIFY_ICON_URL : ''; @@ -150,7 +158,7 @@ export default { const isRoomOrGroupChat = ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isGroupChat(report); const {person, message} = reportAction; - const plainTextPerson = person?.map((f) => Str.removeSMSDomain(f.text ?? '')).join() ?? ''; + const plainTextPerson = person?.map((f) => (Str.isSMSLogin(f.text ?? '') ? formatPhoneNumber(f.text ?? '') : (f.text ?? ''))).join() ?? ''; // Specifically target the comment part of the message let plainTextMessage = ''; diff --git a/src/libs/Notification/LocalNotification/index.ts b/src/libs/Notification/LocalNotification/index.ts index 03484bb1a5be..882d850b3332 100644 --- a/src/libs/Notification/LocalNotification/index.ts +++ b/src/libs/Notification/LocalNotification/index.ts @@ -1,3 +1,5 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyTagLists, Report, ReportAction} from '@src/types/onyx'; @@ -29,8 +31,14 @@ Onyx.connectWithoutView({ }, }); -function showCommentNotification(report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler, derivedReportName: string | undefined) { - BrowserNotifications.pushReportCommentNotification(report, reportAction, onClick, derivedReportName, true); +function showCommentNotification( + report: Report, + reportAction: ReportAction, + onClick: LocalNotificationClickHandler, + derivedReportName: string | undefined, + formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], +) { + BrowserNotifications.pushReportCommentNotification(report, reportAction, onClick, derivedReportName, formatPhoneNumber, true); } function showModifiedExpenseNotification({ diff --git a/src/libs/Notification/LocalNotification/types.ts b/src/libs/Notification/LocalNotification/types.ts index e59ca1917000..5871b1452033 100644 --- a/src/libs/Notification/LocalNotification/types.ts +++ b/src/libs/Notification/LocalNotification/types.ts @@ -13,7 +13,13 @@ type LocalNotificationData = { }; type LocalNotificationModule = { - showCommentNotification: (report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler, derivedReportName: string | undefined) => void; + showCommentNotification: ( + report: Report, + reportAction: ReportAction, + onClick: LocalNotificationClickHandler, + derivedReportName: string | undefined, + formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], + ) => void; showModifiedExpenseNotification: (params: LocalNotificationModifiedExpenseParams) => void; clearReportNotifications: ClearReportNotifications; }; diff --git a/src/libs/NumberFormatUtils/index.ts b/src/libs/NumberFormatUtils/index.ts index 989f267bd9d1..ddedab49a798 100644 --- a/src/libs/NumberFormatUtils/index.ts +++ b/src/libs/NumberFormatUtils/index.ts @@ -4,6 +4,7 @@ import memoize from '@libs/memoize'; import CONST from '@src/CONST'; import type Locale from '@src/types/onyx/Locale'; +import type {Unit} from '@src/types/onyx/Policy'; // Polyfill the Intl API if locale data is not as expected intlPolyfill(); @@ -44,6 +45,20 @@ function formatToParts(locale: Locale | undefined, number: number, options?: Int return createFormatter(locale, options).formatToParts(number); } +/** + * Formats a distance that is already expressed in the given unit (mi/km) into localized parts, so the caller can + * render the number and the unit in the order, spacing, and spelling the locale uses (e.g. "12.50 mi", "12,50 km", "12.50英里"). + */ +function formatDistanceToParts(locale: Locale | undefined, distanceInUnits: number, unit: Unit): Intl.NumberFormatPart[] { + return formatToParts(locale, distanceInUnits, { + style: 'unit', + unit: unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? 'mile' : 'kilometer', + unitDisplay: 'short', + minimumFractionDigits: CONST.DISTANCE_DECIMAL_PLACES, + maximumFractionDigits: CONST.DISTANCE_DECIMAL_PLACES, + }); +} + /** * Test-only: clears the malformed-currency deduplication so warn-assertion tests don't pollute each other. */ @@ -51,4 +66,4 @@ function resetMalformedCurrenciesForTesting() { warnedMalformedCurrencies.clear(); } -export {format, formatToParts, resetMalformedCurrenciesForTesting}; +export {format, formatToParts, formatDistanceToParts, resetMalformedCurrenciesForTesting}; diff --git a/src/libs/OfficeLocationUtils.ts b/src/libs/OfficeLocationUtils.ts new file mode 100644 index 000000000000..ef6a9b8b3adb --- /dev/null +++ b/src/libs/OfficeLocationUtils.ts @@ -0,0 +1,67 @@ +import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {hasCompanyAddress} from './PolicyUtils'; +import {formatAddressToString} from './ReportActionsUtils'; + +type AssignableOfficeLocation = { + /** Key of the office in the workspace's officeLocations, or CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID for the company address */ + officeID: string; + + /** Nickname of the office */ + name: string; + + /** Address of the office on a single line */ + address: string; + + /** Whether members without an assigned office follow this one */ + isPrimary: boolean; +}; + +/** + * Whether the workspace has an office its members can be assigned to. The company address counts as one, as it does on + * the workspace overview. + */ +function hasOfficeLocations(policy: OnyxEntry): boolean { + return hasCompanyAddress(policy) || Object.values(policy?.officeLocations ?? {}).some((officeLocation) => officeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); +} + +/** + * The offices a workspace member can be assigned to. The company address comes first, as on the workspace overview, + * followed by the primary office and then the rest by name. + */ +function getAssignableOfficeLocations(policy: OnyxEntry, translate: LocalizedTranslate, localeCompare: LocaleContextProps['localeCompare']): AssignableOfficeLocation[] { + const officeLocations = Object.entries(policy?.officeLocations ?? {}) + .filter(([, officeLocation]) => officeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .sort( + ([, officeLocation], [, otherOfficeLocation]) => + Number(!!otherOfficeLocation.isDefault) - Number(!!officeLocation.isDefault) || localeCompare(officeLocation.name, otherOfficeLocation.name), + ) + .map(([officeID, officeLocation]) => ({officeID, name: officeLocation.name, address: formatAddressToString(officeLocation.address), isPrimary: !!officeLocation.isDefault})); + if (!hasCompanyAddress(policy)) { + return officeLocations; + } + + // The company address is the primary office unless an office location is set as primary + const companyAddress = { + officeID: CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, + name: translate('common.companyAddress'), + address: formatAddressToString(policy?.address), + isPrimary: !officeLocations.some((officeLocation) => officeLocation.isPrimary), + }; + return [companyAddress, ...officeLocations]; +} + +/** + * The office a member commutes to: the one assigned to them, or the primary office when they have none or the assigned + * office was removed + */ +function getMemberOfficeLocation(officeLocations: AssignableOfficeLocation[], officeID: string | undefined): AssignableOfficeLocation | undefined { + return officeLocations.find((officeLocation) => officeLocation.officeID === officeID) ?? officeLocations.find((officeLocation) => officeLocation.isPrimary); +} + +export {hasOfficeLocations, getAssignableOfficeLocations, getMemberOfficeLocation}; diff --git a/src/libs/OnboardingUtils.ts b/src/libs/OnboardingUtils.ts index 4ad0ec4845b7..5289bb311122 100644 --- a/src/libs/OnboardingUtils.ts +++ b/src/libs/OnboardingUtils.ts @@ -1,9 +1,23 @@ import CONST from '@src/CONST'; import type {OnboardingInvite} from '@src/CONST'; -import type {IntroSelected, OnboardingPurpose} from '@src/types/onyx'; +import type {IntroSelected, OnboardingPurpose, Policy} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {PUBLIC_DOMAINS_SET} from 'expensify-common'; + +import type {getOnboardingMessages, OnboardingMessage} from './actions/Welcome/OnboardingFlow'; + +import {getEmailDomain} from './LoginUtils'; + +type GetJoinWorkspaceMessageParams = { + joinWorkspaceMessages: ReturnType['joinWorkspaceMessages']; + onboardingPolicyID: OnyxEntry; + onboardingPolicy: OnyxEntry; + workEmail: string; + isValidated: boolean; +}; + type SupportedInviteOnboardingChoice = typeof CONST.ONBOARDING_CHOICES.ADMIN | typeof CONST.ONBOARDING_CHOICES.SUBMIT | typeof CONST.ONBOARDING_CHOICES.CHAT_SPLIT; type SupportedPendingInviteIntroSelected = IntroSelected & { choice: SupportedInviteOnboardingChoice; @@ -38,4 +52,20 @@ function isSupportedPendingInviteOnboarding(introSelected: OnyxEntry) */ function getParticipantsOption(participant: OptionData | Participant, personalDetails: OnyxEntry, translate: LocalizedTranslate): Participant & {keyForList: string} { const detail = participant.accountID ? getPersonalDetailsForAccountIDs([participant.accountID], personalDetails)[participant.accountID] : undefined; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const login = detail?.login || participant.login || ''; const formattedLogin = formatPhoneNumberPhoneUtils(login); // When detail has a login the participant is a real Expensify user — use their profile name. @@ -164,7 +163,7 @@ function getParticipantsOption(participant: OptionData | Participant, personalDe displayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: detail, defaultValue: formattedLogin, translate, formatPhoneNumber: formatPhoneNumberPhoneUtils}); } else { const detailDisplayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: detail, defaultValue: formattedLogin, translate, formatPhoneNumber: formatPhoneNumberPhoneUtils}); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string from device contacts should fall through to the formatted phone number + // empty string from device contacts should fall through to the formatted phone number displayName = participant?.text || detailDisplayName; } @@ -173,21 +172,17 @@ function getParticipantsOption(participant: OptionData | Participant, personalDe login, accountID: detail?.accountID, text: displayName, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing firstName: (detail?.firstName || participant.firstName) ?? '', - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing lastName: (detail?.lastName || participant.lastName) ?? '', alternateText: formattedLogin || displayName, icons: [ { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing source: (participant.avatar || detail?.avatar) ?? FallbackAvatar, name: login, type: CONST.ICON_TYPE_AVATAR, id: detail?.accountID, }, ], - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing phoneNumber: (detail?.phoneNumber || participant?.phoneNumber) ?? '', isSelected: participant.selected, selected: participant.selected, // Keep for backwards compatibility @@ -230,9 +225,8 @@ type GetAlternateTextConfig = { lastActions?: Record; isTrackIntentUser?: boolean; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + currentUserAccountID: number; convertToDisplayStringWithoutCurrency?: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; - // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 - currentUserAccountID?: number; currentUserLogin?: string; localeCompare?: LocaleContextProps['localeCompare']; formatPhoneNumber?: LocaleContextProps['formatPhoneNumber']; @@ -395,12 +389,12 @@ type CreateOptionParams = { conciergeReportID: string | undefined; // TODO: Remove optional (?) once all callers pass sortedActions. Refactor issue: https://github.com/Expensify/App/issues/66381 sortedActions?: Record; + currentUserAccountID: number; transactionThreadIDs?: Record; lastActions?: Record; - // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 - currentUserAccountID?: number; // TODO: Remove optional (?) once all callers pass pendingDeleteMemberAccountIDs. Refactor issue: https://github.com/Expensify/App/issues/66421 pendingDeleteMemberAccountIDs?: string[]; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; currentUserLogin?: string; }; @@ -448,6 +442,7 @@ function createOption({ lastActions, currentUserAccountID, pendingDeleteMemberAccountIDs, + formatPhoneNumber, currentUserLogin, }: CreateOptionParams): SearchOptionData { const {showChatPreviewLine = false, forcePolicyNamePreview = false, showPersonalDetails = false, selected, isSelected, isDisabled} = config ?? {}; @@ -520,7 +515,6 @@ function createOption({ result.allReportErrors = getEffectiveReportErrors(reportAttribute); result.brickRoadIndicator = !isEmptyObject(result.allReportErrors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : (reportAttribute?.brickRoadStatus ?? ''); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- below is a boolean expression hasMultipleParticipants = personalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat || reportUtilsIsGroupChat(report); subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translateFn, rules, true, result.private_isArchived); @@ -548,6 +542,7 @@ function createOption({ currentUserAccountID, currentUserLogin, rules, + formatPhoneNumber, }, ); @@ -611,7 +606,12 @@ type GetReportOptionParams = { reportAttributesDerived: ReportAttributesDerivedValue['reports'] | undefined; reportDraft: OnyxEntry; currentUserAccountID: number; - localize: {translate: LocalizedTranslate; dateFnsLocale: DateFnsLocale | undefined; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']}; + localize: { + translate: LocalizedTranslate; + dateFnsLocale: DateFnsLocale | undefined; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; + }; rules: OnyxCollection; policyTags?: OnyxCollection; pendingDeleteMemberAccountIDs: string[] | undefined; @@ -634,9 +634,9 @@ function getReportOption({ policyTags, pendingDeleteMemberAccountIDs, }: GetReportOptionParams): OptionData { - const {translate, dateFnsLocale, convertToDisplayString} = localize; + const {translate, dateFnsLocale, convertToDisplayString, formatPhoneNumber} = localize; const report = getReportOrDraftReport(participant.reportID, undefined, undefined, reportDraft); - const visibleParticipantAccountIDs = getParticipantsAccountIDsForDisplay(report, true); + const visibleParticipantAccountIDs = getParticipantsAccountIDsForDisplay(report, undefined, true); const reportPolicyTags = policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(report?.policyID)}`]; const option = createOption({ @@ -660,6 +660,7 @@ function getReportOption({ // consumers don't render `lastMessageText`, so we verified nothing in this flow depends on it and pass undefined instead of threading it from every caller. sortedActions: undefined, currentUserAccountID, + formatPhoneNumber, pendingDeleteMemberAccountIDs, }); @@ -705,6 +706,7 @@ type GetReportDisplayOptionParams = { policyTags?: OnyxEntry; visibleReportActionsData?: VisibleReportActionsDerivedValue; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; pendingDeleteMemberAccountIDs: string[] | undefined; }; @@ -726,9 +728,10 @@ function getReportDisplayOption({ reportAttributesDerived, policyTags, visibleReportActionsData = {}, + formatPhoneNumber, pendingDeleteMemberAccountIDs, }: GetReportDisplayOptionParams): OptionData { - const visibleParticipantAccountIDs = getParticipantsAccountIDsForDisplay(report, true); + const visibleParticipantAccountIDs = getParticipantsAccountIDsForDisplay(report, undefined, true); const option = createOption({ dateFnsLocale, @@ -751,6 +754,7 @@ function getReportDisplayOption({ // consumers don't render `lastMessageText`, so we verified nothing in this flow depends on it and pass undefined instead of threading it from every caller. sortedActions: undefined, currentUserAccountID, + formatPhoneNumber, pendingDeleteMemberAccountIDs, }); @@ -785,14 +789,19 @@ function getPolicyExpenseReportOption( personalDetails: OnyxEntry, expenseReport: OnyxEntry, policy: OnyxEntry, - localize: {translate: LocalizedTranslate; dateFnsLocale: DateFnsLocale | undefined; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']}, + localize: { + translate: LocalizedTranslate; + dateFnsLocale: DateFnsLocale | undefined; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; + }, currentUserAccountID: number, rules: OnyxCollection, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], policyTags?: OnyxEntry, visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ): SearchOptionData { - const {translate, dateFnsLocale, convertToDisplayString} = localize; + const {translate, dateFnsLocale, convertToDisplayString, formatPhoneNumber} = localize; const visibleParticipantAccountIDs = Object.entries(expenseReport?.participants ?? {}) .filter(([, reportParticipant]) => reportParticipant && !isHiddenForCurrentUser(reportParticipant.notificationPreference)) .map(([accountID]) => Number(accountID)); @@ -818,6 +827,7 @@ function getPolicyExpenseReportOption( policyTags, visibleReportActionsData, currentUserAccountID, + formatPhoneNumber, }); // Update text & alternateText because createOption returns workspace name only if report is owned by the user @@ -926,7 +936,6 @@ function isReportSelected(reportOption: SearchOptionData, selectedOptions: Array return false; } - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return selectedOptions.some((option) => (option.accountID && option.accountID === reportOption.accountID) || (option.reportID && option.reportID === reportOption.reportID)); } @@ -964,6 +973,7 @@ function processReport( currentUserLogin?: string; }, rules: OnyxCollection, + formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], ): { reportMapEntry?: [number, Report]; // The entry to add to reportMapForAccountIDs if applicable reportOption: SearchOption | null; // The report option to add to allReportOptions if applicable @@ -973,7 +983,7 @@ function processReport( } const isOneOnOneChat = reportUtilsIsOneOnOneChat(report); - const accountIDs = getParticipantsAccountIDsForDisplay(report); + const accountIDs = getParticipantsAccountIDsForDisplay(report, undefined); const isChatRoom = reportUtilsIsChatRoom(report); if ((!accountIDs || accountIDs.length === 0) && !isChatRoom) { @@ -1006,21 +1016,29 @@ function processReport( lastActions, currentUserAccountID, pendingDeleteMemberAccountIDs, + formatPhoneNumber, currentUserLogin, }), }, }; } +/** Whether the report belongs to the default workspace (activePolicyID) and is owned by the current user. Combine with a workspace chat check. */ +function isOwnDefaultWorkspaceChat(report: Pick, currentUserAccountID: number, activePolicyID: string | undefined): boolean { + return !!activePolicyID && report.policyID === activePolicyID && report.ownerAccountID === currentUserAccountID; +} + /** - * Sort Report objects by self-DM status, archived status, and last visible action. + * Sort Report objects by self-DM / default workspace chat status, archived status, and last visible action. * Mirrors recentReportComparator's isSelfDM priority so the raw top-N cap in createFilteredOptionList * can't exclude the self-DM report before recentReportComparator gets a chance to keep it. + * The user's own expense chat in the default workspace gets the same priority, so it is kept even when it has no recent activity. */ -const reportSortComparator = (report: Report, privateIsArchivedMap: PrivateIsArchivedMap): string => { +const reportSortComparator = (report: Report, privateIsArchivedMap: PrivateIsArchivedMap, currentUserAccountID: number, activePolicyID: string | undefined): string => { const isArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]; const isSelfDM = report.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM; - return `${isSelfDM ? 1 : 0}_${isArchived ? 0 : 1}_${report.lastVisibleActionCreated ?? ''}`; + const isDefaultWorkspaceChat = report.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT && isOwnDefaultWorkspaceChat(report, currentUserAccountID, activePolicyID); + return `${isSelfDM || isDefaultWorkspaceChat ? 1 : 0}_${isArchived ? 0 : 1}_${report.lastVisibleActionCreated ?? ''}`; }; /** Builds an option list with pre-filtered reports and lazy contact shells. */ @@ -1095,6 +1113,7 @@ function buildFullOption( dateFnsLocale, translate, convertToDisplayString, + formatPhoneNumber, } = context; const privateIsArchived = report ? privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`] : undefined; const policy = policiesCollection?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; @@ -1120,6 +1139,7 @@ function buildFullOption( translate, // Passing pendingDeleteMemberAccountIDs as undefined is intentional, `report` here is always a 1:1 DM, never a group chat. pendingDeleteMemberAccountIDs: undefined, + formatPhoneNumber, }), isHydrated: true, }; @@ -1210,6 +1230,9 @@ function createFilteredOptionList( * TODO: Make it required once every caller passes it. Refactor issue: https://github.com/Expensify/App/issues/66421 */ pendingDeleteMemberAccountIDsByReportID?: Record; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; + /** The user's default workspace. Its expense chat is always kept by the recent-reports cap */ + activePolicyID?: string; }, rules: OnyxCollection, policyTags?: OnyxCollection, @@ -1230,6 +1253,8 @@ function createFilteredOptionList( deferContactsUntilSearch = false, locale, pendingDeleteMemberAccountIDsByReportID, + formatPhoneNumber, + activePolicyID, } = options; // Use the cache-key locale for translated contact fields. @@ -1272,6 +1297,7 @@ function createFilteredOptionList( currentUserAccountID, pendingDeleteMemberAccountIDsByReportID, currentUserLogin, + activePolicyID, ]; const cachedEntry = shouldUseCache ? filteredOptionListCache.get(cacheEntryKey) : undefined; if (cachedEntry && cacheInputs.every((value, index) => value === cachedEntry.inputs.at(index))) { @@ -1289,9 +1315,11 @@ function createFilteredOptionList( return !!report; }); - // Step 2: Select the top N reports by priority (self-DM, then non-archived, then most recent). + // Step 2: Select the top N reports by priority (self-DM and default workspace chat, then non-archived, then most recent). // In search mode, skip sorting because we return all reports anyway - sorting is unnecessary - const sortedReports = isSearching ? reportsArray : optionsOrderBy(reportsArray, (report) => reportSortComparator(report, privateIsArchivedMap), maxRecentReports).options; + const sortedReports = isSearching + ? reportsArray + : optionsOrderBy(reportsArray, (report) => reportSortComparator(report, privateIsArchivedMap, currentUserAccountID, activePolicyID), maxRecentReports).options; // Step 3: If search term is present, build report map with ONLY 1:1 DM reports // This allows personal details to have valid 1:1 DM reportIDs for proper avatar display @@ -1309,7 +1337,7 @@ function createFilteredOptionList( const is1on1DM = reportUtilsIsOneOnOneChat(report); if (is1on1DM) { - const accountIDs = getParticipantsAccountIDsForDisplay(report); + const accountIDs = getParticipantsAccountIDsForDisplay(report, undefined); for (const accountID of accountIDs) { // ALWAYS set 1:1 DMs - prioritize them over policy/group chats // This ensures proper avatar display for personal details @@ -1346,6 +1374,7 @@ function createFilteredOptionList( currentUserLogin, }, rules, + formatPhoneNumber, ); if (reportMapEntry) { const [accountID, reportValue] = reportMapEntry; @@ -1384,6 +1413,7 @@ function createFilteredOptionList( dateFnsLocale: options.dateFnsLocale, convertToDisplayString: options.convertToDisplayString, translate: translateInActiveLocale, + formatPhoneNumber, }, rules, ) @@ -1436,6 +1466,7 @@ type CreateOptionFromReportParams = { convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; convertToDisplayStringWithoutCurrency?: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; pendingDeleteMemberAccountIDs: string[] | undefined; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; }; function createOptionFromReport({ @@ -1459,8 +1490,9 @@ function createOptionFromReport({ convertToDisplayString, convertToDisplayStringWithoutCurrency, pendingDeleteMemberAccountIDs, + formatPhoneNumber, }: CreateOptionFromReportParams) { - const accountIDs = getParticipantsAccountIDsForDisplay(report); + const accountIDs = getParticipantsAccountIDsForDisplay(report, undefined); return { item: report, @@ -1486,6 +1518,7 @@ function createOptionFromReport({ currentUserLogin, isTrackIntentUser, pendingDeleteMemberAccountIDs, + formatPhoneNumber, }), }; } @@ -1816,6 +1849,7 @@ function getUserToInviteOption({ currentUserAccountID, visibleReportActionsData = {}, rules, + formatPhoneNumber, }: GetUserToInviteConfig & {visibleReportActionsData?: VisibleReportActionsDerivedValue; dateFnsLocale: DateFnsLocale | undefined; rules: OnyxCollection}): SearchOptionData | null { if (!searchValue) { return null; @@ -1863,6 +1897,7 @@ function getUserToInviteOption({ config: {showChatPreviewLine}, visibleReportActionsData, currentUserAccountID, + formatPhoneNumber, }); userToInvite.isOptimisticAccount = true; userToInvite.login = searchValue; @@ -1872,11 +1907,8 @@ function getUserToInviteOption({ userToInvite.displayName = displayValue; userToInvite.alternateText = displayValue; } else { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing userToInvite.text = userToInvite.text || displayValue; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing userToInvite.displayName = userToInvite.displayName || displayValue; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing userToInvite.alternateText = userToInvite.alternateText || displayValue; } @@ -1960,7 +1992,7 @@ function isValidReport( const isMoneyRequestReport = option.isMoneyRequestReport; const isSelfDM = option.isSelfDM; const isChatRoom = option.isChatRoom; - const accountIDs = getParticipantsAccountIDsForDisplay(option.item); + const accountIDs = getParticipantsAccountIDsForDisplay(option.item, undefined); if (excludeNonAdminWorkspaces && !isPolicyAdmin(policy)) { return false; @@ -2127,7 +2159,7 @@ function prepareReportOptionsForDisplay( convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; convertToDisplayStringWithoutCurrency?: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; dateFnsLocale: DateFnsLocale | undefined; - currentUserAccountID?: number; + currentUserAccountID: number; currentUserLogin?: string; localeCompare?: LocaleContextProps['localeCompare']; formatPhoneNumber?: LocaleContextProps['formatPhoneNumber']; @@ -2254,7 +2286,8 @@ function prepareReportOptionsForDisplay( : undefined; const oneTransactionThreadReport = oneTransactionThreadReportID ? getReportByID(oneTransactionThreadReportID) : undefined; - isOptionUnread = isUnread(report, oneTransactionThreadReport, option.private_isArchived, reportAttributesDerived?.[report.reportID]?.isEmpty) && !!report.lastActorAccountID; + // isUnread() gates on isEmptyReport, so an empty chat is not shown in bold here. + isOptionUnread = isUnread(report, oneTransactionThreadReport, option.private_isArchived, reportAttributesDerived?.[report.reportID]?.isEmpty); } let lastIOUCreationDate; @@ -2351,12 +2384,12 @@ function getValidOptions( lastActions, currentUserLogin, localeCompare, - formatPhoneNumber, isTrackIntentUser, isOffline, + activePolicyID, ...config }: GetOptionsConfig, - translate: LocalizedTranslate, + localize: {translate: LocalizedTranslate; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']}, rules: OnyxCollection, ): OptionsResult { // Gather shared configs: @@ -2365,6 +2398,7 @@ function getValidOptions( [CONST.EMAIL.NOTIFICATIONS]: true, ...excludeLogins, }; + const {translate, formatPhoneNumber} = localize; // Soft exclusions: hidden from suggestions but can be manually entered (e.g., Guide/AM) const loginsToExcludeFromSuggestions: Record = { @@ -2451,14 +2485,18 @@ function getValidOptions( }, draftComment, chatReport, - // TODO: Pass guideAccountIDs once callers are fully migrated — PR 33 (https://github.com/Expensify/App/issues/66413); hasExpensifyGuidesEmails falls back to allPersonalDetails + // TODO: Pass guideAccountIDs in Phase B. hasExpensifyGuidesEmails falls back to the personal details store until then. See https://github.com/Expensify/App/issues/66413. isDefaultRoom(report.item) ? hasExpensifyGuidesEmails(Object.keys(report.item?.participants ?? {}).map(Number), undefined) : false, report.reportID ? reportAttributesDerived?.[report.reportID]?.isEmpty : undefined, ); }; + // Rank the user's default workspace chat first among workspace chats, so the per-group limit keeps it even when it has no recent activity + const isDefaultWorkspaceChat = (report: SearchOption) => isWorkspaceChat(report) && isOwnDefaultWorkspaceChat(report, currentUserAccountID, activePolicyID); + const comparator = activePolicyID ? (report: SearchOption) => `${isDefaultWorkspaceChat(report) ? 1 : 0}_${recentReportComparator(report)}` : recentReportComparator; + let selfDMChats: Array>; - const groupedOptions = optionsOrderAndGroupBy([isSelfDMChat, isWorkspaceChat], options.reports, recentReportComparator, maxElements, filteringFunction); + const groupedOptions = optionsOrderAndGroupBy([isSelfDMChat, isWorkspaceChat], options.reports, comparator, maxElements, filteringFunction); [selfDMChats, workspaceChats, recentReportOptions] = groupedOptions.options; hasMore = hasMore || groupedOptions.hasMore; @@ -2679,6 +2717,7 @@ function getValidOptions( currentUserAccountID, }, rules, + formatPhoneNumber, ); } @@ -2725,7 +2764,7 @@ type SearchOptionsConfig = { lastActions?: Record; currentUserLogin?: string; localeCompare?: LocaleContextProps['localeCompare']; - formatPhoneNumber?: LocaleContextProps['formatPhoneNumber']; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; conciergeReportID: string | undefined; excludeFromSuggestionsOnly?: Record; isTrackIntentUser?: boolean; @@ -2817,12 +2856,11 @@ function getSearchOptions({ lastActions, currentUserLogin: currentUserLogin ?? currentUserEmail, localeCompare, - formatPhoneNumber, excludeFromSuggestionsOnly, isTrackIntentUser, getReportByID, }, - translate, + {translate, formatPhoneNumber}, rules, ); @@ -2870,9 +2908,7 @@ function formatMemberForList(member: SearchOptionData): MemberForList { const accountID = member.accountID; return { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing text: member.text || member.displayName || '', - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing alternateText: member.alternateText || member.login || '', keyForList: member.keyForList || String(accountID ?? CONST.DEFAULT_NUMBER_ID) || '', @@ -2965,6 +3001,7 @@ function formatSectionsFromSearchTerm( translate: LocalizedTranslate, convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'], dateFnsLocale: DateFnsLocale | undefined, + formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], // Resolves a single report by ID instead of receiving the whole reports collection, so callers only subscribe to the reports they actually need. getReportByID: (reportID: string | undefined) => OnyxEntry, rules: OnyxCollection, @@ -2994,7 +3031,7 @@ function formatSectionsFromSearchTerm( personalDetails, expenseReport, expenseReportPolicy, - {translate, dateFnsLocale, convertToDisplayString}, + {translate, dateFnsLocale, convertToDisplayString, formatPhoneNumber}, currentUserAccountID, rules, reportAttributesDerived, @@ -3036,7 +3073,7 @@ function formatSectionsFromSearchTerm( personalDetails, expenseReport, expenseReportPolicy, - {translate, dateFnsLocale, convertToDisplayString}, + {translate, dateFnsLocale, convertToDisplayString, formatPhoneNumber}, currentUserAccountID, rules, reportAttributesDerived, @@ -3179,6 +3216,7 @@ function filterUserToInvite( countryCode: number, config: FilterUserToInviteConfig, rules: OnyxCollection, + formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], ): SearchOptionData | null { const {canInviteUser = true, excludeLogins = {}, currentUserAccountID} = config; if (!canInviteUser) { @@ -3210,6 +3248,7 @@ function filterUserToInvite( rules, ...config, currentUserAccountID, + formatPhoneNumber, }); } @@ -3226,11 +3265,12 @@ function filterOptions( currentUserAccountID: number, personalDetailsCollection: OnyxEntry, config: FilterUserToInviteConfig, - translate: LocalizedTranslate, + localize: {translate: LocalizedTranslate; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']}, rules: OnyxCollection, ): Options { const trimmedSearchInput = searchInputValue.trim(); const searchInputValueForInvite = config?.searchInputValue ?? trimmedSearchInput; + const {translate, formatPhoneNumber} = localize; const parsedPhoneNumber = parsePhoneNumber(appendCountryCode(Str.removeSMSDomain(trimmedSearchInput), countryCode || CONST.DEFAULT_COUNTRY_CODE)); const searchValue = parsedPhoneNumber.possible && parsedPhoneNumber.number?.e164 ? parsedPhoneNumber.number.e164 : trimmedSearchInput.toLowerCase(); @@ -3256,6 +3296,7 @@ function filterOptions( currentUserAccountID, }, rules, + formatPhoneNumber, ); const workspaceChats = filterWorkspaceChats(options.workspaceChats ?? [], searchTerms); @@ -3316,6 +3357,7 @@ type FilterAndOrderOptionsParams = { personalDetails: OnyxEntry; config: FilterAndOrderConfig; translate: LocalizedTranslate; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; rules: OnyxCollection; activePolicyID: OnyxEntry; }; @@ -3334,12 +3376,24 @@ function filterAndOrderOptions({ personalDetails, config, translate, + formatPhoneNumber, rules, activePolicyID, }: FilterAndOrderOptionsParams): Options { let filterResult = options; if (searchInputValue.trim().length > 0) { - filterResult = filterOptions(options, searchInputValue, countryCode, loginList, currentUserEmail, currentUserAccountID, personalDetails, config, translate, rules); + filterResult = filterOptions( + options, + searchInputValue, + countryCode, + loginList, + currentUserEmail, + currentUserAccountID, + personalDetails, + config, + {translate, formatPhoneNumber}, + rules, + ); } const orderedOptions = combineOrderingOfReportsAndPersonalDetails(filterResult, searchInputValue, activePolicyID, config); diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index f548aab4e3b4..022248c7f394 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -121,6 +121,7 @@ type LazyHydrationContext = { /** Currency formatter used when the option list was built. */ convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; }; type SearchOption = SearchOptionData & { @@ -298,6 +299,8 @@ type GetOptionsConfig = { localeCompare?: LocaleContextProps['localeCompare']; formatPhoneNumber?: LocaleContextProps['formatPhoneNumber']; isTrackIntentUser?: boolean; + /** The user's default workspace. Its expense chat is ranked first in the separated workspace chats so the result limit can't drop it */ + activePolicyID?: OnyxEntry; /** TODO: Should be required field in the future. Refactor issue: https://github.com/Expensify/App/issues/66407 */ isOffline?: boolean; } & GetValidReportsConfig; @@ -321,6 +324,7 @@ type GetUserToInviteConfig = { currentUserEmail: string; currentUserAccountID: number; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; } & Pick; type MemberForList = { diff --git a/src/libs/PaymentHistoryUtils.ts b/src/libs/PaymentHistoryUtils.ts index 366fb6d56a14..bc1a67e9b1a8 100644 --- a/src/libs/PaymentHistoryUtils.ts +++ b/src/libs/PaymentHistoryUtils.ts @@ -87,7 +87,7 @@ function getFoldedModifiers(purchaseList: Purchase[], purchaseIDs: Set): * Refunds, disputes, balance transfers, and cleared payments are separate purchases that point at the bill they change, so they are folded onto that bill instead of listed on their own. */ function getPaymentHistoryRows(purchases: Purchases | null | undefined): PaymentHistoryRow[] { - const purchaseList = Object.values(purchases ?? {}); + const purchaseList = Object.values(purchases ?? {}).filter((purchase) => !!purchase.purchaseID); if (!purchaseList.length) { return []; } @@ -315,5 +315,69 @@ function getBadgeText(badge: PaymentDetailsBadge, state: PaymentHistoryState, tr } } -export {formatDate, getBadgeAppearance, getBadgeText, getPaymentDetailsBadge, getPaymentHistoryRow, getPaymentHistoryRows, getStateTranslationKey}; +/** + * Returns the purchase whose receipt source applies to this bill, walking the failedPurchaseID chain for cleared bills. + */ +function getReceiptPurchase(purchases: Purchases | null | undefined, purchaseID: number): Purchase | null { + if (!purchases) { + return null; + } + const purchase = purchases[String(purchaseID)]; + if (!purchase) { + return null; + } + if (purchase.receipt?.source) { + return purchase; + } + + if (!isClear(purchase) || !purchase.message.failedPurchaseID) { + return null; + } + + const visited = new Set([purchaseID]); + let currentID: number | undefined = purchase.message.failedPurchaseID; + while (currentID !== undefined && !visited.has(currentID)) { + visited.add(currentID); + const current: Purchase | undefined = purchases[String(currentID)]; + if (!current) { + return null; + } + if (current.receipt?.source) { + return current; + } + currentID = current.message.failedPurchaseID; + } + + return null; +} + +function getReceiptSource(purchases: Purchases | null | undefined, purchaseID: number): string | null { + return getReceiptPurchase(purchases, purchaseID)?.receipt?.source ?? null; +} + +function isInvoiceBilling(purchases: Purchases | null | undefined, purchaseID: number): boolean { + return getReceiptPurchase(purchases, purchaseID)?.message.billingType === CONST.BILLING.TYPE_INVOICING; +} + +function getReceiptFileName(source: string, purchaseID: number): string { + try { + const pathname = new URL(source).pathname; + return pathname.split('/').pop() || `receipt-${purchaseID}`; + } catch { + return `receipt-${purchaseID}`; + } +} + +export { + formatDate, + getBadgeAppearance, + getBadgeText, + getPaymentDetailsBadge, + getPaymentHistoryRow, + getPaymentHistoryRows, + getReceiptFileName, + getReceiptSource, + getStateTranslationKey, + isInvoiceBilling, +}; export type {PaymentHistoryRow}; diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 12a4e8880929..b5830dc79fbc 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -8,6 +8,8 @@ import type {OnyxEntry} from 'react-native-onyx'; import type Environment from './Environment/getEnvironment/types'; +import getPlatform from './getPlatform'; + /** * Overrides are a test tool, so they never apply in production. * Android staging and TestFlight are compiled as production, so only the resolved environment tells a real production @@ -38,6 +40,11 @@ function isBetaEnabled(beta: Beta, betas: OnyxEntry, betaConfiguration: } } + // Release desktop web independently while the native navigation fixes roll out. + if ((beta === CONST.BETAS.INSIGHTS_PAGE || beta === CONST.BETAS.INSIGHTS_COMPARE) && getPlatform(true) === CONST.PLATFORM.WEB) { + return true; + } + const hasAllBetasEnabled = canUseAllBetas(betas); const isFeatureEnabled = !!betas?.includes(beta); diff --git a/src/libs/PersonalDetailOptionsListUtils/index.ts b/src/libs/PersonalDetailOptionsListUtils/index.ts index a7d79f745d12..9491f5c42696 100644 --- a/src/libs/PersonalDetailOptionsListUtils/index.ts +++ b/src/libs/PersonalDetailOptionsListUtils/index.ts @@ -541,7 +541,6 @@ function getFilteredRecentAttendees(attendees: Attendee[], recentAttendees: Atte allRecentAttendees .filter((attendee) => { // Deduplicate: use email for regular users, displayName for name-only attendees - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const key = attendee.email || attendee.displayName || ''; if (!key || seenAttendees.has(key)) { return false; @@ -551,7 +550,6 @@ function getFilteredRecentAttendees(attendees: Attendee[], recentAttendees: Atte }) .filter((attendee) => !attendees.find(({email, displayName}) => (attendee.email ? email === attendee.email : displayName === attendee.displayName))) // Use || to fall back to displayName for name-only attendees (empty email) - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing .map((attendee) => attendee.email || attendee.displayName) ); } diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts index 4419f32ecf6c..1b4d89bdd1d7 100644 --- a/src/libs/PolicyDistanceRatesUtils.ts +++ b/src/libs/PolicyDistanceRatesUtils.ts @@ -34,7 +34,7 @@ type DistanceRateNameError = | typeof CONST.INPUT_VALIDATION_ERRORS.INVALID | typeof CONST.INPUT_VALIDATION_ERRORS.TOO_LONG; -/** The reason a proposed distance rate amount is invalid. Shared by the RHP edit form and inline table editing. */ +/** The reason a proposed distance rate amount is invalid. Callers translate it via `getDistanceRateValueErrorMessage`. */ type DistanceRateValueError = typeof CONST.INPUT_VALIDATION_ERRORS.INVALID | typeof CONST.INPUT_VALIDATION_ERRORS.TOO_LOW; /** @@ -102,14 +102,23 @@ function getDistanceRateValueError(rate: string, toLocaleDigit: (arg: string) => return undefined; } +/** Translates a {@link DistanceRateValueError} into a user-facing message. */ +function getDistanceRateValueErrorMessage(translate: LocalizedTranslate, error: DistanceRateValueError): string { + switch (error) { + case CONST.INPUT_VALIDATION_ERRORS.TOO_LOW: + return translate('common.error.lowRateError'); + case CONST.INPUT_VALIDATION_ERRORS.INVALID: + default: + return translate('common.error.invalidRateError'); + } +} + function validateRateValue(values: FormOnyxValues, toLocaleDigit: (arg: string) => string, translate: LocalizedTranslate): FormInputErrors { const errors: FormInputErrors = {}; const error = getDistanceRateValueError(values.rate, toLocaleDigit); - if (error === CONST.INPUT_VALIDATION_ERRORS.INVALID) { - errors.rate = translate('common.error.invalidRateError'); - } else if (error === CONST.INPUT_VALIDATION_ERRORS.TOO_LOW) { - errors.rate = translate('common.error.lowRateError'); + if (error) { + errors.rate = getDistanceRateValueErrorMessage(translate, error); } return errors; @@ -589,4 +598,5 @@ export { getDistanceRateNameError, getDistanceRateNameErrorMessage, getDistanceRateValueError, + getDistanceRateValueErrorMessage, }; diff --git a/src/libs/PolicyPaymentUtils.ts b/src/libs/PolicyPaymentUtils.ts new file mode 100644 index 000000000000..0ceec8be2169 --- /dev/null +++ b/src/libs/PolicyPaymentUtils.ts @@ -0,0 +1,81 @@ +import type {BankAccount, BankAccountList, PersonalDetailsList, Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +/** + * The policy fields that identify the workspace bank account and who pays from it. Components that only attribute a + * payment subscribe to this shape (see `policyPaymentAttributionSelector`) instead of the whole policy. + */ +type PolicyPaymentAttribution = Pick; + +/** + * The workspace's connected bank account as it appears in the current user's own `bankAccountList`, or undefined when + * the account is not shared with them. + */ +function getAccessiblePolicyBankAccount(policy: OnyxEntry, bankAccountList: OnyxEntry): BankAccount | undefined { + const policyBankAccountID = policy?.achAccount?.bankAccountID; + + if (!policyBankAccountID) { + return undefined; + } + + return bankAccountList?.[policyBankAccountID]; +} + +/** + * Whether the user can actually pay from the workspace's connected bank account. This gates every place that would + * otherwise default a payment to `policy.achAccount`. Paying with, or displaying, an account the user has no access to + * is always wrong. See `getAccessiblePolicyBankAccount` for why `bankAccountList` is the authority. + */ +function canAccessPolicyBankAccount(policy: OnyxEntry, bankAccountList: OnyxEntry): boolean { + return !!getAccessiblePolicyBankAccount(policy, bankAccountList); +} + +// Share the login index across rows using the same immutable Onyx snapshot, so each row avoids scanning the list. +const accountIDsByLoginCache = new WeakMap>(); + +function getPolicyReimburserAccountID(policy: OnyxEntry, personalDetails: OnyxEntry): number | undefined { + const reimburserEmail = policy?.reimburser ?? policy?.achAccount?.reimburser; + if (!reimburserEmail || !personalDetails) { + return undefined; + } + + let accountIDsByLogin = accountIDsByLoginCache.get(personalDetails); + if (!accountIDsByLogin) { + accountIDsByLogin = new Map(); + for (const details of Object.values(personalDetails)) { + if (!details?.login) { + continue; + } + accountIDsByLogin.set(details.login, details.accountID); + } + accountIDsByLoginCache.set(personalDetails, accountIDsByLogin); + } + + return accountIDsByLogin.get(reimburserEmail); +} + +/** + * Whether a payment made by `payerAccountID` can be assumed to have been funded by the workspace's connected bank + * account. + */ +function wasPaidWithPolicyBankAccount(policy: OnyxEntry, payerAccountID: number | undefined, reimburserAccountID: number | undefined): boolean { + const reimburserEmail = policy?.reimburser ?? policy?.achAccount?.reimburser; + + // Without a designated payer, any admin pays from the workspace account, so it is the right guess. + if (!reimburserEmail) { + return true; + } + + // The viewer may not have the payer's personal details loaded. We can't rule the payer out, so keep the workspace + // account instead of hiding it for everyone who hasn't interacted with the payer. + if (reimburserAccountID === undefined) { + return true; + } + + return !!payerAccountID && reimburserAccountID === payerAccountID; +} + +export {getPolicyReimburserAccountID, canAccessPolicyBankAccount, getAccessiblePolicyBankAccount, wasPaidWithPolicyBankAccount}; + +export type {PolicyPaymentAttribution}; diff --git a/src/libs/PolicyUtils/approvals.ts b/src/libs/PolicyUtils/approvals.ts new file mode 100644 index 000000000000..12394b974ffa --- /dev/null +++ b/src/libs/PolicyUtils/approvals.ts @@ -0,0 +1,464 @@ +/** + * Approval mode, the default approver, approval workflow rules, and who a report submits or forwards to. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginProvider'; + +import {getCategoryApproverRule} from '@libs/CategoryUtils'; +import {getHRAdvancedModeFinalApprover} from '@libs/merge/HRUtils'; +import {getAccountIDsByLogins} from '@libs/PersonalDetailsUtils'; +import {isApprovalWorkflowRule, isRuleFilterComparison} from '@libs/RuleUtils'; +import {getAllSortedTransactions, getCategory, getTag} from '@libs/TransactionUtils'; +import {isValidAccountRoute} from '@libs/ValidationUtils'; + +import CONST from '@src/CONST'; +import type {OnyxInputOrEntry, Policy, Report} from '@src/types/onyx'; +import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; +import type {ApprovalRule} from '@src/types/onyx/Policy'; +import type Rule from '@src/types/onyx/Rule'; +import type {RuleFilterComparison, RuleFilterNode} from '@src/types/onyx/RuleFilters'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {isPolicyMember} from './members'; +import {isPolicyApprover} from './permissions'; +import {isGroupPolicy} from './policyType'; +import {getTagApproverRule} from './rules'; +import {isSubmitAndClose} from './workflows'; + +/** The report facts an approval workflow rule is matched against. */ +type ApprovalWorkflowContext = { + /** Email of the member who submitted the report. */ + submitterEmail: string; + + /** Email of the approver the report is currently with. Empty when the report has not been approved yet. */ + currentApproverEmail?: string; + + /** Total of the report, in the backend (cents) amount the rules' `amount` filters are written in. */ + reportTotal: number; +}; + +/** The outcome of matching a report against the policy's approval workflow rules. */ +type ApprovalWorkflowRuleMatch = { + /** Email the matched rule forwards the report to. Undefined when the rule finalizes the report instead. */ + forwardsTo?: string; +}; + +/** + * Returns true when the user is both a submitter and an approver, mirroring the Submit/Approve suggested-search eligibility in + * `getSuggestedSearchesVisibility` (SearchUIUtils): a submitter is a member of any group workspace, and an approver is a member of a + * group workspace with a non-optional approval flow whom `isPolicyApprover` recognizes (named approver or someone reports submit/forward to). + */ +function isSubmitterAndApprover(policies: OnyxCollection | null | undefined, currentUserEmail: string | undefined): boolean { + if (!policies || !currentUserEmail) { + return false; + } + let isSubmitter = false; + let isApprover = false; + for (const policy of Object.values(policies)) { + if (!policy) { + continue; + } + isSubmitter = isSubmitter || isGroupPolicy(policy); + if (!isApprover) { + const hasApprovalFlow = isGroupPolicy(policy) && !!policy.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL; + isApprover = hasApprovalFlow && isPolicyApprover(policy, currentUserEmail); + } + if (isSubmitter && isApprover) { + return true; + } + } + return false; +} + +function isControlOnAdvancedApprovalMode(policy: OnyxInputOrEntry): boolean { + return policy?.type === CONST.POLICY.TYPE.CORPORATE && getApprovalWorkflow(policy) === CONST.POLICY.APPROVAL_MODE.ADVANCED; +} + +function getApprovalWorkflow(policy: OnyxEntry): ValueOf { + if (policy?.type === CONST.POLICY.TYPE.PERSONAL) { + return CONST.POLICY.APPROVAL_MODE.OPTIONAL; + } + + return policy?.approvalMode ?? CONST.POLICY.APPROVAL_MODE.ADVANCED; +} + +function getDefaultApprover(policy: OnyxEntry): string { + // We intentionally use || here so an empty-string approver falls back to the owner. ?? would keep the empty string. + return policy?.approver || policy?.owner || ''; +} + +/** + * Whether an approver email points at someone who is no longer on the workspace. The owner always counts as a member, + * and HR advanced (manager) mode is excluded, because its final approver doesn't have to be a workspace member. + */ +function isNonMemberApprover(policy: OnyxEntry, approverEmail: string | undefined): boolean { + return !!approverEmail && !isPolicyMember(policy, approverEmail) && !getHRAdvancedModeFinalApprover(policy); +} + +/** + * Whether the policy has at least one custom approval workflow. A workflow is considered custom when either: + * - the default workflow was modified by changing its first approver or adding an "Approves to" user, or + * - a new workflow was created (a member submits to an approver other than the default approver). + */ +function hasCustomApprovalWorkflow(policy: OnyxEntry): boolean { + if (!policy) { + return false; + } + + const defaultApprover = getDefaultApprover(policy); + + // The default workflow's first approver was changed away from the workspace owner. + if (!!policy.approver && !!policy.owner && policy.approver !== policy.owner) { + return true; + } + + const employees = policy.employeeList ?? {}; + + // The default approver forwards approvals to someone, i.e. an "Approves to" user was added, + // either unconditionally (forwardsTo) or above an approval limit (overLimitForwardsTo). + const defaultApproverEmployee = employees[defaultApprover]; + if (!!defaultApprover && (!!defaultApproverEmployee?.forwardsTo || !!defaultApproverEmployee?.overLimitForwardsTo)) { + return true; + } + + // A new workflow exists when a member submits to an approver other than the default approver. + return Object.values(employees).some( + (employee) => employee?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!employee?.submitsTo && employee.submitsTo !== defaultApprover, + ); +} + +function getRuleApprovers(policy: OnyxEntry, expenseReport: OnyxEntry) { + const categoryApprovers: string[] = []; + const tagApprovers: string[] = []; + const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID); + + // Before submitting to their `submitsTo` (in a policy on Advanced Approvals), submit to category/tag approvers. + // Category approvers are prioritized, then tag approvers. + for (let i = 0; i < allReportTransactions.length; i++) { + const transaction = allReportTransactions.at(i); + const tag = getTag(transaction); + const category = getCategory(transaction); + const categoryApprover = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category)?.approver; + const tagApprover = getTagApproverRule(policy, tag)?.approver; + if (categoryApprover) { + categoryApprovers.push(categoryApprover); + } + + if (tagApprover) { + tagApprovers.push(tagApprover); + } + } + + // Return the unique approvers list + return [...new Set([...categoryApprovers, ...tagApprovers])]; +} + +function getFirstRuleApprover(approvalRules: ApprovalRule[], expenseReport: OnyxEntry, ownerLogin: string | undefined) { + // Pre-build a lookup map of { category: { value → approver }, tag: { value → approver } } + // from the policy's approval rules so that each transaction's category/tag can be resolved in O(1). + const rulesMap: Record<'category' | 'tag', Record> = { + category: {}, + tag: {}, + }; + + for (let i = 0; i < approvalRules.length; i++) { + const rule = approvalRules.at(i); + if (!rule) { + continue; + } + for (let j = 0; j < rule.applyWhen.length; j++) { + const applyWhen = rule.applyWhen.at(j); + if (applyWhen?.condition !== CONST.POLICY.RULE_CONDITIONS.MATCHES) { + continue; + } + if (applyWhen.field === CONST.POLICY.FIELDS.CATEGORY || applyWhen.field === CONST.POLICY.FIELDS.TAG) { + rulesMap[applyWhen.field][applyWhen.value] = rule.approver; + } + } + } + + if (isEmptyObject(rulesMap.category) && isEmptyObject(rulesMap.tag)) { + return ''; + } + + const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID); + + if (!allReportTransactions.length) { + return ''; + } + + let firstCategoryApprover = ''; + let firstTagApprover = ''; + + for (let i = 0; i < allReportTransactions.length; i++) { + const transaction = allReportTransactions.at(i); + const category = getCategory(transaction); + const categoryApprover = rulesMap.category[category]; + + // Category approvers take strict priority over tag approvers. + // Break immediately on the first match so we don't keep scanning transactions unnecessarily. + if (categoryApprover && categoryApprover !== ownerLogin) { + firstCategoryApprover = categoryApprover; + break; + } + + // Only look for a tag approver if we haven't found one yet — no need to re-check on subsequent transactions. + if (!firstTagApprover) { + const tag = getTag(transaction); + const tagApprover = rulesMap.tag[tag]; + + if (tagApprover && tagApprover !== ownerLogin) { + firstTagApprover = tagApprover; + } + } + } + + return firstCategoryApprover || firstTagApprover; +} + +function matchesApprovalWorkflowEmailComparison(node: RuleFilterComparison, email: string | undefined): boolean { + const expectedEmails = (Array.isArray(node.right) ? node.right : [node.right]).map((value) => String(value).toLowerCase()); + const isMatch = !!email && expectedEmails.includes(email.toLowerCase()); + return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO ? !isMatch : isMatch; +} + +function matchesApprovalWorkflowAmountComparison(node: RuleFilterComparison, amount: number): boolean { + const expectedAmount = typeof node.right === 'number' ? node.right : Number(node.right); + if (Number.isNaN(expectedAmount)) { + return false; + } + + switch (node.operator) { + case CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO: + return amount === expectedAmount; + case CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO: + return amount !== expectedAmount; + case CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN: + return amount > expectedAmount; + case CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO: + return amount >= expectedAmount; + case CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN: + return amount < expectedAmount; + case CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO: + return amount <= expectedAmount; + default: + return false; + } +} + +function evaluateApprovalWorkflowFilter(node: RuleFilterNode, context: ApprovalWorkflowContext): boolean { + if (!isRuleFilterComparison(node)) { + const left = evaluateApprovalWorkflowFilter(node.left, context); + const right = evaluateApprovalWorkflowFilter(node.right, context); + return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.OR ? left || right : left && right; + } + + switch (node.left) { + case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: + return matchesApprovalWorkflowEmailComparison(node, context.submitterEmail); + + // A report with no current approver has not been approved by anybody yet, so a rule based on who + // approved it last cannot apply. + case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: + return !!context.currentApproverEmail && matchesApprovalWorkflowEmailComparison(node, context.currentApproverEmail); + case CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT: + return matchesApprovalWorkflowAmountComparison(node, Math.abs(context.reportTotal)); + + default: + return false; + } +} + +/** + * True when every condition in the rule's filter tree is true for `context`. + */ +function evaluateApprovalWorkflowRule(rule: ApprovalWorkflowRule, context: ApprovalWorkflowContext): boolean { + return evaluateApprovalWorkflowFilter(rule.filters, context); +} + +/** + * Check the policy's approval workflow rules to determine where the report goes next. + */ +function getForwardsToFromRules(policy: OnyxEntry, context: ApprovalWorkflowContext, rules: OnyxCollection): ApprovalWorkflowRuleMatch | undefined { + if (!policy?.id || !context.submitterEmail) { + return undefined; + } + + const trigger = context.currentApproverEmail ? CONST.RULES.TRIGGERS.REPORT_APPROVE : CONST.RULES.TRIGGERS.REPORT_SUBMIT; + + // Sort by Onyx key so the rule picked stays the same across evaluations when more than one matches (which should not happen). + const ruleKeys = Object.keys(rules ?? {}).sort(); + for (const ruleKey of ruleKeys) { + const rule = rules?.[ruleKey]; + if (!rule || rule.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policy.id || rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + continue; + } + if (!isApprovalWorkflowRule(rule) || !Object.values(rule.triggers ?? {}).includes(trigger)) { + continue; + } + if (!evaluateApprovalWorkflowRule(rule, context)) { + continue; + } + + return {forwardsTo: Object.values(rule.actions ?? {}).find((action) => action.name === CONST.RULES.ACTIONS.FORWARD_TO)?.approver}; + } + + return undefined; +} + +function getManagerAccountEmail(policy: OnyxEntry, ownerLogin: string | undefined, rules: OnyxCollection, reportTotal = 0): string { + const ruleMatch = getForwardsToFromRules(policy, {submitterEmail: ownerLogin ?? '', reportTotal}, rules); + if (ruleMatch) { + return ruleMatch.forwardsTo ?? ''; + } + + const defaultApprover = getDefaultApprover(policy); + + // For policy using the optional or basic workflow, the manager is the policy default approver. + if (([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy))) { + return defaultApprover; + } + + const employee = policy?.employeeList?.[ownerLogin ?? '']; + if (!employee && !defaultApprover) { + return ''; + } + + return employee?.submitsTo ?? defaultApprover ?? ''; +} + +function getManagerAccountID(policy: OnyxEntry, ownerLogin: string | undefined, rules: OnyxCollection, reportTotal = 0) { + const managerEmail = getManagerAccountEmail(policy, ownerLogin, rules, reportTotal); + return managerEmail ? (getAccountIDsByLogins([managerEmail]).at(0) ?? -1) : -1; +} + +/** + * Returns the email the expense report should submit to per workspace approval config + * (approval rules, employee submitsTo, or default approver for basic/optional workflows). + */ +function getSubmitToEmail( + policy: OnyxEntry, + expenseReport: OnyxEntry, + ownerLogin: string | undefined, + rules: OnyxCollection, + shouldFallBackWhenManagerIsNotMember = false, +): string { + const approvalRules = policy?.rules?.approvalRules; + + if (!isSubmitAndClose(policy) && approvalRules?.length) { + const ruleApprover = getFirstRuleApprover(approvalRules, expenseReport, ownerLogin); + if (ruleApprover) { + return ruleApprover; + } + } + + const ruleMatch = getForwardsToFromRules(policy, {submitterEmail: ownerLogin ?? '', reportTotal: expenseReport?.total ?? 0}, rules); + if (ruleMatch) { + return ruleMatch.forwardsTo ?? ''; + } + + const managerEmail = getManagerAccountEmail(policy, ownerLogin, rules, expenseReport?.total ?? 0); + + // Falls back to the default approver when the manager is unavailable. + if (shouldFallBackWhenManagerIsNotMember && managerEmail && !policy?.employeeList?.[managerEmail] && !getHRAdvancedModeFinalApprover(policy)) { + return getDefaultApprover(policy); + } + + return managerEmail; +} + +/** + * Returns the accountID to whom the given expenseReport submits reports to in the given Policy. + */ +function getSubmitToAccountID( + policy: OnyxEntry, + expenseReport: OnyxEntry, + ownerLogin: string | undefined, + rules: OnyxCollection, + shouldFallBackWhenManagerIsNotMember = false, +): number { + const submitToEmail = getSubmitToEmail(policy, expenseReport, ownerLogin, rules, shouldFallBackWhenManagerIsNotMember); + return submitToEmail ? (getAccountIDsByLogins([submitToEmail]).at(0) ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; +} + +function getSubmitReportManagerAccountID( + policy: OnyxEntry, + expenseReport: OnyxEntry, + submitterLogin: string | undefined, + rules: OnyxCollection, + personalDetailsByLogins: PersonalDetailsByLogin, +): number | undefined { + const approvalRules = policy?.rules?.approvalRules; + const ruleApprover = !isSubmitAndClose(policy) && approvalRules?.length ? getFirstRuleApprover(approvalRules, expenseReport, submitterLogin) : ''; + const hasReliablePolicyRoute = + ([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy)) || + !!ruleApprover || + !!policy?.employeeList?.[submitterLogin ?? '']; + + if (!hasReliablePolicyRoute) { + return undefined; + } + + const submitToEmail = getSubmitToEmail(policy, expenseReport, submitterLogin, rules, true); + const submitToAccountID = submitToEmail ? personalDetailsByLogins[submitToEmail]?.accountID : undefined; + if (submitToAccountID === undefined || !isValidAccountRoute(submitToAccountID)) { + return undefined; + } + + return submitToAccountID; +} + +/** + * Returns the email of the account to forward the report to depending on the approver's approval limit. + * Used for advanced approval mode only. + * + * `submitterEmail` is needed to consult the workspace's approval workflow rules, which describe each hop in + * terms of who submitted the report as well as who is approving it. Without it only the legacy employeeList + * forwardsTo/overLimitForwardsTo is considered. + */ +function getForwardsToAccount(policy: OnyxEntry, employeeEmail: string, reportTotal: number, rules: OnyxCollection, submitterEmail?: string): string { + const ruleMatch = submitterEmail ? getForwardsToFromRules(policy, {submitterEmail, currentApproverEmail: employeeEmail, reportTotal}, rules) : undefined; + if (ruleMatch) { + return ruleMatch.forwardsTo ?? ''; + } + + if (!isControlOnAdvancedApprovalMode(policy)) { + return ''; + } + + const employee = policy?.employeeList?.[employeeEmail]; + if (!employee) { + return ''; + } + + const positiveReportTotal = Math.abs(reportTotal); + if (employee.approvalLimit && employee.overLimitForwardsTo && positiveReportTotal > employee.approvalLimit) { + return employee.overLimitForwardsTo; + } + return employee.forwardsTo ?? ''; +} + +function getWorkflowApprovalsUnavailable(policy: OnyxEntry) { + return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL || !!policy?.errorFields?.approvalMode; +} + +export { + isSubmitterAndApprover, + getApprovalWorkflow, + getDefaultApprover, + isNonMemberApprover, + hasCustomApprovalWorkflow, + getRuleApprovers, + evaluateApprovalWorkflowRule, + getForwardsToFromRules, + getManagerAccountEmail, + getManagerAccountID, + getSubmitToEmail, + getSubmitToAccountID, + getSubmitReportManagerAccountID, + getForwardsToAccount, + getWorkflowApprovalsUnavailable, +}; diff --git a/src/libs/PolicyUtils/domains.ts b/src/libs/PolicyUtils/domains.ts new file mode 100644 index 000000000000..961627780537 --- /dev/null +++ b/src/libs/PolicyUtils/domains.ts @@ -0,0 +1,113 @@ +/** + * Workspace domain names, admin email domains, and the workspace a card or card feed belongs to. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import {isPublicDomain} from '@libs/ValidationUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Card, Policy} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +import {Str} from 'expensify-common'; + +import {isExpensifyTeam} from './members'; + +function getDomainNameForPolicy(policyID?: string): string { + if (!policyID) { + return ''; + } + + return `${CONST.EXPENSIFY_POLICY_DOMAIN}${policyID.toLowerCase()}${CONST.EXPENSIFY_POLICY_DOMAIN_EXTENSION}`; +} + +function getAdminsPrivateEmailDomains(policy?: Policy) { + if (!policy) { + return []; + } + + const adminDomains = Object.entries(policy.employeeList ?? {}).reduce((domains, [email, employee]) => { + if (employee.role !== CONST.POLICY.ROLE.ADMIN) { + return domains; + } + domains.push(Str.extractEmailDomain(email).toLowerCase()); + return domains; + }, [] as string[]); + + const ownerDomains = policy.owner ? [Str.extractEmailDomain(policy.owner).toLowerCase()] : []; + + const privateDomains = [...new Set(adminDomains.concat(ownerDomains))].filter((domain) => !isPublicDomain(domain)); + + // If the policy is not owned by Expensify there is no point in showing the domain for provisioning. + if (!isExpensifyTeam(policy.owner)) { + return privateDomains.filter((domain) => domain !== CONST.EXPENSIFY_PARTNER_NAME && domain !== CONST.EMAIL.GUIDES_DOMAIN); + } + + return privateDomains; +} + +/** + * Determines the most frequent domain from the `acceptedDomains` list + * that appears in the email addresses of policy members. + * + * @param acceptedDomains - List of domains to consider. + * @param policy - The policy object. + */ +function getMostFrequentEmailDomain(acceptedDomains: string[], policy?: Policy) { + if (!policy) { + return undefined; + } + const domainOccurrences = {} as Record; + for (const memberDomain of Object.keys(policy.employeeList ?? {}) + .concat(policy.owner) + .map((email) => Str.extractEmailDomain(email).toLowerCase())) { + if (!acceptedDomains.includes(memberDomain)) { + continue; + } + domainOccurrences[memberDomain] = (domainOccurrences[memberDomain] || 0) + 1; + } + let mostFrequent = {domain: '', count: 0}; + for (const [domain, count] of Object.entries(domainOccurrences)) { + if (count <= mostFrequent.count) { + continue; + } + mostFrequent = {domain, count}; + } + if (mostFrequent.count === 0) { + return undefined; + } + return mostFrequent.domain; +} + +const getPolicyIDFromDomainName = (domainName: string): string | undefined => domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1]?.toUpperCase(); + +/** + * Returns the workspace an assigned card belongs to. + * + * A card names its workspace with `fundID`, which is the policy's `policyAccountID`. That holds whatever the feed's + * domain looks like, so a card on a company's own domain resolves the same workspace as one on a workspace feed's + * `expensify-policy.exfy` domain. The domain is read only as a fallback, for a card that arrives without a `fundID`. + */ +const getPolicyForAssignedCard = (card: OnyxEntry>, policies: OnyxCollection): OnyxEntry => { + const workspaceAccountID = Number(card?.fundID); + const policyForFund = workspaceAccountID ? Object.values(policies ?? {}).find((policy) => policy?.policyAccountID === workspaceAccountID) : undefined; + if (policyForFund) { + return policyForFund; + } + + const policyID = card?.domainName ? getPolicyIDFromDomainName(card.domainName) : undefined; + return policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] : undefined; +}; + +const getDescriptionForPolicyDomainCard = (domainName: string, policies: OnyxCollection): string => { + // A domain name containing a policyID indicates that this is a workspace feed + const policyID = getPolicyIDFromDomainName(domainName); + if (policyID) { + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + return policy?.name ?? domainName; + } + return domainName; +}; + +export {getDomainNameForPolicy, getAdminsPrivateEmailDomains, getMostFrequentEmailDomain, getPolicyIDFromDomainName, getPolicyForAssignedCard, getDescriptionForPolicyDomainCard}; diff --git a/src/libs/PolicyUtils/errors.ts b/src/libs/PolicyUtils/errors.ts new file mode 100644 index 000000000000..23d7d96beb68 --- /dev/null +++ b/src/libs/PolicyUtils/errors.ts @@ -0,0 +1,149 @@ +/** + * Policy error flags, the brick-road indicator, and settings error and pending-action helpers. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import {isQBORefreshTokenExpiringSoon} from '@libs/AccountingUtils'; +import {hasSynchronizationErrorMessage} from '@libs/actions/connections'; +import {isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; + +import CONST from '@src/CONST'; +import type {Policy, PolicyCategories} from '@src/types/onyx'; +import type {ErrorFields, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon'; +import type {ConnectionName} from '@src/types/onyx/Policy'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {getAccountingConnectionNames} from './connections'; +import {isPolicyAdmin} from './permissions'; + +/** + * Checks if we have any errors stored within the policy?.employeeList. Determines whether we should show a red brick road error or not. + */ +function shouldShowEmployeeListError(policy: OnyxEntry): boolean { + return isPolicyAdmin(policy) && Object.values(policy?.employeeList ?? {}).some((employee) => Object.keys(employee?.errors ?? {}).length > 0); +} + +/** + * Check if the policy has any tax rate errors. + */ +function shouldShowTaxRateError(policy: OnyxEntry): boolean { + return ( + isPolicyAdmin(policy) && + Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0 || Object.values(taxRate?.errorFields ?? {}).some(Boolean)) + ); +} + +/** + * Check if the policy has any errors within the categories. + */ +function hasPolicyCategoriesError(policyCategories: OnyxEntry): boolean { + return Object.keys(policyCategories ?? {}).some((categoryName) => Object.keys(policyCategories?.[categoryName]?.errors ?? {}).length > 0); +} + +/** + * Check if the policy has any errors within the rules. + */ +function hasPolicyRulesError(policy: OnyxEntry, hasMerchantRuleErrors: boolean): boolean { + const agentRules = Object.values(policy?.rules?.agentRules ?? {}); + + return hasMerchantRuleErrors || agentRules.some((rule) => rule && Object.keys(rule.errors ?? {}).length > 0); +} + +/** + * Checks if the policy had a sync error. + */ +function shouldShowSyncError(policy: OnyxEntry, isSyncInProgress: boolean, connectionNames?: readonly ConnectionName[]): boolean { + const connectionsToCheck = connectionNames ?? (Object.keys(policy?.connections ?? {}) as ConnectionName[]); + return isPolicyAdmin(policy) && connectionsToCheck.some((connection) => !!hasSynchronizationErrorMessage(policy, connection, isSyncInProgress)); +} + +/** + * Check if the policy has any error fields. + */ +function shouldShowPolicyErrorFields(policy: OnyxEntry): boolean { + return isPolicyAdmin(policy) && Object.values(policy?.errorFields ?? {}).some((fieldErrors) => Object.keys(fieldErrors ?? {}).length > 0); +} + +/** + * Check if the policy has any errors, and if it doesn't, then check if it has any error fields. + */ +function shouldShowPolicyError(policy: OnyxEntry): boolean { + return Object.keys(policy?.errors ?? {}).length > 0 ? isPolicyAdmin(policy) : shouldShowPolicyErrorFields(policy); +} + +/** + * Checks if we have any errors stored within the policy custom units. + */ +function shouldShowCustomUnitsError(policy: OnyxEntry): boolean { + return isPolicyAdmin(policy) && Object.keys(policy?.customUnits?.errors ?? {}).length > 0; +} + +function shouldShowQBOReimbursableExportDestinationAccountError(policy: OnyxEntry): boolean { + const qboConfig = policy?.connections?.quickbooksOnline?.config; + return isPolicyAdmin(policy) && !!qboConfig?.reimbursableExpensesExportDestination && !qboConfig.reimbursableExpensesAccount; +} + +/** + * Get the brick road indicator status for a policy. The policy has an error status if there is a policy member error, a custom unit error or a field error. + */ +function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry, isConnectionInProgress: boolean): ValueOf | undefined { + if ( + shouldShowEmployeeListError(policy) || + shouldShowCustomUnitsError(policy) || + shouldShowPolicyErrorFields(policy) || + shouldShowSyncError(policy, isConnectionInProgress, getAccountingConnectionNames()) || + shouldShowQBOReimbursableExportDestinationAccountError(policy) || + shouldShowHRConnectionError(policy, isConnectionInProgress, isPolicyAdmin(policy)) + ) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + } + return undefined; +} + +/** + * Returns whether the Merge HR setup still needs to be completed for a policy. + */ +const isMergeHRCompleteSetupNeededSelector = (policy: OnyxEntry) => isMergeHRCompleteSetupNeeded(policy); + +/** + * Returns whether an admin should be warned that the workspace's QuickBooks Online connection is about to expire. + */ +const isQBORefreshTokenExpiringSoonSelector = (policy: OnyxEntry) => isPolicyAdmin(policy) && isQBORefreshTokenExpiringSoon(policy); + +function areSettingsInErrorFields(settings?: string[], errorFields?: ErrorFields) { + if (settings === undefined || errorFields === undefined) { + return false; + } + + const keys = Object.keys(errorFields); + return settings.some((setting) => keys.includes(setting)); +} + +function settingsPendingAction(settings?: string[], pendingFields?: PendingFields): PendingAction | undefined { + if (settings === undefined || pendingFields === undefined) { + return null; + } + + const key = Object.keys(pendingFields).find((setting) => settings.includes(setting)); + if (!key) { + return; + } + return pendingFields[key]; +} + +export { + shouldShowEmployeeListError, + shouldShowTaxRateError, + hasPolicyCategoriesError, + hasPolicyRulesError, + shouldShowSyncError, + shouldShowPolicyError, + shouldShowCustomUnitsError, + shouldShowQBOReimbursableExportDestinationAccountError, + getPolicyBrickRoadIndicatorStatus, + isMergeHRCompleteSetupNeededSelector, + isQBORefreshTokenExpiringSoonSelector, + areSettingsInErrorFields, + settingsPendingAction, +}; diff --git a/src/libs/PolicyUtils/features.ts b/src/libs/PolicyUtils/features.ts new file mode 100644 index 000000000000..ef57eeeaa58c --- /dev/null +++ b/src/libs/PolicyUtils/features.ts @@ -0,0 +1,106 @@ +/** + * Whether a workspace feature is enabled, including rules, invoice fields, attendee tracking, time tracking, and MCP. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import {hasAnyCategoryRules} from '@libs/CategoryUtils'; +import {convertToBackendAmount} from '@libs/CurrencyUtils'; +import {isAnyHRConnected} from '@libs/merge/HRUtils'; +import {isAnyRecruitingConnected} from '@libs/merge/RecruitingUtils'; + +import CONST from '@src/CONST'; +import type {Policy, PolicyCategories} from '@src/types/onyx'; +import type {PolicyFeatureName} from '@src/types/onyx/Policy'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {hasAccountingFeatureConnection} from './connections'; +import {canPolicyAccessFeature, isControlPolicy, isPaidGroupPolicy} from './policyType'; + +/** + * For backwards compatibility with Expensify Classic, attendee tracking defaults to enabled + * on Control policies when the property is undefined. + */ +function isAttendeeTrackingEnabled(policy: OnyxEntry): boolean { + return (isControlPolicy(policy) && policy?.isAttendeeTrackingEnabled) ?? true; +} + +function arePolicyRulesEnabled(policy: OnyxEntry, policyCategories?: PolicyCategories | null): boolean { + if (!isPaidGroupPolicy(policy)) { + return false; + } + if (policy?.areRulesEnabled === true) { + return true; + } + if (policy?.areRulesEnabled === false) { + return false; + } + // areRulesEnabled is undefined - this can happen in case of migrated old Control policies, in such case users might have set up category rules in Classic and we should show Rules as enabled + if (!isControlPolicy(policy)) { + return false; + } + return hasAnyCategoryRules(policyCategories ?? undefined); +} + +/** + * Whether Invoice Fields is enabled for the policy. + * Respects the `areInvoiceFieldsEnabled` toggle and verifies the policy has access to the feature (Control only). + */ +function isInvoiceFieldsEnabled(policy: OnyxEntry | null): boolean { + return !!policy?.areInvoiceFieldsEnabled && canPolicyAccessFeature(policy ?? undefined, CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED); +} + +function isPolicyFeatureEnabled(policy: OnyxEntry, featureName: PolicyFeatureName, policyCategories?: PolicyCategories | null): boolean { + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED) { + return arePolicyRulesEnabled(policy, policyCategories); + } + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED) { + return isInvoiceFieldsEnabled(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED) { + return !!policy?.tax?.trackingEnabled; + } + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED) { + return policy?.[featureName] ? !!policy?.[featureName] : hasAccountingFeatureConnection(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED) { + return isMCPEnabled(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED) { + return policy?.isHREnabled === true || isAnyHRConnected(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED) { + return policy?.isRecruitingEnabled === true || isAnyRecruitingConnected(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED) { + return policy?.receiptPartners?.enabled ?? false; + } + if (featureName === CONST.POLICY.MORE_FEATURES.IS_TIME_TRACKING_ENABLED) { + return isTimeTrackingEnabled(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.IS_ATTENDEE_TRACKING_ENABLED) { + return isAttendeeTrackingEnabled(policy); + } + + return !!policy?.[featureName]; +} + +/** + * Returns if the policy has the Time Tracking feature enabled. + */ +function isTimeTrackingEnabled(policy: OnyxEntry): boolean { + return !!policy?.units?.time?.enabled; +} + +/** MCP is on for every workspace unless an admin has explicitly turned it off, so an absent flag reads as enabled. */ +function isMCPEnabled(policy: OnyxEntry): boolean { + return policy?.isMCPEnabled ?? true; +} + +/** + * Returns the policy's default hourly rate for the Time Tracking feature. + */ +function getDefaultTimeTrackingRate(policy: Partial>): number | undefined { + return policy?.units?.time?.rate !== undefined ? convertToBackendAmount(policy.units.time.rate) : undefined; +} + +export {isAttendeeTrackingEnabled, arePolicyRulesEnabled, isInvoiceFieldsEnabled, isPolicyFeatureEnabled, isTimeTrackingEnabled, isMCPEnabled, getDefaultTimeTrackingRate}; diff --git a/src/libs/PolicyUtils/getHighestPolicyRole.ts b/src/libs/PolicyUtils/getHighestPolicyRole.ts deleted file mode 100644 index 7b1dd306f738..000000000000 --- a/src/libs/PolicyUtils/getHighestPolicyRole.ts +++ /dev/null @@ -1,34 +0,0 @@ -import CONST from '@src/CONST'; -import type {OnyxInputOrEntry, Policy} from '@src/types/onyx'; - -import type {TupleToUnion} from 'type-fest'; - -/** - * Workspace roles from highest to lowest privilege, matching the role hierarchy in Auth. - */ -const POLICY_ROLE_RANKING = [ - CONST.POLICY.ROLE.ADMIN, - CONST.POLICY.ROLE.PAYMENTS_ADMIN, - CONST.POLICY.ROLE.CARD_ADMIN, - CONST.POLICY.ROLE.PEOPLE_ADMIN, - CONST.POLICY.ROLE.AUDITOR, - CONST.POLICY.ROLE.EDITOR, - CONST.POLICY.ROLE.USER, - CONST.POLICY.ROLE.GUEST, -] as const; - -/** - * Returns the highest-privilege role held across the given policies. - * Defaults to user when no ranked role is found, so a member on any workspace is not reported as a guest. - */ -function getHighestPolicyRole(policyList: Array>>): TupleToUnion { - const roles = new Set(policyList.map((policy) => policy?.role)); - for (const rankedRole of POLICY_ROLE_RANKING) { - if (roles.has(rankedRole)) { - return rankedRole; - } - } - return CONST.POLICY.ROLE.USER; -} - -export default getHighestPolicyRole; diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index c1d8e3fecc80..b8561de29653 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -1,1982 +1,4 @@ -import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; -import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginProvider'; - -import {isQBORefreshTokenExpiringSoon} from '@libs/AccountingUtils'; -import {getBankAccountFromID} from '@libs/actions/BankAccounts'; -import {hasSynchronizationErrorMessage} from '@libs/actions/connections'; -import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; -import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; -import {getApiRoot} from '@libs/ApiUtils'; -import {getCategoryApproverRule, hasAnyCategoryRules} from '@libs/CategoryUtils'; -import {convertToBackendAmount} from '@libs/CurrencyUtils'; -import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; -import {getHRAdvancedModeFinalApprover, isAnyHRConnected, isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; -import {isAnyRecruitingConnected} from '@libs/merge/RecruitingUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import {getIsOffline} from '@libs/NetworkState'; -import {getAccountIDsByLogins} from '@libs/PersonalDetailsUtils'; -import {isApprovalWorkflowRule, isRuleFilterComparison} from '@libs/RuleUtils'; -import {getAllSortedTransactions, getCategory, getTag} from '@libs/TransactionUtils'; -import {generateAccountID} from '@libs/UserUtils'; -import {isPublicDomain, isValidAccountRoute} from '@libs/ValidationUtils'; -import {getEffectiveWorkArrangement} from '@libs/WorkArrangementUtils'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {PolicyType} from '@src/types/form/WorkspaceConfirmationForm'; -import type {Card, OnyxInputOrEntry, PersonalDetailsList, Policy, PolicyCategories, PolicyEmployeeList, Report, TravelSettings} from '@src/types/onyx'; -import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; -import type {ErrorFields, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon'; -import type {ApprovalRule, CommuterExclusions, ConnectionName, PolicyFeatureName} from '@src/types/onyx/Policy'; -import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; -import type Rule from '@src/types/onyx/Rule'; -import type {RuleFilterComparison, RuleFilterNode} from '@src/types/onyx/RuleFilters'; -import type {WorkspaceTravelSettings} from '@src/types/onyx/TravelSettings'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; - -import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; - -import {Str} from 'expensify-common'; - -import {getAccountingConnectionNames, hasAccountingFeatureConnection} from './connections'; -import {getPerDiemCustomUnit, isPerDiemEligiblePolicy} from './customUnits'; -import {canMemberWrite, getPolicyRole, isPolicyAdmin, isPolicyApprover, isPolicyOwner, isPolicyUser} from './permissions'; -import {canPolicyAccessFeature, isArchivedPolicy, isCollectPolicy, isControlPolicy, isGroupPolicy, isPaidGroupPolicy, isSubmitPolicy} from './policyType'; - -type MemberEmailsToAccountIDs = Record; - -type TravelStep = ValueOf; - -type WorkspaceDetails = { - policyID: string | undefined; - name: string; -}; - -/** The report facts an approval workflow rule is matched against. */ -type ApprovalWorkflowContext = { - /** Email of the member who submitted the report. */ - submitterEmail: string; - - /** Email of the approver the report is currently with. Empty when the report has not been approved yet. */ - currentApproverEmail?: string; - - /** Total of the report, in the backend (cents) amount the rules' `amount` filters are written in. */ - reportTotal: number; -}; - -/** The outcome of matching a report against the policy's approval workflow rules. */ -type ApprovalWorkflowRuleMatch = { - /** Email the matched rule forwards the report to. Undefined when the rule finalizes the report instead. */ - forwardsTo?: string; -}; - -/** - * Returns true if the policy has no fieldList or its fieldList is empty. - */ -function isPolicyFieldListEmpty(policy: OnyxEntry): boolean { - return !policy?.fieldList || Object.keys(policy.fieldList).length === 0; -} - -/** - * Whether the current user can unarchive the workspace: only the owner of an archived workspace can, and only - * while the archive policies beta is enabled. Takes primitives so it works with both a Policy and a workspace row. - */ -function canUnarchivePolicy(isArchived: boolean, ownerAccountID: number | undefined, currentUserAccountID: number, isArchivePoliciesBetaEnabled: boolean): boolean { - return isArchivePoliciesBetaEnabled && isArchived && ownerAccountID === currentUserAccountID; -} - -/** - * Filter out the active policies, which will exclude policies with pending deletion - * and policies the current user doesn't belong to. - * These are policies that we can use to create reports with in NewDot. - */ -function getActivePolicies(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { - return Object.values(policies ?? {}).filter( - (policy): policy is Policy => - !!policy && - policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && - !!policy.name && - !!policy.id && - !!getPolicyRole(policy, currentUserLogin) && - !isArchivedPolicy(policy), - ); -} - -/** - * Filter out the active policies, which will exclude policies with pending deletion - * and policies the current user doesn't belong to. - * These will be policies that has expense chat enabled. - * These are policies that we can use to create reports with in NewDot. - */ -function getActivePoliciesWithExpenseChat(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { - return Object.values(policies ?? {}).filter( - (policy): policy is Policy => - !!policy && - policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && - !!policy.name && - !!policy.id && - !!getPolicyRole(policy, currentUserLogin) && - (isPaidGroupPolicy(policy) || isSubmitPolicy(policy)) && - !isArchivedPolicy(policy), - ); -} - -function getActivePoliciesWithExpenseChatAndPerDiemEnabled(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { - return getActivePoliciesWithExpenseChat(policies, currentUserLogin).filter(isPerDiemEligiblePolicy); -} - -function getActivePoliciesWithExpenseChatAndTimeEnabled(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { - return getActivePoliciesWithExpenseChat(policies, currentUserLogin).filter(isTimeTrackingEnabled); -} - -/** - * Checks if we have any errors stored within the policy?.employeeList. Determines whether we should show a red brick road error or not. - */ -function shouldShowEmployeeListError(policy: OnyxEntry): boolean { - return isPolicyAdmin(policy) && Object.values(policy?.employeeList ?? {}).some((employee) => Object.keys(employee?.errors ?? {}).length > 0); -} - -/** - * Check if the policy has any tax rate errors. - */ -function shouldShowTaxRateError(policy: OnyxEntry): boolean { - return ( - isPolicyAdmin(policy) && - Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0 || Object.values(taxRate?.errorFields ?? {}).some(Boolean)) - ); -} - -/** - * Check if the policy has any errors within the categories. - */ -function hasPolicyCategoriesError(policyCategories: OnyxEntry): boolean { - return Object.keys(policyCategories ?? {}).some((categoryName) => Object.keys(policyCategories?.[categoryName]?.errors ?? {}).length > 0); -} - -/** - * Check if the policy has any errors within the rules. - */ -function hasPolicyRulesError(policy: OnyxEntry, hasMerchantRuleErrors: boolean): boolean { - const agentRules = Object.values(policy?.rules?.agentRules ?? {}); - - return hasMerchantRuleErrors || agentRules.some((rule) => rule && Object.keys(rule.errors ?? {}).length > 0); -} - -/** - * Checks if the policy had a sync error. - */ -function shouldShowSyncError(policy: OnyxEntry, isSyncInProgress: boolean, connectionNames?: readonly ConnectionName[]): boolean { - const connectionsToCheck = connectionNames ?? (Object.keys(policy?.connections ?? {}) as ConnectionName[]); - return isPolicyAdmin(policy) && connectionsToCheck.some((connection) => !!hasSynchronizationErrorMessage(policy, connection, isSyncInProgress)); -} - -/** - * Check if the policy has any error fields. - */ -function shouldShowPolicyErrorFields(policy: OnyxEntry): boolean { - return isPolicyAdmin(policy) && Object.values(policy?.errorFields ?? {}).some((fieldErrors) => Object.keys(fieldErrors ?? {}).length > 0); -} - -/** - * Check if the policy has any errors, and if it doesn't, then check if it has any error fields. - */ -function shouldShowPolicyError(policy: OnyxEntry): boolean { - return Object.keys(policy?.errors ?? {}).length > 0 ? isPolicyAdmin(policy) : shouldShowPolicyErrorFields(policy); -} - -/** - * Checks if we have any errors stored within the policy custom units. - */ -function shouldShowCustomUnitsError(policy: OnyxEntry): boolean { - return isPolicyAdmin(policy) && Object.keys(policy?.customUnits?.errors ?? {}).length > 0; -} - -/** - * The workspace-wide work arrangement, which members follow unless they were given one of their own. - */ -function hasOfficeWorkArrangement(commuterExclusions: CommuterExclusions | undefined): boolean { - return getEffectiveWorkArrangement(undefined, commuterExclusions?.isOfficeWorkArrangement); -} - -function getPolicyIDOrDefault(policyID?: string) { - if (!policyID || policyID === CONST.POLICY.OWNER_EMAIL_FAKE) { - return '-1'; - } - return policyID; -} - -/** Return the emails that can receive a shared bank account from the current user. */ -function getEligibleBankAccountShareRecipientEmails(policies: OnyxCollection | null, currentUserLogin: string | undefined, bankAccountID: string | undefined): string[] { - if (!currentUserLogin) { - return []; - } - - const currentBankAccount = getBankAccountFromID(Number(bankAccountID)); - const isOffline = getIsOffline(); - const activePolicies = getActivePolicies(policies, currentUserLogin).filter( - (policy) => shouldShowPolicy(policy, isOffline, currentUserLogin) && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS), - ); - const recipientEmails = new Set(); - const shareesSet = new Set(currentBankAccount?.accountData?.sharees ?? []); - - for (const policy of activePolicies) { - for (const [email, employee] of Object.entries(policy.employeeList ?? {})) { - if ( - employee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || - !employee.role || - !canMemberWrite(policy, email, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS) || - email === currentUserLogin || - recipientEmails.has(email) || - shareesSet.has(email) || - (isExpensifyTeam(email) && shouldFilterExpensifyTeam(policy.owner, currentUserLogin)) - ) { - continue; - } - - recipientEmails.add(email); - } - } - - return Array.from(recipientEmails); -} - -/** Return whether the current user has someone they can share a bank account with. */ -function hasEligibleBankAccountShareRecipient(policies: OnyxCollection | null, currentUserLogin: string | undefined, bankAccountID: string | undefined): boolean { - return getEligibleBankAccountShareRecipientEmails(policies, currentUserLogin, bankAccountID).length > 0; -} - -/** - * Get the brick road indicator status for a policy. The policy has an error status if there is a policy member error, a custom unit error or a field error. - */ -function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry, isConnectionInProgress: boolean): ValueOf | undefined { - if ( - shouldShowEmployeeListError(policy) || - shouldShowCustomUnitsError(policy) || - shouldShowPolicyErrorFields(policy) || - shouldShowSyncError(policy, isConnectionInProgress, getAccountingConnectionNames()) || - shouldShowQBOReimbursableExportDestinationAccountError(policy) || - shouldShowHRConnectionError(policy, isConnectionInProgress, isPolicyAdmin(policy)) - ) { - return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - } - return undefined; -} - -/** - * Returns whether the Merge HR setup still needs to be completed for a policy. - */ -const isMergeHRCompleteSetupNeededSelector = (policy: OnyxEntry) => isMergeHRCompleteSetupNeeded(policy); - -/** - * Returns whether an admin should be warned that the workspace's QuickBooks Online connection is about to expire. - */ -const isQBORefreshTokenExpiringSoonSelector = (policy: OnyxEntry) => isPolicyAdmin(policy) && isQBORefreshTokenExpiringSoon(policy); - -/** - * Check if the policy can be displayed - * If shouldShowPendingDeletePolicy is true, show the policy pending deletion. - * If shouldShowPendingDeletePolicy is false, show the policy pending deletion only if there is an error. - * Note: Using a local ONYXKEYS.NETWORK subscription will cause a delay in - * updating the screen. Passing the offline status from the component. - */ -function shouldShowPolicy(policy: OnyxEntry, shouldShowPendingDeletePolicy: boolean, currentUserLogin: string | undefined, includeArchivedPolicy = false): boolean { - if (!includeArchivedPolicy && isArchivedPolicy(policy)) { - return false; - } - return ( - !!policy?.isJoinRequestPending || - (!!policy && - policy?.type !== CONST.POLICY.TYPE.PERSONAL && - (shouldShowPendingDeletePolicy || policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0) && - !!getPolicyRole(policy, currentUserLogin)) - ); -} - -/** - * Checks if a specific user is a member of the policy. - * Note that employeeList can be blank when the policy is not the user's default/active policy - */ -function isPolicyMember(policy: OnyxEntry, userLogin: string | undefined): boolean { - return !!policy && !!userLogin && (!!policy.employeeList?.[userLogin] || policy.owner === userLogin); -} - -function isMemberInHomeAndOfficeWorkspace(policy: OnyxEntry, memberLogin: string): boolean { - return !!memberLogin && policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && !!policy.employeeList?.[memberLogin]; -} - -function isPolicyMemberWithoutPendingDelete(currentUserLogin: string | undefined, policy: OnyxEntry): boolean { - if (!currentUserLogin || !policy?.id) { - return false; - } - - const policyEmployee = policy?.employeeList?.[currentUserLogin]; - return !!policyEmployee && policyEmployee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; -} - -/** - * Resolve the email of the workspace's Authorized Payer (reimburser), matching the canonical resolution used across the app. - * Returns undefined when reimbursement is disabled or no payer can be resolved. - */ -function getReimburserEmail(policy: OnyxEntry): string | undefined { - if (!policy) { - return undefined; - } - - const reimbursementChoice = getReimbursementChoice(policy); - const isAutoReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; - const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; - - // Reimbursement is disabled for this workspace. - if (!isAutoReimbursement && !isManualReimbursement) { - return undefined; - } - - return policy.reimburser ?? policy.achAccount?.reimburser ?? (isManualReimbursement ? policy.owner : undefined); -} - -/** - * Payer fields to merge into the successData of an ownership transfer. The backend keeps the former payer when the - * workspace has a bank account, so only reassign when there is none and the outgoing owner is the resolved payer. - */ -function getOwnerChangePayerSuccessData(policy: OnyxEntry, newOwnerLogin: string): NullishDeep { - if (!policy || policy.achAccount?.bankAccountID || getReimburserEmail(policy) !== policy.owner) { - return {}; - } - - return { - ...(policy.reimburser ? {reimburser: newOwnerLogin} : {}), - ...(policy.achAccount?.reimburser ? {achAccount: {reimburser: newOwnerLogin}} : {}), - }; -} - -function isPolicyPayer(policy: OnyxEntry, currentUserLogin: string | undefined): boolean { - if (!policy) { - return false; - } - - const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN; - const reimbursementChoice = getReimbursementChoice(policy); - const isAutoReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; - const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; - - // Reimbursement is disabled for this workspace. - if (!isAutoReimbursement && !isManualReimbursement) { - return false; - } - - const reimburserEmail = getReimburserEmail(policy); - - // No designated reimburser means any workspace admin can pay. - if (!reimburserEmail) { - return isAdmin; - } - - const canPayOnPolicy = isAdmin || (!!currentUserLogin && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)); - - return canPayOnPolicy && currentUserLogin === reimburserEmail; -} - -/** Check if the passed employee holds an active Expensify Card on the policy, as reported by the backend in the policy's employeeList */ -function hasActiveExpensifyCard(policy: OnyxEntry, employeeLogin: string) { - const primaryLogin = policy?.primaryLoginsInvited?.[employeeLogin]; - return !!policy?.employeeList?.[employeeLogin]?.hasActiveExpensifyCard || (!!primaryLogin && !!policy?.employeeList?.[primaryLogin]?.hasActiveExpensifyCard); -} - -function isExpensifyTeam(email: string | undefined): boolean { - const emailDomain = Str.extractEmailDomain(email ?? ''); - return emailDomain === CONST.EXPENSIFY_PARTNER_NAME || emailDomain === CONST.EMAIL.GUIDES_DOMAIN; -} - -/** Whether Expensify team members should be hidden from the given policy/user combination. */ -function shouldFilterExpensifyTeam(policyOwner: string | undefined, currentUserLogin: string | undefined): boolean { - return !!policyOwner && !!currentUserLogin && !isExpensifyTeam(policyOwner) && !isExpensifyTeam(currentUserLogin); -} - -/** - * Creates a selector for useOnyx that computes the filtered member count. - * Returns a primitive number to prevent unnecessary re-renders when unrelated personal details change. - */ -function createFilteredMemberCountSelector( - employeeList: PolicyEmployeeList | undefined, - policyOwner: string | undefined, - currentUserLogin: string | undefined, - personalDetailsByLogins: PersonalDetailsByLogin, -) { - return (personalDetails: PersonalDetailsList | undefined): number => { - const shouldFilter = shouldFilterExpensifyTeam(policyOwner, currentUserLogin); - const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(employeeList, personalDetailsByLogins, false, false); - - return Object.keys(policyMemberEmailsToAccountIDs).reduce((count, email) => { - const accountID = policyMemberEmailsToAccountIDs[email]; - const details = personalDetails?.[accountID]; - - if (shouldFilter && isExpensifyTeam(details?.login ?? details?.displayName)) { - return count; - } - - return count + 1; - }, 0); - }; -} - -/** - * Creates a selector for useOnyx that returns formatted text for invoice configuration. - * Returns a primitive string to prevent unnecessary re-renders when unrelated bank account data changes. - * This matches the filtering logic used by PaymentMethodList with filterType=BUSINESS on the invoices page. - */ -function createInvoiceConfigurationTextSelector(translate: LocaleContextProps['translate'], invoiceCompany: string) { - return (bankAccountList: OnyxEntry>): string => { - const count = Object.values(bankAccountList ?? {}).filter((account) => account.accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS).length; - const bankAccountsText = count > 0 ? `${count} ${translate('common.bankAccounts').toLowerCase()}` : ''; - return [bankAccountsText, invoiceCompany].filter(Boolean).join(', '); - }; -} - -/** - * Get the active group policies where the current user can create policy rooms. - */ -function getPoliciesForRoomCreation(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { - return getActivePolicies(policies, currentUserLogin).filter( - (policy) => policy.type !== CONST.POLICY.TYPE.PERSONAL && canMemberWrite(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.ROOMS), - ); -} - -/** - * Create an object mapping member emails to their accountIDs. Filter for members without errors if includeMemberWithErrors is false, and get the login email from the personalDetail object using the accountID. - * - * If includeMemberWithErrors is false, We only return members without errors. Otherwise, the members with errors would immediately be removed before the user has a chance to read the error. - */ -function getMemberAccountIDsForWorkspace( - employeeList: PolicyEmployeeList | undefined, - personalDetailsByLogins: PersonalDetailsByLogin, - includeMemberWithErrors = false, - includeMemberWithPendingDelete = true, -): MemberEmailsToAccountIDs { - const members = employeeList ?? {}; - const memberEmailsToAccountIDs: MemberEmailsToAccountIDs = {}; - for (const email of Object.keys(members)) { - if (!includeMemberWithErrors) { - const member = members?.[email]; - if (Object.keys(member?.errors ?? {})?.length > 0) { - continue; - } - } - if (!includeMemberWithPendingDelete) { - const member = members?.[email]; - if (member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - continue; - } - } - const personalDetail = personalDetailsByLogins?.[email]; - if (!personalDetail?.login) { - continue; - } - memberEmailsToAccountIDs[email] = Number(personalDetail.accountID); - } - return memberEmailsToAccountIDs; -} - -/** - * Resolves the accountID for a submit-to recipient chosen in the submit-to popover. - * Uses personal details first, then the workspace employee list (same source as ReportSubmitToContent). - * When the member is in employeeList but not yet in personal details, returns a stable optimistic accountID. - */ -function getAccountIDForSubmitManagerEmail( - managerEmail: string | undefined, - employeeList: PolicyEmployeeList | undefined, - personalDetailsByLogins: PersonalDetailsByLogin, -): number | undefined { - const trimmed = managerEmail?.trim(); - if (!trimmed) { - return undefined; - } - - const fromPersonalDetails = personalDetailsByLogins[trimmed]?.accountID; - if (fromPersonalDetails !== undefined) { - return fromPersonalDetails; - } - - if (!employeeList) { - return undefined; - } - - const normalizedEmail = trimmed.toLowerCase(); - const memberAccountIDs = getMemberAccountIDsForWorkspace(employeeList, personalDetailsByLogins, true, false); - - for (const [email, accountID] of Object.entries(memberAccountIDs)) { - if (email.toLowerCase() === normalizedEmail) { - return accountID; - } - } - - for (const [listKey, employee] of Object.entries(employeeList)) { - const employeeEmail = (employee.email ?? listKey).trim(); - const listKeyNormalized = listKey.trim().toLowerCase(); - const employeeEmailNormalized = employeeEmail.toLowerCase(); - - if (employeeEmailNormalized !== normalizedEmail && listKeyNormalized !== normalizedEmail) { - continue; - } - - const accountIDFromMap = memberAccountIDs[employeeEmail] ?? memberAccountIDs[listKey]; - - if (accountIDFromMap !== undefined) { - return accountIDFromMap; - } - - return generateAccountID(trimmed); - } - - return undefined; -} - -/** - * Get login list that we should not show in the workspace invite options - */ -function getIneligibleInvitees(employeeList?: PolicyEmployeeList): string[] { - const policyEmployeeList = employeeList ?? {}; - const memberEmailsToExclude: string[] = [...CONST.EXPENSIFY_EMAILS]; - for (const email of Object.keys(policyEmployeeList)) { - const policyEmployee = policyEmployeeList?.[email]; - // Policy members that are pending delete or have errors are not valid and we should show them in the invite options (don't exclude them). - if (policyEmployee?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policyEmployee?.errors ?? {}).length > 0) { - continue; - } - if (!email) { - continue; - } - memberEmailsToExclude.push(email); - } - - return memberEmailsToExclude; -} - -/** - * Get excluded users as a Record for use in search selector - */ -function getExcludedUsers(employeeList?: PolicyEmployeeList): Record { - return Object.fromEntries(getIneligibleInvitees(employeeList).map((login) => [login, true])); -} - -/** - * Get Guide and Account Manager information including their emails/logins and exclusion record. - * Used for filtering Guide/AM from contact lists while allowing manual entry. - * - * @param policy - The policy to get the assigned guide from - * @param accountManagerAccountID - The account manager's account ID from the account object - * @param personalDetails - Personal details collection to look up account manager login - * @returns Object containing extracted emails/logins and exclusions record - */ -function getGuideAndAccountManagerInfo( - policy: OnyxEntry, - accountManagerAccountID: number | undefined, - personalDetails: OnyxEntry, -): { - assignedGuideEmail: string | undefined; - accountManagerLogin: string | undefined; - exclusions: Record; -} { - const assignedGuideEmail = policy?.assignedGuide?.email?.toLowerCase(); - const accountManagerLogin = accountManagerAccountID ? personalDetails?.[accountManagerAccountID]?.login?.toLowerCase() : undefined; - - const exclusions: Record = {}; - if (assignedGuideEmail) { - exclusions[assignedGuideEmail] = true; - } - if (accountManagerLogin) { - exclusions[accountManagerLogin] = true; - } - - return { - assignedGuideEmail, - accountManagerLogin, - exclusions, - }; -} - -/** - * Build a soft-exclusion map of Expensify-team logins (current and former AMs/Guides) so search - * filters don't surface internal Expensify staff to customers. Mirrors the visibility rule used - * by WorkspaceMembersPage: skip the filter when the current user is themselves Expensify staff, - * and preserve Expensify-team members of any Expensify-owned policy the current user belongs to. - */ -function getExpensifyTeamExclusions(personalDetails: OnyxEntry, policies: OnyxCollection, currentUserLogin: string | undefined): Record { - if (!currentUserLogin || isExpensifyTeam(currentUserLogin)) { - return {}; - } - - const lowerCurrentUserLogin = currentUserLogin.toLowerCase(); - const allowedExpensifyTeamLogins = new Set(); - - for (const policy of Object.values(policies ?? {})) { - if (!policy?.owner || !policy.employeeList || !isExpensifyTeam(policy.owner)) { - continue; - } - const currentUserIsInPolicy = Object.keys(policy.employeeList).some((email) => email.toLowerCase() === lowerCurrentUserLogin); - if (!currentUserIsInPolicy) { - continue; - } - for (const email of Object.keys(policy.employeeList)) { - const lowerEmail = email.toLowerCase(); - if (isExpensifyTeam(lowerEmail)) { - allowedExpensifyTeamLogins.add(lowerEmail); - } - } - } - - const exclusion: Record = {}; - for (const details of Object.values(personalDetails ?? {})) { - const login = details?.login?.toLowerCase(); - if (!login || !isExpensifyTeam(login)) { - continue; - } - if (allowedExpensifyTeamLogins.has(login)) { - continue; - } - exclusion[login] = true; - } - return exclusion; -} - -/** - * Get soft exclusions (Guide/Account Manager) that should be hidden from auto-suggestions - * but can still be manually entered by the user. - * - * @param policy - The policy to get the assigned guide from - * @param accountManagerAccountID - The account manager's account ID from the account object - * @param personalDetails - Personal details collection to look up account manager login - * @returns Record mapping lowercase emails to true for Guide and Account Manager - */ -function getSoftExclusionsForGuideAndAccountManager( - policy: OnyxEntry, - accountManagerAccountID: number | undefined, - personalDetails: OnyxEntry, -): Record { - return getGuideAndAccountManagerInfo(policy, accountManagerAccountID, personalDetails).exclusions; -} - -/** - * Filter out Guide and Account Manager from a list of items. - * Used for filtering local data (e.g., policy.employeeList) that isn't filtered at the data layer. - * - * @param items - Array of items to filter (must have login or alternateText properties) - * @param assignedGuideEmail - The assigned guide's email (should be lowercase) - * @param accountManagerLogin - The account manager's login (should be lowercase) - * @returns Filtered array with Guide and Account Manager removed - */ -function filterGuideAndAccountManager( - items: T[], - assignedGuideEmail: string | undefined, - accountManagerLogin: string | undefined, -): T[] { - return items.filter((item) => { - const itemLogin = item.login?.toLowerCase(); - const itemAltText = item.alternateText?.toLowerCase(); - - if (assignedGuideEmail && (itemLogin === assignedGuideEmail || itemAltText === assignedGuideEmail)) { - return false; - } - - if (accountManagerLogin && (itemLogin === accountManagerLogin || itemAltText === accountManagerLogin)) { - return false; - } - - return true; - }); -} - -/** - * Checks if a policy has any custom categories (categories not in the default list) - */ -function hasCustomCategories(policyCategories: OnyxEntry): boolean { - if (!policyCategories) { - return false; - } - - const defaultCategoryNames = new Set(Object.values(CONST.POLICY.DEFAULT_CATEGORIES)); - - return Object.values(policyCategories).some((category) => category && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !defaultCategoryNames.has(category.name)); -} - -/** - * Checks whether a policy max expense amount (e.g. `maxExpenseAmountNoReceipt`) is actually configured. - * - * `0` is a valid limit meaning "always required", so a falsy check would wrongly treat an explicit $0.00 as unset. - * Only `undefined` and `CONST.DISABLED_MAX_EXPENSE_VALUE` mean the amount is not set. - */ -function isMaxExpenseAmountSet(value: number | undefined): value is number { - return value !== undefined && value !== CONST.DISABLED_MAX_EXPENSE_VALUE; -} - -/** - * Checks if a policy has any rules configured (structured rules, individual expense limits, or prohibited expenses). - */ -function hasConfiguredRules(policy: OnyxEntry, policyCategories: PolicyCategories | null | undefined, hasExpenseDefaultRules: boolean): boolean { - if (!policy) { - return false; - } - - if (hasExpenseDefaultRules) { - return true; - } - - if (!!policy.customRules && policy.customRules.trim().length > 0) { - return true; - } - - const {rules: policyRules} = policy; - if (!!policyRules?.approvalRules && policyRules.approvalRules.length > 0) { - return true; - } - if (!!policyRules?.expenseRules && policyRules.expenseRules.length > 0) { - return true; - } - if (!!policy.maxExpenseAmount && policy.maxExpenseAmount !== CONST.DISABLED_MAX_EXPENSE_VALUE && policy.maxExpenseAmount !== CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT) { - return true; - } - if (!!policy.maxExpenseAge && policy.maxExpenseAge !== CONST.DISABLED_MAX_EXPENSE_VALUE && policy.maxExpenseAge !== CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE) { - return true; - } - if ( - !!policy.maxExpenseAmountNoReceipt && - policy.maxExpenseAmountNoReceipt !== CONST.DISABLED_MAX_EXPENSE_VALUE && - policy.maxExpenseAmountNoReceipt !== CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT - ) { - return true; - } - if ( - !!policy.maxExpenseAmountNoItemizedReceipt && - policy.maxExpenseAmountNoItemizedReceipt !== CONST.DISABLED_MAX_EXPENSE_VALUE && - policy.maxExpenseAmountNoItemizedReceipt !== CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT - ) { - return true; - } - - if (policy.defaultBillable) { - return true; - } - if (policy.defaultReimbursable === false) { - return true; - } - if (policy.eReceipts) { - return true; - } - if (policy.requireCompanyCardsEnabled) { - return true; - } - - const {prohibitedExpenses} = policy; - if ( - !!prohibitedExpenses && - Object.entries(CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES).some(([key, defaultValue]) => { - const value = prohibitedExpenses[key as keyof typeof CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES]; - return value !== undefined && value !== defaultValue; - }) - ) { - return true; - } - - return hasAnyCategoryRules(policyCategories ?? undefined); -} - -function getOwnedPaidPolicies(policies: OnyxCollection | null, currentUserAccountID: number | undefined): Policy[] { - return Object.values(policies ?? {}).filter((policy): policy is Policy => isPolicyOwner(policy, currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID) && isPaidGroupPolicy(policy)); -} - -/** - * For Submit workspaces, certain features require upgrading the plan before enabling. - * When conditions match, navigates to the workspace upgrade flow and returns true (caller should not enable the feature). - * @returns true if upgrade navigation was performed; false otherwise - */ -function tryNavigateToSubmitWorkspaceUpgrade(policy: OnyxEntry, isEnabling: boolean, upgradeFeatureAlias: string, backTo?: string): boolean { - if (!policy?.id || !isEnabling || !isSubmitPolicy(policy)) { - return false; - } - // Default the upgrade page's back destination to More Features (the source of most upgrade-gated toggles). - // Entry points outside More Features (e.g. the Roles flow) pass their own backTo so the user returns there. - Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policy?.id, upgradeFeatureAlias, backTo ?? ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policy?.id))); - return true; -} - -/** - * For backwards compatibility with Expensify Classic, attendee tracking defaults to enabled - * on Control policies when the property is undefined. - */ -function isAttendeeTrackingEnabled(policy: OnyxEntry): boolean { - return (isControlPolicy(policy) && policy?.isAttendeeTrackingEnabled) ?? true; -} - -/** - * Collect workspaces can access a limited subset of Rules features. When a Collect admin tries to - * access a Control-only Rules feature, navigate to the upgrade flow and return true. - * - * @param shouldReplace - Replace the current screen instead of pushing the upgrade page on top of it. Use this when - * the redirect happens from the Control-only page itself, so pressing Back doesn't land back on a page Collect - * can't use. Press handlers on a page Collect *can* use should keep the default push. - */ -function tryNavigateToControlPolicyUpgrade(policy: OnyxEntry, upgradeFeatureAlias: string, backTo?: string, shouldReplace = false): boolean { - if (!policy?.id || isControlPolicy(policy) || !isCollectPolicy(policy)) { - return false; - } - - Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policy.id, upgradeFeatureAlias, backTo ?? ROUTES.WORKSPACE_RULES.getRoute(policy.id)), {forceReplace: shouldReplace}); - return true; -} - -/** - * Checks if policy's scheduled submit / auto reporting frequency is "instant". - * Note: Free policies have "instant" submit always enabled. - */ -function isInstantSubmitEnabled(policy: OnyxInputOrEntry): boolean { - return policy?.autoReporting === true && policy?.autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; -} - -/** - * Checks if policy's scheduled submit / auto reporting frequency is not "instant". - */ -function isDelayedSubmissionEnabled(policy: OnyxInputOrEntry): boolean { - return policy?.autoReporting === true && policy?.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; -} - -/** - * This gets a "corrected" value for autoReportingFrequency. The purpose of this function is to encapsulate some logic around the "immediate" frequency. - * - * - "immediate" is actually not immediate. For that you want "instant". - * - (immediate && harvesting.enabled) === daily - * - (immediate && !harvesting.enabled) === manual - * - * Note that "daily" and "manual" only exist as options for the API, not in the database or Onyx. - */ -function getCorrectedAutoReportingFrequency(policy: OnyxInputOrEntry): ValueOf | undefined { - if (policy?.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE) { - return policy?.autoReportingFrequency; - } - - if (policy?.harvesting?.enabled) { - // This is actually not really "immediate". It's "daily". Surprise! - return CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE; - } - - // "manual" is really just "immediate" (aka "daily") with harvesting disabled - return CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL; -} - -/** - * Checks if policy's approval mode is "optional", a.k.a. "Submit & Close" - */ -function isSubmitAndClose(policy: OnyxInputOrEntry): boolean { - return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL; -} - -/** - * Resolves a workspace's reimbursement choice to one of the three values in `CONST.POLICY.REIMBURSEMENT_CHOICES`. - * Comparing the raw field instead makes a workspace reporting a deprecated value look like it has reimbursement - * disabled, which hides Pay on its approved reports. - */ -function getReimbursementChoice(policy: OnyxInputOrEntry): ValueOf | undefined { - switch (policy?.reimbursementChoice) { - case CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO: - return CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; - case CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL: - return CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; - default: - return policy?.reimbursementChoice; - } -} - -function arePaymentsEnabled(policy: OnyxInputOrEntry): boolean { - return getReimbursementChoice(policy) !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; -} - -/** Whether the workspace has everything auto-pay approved reports needs: workflows, direct reimbursements, and a business bank account. */ -function isAutoPayApprovedReportsAvailable(policy: OnyxInputOrEntry): boolean { - return !!policy?.areWorkflowsEnabled && getReimbursementChoice(policy) === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES && !!policy?.achAccount?.bankAccountID; -} - -/** - * Returns true when the user is both a submitter and an approver, mirroring the Submit/Approve suggested-search eligibility in - * `getSuggestedSearchesVisibility` (SearchUIUtils): a submitter is a member of any group workspace, and an approver is a member of a - * group workspace with a non-optional approval flow whom `isPolicyApprover` recognizes (named approver or someone reports submit/forward to). - */ -function isSubmitterAndApprover(policies: OnyxCollection | null | undefined, currentUserEmail: string | undefined): boolean { - if (!policies || !currentUserEmail) { - return false; - } - let isSubmitter = false; - let isApprover = false; - for (const policy of Object.values(policies)) { - if (!policy) { - continue; - } - isSubmitter = isSubmitter || isGroupPolicy(policy); - if (!isApprover) { - const hasApprovalFlow = isGroupPolicy(policy) && !!policy.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL; - isApprover = hasApprovalFlow && isPolicyApprover(policy, currentUserEmail); - } - if (isSubmitter && isApprover) { - return true; - } - } - return false; -} - -function isControlOnAdvancedApprovalMode(policy: OnyxInputOrEntry): boolean { - return policy?.type === CONST.POLICY.TYPE.CORPORATE && getApprovalWorkflow(policy) === CONST.POLICY.APPROVAL_MODE.ADVANCED; -} - -/** - * Checks if policy has Dynamic External Workflow enabled - */ -function hasDynamicExternalWorkflow(policy: OnyxEntry): boolean { - return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL; -} - -/** - * Checks if the approval workflow configuration must stay hidden because the workspace's Dynamic External Workflow has - * "Hide People Table Columns" set. The backend only returns `dynamicExternalWorkflowHidePeople` when it is `true`, so an - * absent flag means "show". The approval mode is checked too, so a stale flag can't hide workflows on a policy that no - * longer uses a Dynamic External Workflow. - */ -function shouldHideDynamicExternalWorkflowPeople(policy: OnyxEntry): boolean { - return hasDynamicExternalWorkflow(policy) && !!policy?.dynamicExternalWorkflowHidePeople; -} - -function goBackFromInvalidPolicy() { - Navigation.goBack(ROUTES.WORKSPACES_LIST.route); -} - -function arePolicyRulesEnabled(policy: OnyxEntry, policyCategories?: PolicyCategories | null): boolean { - if (!isPaidGroupPolicy(policy)) { - return false; - } - if (policy?.areRulesEnabled === true) { - return true; - } - if (policy?.areRulesEnabled === false) { - return false; - } - // areRulesEnabled is undefined - this can happen in case of migrated old Control policies, in such case users might have set up category rules in Classic and we should show Rules as enabled - if (!isControlPolicy(policy)) { - return false; - } - return hasAnyCategoryRules(policyCategories ?? undefined); -} - -/** - * Whether Invoice Fields is enabled for the policy. - * Respects the `areInvoiceFieldsEnabled` toggle and verifies the policy has access to the feature (Control only). - */ -function isInvoiceFieldsEnabled(policy: OnyxEntry | null): boolean { - return !!policy?.areInvoiceFieldsEnabled && canPolicyAccessFeature(policy ?? undefined, CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED); -} - -function isPolicyFeatureEnabled(policy: OnyxEntry, featureName: PolicyFeatureName, policyCategories?: PolicyCategories | null): boolean { - if (featureName === CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED) { - return arePolicyRulesEnabled(policy, policyCategories); - } - if (featureName === CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED) { - return isInvoiceFieldsEnabled(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED) { - return !!policy?.tax?.trackingEnabled; - } - if (featureName === CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED) { - return policy?.[featureName] ? !!policy?.[featureName] : hasAccountingFeatureConnection(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED) { - return isMCPEnabled(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED) { - return policy?.isHREnabled === true || isAnyHRConnected(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED) { - return policy?.isRecruitingEnabled === true || isAnyRecruitingConnected(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED) { - return policy?.receiptPartners?.enabled ?? false; - } - if (featureName === CONST.POLICY.MORE_FEATURES.IS_TIME_TRACKING_ENABLED) { - return isTimeTrackingEnabled(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.IS_ATTENDEE_TRACKING_ENABLED) { - return isAttendeeTrackingEnabled(policy); - } - - return !!policy?.[featureName]; -} - -function getApprovalWorkflow(policy: OnyxEntry): ValueOf { - if (policy?.type === CONST.POLICY.TYPE.PERSONAL) { - return CONST.POLICY.APPROVAL_MODE.OPTIONAL; - } - - return policy?.approvalMode ?? CONST.POLICY.APPROVAL_MODE.ADVANCED; -} - -function getDefaultApprover(policy: OnyxEntry): string { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return policy?.approver || policy?.owner || ''; -} - -/** - * Whether an approver email points at someone who is no longer on the workspace. The owner always counts as a member, - * and HR advanced (manager) mode is excluded, because its final approver doesn't have to be a workspace member. - */ -function isNonMemberApprover(policy: OnyxEntry, approverEmail: string | undefined): boolean { - return !!approverEmail && !isPolicyMember(policy, approverEmail) && !getHRAdvancedModeFinalApprover(policy); -} - -/** - * Whether the policy has at least one custom approval workflow. A workflow is considered custom when either: - * - the default workflow was modified by changing its first approver or adding an "Approves to" user, or - * - a new workflow was created (a member submits to an approver other than the default approver). - */ -function hasCustomApprovalWorkflow(policy: OnyxEntry): boolean { - if (!policy) { - return false; - } - - const defaultApprover = getDefaultApprover(policy); - - // The default workflow's first approver was changed away from the workspace owner. - if (!!policy.approver && !!policy.owner && policy.approver !== policy.owner) { - return true; - } - - const employees = policy.employeeList ?? {}; - - // The default approver forwards approvals to someone, i.e. an "Approves to" user was added, - // either unconditionally (forwardsTo) or above an approval limit (overLimitForwardsTo). - const defaultApproverEmployee = employees[defaultApprover]; - if (!!defaultApprover && (!!defaultApproverEmployee?.forwardsTo || !!defaultApproverEmployee?.overLimitForwardsTo)) { - return true; - } - - // A new workflow exists when a member submits to an approver other than the default approver. - return Object.values(employees).some( - (employee) => employee?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!employee?.submitsTo && employee.submitsTo !== defaultApprover, - ); -} - -function getRuleApprovers(policy: OnyxEntry, expenseReport: OnyxEntry) { - const categoryApprovers: string[] = []; - const tagApprovers: string[] = []; - const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID); - - // Before submitting to their `submitsTo` (in a policy on Advanced Approvals), submit to category/tag approvers. - // Category approvers are prioritized, then tag approvers. - for (let i = 0; i < allReportTransactions.length; i++) { - const transaction = allReportTransactions.at(i); - const tag = getTag(transaction); - const category = getCategory(transaction); - const categoryApprover = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category)?.approver; - const tagApprover = getTagApproverRule(policy, tag)?.approver; - if (categoryApprover) { - categoryApprovers.push(categoryApprover); - } - - if (tagApprover) { - tagApprovers.push(tagApprover); - } - } - - // Return the unique approvers list - return [...new Set([...categoryApprovers, ...tagApprovers])]; -} - -function getFirstRuleApprover(approvalRules: ApprovalRule[], expenseReport: OnyxEntry, ownerLogin: string | undefined) { - // Pre-build a lookup map of { category: { value → approver }, tag: { value → approver } } - // from the policy's approval rules so that each transaction's category/tag can be resolved in O(1). - const rulesMap: Record<'category' | 'tag', Record> = { - category: {}, - tag: {}, - }; - - for (let i = 0; i < approvalRules.length; i++) { - const rule = approvalRules.at(i); - if (!rule) { - continue; - } - for (let j = 0; j < rule.applyWhen.length; j++) { - const applyWhen = rule.applyWhen.at(j); - if (applyWhen?.condition !== CONST.POLICY.RULE_CONDITIONS.MATCHES) { - continue; - } - if (applyWhen.field === CONST.POLICY.FIELDS.CATEGORY || applyWhen.field === CONST.POLICY.FIELDS.TAG) { - rulesMap[applyWhen.field][applyWhen.value] = rule.approver; - } - } - } - - if (isEmptyObject(rulesMap.category) && isEmptyObject(rulesMap.tag)) { - return ''; - } - - const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID); - - if (!allReportTransactions.length) { - return ''; - } - - let firstCategoryApprover = ''; - let firstTagApprover = ''; - - for (let i = 0; i < allReportTransactions.length; i++) { - const transaction = allReportTransactions.at(i); - const category = getCategory(transaction); - const categoryApprover = rulesMap.category[category]; - - // Category approvers take strict priority over tag approvers. - // Break immediately on the first match so we don't keep scanning transactions unnecessarily. - if (categoryApprover && categoryApprover !== ownerLogin) { - firstCategoryApprover = categoryApprover; - break; - } - - // Only look for a tag approver if we haven't found one yet — no need to re-check on subsequent transactions. - if (!firstTagApprover) { - const tag = getTag(transaction); - const tagApprover = rulesMap.tag[tag]; - - if (tagApprover && tagApprover !== ownerLogin) { - firstTagApprover = tagApprover; - } - } - } - - return firstCategoryApprover || firstTagApprover; -} - -function matchesApprovalWorkflowEmailComparison(node: RuleFilterComparison, email: string | undefined): boolean { - const expectedEmails = (Array.isArray(node.right) ? node.right : [node.right]).map((value) => String(value).toLowerCase()); - const isMatch = !!email && expectedEmails.includes(email.toLowerCase()); - return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO ? !isMatch : isMatch; -} - -function matchesApprovalWorkflowAmountComparison(node: RuleFilterComparison, amount: number): boolean { - const expectedAmount = typeof node.right === 'number' ? node.right : Number(node.right); - if (Number.isNaN(expectedAmount)) { - return false; - } - - switch (node.operator) { - case CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO: - return amount === expectedAmount; - case CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO: - return amount !== expectedAmount; - case CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN: - return amount > expectedAmount; - case CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO: - return amount >= expectedAmount; - case CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN: - return amount < expectedAmount; - case CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO: - return amount <= expectedAmount; - default: - return false; - } -} - -function evaluateApprovalWorkflowFilter(node: RuleFilterNode, context: ApprovalWorkflowContext): boolean { - if (!isRuleFilterComparison(node)) { - const left = evaluateApprovalWorkflowFilter(node.left, context); - const right = evaluateApprovalWorkflowFilter(node.right, context); - return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.OR ? left || right : left && right; - } - - switch (node.left) { - case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: - return matchesApprovalWorkflowEmailComparison(node, context.submitterEmail); - - // A report with no current approver has not been approved by anybody yet, so a rule based on who - // approved it last cannot apply. - case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: - return !!context.currentApproverEmail && matchesApprovalWorkflowEmailComparison(node, context.currentApproverEmail); - case CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT: - return matchesApprovalWorkflowAmountComparison(node, Math.abs(context.reportTotal)); - - default: - return false; - } -} - -/** - * True when every condition in the rule's filter tree is true for `context`. - */ -function evaluateApprovalWorkflowRule(rule: ApprovalWorkflowRule, context: ApprovalWorkflowContext): boolean { - return evaluateApprovalWorkflowFilter(rule.filters, context); -} - -/** - * Check the policy's approval workflow rules to determine where the report goes next. - */ -function getForwardsToFromRules(policy: OnyxEntry, context: ApprovalWorkflowContext, rules: OnyxCollection): ApprovalWorkflowRuleMatch | undefined { - if (!policy?.id || !context.submitterEmail) { - return undefined; - } - - const trigger = context.currentApproverEmail ? CONST.RULES.TRIGGERS.REPORT_APPROVE : CONST.RULES.TRIGGERS.REPORT_SUBMIT; - - // Sort by Onyx key so the rule picked stays the same across evaluations when more than one matches (which should not happen). - const ruleKeys = Object.keys(rules ?? {}).sort(); - for (const ruleKey of ruleKeys) { - const rule = rules?.[ruleKey]; - if (!rule || rule.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policy.id || rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - continue; - } - if (!isApprovalWorkflowRule(rule) || !Object.values(rule.triggers ?? {}).includes(trigger)) { - continue; - } - if (!evaluateApprovalWorkflowRule(rule, context)) { - continue; - } - - return {forwardsTo: Object.values(rule.actions ?? {}).find((action) => action.name === CONST.RULES.ACTIONS.FORWARD_TO)?.approver}; - } - - return undefined; -} - -function getManagerAccountEmail(policy: OnyxEntry, ownerLogin: string | undefined, rules: OnyxCollection, reportTotal = 0): string { - const ruleMatch = getForwardsToFromRules(policy, {submitterEmail: ownerLogin ?? '', reportTotal}, rules); - if (ruleMatch) { - return ruleMatch.forwardsTo ?? ''; - } - - const defaultApprover = getDefaultApprover(policy); - - // For policy using the optional or basic workflow, the manager is the policy default approver. - if (([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy))) { - return defaultApprover; - } - - const employee = policy?.employeeList?.[ownerLogin ?? '']; - if (!employee && !defaultApprover) { - return ''; - } - - return employee?.submitsTo ?? defaultApprover ?? ''; -} - -function getManagerAccountID(policy: OnyxEntry, ownerLogin: string | undefined, rules: OnyxCollection, reportTotal = 0) { - const managerEmail = getManagerAccountEmail(policy, ownerLogin, rules, reportTotal); - return managerEmail ? (getAccountIDsByLogins([managerEmail]).at(0) ?? -1) : -1; -} - -/** - * Returns the email the expense report should submit to per workspace approval config - * (approval rules, employee submitsTo, or default approver for basic/optional workflows). - */ -function getSubmitToEmail( - policy: OnyxEntry, - expenseReport: OnyxEntry, - ownerLogin: string | undefined, - rules: OnyxCollection, - shouldFallBackWhenManagerIsNotMember = false, -): string { - const approvalRules = policy?.rules?.approvalRules; - - if (!isSubmitAndClose(policy) && approvalRules?.length) { - const ruleApprover = getFirstRuleApprover(approvalRules, expenseReport, ownerLogin); - if (ruleApprover) { - return ruleApprover; - } - } - - const ruleMatch = getForwardsToFromRules(policy, {submitterEmail: ownerLogin ?? '', reportTotal: expenseReport?.total ?? 0}, rules); - if (ruleMatch) { - return ruleMatch.forwardsTo ?? ''; - } - - const managerEmail = getManagerAccountEmail(policy, ownerLogin, rules, expenseReport?.total ?? 0); - - // Falls back to the default approver when the manager is unavailable. - if (shouldFallBackWhenManagerIsNotMember && managerEmail && !policy?.employeeList?.[managerEmail] && !getHRAdvancedModeFinalApprover(policy)) { - return getDefaultApprover(policy); - } - - return managerEmail; -} - -/** - * Returns the accountID to whom the given expenseReport submits reports to in the given Policy. - */ -function getSubmitToAccountID( - policy: OnyxEntry, - expenseReport: OnyxEntry, - ownerLogin: string | undefined, - rules: OnyxCollection, - shouldFallBackWhenManagerIsNotMember = false, -): number { - const submitToEmail = getSubmitToEmail(policy, expenseReport, ownerLogin, rules, shouldFallBackWhenManagerIsNotMember); - return submitToEmail ? (getAccountIDsByLogins([submitToEmail]).at(0) ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; -} - -function getSubmitReportManagerAccountID( - policy: OnyxEntry, - expenseReport: OnyxEntry, - submitterLogin: string | undefined, - rules: OnyxCollection, - personalDetailsByLogins: PersonalDetailsByLogin, -): number | undefined { - const approvalRules = policy?.rules?.approvalRules; - const ruleApprover = !isSubmitAndClose(policy) && approvalRules?.length ? getFirstRuleApprover(approvalRules, expenseReport, submitterLogin) : ''; - const hasReliablePolicyRoute = - ([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy)) || - !!ruleApprover || - !!policy?.employeeList?.[submitterLogin ?? '']; - - if (!hasReliablePolicyRoute) { - return undefined; - } - - const submitToEmail = getSubmitToEmail(policy, expenseReport, submitterLogin, rules, true); - const submitToAccountID = submitToEmail ? personalDetailsByLogins[submitToEmail]?.accountID : undefined; - if (submitToAccountID === undefined || !isValidAccountRoute(submitToAccountID)) { - return undefined; - } - - return submitToAccountID; -} - -/** - * Returns the email of the account to forward the report to depending on the approver's approval limit. - * Used for advanced approval mode only. - * - * `submitterEmail` is needed to consult the workspace's approval workflow rules, which describe each hop in - * terms of who submitted the report as well as who is approving it. Without it only the legacy employeeList - * forwardsTo/overLimitForwardsTo is considered. - */ -function getForwardsToAccount(policy: OnyxEntry, employeeEmail: string, reportTotal: number, rules: OnyxCollection, submitterEmail?: string): string { - const ruleMatch = submitterEmail ? getForwardsToFromRules(policy, {submitterEmail, currentApproverEmail: employeeEmail, reportTotal}, rules) : undefined; - if (ruleMatch) { - return ruleMatch.forwardsTo ?? ''; - } - - if (!isControlOnAdvancedApprovalMode(policy)) { - return ''; - } - - const employee = policy?.employeeList?.[employeeEmail]; - if (!employee) { - return ''; - } - - const positiveReportTotal = Math.abs(reportTotal); - if (employee.approvalLimit && employee.overLimitForwardsTo && positiveReportTotal > employee.approvalLimit) { - return employee.overLimitForwardsTo; - } - return employee.forwardsTo ?? ''; -} - -/** - * Returns the accountID of the policy reimburser, if not available returns -1. - */ -function getReimburserAccountID(policy: OnyxEntry): number { - const reimburserEmail = policy?.achAccount?.reimburser ?? policy?.owner ?? ''; - return reimburserEmail ? (getAccountIDsByLogins([reimburserEmail]).at(0) ?? -1) : -1; -} - -function getAdminEmployees(policy: OnyxEntry): PolicyEmployee[] { - if (!policy?.employeeList) { - return []; - } - return Object.keys(policy.employeeList) - .map((email) => ({...policy.employeeList?.[email], email})) - .filter((employee) => employee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && employee.role === CONST.POLICY.ROLE.ADMIN); -} - -/** Return active policies where current user is an admin */ -function getActiveAdminWorkspaces(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { - const activePolicies = getActivePolicies(policies, currentUserLogin); - return activePolicies.filter((policy) => shouldShowPolicy(policy, getIsOffline(), currentUserLogin) && isPolicyAdmin(policy, currentUserLogin)); -} - -/** Return active policies where current user is an employee (of the role "user") */ -function getActiveEmployeeWorkspaces(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { - const activePolicies = getActivePolicies(policies, currentUserLogin); - return activePolicies.filter((policy) => shouldShowPolicy(policy, getIsOffline(), currentUserLogin) && isPolicyUser(policy, currentUserLogin)); -} - -/** - * Checks whether the current user has a policy with admin access - */ -function hasActiveAdminWorkspaces(currentUserLogin: string | undefined, policies?: OnyxCollection) { - return getActiveAdminWorkspaces(policies, currentUserLogin).length > 0; -} - -/** Whether the user can send invoice from the workspace */ -function canSendInvoiceFromWorkspace(policy: OnyxEntry): boolean { - return policy?.areInvoicesEnabled ?? false; -} - -/** Whether the user can submit per diem expense from the workspace */ -function canSubmitPerDiemExpenseFromWorkspace(policy: OnyxEntry): boolean { - const perDiemCustomUnit = getPerDiemCustomUnit(policy); - return isPaidGroupPolicy(policy) && !isEmptyObject(perDiemCustomUnit) && !!perDiemCustomUnit?.enabled; -} - -/** Whether the user can send invoice */ -function canSendInvoice(policies: OnyxCollection | null, currentUserLogin: string | undefined): boolean { - return getActiveAdminWorkspaces(policies, currentUserLogin).some((policy) => canSendInvoiceFromWorkspace(policy)); -} - -function areSettingsInErrorFields(settings?: string[], errorFields?: ErrorFields) { - if (settings === undefined || errorFields === undefined) { - return false; - } - - const keys = Object.keys(errorFields); - return settings.some((setting) => keys.includes(setting)); -} - -function settingsPendingAction(settings?: string[], pendingFields?: PendingFields): PendingAction | undefined { - if (settings === undefined || pendingFields === undefined) { - return null; - } - - const key = Object.keys(pendingFields).find((setting) => settings.includes(setting)); - if (!key) { - return; - } - return pendingFields[key]; -} - -/** - * Sort the workspaces by their name, while keeping the selected one at the beginning. - * @param workspace1 Details of the first workspace to be compared. - * @param workspace2 Details of the second workspace to be compared. - * @param selectedWorkspaceID ID of the selected workspace which needs to be at the beginning. - */ -const sortWorkspacesBySelected = ( - workspace1: WorkspaceDetails, - workspace2: WorkspaceDetails, - selectedWorkspaceIDs: string[] | undefined, - localeCompare: LocaleContextProps['localeCompare'], -): number => { - if (workspace1.policyID && selectedWorkspaceIDs?.includes(workspace1?.policyID) && workspace2.policyID && selectedWorkspaceIDs?.includes(workspace2.policyID)) { - return localeCompare(workspace1.name?.toLowerCase() ?? '', workspace2.name?.toLowerCase() ?? ''); - } - if (workspace1.policyID && selectedWorkspaceIDs?.includes(workspace1?.policyID)) { - return -1; - } - if (workspace2.policyID && selectedWorkspaceIDs?.includes(workspace2.policyID)) { - return 1; - } - return localeCompare(workspace1.name?.toLowerCase() ?? '', workspace2.name?.toLowerCase() ?? ''); -}; - -function goBackWhenEnableFeature() { - setTimeout(() => { - Navigation.goBack(); - }, CONST.WORKSPACE_ENABLE_FEATURE_REDIRECT_DELAY); -} - -function navigateToExpensifyCardPage(policyID: string) { - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD.getRoute(policyID)); - }); -} - -/** - * Check if the policy member is deleted from the workspace - */ -function isDeletedPolicyEmployee(policyEmployee: PolicyEmployee, isOffline: boolean) { - return !isOffline && policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyEmployee.errors); -} - -function hasOnlyPersonalPolicies(policies: OnyxCollection) { - return !Object.values(policies ?? {}).some((policy) => policy && policy.type !== CONST.POLICY.TYPE.PERSONAL && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); -} - -function getTagApproverRule(policy: OnyxEntry, tagName: string) { - if (!policy) { - return; - } - - const approvalRules = policy.rules?.approvalRules ?? []; - const approverRule = approvalRules.find((rule) => - rule.applyWhen.find(({condition, field, value}) => condition === CONST.POLICY.RULE_CONDITIONS.MATCHES && field === CONST.POLICY.FIELDS.TAG && value === tagName), - ); - - return approverRule; -} - -function getDomainNameForPolicy(policyID?: string): string { - if (!policyID) { - return ''; - } - - return `${CONST.EXPENSIFY_POLICY_DOMAIN}${policyID.toLowerCase()}${CONST.EXPENSIFY_POLICY_DOMAIN_EXTENSION}`; -} - -function getWorkflowApprovalsUnavailable(policy: OnyxEntry) { - return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL || !!policy?.errorFields?.approvalMode; -} - -function getUserFriendlyWorkspaceType(workspaceType: ValueOf, translate: LocalizedTranslate) { - switch (workspaceType) { - case CONST.POLICY.TYPE.CORPORATE: - return translate('workspace.type.control'); - case CONST.POLICY.TYPE.TEAM: - return translate('workspace.type.collect'); - case CONST.POLICY.TYPE.SUBMIT: - return translate('workspace.type.submit'); - default: - return translate('workspace.type.free'); - } -} - -/** - * Returns the plan type to pre-select when creating a workspace: Corporate when the user already belongs to a - * Control workspace, otherwise Team. - */ -function getDefaultWorkspacePlanType(policies: OnyxCollection | null): PolicyType { - const isMemberOfControlWorkspace = Object.values(policies ?? {}).some((policy) => policy?.type === CONST.POLICY.TYPE.CORPORATE); - return isMemberOfControlWorkspace ? CONST.POLICY.TYPE.CORPORATE : CONST.POLICY.TYPE.TEAM; -} - -function isPolicyAccessible(policy: OnyxEntry, currentUserLogin: string): boolean { - return ( - !isEmptyObject(policy) && - (Object.keys(policy).length !== 1 || isEmptyObject(policy.errors)) && - !!policy?.id && - policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && - !!getPolicyRole(policy, currentUserLogin) - ); -} - -function getGroupPaidPolicies(policies: OnyxCollection | null) { - if (isEmptyObject(policies)) { - return CONST.EMPTY_ARRAY; - } - return Object.values(policies).filter((policy) => isPaidGroupPolicy(policy) && !policy?.isJoinRequestPending && shouldShowPolicy(policy, false, undefined)); -} - -function hasAnyPaidPolicy(policies: OnyxCollection | null) { - return getGroupPaidPolicies(policies).length > 0; -} - -/** - * Returns the group workspaces where the user can create a report: paid (Team/Corporate) workspaces, - * plus Submit workspaces. Submit workspaces are free but still support report creation, so they belong - * here even though they're excluded from `getGroupPaidPolicies`. - */ -function getGroupPoliciesWhereReportCanBeCreated(policies: OnyxCollection | null, currentUserLogin?: string) { - if (isEmptyObject(policies)) { - return CONST.EMPTY_ARRAY; - } - return Object.values(policies).filter( - (policy): policy is Policy => - !!policy && - !policy.isJoinRequestPending && - (isPaidGroupPolicy(policy) || isSubmitPolicy(policy)) && - shouldShowPolicy(policy, false, currentUserLogin) && - !isTeachersUnitePolicyID(policy.id), - ); -} - -/** - * Resolves the default workspace for report creation: the active policy when it's one of the eligible - * workspaces, otherwise the only eligible workspace, else undefined. - */ -function getDefaultChatEnabledPolicy( - groupPoliciesWithChatEnabled: ReadonlyArray>, - activePolicy?: OnyxInputOrEntry | null, -): OnyxInputOrEntry | undefined { - // Only default to the active policy when it's actually eligible, so we never pick an ineligible policy - // (e.g. a personal or free workspace) over an eligible fallback. - if (activePolicy && isGroupPolicy(activePolicy) && groupPoliciesWithChatEnabled.some((policy) => policy?.id === activePolicy.id)) { - return activePolicy; - } - - if (groupPoliciesWithChatEnabled.length === 1) { - return groupPoliciesWithChatEnabled.at(0); - } - - return undefined; -} - -/** Scalar version for useOnyx selectors: an eligible-policy array makes useOnyx deep-compare every policy on each policy_ update */ -function getDefaultChatEnabledPolicySelection(policies: OnyxCollection | null, currentUserLogin: string | undefined, activePolicyID: string | undefined) { - const groupPoliciesWithChatEnabled = getGroupPoliciesWhereReportCanBeCreated(policies, currentUserLogin); - const activePolicy = activePolicyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`] : undefined; - - return { - defaultChatEnabledPolicyID: getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled, activePolicy)?.id, - hasMultipleChatEnabledPolicies: groupPoliciesWithChatEnabled.length > 1, - }; -} - -function hasOtherControlWorkspaces(adminPolicies: Policy[] | undefined, currentPolicyID: string) { - const otherControlWorkspaces = adminPolicies?.filter((policy) => policy?.id !== currentPolicyID && isControlPolicy(policy)); - return (otherControlWorkspaces?.length ?? 0) > 0; -} - -/** - * Determines whether workspace deletion should be blocked for an Invoicify user. - * - * The function normalizes owner policies to a flat `Policy[]` regardless of whether - * they are provided as an `OnyxCollection` or a precomputed array. For Invoicify users, - * it ensures that at least one other controllable workspace exists after excluding - * policies with pending ADD or DELETE actions. - * - * The filtering is done lazily to avoid unnecessary work for non-Invoicify users. - * - * @param isSubscriptionTypeOfInvoicing - Whether the user is on an Invoicify subscription - * @param ownerPolicies - Owner policies as an Onyx collection or a normalized array - * @param currentPolicyID - Policy ID of the workspace being evaluated - * @param accountID - Account ID used to resolve owned paid policies when needed - * - */ -function shouldBlockWorkspaceDeletionForInvoicifyUser( - isSubscriptionTypeOfInvoicing: boolean, - ownerPolicies: OnyxCollection | Policy[] | undefined, - currentPolicyID: string | undefined, - accountID?: number, -) { - if (!isSubscriptionTypeOfInvoicing || !currentPolicyID) { - return false; - } - const normalizedOwnerPolicies: Policy[] = (() => { - if (!ownerPolicies) { - return []; - } - - if (Array.isArray(ownerPolicies)) { - return ownerPolicies; - } - - if (accountID) { - return getOwnedPaidPolicies(ownerPolicies, accountID); - } - - return Object.values(ownerPolicies).filter((policy): policy is Policy => !!policy); - })(); - - const ownerPoliciesWithoutPendingActions = (normalizedOwnerPolicies ?? []).filter( - (policy) => policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - ); - - return !hasOtherControlWorkspaces(ownerPoliciesWithoutPendingActions, currentPolicyID); -} - -// If no policyID is provided, it indicates the workspace upgrade/downgrade URL -// is being accessed from the Subscriptions page without a specific policyID. -// In this case, check if the user is an admin on more than one policy. -// If the user is an admin for multiple policies, we can render the page as it contains a condition -// to navigate them to the Workspaces page when no policyID is provided, instead of showing the Upgrade/Downgrade button. -// If the user is not an admin for multiple policies, they are not allowed to perform this action, and the NotFoundPage is displayed. -function canModifyPlan(ownerPolicies: Policy[] | undefined, policy: OnyxEntry) { - if (!policy?.id) { - return (ownerPolicies?.length ?? 0) > 1; - } - - return !!policy && isPolicyAdmin(policy); -} - -function getAdminsPrivateEmailDomains(policy?: Policy) { - if (!policy) { - return []; - } - - const adminDomains = Object.entries(policy.employeeList ?? {}).reduce((domains, [email, employee]) => { - if (employee.role !== CONST.POLICY.ROLE.ADMIN) { - return domains; - } - domains.push(Str.extractEmailDomain(email).toLowerCase()); - return domains; - }, [] as string[]); - - const ownerDomains = policy.owner ? [Str.extractEmailDomain(policy.owner).toLowerCase()] : []; - - const privateDomains = [...new Set(adminDomains.concat(ownerDomains))].filter((domain) => !isPublicDomain(domain)); - - // If the policy is not owned by Expensify there is no point in showing the domain for provisioning. - if (!isExpensifyTeam(policy.owner)) { - return privateDomains.filter((domain) => domain !== CONST.EXPENSIFY_PARTNER_NAME && domain !== CONST.EMAIL.GUIDES_DOMAIN); - } - - return privateDomains; -} - -/** - * Determines the most frequent domain from the `acceptedDomains` list - * that appears in the email addresses of policy members. - * - * @param acceptedDomains - List of domains to consider. - * @param policy - The policy object. - */ -function getMostFrequentEmailDomain(acceptedDomains: string[], policy?: Policy) { - if (!policy) { - return undefined; - } - const domainOccurrences = {} as Record; - for (const memberDomain of Object.keys(policy.employeeList ?? {}) - .concat(policy.owner) - .map((email) => Str.extractEmailDomain(email).toLowerCase())) { - if (!acceptedDomains.includes(memberDomain)) { - continue; - } - domainOccurrences[memberDomain] = (domainOccurrences[memberDomain] || 0) + 1; - } - let mostFrequent = {domain: '', count: 0}; - for (const [domain, count] of Object.entries(domainOccurrences)) { - if (count <= mostFrequent.count) { - continue; - } - mostFrequent = {domain, count}; - } - if (mostFrequent.count === 0) { - return undefined; - } - return mostFrequent.domain; -} - -const getPolicyIDFromDomainName = (domainName: string): string | undefined => domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1]?.toUpperCase(); - -/** - * Returns the workspace an assigned card belongs to. - * - * A card names its workspace with `fundID`, which is the policy's `policyAccountID`. That holds whatever the feed's - * domain looks like, so a card on a company's own domain resolves the same workspace as one on a workspace feed's - * `expensify-policy.exfy` domain. The domain is read only as a fallback, for a card that arrives without a `fundID`. - */ -const getPolicyForAssignedCard = (card: OnyxEntry>, policies: OnyxCollection): OnyxEntry => { - const workspaceAccountID = Number(card?.fundID); - const policyForFund = workspaceAccountID ? Object.values(policies ?? {}).find((policy) => policy?.policyAccountID === workspaceAccountID) : undefined; - if (policyForFund) { - return policyForFund; - } - - const policyID = card?.domainName ? getPolicyIDFromDomainName(card.domainName) : undefined; - return policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] : undefined; -}; - -const getDescriptionForPolicyDomainCard = (domainName: string, policies: OnyxCollection): string => { - // A domain name containing a policyID indicates that this is a workspace feed - const policyID = getPolicyIDFromDomainName(domainName); - if (policyID) { - const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - return policy?.name ?? domainName; - } - return domainName; -}; - -/** Whether a workspace has been provisioned with a Spotnana entity. */ -function isWorkspaceProvisionedForTravel(travelSettings?: WorkspaceTravelSettings): boolean { - return !!(travelSettings?.spotnanaCompanyID ?? travelSettings?.associatedTravelDomainAccountID); -} - -/** Whether travel is enabled on a workspace, meaning it can be used to book a trip. */ -function hasAcceptedTravelTerms(policy: OnyxEntry, accountTravelSettings: TravelSettings | undefined): boolean { - return !!(policy?.travelSettings?.hasAcceptedTerms ?? (accountTravelSettings?.hasAcceptedTerms && isWorkspaceProvisionedForTravel(policy?.travelSettings))); -} - -function isNonUSDPolicy(policy: OnyxEntry): boolean { - return !!policy?.outputCurrency && policy.outputCurrency !== CONST.CURRENCY.USD; -} - -/** - * Determines which travel step should be shown based on policy state - */ -function getTravelStep( - policy: OnyxEntry, - travelSettings: TravelSettings | undefined, - isTravelVerifiedBetaEnabled: boolean, - policies: OnyxCollection, - currentUserLogin: string | undefined, -): TravelStep { - const adminDomains = getAdminsPrivateEmailDomains(policy); - const activePolicies = getActivePolicies(policies, currentUserLogin); - const groupPaidPolicies = activePolicies.filter(isPaidGroupPolicy); - - if (adminDomains.length === 0 || groupPaidPolicies.length < 1 || !isPaidGroupPolicy(policy)) { - return CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL; - } - if (policy?.travelSettings?.hasAcceptedTerms) { - return CONST.TRAVEL.STEPS.BOOK_OR_MANAGE_YOUR_TRIP; - } - if (!isTravelVerifiedBetaEnabled && travelSettings?.lastTravelSignupRequestTime) { - return CONST.TRAVEL.STEPS.REVIEWING_REQUEST; - } - return CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL; -} - -/** - * Returns if the policy has the Time Tracking feature enabled. - */ -function isTimeTrackingEnabled(policy: OnyxEntry): boolean { - return !!policy?.units?.time?.enabled; -} - -/** MCP is on for every workspace unless an admin has explicitly turned it off, so an absent flag reads as enabled. */ -function isMCPEnabled(policy: OnyxEntry): boolean { - return policy?.isMCPEnabled ?? true; -} - -/** - * Returns the policy's default hourly rate for the Time Tracking feature. - */ -function getDefaultTimeTrackingRate(policy: Partial>): number | undefined { - return policy?.units?.time?.rate !== undefined ? convertToBackendAmount(policy.units.time.rate) : undefined; -} - -function sortPoliciesByName(policies: Policy[], localeCompare: (a: string, b: string) => number): Policy[] { - // copy first: callers pass memoized selector output, sorting it in place makes the next deepEqual report a change - return [...policies].sort((a, b) => localeCompare(a.name || '', b.name || '')); -} - -/** - * Builds a source URL for rendering a policy document PDF. - * Local blob/file URIs (from optimistic uploads) are returned directly. - * Remote URLs are routed through the authenticated GetPolicyRulesDocument streaming endpoint. - * The stored URL (which contains a unique timestamp per upload) is appended as a version - * parameter so the browser treats each replacement as a distinct resource. - */ -function getRulesDocumentSourceURL(rulesDocumentURL: string | undefined, policyID: string | undefined, encryptedAuthToken: string): string { - if (!rulesDocumentURL || !policyID) { - return ''; - } - - const isLocalFile = rulesDocumentURL.startsWith('blob:') || rulesDocumentURL.startsWith('file:'); - if (isLocalFile) { - return rulesDocumentURL; - } - - return addEncryptedAuthTokenToURL( - // Each PDF upload gets a unique S3 key, so rulesDocumentURL changes on every replacement. - // Encoding it as cacheBuster ensures the full streaming URL is also unique, preventing stale browser/pdfjs cache. - `${getApiRoot({shouldUseSecure: false})}api/GetPolicyRulesDocument?policyID=${policyID}&cacheBuster=${encodeURIComponent(rulesDocumentURL)}`, - encryptedAuthToken, - true, - ); -} - -export { - getActivePolicies, - getActivePoliciesWithExpenseChat, - getAdminEmployees, - getIneligibleInvitees, - getExcludedUsers, - getMemberAccountIDsForWorkspace, - getAccountIDForSubmitManagerEmail, - getGuideAndAccountManagerInfo, - getSoftExclusionsForGuideAndAccountManager, - getExpensifyTeamExclusions, - filterGuideAndAccountManager, - getPolicyBrickRoadIndicatorStatus, - hasCustomCategories, - hasConfiguredRules, - isMaxExpenseAmountSet, - goBackFromInvalidPolicy, - shouldShowSyncError, - shouldShowCustomUnitsError, - shouldShowEmployeeListError, - hasPolicyCategoriesError, - hasPolicyRulesError, - shouldShowPolicyError, - shouldShowTaxRateError, - isExpensifyTeam, - shouldFilterExpensifyTeam, - createFilteredMemberCountSelector, - createInvoiceConfigurationTextSelector, - isDeletedPolicyEmployee, - isInstantSubmitEnabled, - isDelayedSubmissionEnabled, - getCorrectedAutoReportingFrequency, - hasEligibleBankAccountShareRecipient, - arePolicyRulesEnabled, - isPolicyFeatureEnabled, - isPolicyFieldListEmpty, - canUnarchivePolicy, - isPolicyMember, - isMemberInHomeAndOfficeWorkspace, - isPolicyPayer, - getReimburserEmail, - getOwnerChangePayerSuccessData, - arePaymentsEnabled, - isAutoPayApprovedReportsAvailable, - getReimbursementChoice, - isSubmitterAndApprover, - isSubmitAndClose, - shouldShowPolicy, - getActiveAdminWorkspaces, - hasActiveAdminWorkspaces, - getOwnedPaidPolicies, - canSendInvoiceFromWorkspace, - canSubmitPerDiemExpenseFromWorkspace, - canSendInvoice, - getEligibleBankAccountShareRecipientEmails, - hasOfficeWorkArrangement, - getPolicyIDOrDefault, - sortWorkspacesBySelected, - goBackWhenEnableFeature, - navigateToExpensifyCardPage, - getDefaultApprover, - isNonMemberApprover, - hasCustomApprovalWorkflow, - getApprovalWorkflow, - getReimburserAccountID, - isAttendeeTrackingEnabled, - hasOnlyPersonalPolicies, - areSettingsInErrorFields, - settingsPendingAction, - getGroupPaidPolicies, - getGroupPoliciesWhereReportCanBeCreated, - getDefaultChatEnabledPolicy, - getDefaultChatEnabledPolicySelection, - getForwardsToAccount, - getForwardsToFromRules, - evaluateApprovalWorkflowRule, - getSubmitToAccountID, - getSubmitReportManagerAccountID, - getTagApproverRule, - getDomainNameForPolicy, - getWorkflowApprovalsUnavailable, - getUserFriendlyWorkspaceType, - getDefaultWorkspacePlanType, - isPolicyAccessible, - hasOtherControlWorkspaces, - shouldBlockWorkspaceDeletionForInvoicifyUser, - getManagerAccountEmail, - getSubmitToEmail, - getRuleApprovers, - canModifyPlan, - getAdminsPrivateEmailDomains, - getMostFrequentEmailDomain, - getPolicyIDFromDomainName, - getPolicyForAssignedCard, - getDescriptionForPolicyDomainCard, - getManagerAccountID, - getActiveEmployeeWorkspaces, - isPolicyMemberWithoutPendingDelete, - hasDynamicExternalWorkflow, - shouldHideDynamicExternalWorkflowPeople, - getActivePoliciesWithExpenseChatAndPerDiemEnabled, - getPoliciesForRoomCreation, - isInvoiceFieldsEnabled, - getTravelStep, - isWorkspaceProvisionedForTravel, - hasAcceptedTravelTerms, - isNonUSDPolicy, - isTimeTrackingEnabled, - isMCPEnabled, - getDefaultTimeTrackingRate, - getActivePoliciesWithExpenseChatAndTimeEnabled, - sortPoliciesByName, - hasActiveExpensifyCard, - tryNavigateToSubmitWorkspaceUpgrade, - tryNavigateToControlPolicyUpgrade, - getRulesDocumentSourceURL, - hasAnyPaidPolicy, - isMergeHRCompleteSetupNeededSelector, - isQBORefreshTokenExpiringSoonSelector, -}; - -// Re-exported with `export *` rather than through the block above: a named re-export becomes a getter that throws +// Re-exported with `export *` rather than named re-exports: a named re-export becomes a getter that throws // while this module is still loading, which breaks tests that spread `jest.requireActual('@libs/PolicyUtils')` // from inside an import cycle. export * from './tag'; @@ -1988,5 +10,14 @@ export * from './xero'; export * from './sageIntacct'; export * from './customUnits'; export * from './tax'; - -export type {MemberEmailsToAccountIDs}; +export * from './rules'; +export * from './features'; +export * from './workflows'; +export * from './errors'; +export * from './approvals'; +export * from './payments'; +export * from './policy'; +export * from './navigation'; +export * from './members'; +export * from './domains'; +export * from './travel'; diff --git a/src/libs/PolicyUtils/members.ts b/src/libs/PolicyUtils/members.ts new file mode 100644 index 000000000000..9735c5395cba --- /dev/null +++ b/src/libs/PolicyUtils/members.ts @@ -0,0 +1,411 @@ +/** + * Workspace membership, Expensify-team filtering, invite exclusions, and member account IDs. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginProvider'; + +import {getBankAccountFromID} from '@libs/actions/BankAccounts'; +import {getIsOffline} from '@libs/NetworkState'; +import {generateAccountID} from '@libs/UserUtils'; + +import CONST from '@src/CONST'; +import type {PersonalDetailsList, Policy, PolicyEmployeeList} from '@src/types/onyx'; +import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +import {Str} from 'expensify-common'; + +import {canMemberWrite} from './permissions'; +import {getActivePolicies, shouldShowPolicy} from './policy'; + +type MemberEmailsToAccountIDs = Record; + +/** Return the emails that can receive a shared bank account from the current user. */ +function getEligibleBankAccountShareRecipientEmails(policies: OnyxCollection | null, currentUserLogin: string | undefined, bankAccountID: string | undefined): string[] { + if (!currentUserLogin) { + return []; + } + + const currentBankAccount = getBankAccountFromID(Number(bankAccountID)); + const isOffline = getIsOffline(); + const activePolicies = getActivePolicies(policies, currentUserLogin).filter( + (policy) => shouldShowPolicy(policy, isOffline, currentUserLogin) && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS), + ); + const recipientEmails = new Set(); + const shareesSet = new Set(currentBankAccount?.accountData?.sharees ?? []); + + for (const policy of activePolicies) { + for (const [email, employee] of Object.entries(policy.employeeList ?? {})) { + if ( + employee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || + !employee.role || + !canMemberWrite(policy, email, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS) || + email === currentUserLogin || + recipientEmails.has(email) || + shareesSet.has(email) || + (isExpensifyTeam(email) && shouldFilterExpensifyTeam(policy.owner, currentUserLogin)) + ) { + continue; + } + + recipientEmails.add(email); + } + } + + return Array.from(recipientEmails); +} + +/** Return whether the current user has someone they can share a bank account with. */ +function hasEligibleBankAccountShareRecipient(policies: OnyxCollection | null, currentUserLogin: string | undefined, bankAccountID: string | undefined): boolean { + return getEligibleBankAccountShareRecipientEmails(policies, currentUserLogin, bankAccountID).length > 0; +} + +/** + * Checks if a specific user is a member of the policy. + * Note that employeeList can be blank when the policy is not the user's default/active policy + */ +function isPolicyMember(policy: OnyxEntry, userLogin: string | undefined): boolean { + return !!policy && !!userLogin && (!!policy.employeeList?.[userLogin] || policy.owner === userLogin); +} + +function isMemberInHomeAndOfficeWorkspace(policy: OnyxEntry, memberLogin: string): boolean { + return !!memberLogin && policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && !!policy.employeeList?.[memberLogin]; +} + +function isPolicyMemberWithoutPendingDelete(currentUserLogin: string | undefined, policy: OnyxEntry): boolean { + if (!currentUserLogin || !policy?.id) { + return false; + } + + const policyEmployee = policy?.employeeList?.[currentUserLogin]; + return !!policyEmployee && policyEmployee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; +} + +/** Check if the passed employee holds an active Expensify Card on the policy, as reported by the backend in the policy's employeeList */ +function hasActiveExpensifyCard(policy: OnyxEntry, employeeLogin: string) { + const primaryLogin = policy?.primaryLoginsInvited?.[employeeLogin]; + return !!policy?.employeeList?.[employeeLogin]?.hasActiveExpensifyCard || (!!primaryLogin && !!policy?.employeeList?.[primaryLogin]?.hasActiveExpensifyCard); +} + +function isExpensifyTeam(email: string | undefined): boolean { + const emailDomain = Str.extractEmailDomain(email ?? ''); + return emailDomain === CONST.EXPENSIFY_PARTNER_NAME || emailDomain === CONST.EMAIL.GUIDES_DOMAIN; +} + +/** Whether Expensify team members should be hidden from the given policy/user combination. */ +function shouldFilterExpensifyTeam(policyOwner: string | undefined, currentUserLogin: string | undefined): boolean { + return !!policyOwner && !!currentUserLogin && !isExpensifyTeam(policyOwner) && !isExpensifyTeam(currentUserLogin); +} + +/** + * Creates a selector for useOnyx that computes the filtered member count. + * Returns a primitive number to prevent unnecessary re-renders when unrelated personal details change. + */ +function createFilteredMemberCountSelector( + employeeList: PolicyEmployeeList | undefined, + policyOwner: string | undefined, + currentUserLogin: string | undefined, + personalDetailsByLogins: PersonalDetailsByLogin, +) { + return (personalDetails: PersonalDetailsList | undefined): number => { + const shouldFilter = shouldFilterExpensifyTeam(policyOwner, currentUserLogin); + const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(employeeList, personalDetailsByLogins, false, false); + + return Object.keys(policyMemberEmailsToAccountIDs).reduce((count, email) => { + const accountID = policyMemberEmailsToAccountIDs[email]; + const details = personalDetails?.[accountID]; + + if (shouldFilter && isExpensifyTeam(details?.login ?? details?.displayName)) { + return count; + } + + return count + 1; + }, 0); + }; +} + +/** + * Create an object mapping member emails to their accountIDs. Filter for members without errors if includeMemberWithErrors is false, and get the login email from the personalDetail object using the accountID. + * + * If includeMemberWithErrors is false, We only return members without errors. Otherwise, the members with errors would immediately be removed before the user has a chance to read the error. + */ +function getMemberAccountIDsForWorkspace( + employeeList: PolicyEmployeeList | undefined, + personalDetailsByLogins: PersonalDetailsByLogin, + includeMemberWithErrors = false, + includeMemberWithPendingDelete = true, +): MemberEmailsToAccountIDs { + const members = employeeList ?? {}; + const memberEmailsToAccountIDs: MemberEmailsToAccountIDs = {}; + for (const email of Object.keys(members)) { + if (!includeMemberWithErrors) { + const member = members?.[email]; + if (Object.keys(member?.errors ?? {})?.length > 0) { + continue; + } + } + if (!includeMemberWithPendingDelete) { + const member = members?.[email]; + if (member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + continue; + } + } + const personalDetail = personalDetailsByLogins?.[email]; + if (!personalDetail?.login) { + continue; + } + memberEmailsToAccountIDs[email] = Number(personalDetail.accountID); + } + return memberEmailsToAccountIDs; +} + +/** + * Resolves the accountID for a submit-to recipient chosen in the submit-to popover. + * Uses personal details first, then the workspace employee list (same source as ReportSubmitToContent). + * When the member is in employeeList but not yet in personal details, returns a stable optimistic accountID. + */ +function getAccountIDForSubmitManagerEmail( + managerEmail: string | undefined, + employeeList: PolicyEmployeeList | undefined, + personalDetailsByLogins: PersonalDetailsByLogin, +): number | undefined { + const trimmed = managerEmail?.trim(); + if (!trimmed) { + return undefined; + } + + const fromPersonalDetails = personalDetailsByLogins[trimmed]?.accountID; + if (fromPersonalDetails !== undefined) { + return fromPersonalDetails; + } + + if (!employeeList) { + return undefined; + } + + const normalizedEmail = trimmed.toLowerCase(); + const memberAccountIDs = getMemberAccountIDsForWorkspace(employeeList, personalDetailsByLogins, true, false); + + for (const [email, accountID] of Object.entries(memberAccountIDs)) { + if (email.toLowerCase() === normalizedEmail) { + return accountID; + } + } + + for (const [listKey, employee] of Object.entries(employeeList)) { + const employeeEmail = (employee.email ?? listKey).trim(); + const listKeyNormalized = listKey.trim().toLowerCase(); + const employeeEmailNormalized = employeeEmail.toLowerCase(); + + if (employeeEmailNormalized !== normalizedEmail && listKeyNormalized !== normalizedEmail) { + continue; + } + + const accountIDFromMap = memberAccountIDs[employeeEmail] ?? memberAccountIDs[listKey]; + + if (accountIDFromMap !== undefined) { + return accountIDFromMap; + } + + return generateAccountID(trimmed); + } + + return undefined; +} + +/** + * Get login list that we should not show in the workspace invite options + */ +function getIneligibleInvitees(employeeList?: PolicyEmployeeList): string[] { + const policyEmployeeList = employeeList ?? {}; + const memberEmailsToExclude: string[] = [...CONST.EXPENSIFY_EMAILS]; + for (const email of Object.keys(policyEmployeeList)) { + const policyEmployee = policyEmployeeList?.[email]; + // Policy members that are pending delete or have errors are not valid and we should show them in the invite options (don't exclude them). + if (policyEmployee?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policyEmployee?.errors ?? {}).length > 0) { + continue; + } + if (!email) { + continue; + } + memberEmailsToExclude.push(email); + } + + return memberEmailsToExclude; +} + +/** + * Get excluded users as a Record for use in search selector + */ +function getExcludedUsers(employeeList?: PolicyEmployeeList): Record { + return Object.fromEntries(getIneligibleInvitees(employeeList).map((login) => [login, true])); +} + +/** + * Get Guide and Account Manager information including their emails/logins and exclusion record. + * Used for filtering Guide/AM from contact lists while allowing manual entry. + * + * @param policy - The policy to get the assigned guide from + * @param accountManagerAccountID - The account manager's account ID from the account object + * @param personalDetails - Personal details collection to look up account manager login + * @returns Object containing extracted emails/logins and exclusions record + */ +function getGuideAndAccountManagerInfo( + policy: OnyxEntry, + accountManagerAccountID: number | undefined, + personalDetails: OnyxEntry, +): { + assignedGuideEmail: string | undefined; + accountManagerLogin: string | undefined; + exclusions: Record; +} { + const assignedGuideEmail = policy?.assignedGuide?.email?.toLowerCase(); + const accountManagerLogin = accountManagerAccountID ? personalDetails?.[accountManagerAccountID]?.login?.toLowerCase() : undefined; + + const exclusions: Record = {}; + if (assignedGuideEmail) { + exclusions[assignedGuideEmail] = true; + } + if (accountManagerLogin) { + exclusions[accountManagerLogin] = true; + } + + return { + assignedGuideEmail, + accountManagerLogin, + exclusions, + }; +} + +/** + * Build a soft-exclusion map of Expensify-team logins (current and former AMs/Guides) so search + * filters don't surface internal Expensify staff to customers. Mirrors the visibility rule used + * by WorkspaceMembersPage: skip the filter when the current user is themselves Expensify staff, + * and preserve Expensify-team members of any Expensify-owned policy the current user belongs to. + */ +function getExpensifyTeamExclusions(personalDetails: OnyxEntry, policies: OnyxCollection, currentUserLogin: string | undefined): Record { + if (!currentUserLogin || isExpensifyTeam(currentUserLogin)) { + return {}; + } + + const lowerCurrentUserLogin = currentUserLogin.toLowerCase(); + const allowedExpensifyTeamLogins = new Set(); + + for (const policy of Object.values(policies ?? {})) { + if (!policy?.owner || !policy.employeeList || !isExpensifyTeam(policy.owner)) { + continue; + } + const currentUserIsInPolicy = Object.keys(policy.employeeList).some((email) => email.toLowerCase() === lowerCurrentUserLogin); + if (!currentUserIsInPolicy) { + continue; + } + for (const email of Object.keys(policy.employeeList)) { + const lowerEmail = email.toLowerCase(); + if (isExpensifyTeam(lowerEmail)) { + allowedExpensifyTeamLogins.add(lowerEmail); + } + } + } + + const exclusion: Record = {}; + for (const details of Object.values(personalDetails ?? {})) { + const login = details?.login?.toLowerCase(); + if (!login || !isExpensifyTeam(login)) { + continue; + } + if (allowedExpensifyTeamLogins.has(login)) { + continue; + } + exclusion[login] = true; + } + return exclusion; +} + +/** + * Get soft exclusions (Guide/Account Manager) that should be hidden from auto-suggestions + * but can still be manually entered by the user. + * + * @param policy - The policy to get the assigned guide from + * @param accountManagerAccountID - The account manager's account ID from the account object + * @param personalDetails - Personal details collection to look up account manager login + * @returns Record mapping lowercase emails to true for Guide and Account Manager + */ +function getSoftExclusionsForGuideAndAccountManager( + policy: OnyxEntry, + accountManagerAccountID: number | undefined, + personalDetails: OnyxEntry, +): Record { + return getGuideAndAccountManagerInfo(policy, accountManagerAccountID, personalDetails).exclusions; +} + +/** + * Filter out Guide and Account Manager from a list of items. + * Used for filtering local data (e.g., policy.employeeList) that isn't filtered at the data layer. + * + * @param items - Array of items to filter (must have login or alternateText properties) + * @param assignedGuideEmail - The assigned guide's email (should be lowercase) + * @param accountManagerLogin - The account manager's login (should be lowercase) + * @returns Filtered array with Guide and Account Manager removed + */ +function filterGuideAndAccountManager( + items: T[], + assignedGuideEmail: string | undefined, + accountManagerLogin: string | undefined, +): T[] { + return items.filter((item) => { + const itemLogin = item.login?.toLowerCase(); + const itemAltText = item.alternateText?.toLowerCase(); + + if (assignedGuideEmail && (itemLogin === assignedGuideEmail || itemAltText === assignedGuideEmail)) { + return false; + } + + if (accountManagerLogin && (itemLogin === accountManagerLogin || itemAltText === accountManagerLogin)) { + return false; + } + + return true; + }); +} + +function getAdminEmployees(policy: OnyxEntry): PolicyEmployee[] { + if (!policy?.employeeList) { + return []; + } + return Object.keys(policy.employeeList) + .map((email) => ({...policy.employeeList?.[email], email})) + .filter((employee) => employee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && employee.role === CONST.POLICY.ROLE.ADMIN); +} + +/** + * Check if the policy member is deleted from the workspace + */ +function isDeletedPolicyEmployee(policyEmployee: PolicyEmployee, isOffline: boolean) { + return !isOffline && policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyEmployee.errors); +} + +export { + getAdminEmployees, + getIneligibleInvitees, + getExcludedUsers, + getMemberAccountIDsForWorkspace, + getAccountIDForSubmitManagerEmail, + getGuideAndAccountManagerInfo, + getSoftExclusionsForGuideAndAccountManager, + getExpensifyTeamExclusions, + filterGuideAndAccountManager, + isExpensifyTeam, + shouldFilterExpensifyTeam, + createFilteredMemberCountSelector, + isDeletedPolicyEmployee, + hasEligibleBankAccountShareRecipient, + isPolicyMember, + isMemberInHomeAndOfficeWorkspace, + getEligibleBankAccountShareRecipientEmails, + isPolicyMemberWithoutPendingDelete, + hasActiveExpensifyCard, +}; + +export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/PolicyUtils/navigation.ts b/src/libs/PolicyUtils/navigation.ts new file mode 100644 index 000000000000..1f2caad7d88a --- /dev/null +++ b/src/libs/PolicyUtils/navigation.ts @@ -0,0 +1,63 @@ +/** + * Workspace upgrade redirects and navigation after enabling a feature. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {isCollectPolicy, isControlPolicy, isSubmitPolicy} from './policyType'; + +/** + * For Submit workspaces, certain features require upgrading the plan before enabling. + * When conditions match, navigates to the workspace upgrade flow and returns true (caller should not enable the feature). + * @returns true if upgrade navigation was performed; false otherwise + */ +function tryNavigateToSubmitWorkspaceUpgrade(policy: OnyxEntry, isEnabling: boolean, upgradeFeatureAlias: string, backTo?: string): boolean { + if (!policy?.id || !isEnabling || !isSubmitPolicy(policy)) { + return false; + } + // Default the upgrade page's back destination to More Features (the source of most upgrade-gated toggles). + // Entry points outside More Features (e.g. the Roles flow) pass their own backTo so the user returns there. + Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policy?.id, upgradeFeatureAlias, backTo ?? ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policy?.id))); + return true; +} + +/** + * Collect workspaces can access a limited subset of Rules features. When a Collect admin tries to + * access a Control-only Rules feature, navigate to the upgrade flow and return true. + * + * @param shouldReplace - Replace the current screen instead of pushing the upgrade page on top of it. Use this when + * the redirect happens from the Control-only page itself, so pressing Back doesn't land back on a page Collect + * can't use. Press handlers on a page Collect *can* use should keep the default push. + */ +function tryNavigateToControlPolicyUpgrade(policy: OnyxEntry, upgradeFeatureAlias: string, backTo?: string, shouldReplace = false): boolean { + if (!policy?.id || isControlPolicy(policy) || !isCollectPolicy(policy)) { + return false; + } + + Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policy.id, upgradeFeatureAlias, backTo ?? ROUTES.WORKSPACE_RULES.getRoute(policy.id)), {forceReplace: shouldReplace}); + return true; +} + +function goBackFromInvalidPolicy() { + Navigation.goBack(ROUTES.WORKSPACES_LIST.route); +} + +function goBackWhenEnableFeature() { + setTimeout(() => { + Navigation.goBack(); + }, CONST.WORKSPACE_ENABLE_FEATURE_REDIRECT_DELAY); +} + +function navigateToExpensifyCardPage(policyID: string) { + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD.getRoute(policyID)); + }); +} + +export {tryNavigateToSubmitWorkspaceUpgrade, tryNavigateToControlPolicyUpgrade, goBackFromInvalidPolicy, goBackWhenEnableFeature, navigateToExpensifyCardPage}; diff --git a/src/libs/PolicyUtils/payments.ts b/src/libs/PolicyUtils/payments.ts new file mode 100644 index 000000000000..45fbd863a014 --- /dev/null +++ b/src/libs/PolicyUtils/payments.ts @@ -0,0 +1,133 @@ +/** + * The workspace reimburser and payer, the reimbursement choice, and auto-pay availability. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import {getAccountIDsByLogins} from '@libs/PersonalDetailsUtils'; + +import CONST from '@src/CONST'; +import type {OnyxInputOrEntry, Policy} from '@src/types/onyx'; + +import type {NullishDeep, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {canMemberWrite} from './permissions'; +import {isGroupPolicy} from './policyType'; + +/** + * Resolve the email of the workspace's Authorized Payer (reimburser), matching the canonical resolution used across the app. + * Returns undefined when reimbursement is disabled or no payer can be resolved. + */ +function getReimburserEmail(policy: OnyxEntry): string | undefined { + if (!policy) { + return undefined; + } + + const reimbursementChoice = getReimbursementChoice(policy); + const isAutoReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; + const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; + + // Reimbursement is disabled for this workspace. + if (!isAutoReimbursement && !isManualReimbursement) { + return undefined; + } + + return policy.reimburser ?? policy.achAccount?.reimburser ?? (isManualReimbursement ? policy.owner : undefined); +} + +/** + * Payer fields to merge into the successData of an ownership transfer. The backend keeps the former payer when the + * workspace has a bank account, so only reassign when there is none and the outgoing owner is the resolved payer. + */ +function getOwnerChangePayerSuccessData(policy: OnyxEntry, newOwnerLogin: string): NullishDeep { + if (!policy || policy.achAccount?.bankAccountID || getReimburserEmail(policy) !== policy.owner) { + return {}; + } + + return { + ...(policy.reimburser ? {reimburser: newOwnerLogin} : {}), + ...(policy.achAccount?.reimburser ? {achAccount: {reimburser: newOwnerLogin}} : {}), + }; +} + +function isPolicyPayer(policy: OnyxEntry, currentUserLogin: string | undefined): boolean { + if (!policy) { + return false; + } + + const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN; + const reimbursementChoice = getReimbursementChoice(policy); + const isAutoReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; + const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; + + // Reimbursement is disabled for this workspace. + if (!isAutoReimbursement && !isManualReimbursement) { + return false; + } + + const reimburserEmail = getReimburserEmail(policy); + + // No designated reimburser means any workspace admin can pay. + if (!reimburserEmail) { + return isAdmin; + } + + const canPayOnPolicy = isAdmin || (!!currentUserLogin && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)); + + return canPayOnPolicy && currentUserLogin === reimburserEmail; +} + +/** + * Whether an admin/payments admin who isn't the designated workspace payer can still pay reports on the policy. + * Unlike `isPolicyPayer`/`isPayer`, this must not drive active prompting (badges, GBRs, next steps, pay to-dos), which stay payer-only. + */ +function canAdminPayReport(policy: OnyxInputOrEntry, currentUserLogin: string): boolean { + const reimbursementChoice = getReimbursementChoice(policy); + const isReimbursementConfigured = + reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES || reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; + + return isGroupPolicy(policy) && isReimbursementConfigured && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS); +} + +/** + * Resolves a workspace's reimbursement choice to one of the three values in `CONST.POLICY.REIMBURSEMENT_CHOICES`. + * Comparing the raw field instead makes a workspace reporting a deprecated value look like it has reimbursement + * disabled, which hides Pay on its approved reports. + */ +function getReimbursementChoice(policy: OnyxInputOrEntry): ValueOf | undefined { + switch (policy?.reimbursementChoice) { + case CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO: + return CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; + case CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL: + return CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; + default: + return policy?.reimbursementChoice; + } +} + +function arePaymentsEnabled(policy: OnyxInputOrEntry): boolean { + return getReimbursementChoice(policy) !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; +} + +/** Whether the workspace has everything auto-pay approved reports needs: workflows, direct reimbursements, and a business bank account. */ +function isAutoPayApprovedReportsAvailable(policy: OnyxInputOrEntry): boolean { + return !!policy?.areWorkflowsEnabled && getReimbursementChoice(policy) === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES && !!policy?.achAccount?.bankAccountID; +} + +/** + * Returns the accountID of the policy reimburser, if not available returns -1. + */ +function getReimburserAccountID(policy: OnyxEntry): number { + const reimburserEmail = policy?.achAccount?.reimburser ?? policy?.owner ?? ''; + return reimburserEmail ? (getAccountIDsByLogins([reimburserEmail]).at(0) ?? -1) : -1; +} + +export { + getReimburserEmail, + getOwnerChangePayerSuccessData, + isPolicyPayer, + canAdminPayReport, + getReimbursementChoice, + arePaymentsEnabled, + isAutoPayApprovedReportsAvailable, + getReimburserAccountID, +}; diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts index 92465cc8b71a..4a559c966495 100644 --- a/src/libs/PolicyUtils/permissions.ts +++ b/src/libs/PolicyUtils/permissions.ts @@ -6,7 +6,7 @@ import CONST from '@src/CONST'; import type {OnyxInputOrEntry, Policy} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; import {isArchivedPolicy, isControlPolicy, isSubmitPolicy} from './policyType'; @@ -45,8 +45,8 @@ const isPolicyOwner = (policy: OnyxInputOrEntry>, * Only a member who was invited to the chat can be removed from it. Everybody else is there by virtue of the * workspace configuration, so their membership is governed by that configuration and not by this screen — see the * expense chat rules in `contributingGuides/philosophies/SECURITY.md`. That covers admins, the policy owner, - * approvers, who are auto-added to the chats of everybody who submits to them, and auditors, who are default members - * of every workspace chat. + * approvers, who are auto-added to the chats of everybody who submits to them, and members with read-only access to all + * reports (auditors and payments admins), who are default members of every workspace chat. * * Fails closed on a missing `login`: without one we cannot resolve the member's role, and offering removal for a * member whose role is unknown could remove a workspace admin. Both the member list and the member details page must @@ -65,14 +65,14 @@ const isPolicyOwner = (policy: OnyxInputOrEntry>, * protection, so every caller must state it even when it is `undefined`. */ const isRoomMemberProtectedByPolicyRole = (policy: OnyxInputOrEntry, login: string | undefined, accountID: number | undefined): boolean => - isPolicyOwner(policy, accountID) || !login || isPolicyAdmin(policy, login, false) || isPolicyAuditor(policy, login, false) || isPolicyApprover(policy, login); + isPolicyOwner(policy, accountID) || !login || isPolicyAdmin(policy, login, false) || hasReadOnlyAllReportsAccess(policy, login, false) || isPolicyApprover(policy, login); const ALL_POLICY_FEATURES = Object.values(CONST.POLICY.POLICY_FEATURE); -function buildFeatureAccessMap(access: PolicyFeatureAccess, excludedFeature?: PolicyFeature): Partial> { +function buildFeatureAccessMap(access: PolicyFeatureAccess, excludedFeatures: PolicyFeature[] = []): Partial> { const features: Partial> = {}; for (const feature of ALL_POLICY_FEATURES) { - if (feature === excludedFeature) { + if (excludedFeatures.includes(feature)) { continue; } features[feature] = access; @@ -84,7 +84,8 @@ const WRITE_ALL_POLICY_FEATURES = buildFeatureAccessMap(CONST.POLICY.POLICY_FEAT const READ_ALL_POLICY_FEATURES = buildFeatureAccessMap(CONST.POLICY.POLICY_FEATURE_ACCESS.READ); -const EDITOR_POLICY_FEATURES = buildFeatureAccessMap(CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE, CONST.POLICY.POLICY_FEATURE.ASSIGN_ELEVATED_ROLES); +// Editor inherits every feature except the ones that only elevated roles hold. +const EDITOR_POLICY_FEATURES = buildFeatureAccessMap(CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE, [CONST.POLICY.POLICY_FEATURE.ASSIGN_ELEVATED_ROLES, CONST.POLICY.POLICY_FEATURE.ALL_REPORTS]); const ROLE_PERMISSION_BUNDLES: Record>> = { [CONST.POLICY.ROLE.ADMIN]: WRITE_ALL_POLICY_FEATURES, @@ -96,6 +97,7 @@ const ROLE_PERMISSION_BUNDLES: Record, login: string, feature return hasPolicyFeaturePermission(policy, login, feature, CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE); } -function canMemberAssignRole(policy: OnyxInputOrEntry, login: string, role: string | undefined): boolean { +/** + * Whether the role can see every workspace report but can't administer them. Today that means Auditor and Payments Admin. + * These members must not get room administration, such as editing a room's avatar or description. + * + * When `login` belongs to somebody other than the current user, it must be passed so the role comes from the employee list. + */ +function hasReadOnlyAllReportsAccess(policy: OnyxInputOrEntry, login?: string, shouldCheckGlobalPolicyRole = true): boolean { + const role = getPolicyRole(policy, login, shouldCheckGlobalPolicyRole); if (!role) { return false; } - // Guest role assignment is temporarily disabled until the remaining guest issues are fixed. - if (role === CONST.POLICY.ROLE.GUEST) { + // Downgrading to Team changes the policy type before the stale role is cleared, so the type must be checked too. + if (isControlPolicyOnlyRole(role) && (!policy || !isControlPolicy(policy))) { + return false; + } + + return ROLE_PERMISSION_BUNDLES[role]?.[CONST.POLICY.POLICY_FEATURE.ALL_REPORTS] === CONST.POLICY.POLICY_FEATURE_ACCESS.READ; +} + +function canMemberAssignRole(policy: OnyxInputOrEntry, login: string, role: string | undefined): boolean { + if (!role) { return false; } @@ -182,6 +203,28 @@ function canMemberAssignRole(policy: OnyxInputOrEntry, login: string, ro return isCorporatePolicy && canMemberWrite(policy, login, CONST.POLICY.POLICY_FEATURE.MEMBERS) && isNonElevatedRole; } +/** + * The roles a member can be moved to, in the order the role selector lists them. Owner and Editor are left out + * because neither is assignable through the role selector. + */ +const SELECTABLE_ROLES = [ + CONST.POLICY.ROLE.ADMIN, + CONST.POLICY.ROLE.AUDITOR, + CONST.POLICY.ROLE.GUEST, + CONST.POLICY.ROLE.CARD_ADMIN, + CONST.POLICY.ROLE.PEOPLE_ADMIN, + CONST.POLICY.ROLE.PAYMENTS_ADMIN, + CONST.POLICY.ROLE.USER, +] as const; + +/** + * The roles the given member may assign on this policy, optionally narrowed to a permitted set (for example an + * Authorized Payer may only hold a role that can pay). + */ +function getSelectableRoles(policy: OnyxInputOrEntry, login: string, allowedRoles?: Array>): Array> { + return SELECTABLE_ROLES.filter((role) => canMemberAssignRole(policy, login, role) && (!allowedRoles || allowedRoles.includes(role))); +} + // Whether the member can assign any elevated role: admins (via assignElevatedRoles) on any policy, or People Admins (up to auditor) on Control. function canMemberAssignElevatedRole(policy: OnyxInputOrEntry, login: string): boolean { return canMemberWrite(policy, login, CONST.POLICY.POLICY_FEATURE.ASSIGN_ELEVATED_ROLES) || canMemberAssignRole(policy, login, CONST.POLICY.ROLE.AUDITOR); @@ -213,6 +256,34 @@ function getPolicyRole(policy: OnyxInputOrEntry, currentUserLogin?: stri return (employeeList?.[currentUserLogin] ?? employeeList?.[currentUserLogin.toLowerCase()])?.role; } +/** + * Workspace roles from highest to lowest privilege, matching the role hierarchy in Auth. + */ +const POLICY_ROLE_RANKING = [ + CONST.POLICY.ROLE.ADMIN, + CONST.POLICY.ROLE.PAYMENTS_ADMIN, + CONST.POLICY.ROLE.CARD_ADMIN, + CONST.POLICY.ROLE.PEOPLE_ADMIN, + CONST.POLICY.ROLE.AUDITOR, + CONST.POLICY.ROLE.EDITOR, + CONST.POLICY.ROLE.USER, + CONST.POLICY.ROLE.GUEST, +] as const; + +/** + * Returns the highest-privilege role held across the given policies. + * Defaults to user when no ranked role is found, so a member on any workspace is not reported as a guest. + */ +function getHighestPolicyRole(policyList: Array>>): TupleToUnion { + const roles = new Set(policyList.map((policy) => policy?.role)); + for (const rankedRole of POLICY_ROLE_RANKING) { + if (roles.has(rankedRole)) { + return rankedRole; + } + } + return CONST.POLICY.ROLE.USER; +} + /** * Whether the given role is allowed to pay (reimburse) on a workspace. */ @@ -301,10 +372,13 @@ export { isControlPolicyOnlyRole, canMemberRead, canMemberWrite, + hasReadOnlyAllReportsAccess, canMemberAssignRole, + getSelectableRoles, canMemberAssignElevatedRole, canMemberManageMemberWithRole, getPolicyRole, + getHighestPolicyRole, canRolePay, PAYER_ROLES, isPolicyApprover, diff --git a/src/libs/PolicyUtils/policy.ts b/src/libs/PolicyUtils/policy.ts new file mode 100644 index 000000000000..23faa9cc4acb --- /dev/null +++ b/src/libs/PolicyUtils/policy.ts @@ -0,0 +1,450 @@ +/** + * Active and visible workspaces, plan type, invoice and per diem availability, and workspace sorting. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; + +import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; +import {getIsOffline} from '@libs/NetworkState'; +import {getEffectiveWorkArrangement} from '@libs/WorkArrangementUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PolicyType} from '@src/types/form/WorkspaceConfirmationForm'; +import type {OnyxInputOrEntry, Policy} from '@src/types/onyx'; +import type {CommuterExclusions} from '@src/types/onyx/Policy'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {hasAccountingConnections} from './connections'; +import {getPerDiemCustomUnit, isPerDiemEligiblePolicy} from './customUnits'; +import {isTimeTrackingEnabled} from './features'; +import {canEditWorkspaceSettings, canMemberWrite, getPolicyRole, isPolicyAdmin, isPolicyOwner, isPolicyUser} from './permissions'; +import {isArchivedPolicy, isControlPolicy, isGroupPolicy, isPaidGroupPolicy, isSubmitPolicy} from './policyType'; + +type WorkspaceDetails = { + policyID: string | undefined; + name: string; +}; + +/** + * Returns true if the policy has no fieldList or its fieldList is empty. + */ +function isPolicyFieldListEmpty(policy: OnyxEntry): boolean { + return !policy?.fieldList || Object.keys(policy.fieldList).length === 0; +} + +/** + * Whether the current user can unarchive the workspace: only the owner of an archived workspace can, and only + * while the archive policies beta is enabled. Takes primitives so it works with both a Policy and a workspace row. + */ +function canUnarchivePolicy(isArchived: boolean, ownerAccountID: number | undefined, currentUserAccountID: number, isArchivePoliciesBetaEnabled: boolean): boolean { + return isArchivePoliciesBetaEnabled && isArchived && ownerAccountID === currentUserAccountID; +} + +/** + * Filter out the active policies, which will exclude policies with pending deletion + * and policies the current user doesn't belong to. + * These are policies that we can use to create reports with in NewDot. + */ +function getActivePolicies(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { + return Object.values(policies ?? {}).filter( + (policy): policy is Policy => + !!policy && + policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && + !!policy.name && + !!policy.id && + !!getPolicyRole(policy, currentUserLogin) && + !isArchivedPolicy(policy), + ); +} + +/** + * Filter out the active policies, which will exclude policies with pending deletion + * and policies the current user doesn't belong to. + * These will be policies that has expense chat enabled. + * These are policies that we can use to create reports with in NewDot. + */ +function getActivePoliciesWithExpenseChat(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { + return Object.values(policies ?? {}).filter( + (policy): policy is Policy => + !!policy && + policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && + !!policy.name && + !!policy.id && + !!getPolicyRole(policy, currentUserLogin) && + (isPaidGroupPolicy(policy) || isSubmitPolicy(policy)) && + !isArchivedPolicy(policy), + ); +} + +function getActivePoliciesWithExpenseChatAndPerDiemEnabled(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { + return getActivePoliciesWithExpenseChat(policies, currentUserLogin).filter(isPerDiemEligiblePolicy); +} + +function getActivePoliciesWithExpenseChatAndTimeEnabled(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { + return getActivePoliciesWithExpenseChat(policies, currentUserLogin).filter(isTimeTrackingEnabled); +} + +/** + * Whether the workspace has a company address + */ +function hasCompanyAddress(policy: OnyxEntry): boolean { + return !!policy?.address?.addressStreet?.trim(); +} + +/** + * The workspace-wide work arrangement, which members follow unless they were given one of their own. + */ +function hasOfficeWorkArrangement(commuterExclusions: CommuterExclusions | undefined): boolean { + return getEffectiveWorkArrangement(undefined, commuterExclusions?.isOfficeWorkArrangement); +} + +function getPolicyIDOrDefault(policyID?: string) { + if (!policyID || policyID === CONST.POLICY.OWNER_EMAIL_FAKE) { + return '-1'; + } + return policyID; +} + +/** + * Check if the policy can be displayed + * If shouldShowPendingDeletePolicy is true, show the policy pending deletion. + * If shouldShowPendingDeletePolicy is false, show the policy pending deletion only if there is an error. + * Note: Using a local ONYXKEYS.NETWORK subscription will cause a delay in + * updating the screen. Passing the offline status from the component. + */ +function shouldShowPolicy(policy: OnyxEntry, shouldShowPendingDeletePolicy: boolean, currentUserLogin: string | undefined, includeArchivedPolicy = false): boolean { + if (!includeArchivedPolicy && isArchivedPolicy(policy)) { + return false; + } + return ( + !!policy?.isJoinRequestPending || + (!!policy && + policy?.type !== CONST.POLICY.TYPE.PERSONAL && + (shouldShowPendingDeletePolicy || policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0) && + !!getPolicyRole(policy, currentUserLogin)) + ); +} + +/** + * Creates a selector for useOnyx that returns formatted text for invoice configuration. + * Returns a primitive string to prevent unnecessary re-renders when unrelated bank account data changes. + * This matches the filtering logic used by PaymentMethodList with filterType=BUSINESS on the invoices page. + */ +function createInvoiceConfigurationTextSelector(translate: LocaleContextProps['translate'], invoiceCompany: string) { + return (bankAccountList: OnyxEntry>): string => { + const count = Object.values(bankAccountList ?? {}).filter((account) => account.accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS).length; + const bankAccountsText = count > 0 ? `${count} ${translate('common.bankAccounts').toLowerCase()}` : ''; + return [bankAccountsText, invoiceCompany].filter(Boolean).join(', '); + }; +} + +/** + * Get the active group policies where the current user can create policy rooms. + */ +function getPoliciesForRoomCreation(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { + return getActivePolicies(policies, currentUserLogin).filter( + (policy) => policy.type !== CONST.POLICY.TYPE.PERSONAL && canMemberWrite(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.ROOMS), + ); +} + +function getOwnedPaidPolicies(policies: OnyxCollection | null, currentUserAccountID: number | undefined): Policy[] { + return Object.values(policies ?? {}).filter((policy): policy is Policy => isPolicyOwner(policy, currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID) && isPaidGroupPolicy(policy)); +} + +/** Whether the user can add a category to the workspace straight from an expense's category picker */ +function canCreateCategoryInSitu(policy: OnyxEntry, login?: string) { + return canEditWorkspaceSettings(policy, login) && !hasAccountingConnections(policy) && !!policy?.areCategoriesEnabled; +} + +/** + * Whether the policy lets an expense with the given `reimbursable` value show the reimbursable toggle. + * + * A locked field (`disabledFields.reimbursable`) normally hides the toggle, but an expense that already + * disagrees with the policy default has to stay correctable - otherwise it is silently stuck on the wrong value. + * An absent `defaultReimbursable` or `isReimbursable` means "reimbursable", matching the expense creation path + * and `getReimbursable`. + */ +function canShowReimbursableToggleForPolicy(policy: OnyxEntry, isReimbursable: boolean | undefined): boolean { + if (!policy?.disabledFields?.reimbursable) { + return true; + } + return (isReimbursable ?? true) !== (policy.defaultReimbursable ?? true); +} + +/** Return active policies where current user is an admin */ +function getActiveAdminWorkspaces(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { + const activePolicies = getActivePolicies(policies, currentUserLogin); + return activePolicies.filter((policy) => shouldShowPolicy(policy, getIsOffline(), currentUserLogin) && isPolicyAdmin(policy, currentUserLogin)); +} + +/** Return active policies where current user is an employee (of the role "user") */ +function getActiveEmployeeWorkspaces(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { + const activePolicies = getActivePolicies(policies, currentUserLogin); + return activePolicies.filter((policy) => shouldShowPolicy(policy, getIsOffline(), currentUserLogin) && isPolicyUser(policy, currentUserLogin)); +} + +/** + * Checks whether the current user has a policy with admin access + */ +function hasActiveAdminWorkspaces(currentUserLogin: string | undefined, policies?: OnyxCollection) { + return getActiveAdminWorkspaces(policies, currentUserLogin).length > 0; +} + +/** Whether the user can send invoice from the workspace */ +function canSendInvoiceFromWorkspace(policy: OnyxEntry): boolean { + return policy?.areInvoicesEnabled ?? false; +} + +/** Whether the user can submit per diem expense from the workspace */ +function canSubmitPerDiemExpenseFromWorkspace(policy: OnyxEntry): boolean { + const perDiemCustomUnit = getPerDiemCustomUnit(policy); + return isPaidGroupPolicy(policy) && !isEmptyObject(perDiemCustomUnit) && !!perDiemCustomUnit?.enabled; +} + +/** Whether the user can send invoice */ +function canSendInvoice(policies: OnyxCollection | null, currentUserLogin: string | undefined): boolean { + return getActiveAdminWorkspaces(policies, currentUserLogin).some((policy) => canSendInvoiceFromWorkspace(policy)); +} + +/** + * Sort the workspaces by their name, while keeping the selected one at the beginning. + * @param workspace1 Details of the first workspace to be compared. + * @param workspace2 Details of the second workspace to be compared. + * @param selectedWorkspaceID ID of the selected workspace which needs to be at the beginning. + */ +const sortWorkspacesBySelected = ( + workspace1: WorkspaceDetails, + workspace2: WorkspaceDetails, + selectedWorkspaceIDs: string[] | undefined, + localeCompare: LocaleContextProps['localeCompare'], +): number => { + if (workspace1.policyID && selectedWorkspaceIDs?.includes(workspace1?.policyID) && workspace2.policyID && selectedWorkspaceIDs?.includes(workspace2.policyID)) { + return localeCompare(workspace1.name?.toLowerCase() ?? '', workspace2.name?.toLowerCase() ?? ''); + } + if (workspace1.policyID && selectedWorkspaceIDs?.includes(workspace1?.policyID)) { + return -1; + } + if (workspace2.policyID && selectedWorkspaceIDs?.includes(workspace2.policyID)) { + return 1; + } + return localeCompare(workspace1.name?.toLowerCase() ?? '', workspace2.name?.toLowerCase() ?? ''); +}; + +function hasOnlyPersonalPolicies(policies: OnyxCollection) { + return !Object.values(policies ?? {}).some((policy) => policy && policy.type !== CONST.POLICY.TYPE.PERSONAL && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); +} + +function getUserFriendlyWorkspaceType(workspaceType: ValueOf, translate: LocalizedTranslate) { + switch (workspaceType) { + case CONST.POLICY.TYPE.CORPORATE: + return translate('workspace.type.control'); + case CONST.POLICY.TYPE.TEAM: + return translate('workspace.type.collect'); + case CONST.POLICY.TYPE.SUBMIT: + return translate('workspace.type.submit'); + default: + return translate('workspace.type.free'); + } +} + +/** + * Returns the plan type to pre-select when creating a workspace: Corporate when the user already belongs to a + * Control workspace, otherwise Team. + */ +function getDefaultWorkspacePlanType(policies: OnyxCollection | null): PolicyType { + const isMemberOfControlWorkspace = Object.values(policies ?? {}).some((policy) => policy?.type === CONST.POLICY.TYPE.CORPORATE); + return isMemberOfControlWorkspace ? CONST.POLICY.TYPE.CORPORATE : CONST.POLICY.TYPE.TEAM; +} + +function isPolicyAccessible(policy: OnyxEntry, currentUserLogin: string): boolean { + return ( + !isEmptyObject(policy) && + (Object.keys(policy).length !== 1 || isEmptyObject(policy.errors)) && + !!policy?.id && + policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && + !!getPolicyRole(policy, currentUserLogin) + ); +} + +function getGroupPaidPolicies(policies: OnyxCollection | null) { + if (isEmptyObject(policies)) { + return CONST.EMPTY_ARRAY; + } + return Object.values(policies).filter((policy) => isPaidGroupPolicy(policy) && !policy?.isJoinRequestPending && shouldShowPolicy(policy, false, undefined)); +} + +function hasAnyPaidPolicy(policies: OnyxCollection | null) { + return getGroupPaidPolicies(policies).length > 0; +} + +/** + * Whether the user can create a report on this workspace: a paid (Team/Corporate) or Submit workspace the user + * has a role on. Submit workspaces are free but still support report creation, so they belong here even though + * they're excluded from `getGroupPaidPolicies`. A policy stub (e.g. the `{id, name}` the backend sends for a + * preferred workspace the user is not a member of) has no type or role and is rejected. + */ +function canCreateReportOnPolicy(policy: OnyxEntry, currentUserLogin?: string): policy is Policy { + return ( + !!policy && + !policy.isJoinRequestPending && + (isPaidGroupPolicy(policy) || isSubmitPolicy(policy)) && + shouldShowPolicy(policy, false, currentUserLogin) && + !isTeachersUnitePolicyID(policy.id) + ); +} + +/** Returns the group workspaces where the user can create a report; the collection form of `canCreateReportOnPolicy`. */ +function getGroupPoliciesWhereReportCanBeCreated(policies: OnyxCollection | null, currentUserLogin?: string) { + if (isEmptyObject(policies)) { + return CONST.EMPTY_ARRAY; + } + return Object.values(policies).filter((policy): policy is Policy => canCreateReportOnPolicy(policy, currentUserLogin)); +} + +/** + * Resolves the default workspace for report creation: the active policy when it's one of the eligible + * workspaces, otherwise the only eligible workspace, else undefined. + */ +function getDefaultChatEnabledPolicy( + groupPoliciesWithChatEnabled: ReadonlyArray>, + activePolicy?: OnyxInputOrEntry | null, +): OnyxInputOrEntry | undefined { + // Only default to the active policy when it's actually eligible, so we never pick an ineligible policy + // (e.g. a personal or free workspace) over an eligible fallback. + if (activePolicy && isGroupPolicy(activePolicy) && groupPoliciesWithChatEnabled.some((policy) => policy?.id === activePolicy.id)) { + return activePolicy; + } + + if (groupPoliciesWithChatEnabled.length === 1) { + return groupPoliciesWithChatEnabled.at(0); + } + + return undefined; +} + +/** Scalar version for useOnyx selectors: an eligible-policy array makes useOnyx deep-compare every policy on each policy_ update */ +function getDefaultChatEnabledPolicySelection(policies: OnyxCollection | null, currentUserLogin: string | undefined, activePolicyID: string | undefined) { + const groupPoliciesWithChatEnabled = getGroupPoliciesWhereReportCanBeCreated(policies, currentUserLogin); + const activePolicy = activePolicyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`] : undefined; + + return { + defaultChatEnabledPolicyID: getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled, activePolicy)?.id, + hasChatEnabledPolicies: groupPoliciesWithChatEnabled.length > 0, + hasMultipleChatEnabledPolicies: groupPoliciesWithChatEnabled.length > 1, + }; +} + +function hasOtherControlWorkspaces(adminPolicies: Policy[] | undefined, currentPolicyID: string) { + const otherControlWorkspaces = adminPolicies?.filter((policy) => policy?.id !== currentPolicyID && isControlPolicy(policy)); + return (otherControlWorkspaces?.length ?? 0) > 0; +} + +/** + * Determines whether workspace deletion should be blocked for an Invoicify user. + * + * The function normalizes owner policies to a flat `Policy[]` regardless of whether + * they are provided as an `OnyxCollection` or a precomputed array. For Invoicify users, + * it ensures that at least one other controllable workspace exists after excluding + * policies with pending ADD or DELETE actions. + * + * The filtering is done lazily to avoid unnecessary work for non-Invoicify users. + * + * @param isSubscriptionTypeOfInvoicing - Whether the user is on an Invoicify subscription + * @param ownerPolicies - Owner policies as an Onyx collection or a normalized array + * @param currentPolicyID - Policy ID of the workspace being evaluated + * @param accountID - Account ID used to resolve owned paid policies when needed + * + */ +function shouldBlockWorkspaceDeletionForInvoicifyUser( + isSubscriptionTypeOfInvoicing: boolean, + ownerPolicies: OnyxCollection | Policy[] | undefined, + currentPolicyID: string | undefined, + accountID?: number, +) { + if (!isSubscriptionTypeOfInvoicing || !currentPolicyID) { + return false; + } + const normalizedOwnerPolicies: Policy[] = (() => { + if (!ownerPolicies) { + return []; + } + + if (Array.isArray(ownerPolicies)) { + return ownerPolicies; + } + + if (accountID) { + return getOwnedPaidPolicies(ownerPolicies, accountID); + } + + return Object.values(ownerPolicies).filter((policy): policy is Policy => !!policy); + })(); + + const ownerPoliciesWithoutPendingActions = (normalizedOwnerPolicies ?? []).filter( + (policy) => policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ); + + return !hasOtherControlWorkspaces(ownerPoliciesWithoutPendingActions, currentPolicyID); +} + +// If no policyID is provided, it indicates the workspace upgrade/downgrade URL +// is being accessed from the Subscriptions page without a specific policyID. +// In this case, check if the user is an admin on more than one policy. +// If the user is an admin for multiple policies, we can render the page as it contains a condition +// to navigate them to the Workspaces page when no policyID is provided, instead of showing the Upgrade/Downgrade button. +// If the user is not an admin for multiple policies, they are not allowed to perform this action, and the NotFoundPage is displayed. +function canModifyPlan(ownerPolicies: Policy[] | undefined, policy: OnyxEntry) { + if (!policy?.id) { + return (ownerPolicies?.length ?? 0) > 1; + } + + return !!policy && isPolicyAdmin(policy); +} + +function sortPoliciesByName(policies: Policy[], localeCompare: (a: string, b: string) => number): Policy[] { + // copy first: callers pass memoized selector output, sorting it in place makes the next deepEqual report a change + return [...policies].sort((a, b) => localeCompare(a.name || '', b.name || '')); +} + +export { + isPolicyFieldListEmpty, + canUnarchivePolicy, + getActivePolicies, + getActivePoliciesWithExpenseChat, + getActivePoliciesWithExpenseChatAndPerDiemEnabled, + getActivePoliciesWithExpenseChatAndTimeEnabled, + hasCompanyAddress, + hasOfficeWorkArrangement, + getPolicyIDOrDefault, + shouldShowPolicy, + createInvoiceConfigurationTextSelector, + getPoliciesForRoomCreation, + getOwnedPaidPolicies, + canCreateCategoryInSitu, + canShowReimbursableToggleForPolicy, + getActiveAdminWorkspaces, + getActiveEmployeeWorkspaces, + hasActiveAdminWorkspaces, + canSendInvoiceFromWorkspace, + canSubmitPerDiemExpenseFromWorkspace, + canSendInvoice, + sortWorkspacesBySelected, + hasOnlyPersonalPolicies, + getUserFriendlyWorkspaceType, + getDefaultWorkspacePlanType, + isPolicyAccessible, + getGroupPaidPolicies, + hasAnyPaidPolicy, + canCreateReportOnPolicy, + getGroupPoliciesWhereReportCanBeCreated, + getDefaultChatEnabledPolicy, + getDefaultChatEnabledPolicySelection, + hasOtherControlWorkspaces, + shouldBlockWorkspaceDeletionForInvoicifyUser, + canModifyPlan, + sortPoliciesByName, +}; diff --git a/src/libs/PolicyUtils/rules.ts b/src/libs/PolicyUtils/rules.ts new file mode 100644 index 000000000000..6bc7d617f519 --- /dev/null +++ b/src/libs/PolicyUtils/rules.ts @@ -0,0 +1,147 @@ +/** + * Custom categories, max expense limits, configured-rule checks, tag approver rules, and the rules document URL. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import {getApiRoot} from '@libs/ApiUtils'; +import {hasAnyCategoryRules} from '@libs/CategoryUtils'; + +import CONST from '@src/CONST'; +import type {Policy, PolicyCategories} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +/** + * Checks if a policy has any custom categories (categories not in the default list) + */ +function hasCustomCategories(policyCategories: OnyxEntry): boolean { + if (!policyCategories) { + return false; + } + + const defaultCategoryNames = new Set(Object.values(CONST.POLICY.DEFAULT_CATEGORIES)); + + return Object.values(policyCategories).some((category) => category && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !defaultCategoryNames.has(category.name)); +} + +/** + * Checks whether a policy max expense amount (e.g. `maxExpenseAmountNoReceipt`) is actually configured. + * + * `0` is a valid limit meaning "always required", so a falsy check would wrongly treat an explicit $0.00 as unset. + * Only `undefined` and `CONST.DISABLED_MAX_EXPENSE_VALUE` mean the amount is not set. + */ +function isMaxExpenseAmountSet(value: number | undefined): value is number { + return value !== undefined && value !== CONST.DISABLED_MAX_EXPENSE_VALUE; +} + +/** + * Checks if a policy has any rules configured (structured rules, individual expense limits, or prohibited expenses). + */ +function hasConfiguredRules(policy: OnyxEntry, policyCategories: PolicyCategories | null | undefined, hasExpenseDefaultRules: boolean): boolean { + if (!policy) { + return false; + } + + if (hasExpenseDefaultRules) { + return true; + } + + if (!!policy.customRules && policy.customRules.trim().length > 0) { + return true; + } + + const {rules: policyRules} = policy; + if (!!policyRules?.approvalRules && policyRules.approvalRules.length > 0) { + return true; + } + if (!!policyRules?.expenseRules && policyRules.expenseRules.length > 0) { + return true; + } + if (!!policy.maxExpenseAmount && policy.maxExpenseAmount !== CONST.DISABLED_MAX_EXPENSE_VALUE && policy.maxExpenseAmount !== CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT) { + return true; + } + if (!!policy.maxExpenseAge && policy.maxExpenseAge !== CONST.DISABLED_MAX_EXPENSE_VALUE && policy.maxExpenseAge !== CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE) { + return true; + } + if ( + !!policy.maxExpenseAmountNoReceipt && + policy.maxExpenseAmountNoReceipt !== CONST.DISABLED_MAX_EXPENSE_VALUE && + policy.maxExpenseAmountNoReceipt !== CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT + ) { + return true; + } + if ( + !!policy.maxExpenseAmountNoItemizedReceipt && + policy.maxExpenseAmountNoItemizedReceipt !== CONST.DISABLED_MAX_EXPENSE_VALUE && + policy.maxExpenseAmountNoItemizedReceipt !== CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT + ) { + return true; + } + + if (policy.defaultBillable) { + return true; + } + if (policy.defaultReimbursable === false) { + return true; + } + if (policy.eReceipts) { + return true; + } + if (policy.requireCompanyCardsEnabled) { + return true; + } + + const {prohibitedExpenses} = policy; + if ( + !!prohibitedExpenses && + Object.entries(CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES).some(([key, defaultValue]) => { + const value = prohibitedExpenses[key as keyof typeof CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES]; + return value !== undefined && value !== defaultValue; + }) + ) { + return true; + } + + return hasAnyCategoryRules(policyCategories ?? undefined); +} + +function getTagApproverRule(policy: OnyxEntry, tagName: string) { + if (!policy) { + return; + } + + const approvalRules = policy.rules?.approvalRules ?? []; + const approverRule = approvalRules.find((rule) => + rule.applyWhen.find(({condition, field, value}) => condition === CONST.POLICY.RULE_CONDITIONS.MATCHES && field === CONST.POLICY.FIELDS.TAG && value === tagName), + ); + + return approverRule; +} + +/** + * Builds a source URL for rendering a policy document PDF. + * Local blob/file URIs (from optimistic uploads) are returned directly. + * Remote URLs are routed through the authenticated GetPolicyRulesDocument streaming endpoint. + * The stored URL (which contains a unique timestamp per upload) is appended as a version + * parameter so the browser treats each replacement as a distinct resource. + */ +function getRulesDocumentSourceURL(rulesDocumentURL: string | undefined, policyID: string | undefined, encryptedAuthToken: string): string { + if (!rulesDocumentURL || !policyID) { + return ''; + } + + const isLocalFile = rulesDocumentURL.startsWith('blob:') || rulesDocumentURL.startsWith('file:'); + if (isLocalFile) { + return rulesDocumentURL; + } + + return addEncryptedAuthTokenToURL( + // Each PDF upload gets a unique S3 key, so rulesDocumentURL changes on every replacement. + // Encoding it as cacheBuster ensures the full streaming URL is also unique, preventing stale browser/pdfjs cache. + `${getApiRoot({shouldUseSecure: false})}api/GetPolicyRulesDocument?policyID=${policyID}&cacheBuster=${encodeURIComponent(rulesDocumentURL)}`, + encryptedAuthToken, + true, + ); +} + +export {hasCustomCategories, isMaxExpenseAmountSet, hasConfiguredRules, getTagApproverRule, getRulesDocumentSourceURL}; diff --git a/src/libs/PolicyUtils/tag.ts b/src/libs/PolicyUtils/tag.ts index 971c1c214afa..9489380a28b5 100644 --- a/src/libs/PolicyUtils/tag.ts +++ b/src/libs/PolicyUtils/tag.ts @@ -253,6 +253,16 @@ function getGLCodeFromPolicyTag(tag: {['GL Code']?: string | number} | undefined return glCode != null ? String(glCode).replaceAll('"', '') : ''; } +/** + * Whether at least one selectable tag on the workspace, in any tag list, has a non-blank GL code. Disabled and + * pending-deletion tags can't be picked in the tag picker, so their GL codes don't count. + */ +function hasAnyTagGLCode(policyTagLists: OnyxEntry): boolean { + return getTagLists(policyTagLists).some((tagList) => + Object.values(tagList.tags ?? {}).some((tag) => !!tag?.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!getGLCodeFromPolicyTag(tag).trim()), + ); +} + /** * Escape colon from tag name */ @@ -367,6 +377,7 @@ export { getLengthOfTag, getTagGLCode, getGLCodeFromPolicyTag, + hasAnyTagGLCode, escapeTagName, isDefaultTagName, getCountOfEnabledTagsOfList, diff --git a/src/libs/PolicyUtils/travel.ts b/src/libs/PolicyUtils/travel.ts new file mode 100644 index 000000000000..e8d805aa69e5 --- /dev/null +++ b/src/libs/PolicyUtils/travel.ts @@ -0,0 +1,58 @@ +/** + * Travel provisioning, accepted terms, and which travel step to show. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import CONST from '@src/CONST'; +import type {Policy, TravelSettings} from '@src/types/onyx'; +import type {WorkspaceTravelSettings} from '@src/types/onyx/TravelSettings'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {getAdminsPrivateEmailDomains} from './domains'; +import {getActivePolicies} from './policy'; +import {isPaidGroupPolicy} from './policyType'; + +type TravelStep = ValueOf; + +/** Whether a workspace has been provisioned with a Spotnana entity. */ +function isWorkspaceProvisionedForTravel(travelSettings?: WorkspaceTravelSettings): boolean { + return !!(travelSettings?.spotnanaCompanyID ?? travelSettings?.associatedTravelDomainAccountID); +} + +/** Whether travel is enabled on a workspace, meaning it can be used to book a trip. */ +function hasAcceptedTravelTerms(policy: OnyxEntry, accountTravelSettings: TravelSettings | undefined): boolean { + return !!(policy?.travelSettings?.hasAcceptedTerms ?? (accountTravelSettings?.hasAcceptedTerms && isWorkspaceProvisionedForTravel(policy?.travelSettings))); +} + +function isNonUSDPolicy(policy: OnyxEntry): boolean { + return !!policy?.outputCurrency && policy.outputCurrency !== CONST.CURRENCY.USD; +} + +/** + * Determines which travel step should be shown based on policy state + */ +function getTravelStep( + policy: OnyxEntry, + travelSettings: TravelSettings | undefined, + isTravelVerifiedBetaEnabled: boolean, + policies: OnyxCollection, + currentUserLogin: string | undefined, +): TravelStep { + const adminDomains = getAdminsPrivateEmailDomains(policy); + const activePolicies = getActivePolicies(policies, currentUserLogin); + const groupPaidPolicies = activePolicies.filter(isPaidGroupPolicy); + + if (adminDomains.length === 0 || groupPaidPolicies.length < 1 || !isPaidGroupPolicy(policy)) { + return CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL; + } + if (policy?.travelSettings?.hasAcceptedTerms) { + return CONST.TRAVEL.STEPS.BOOK_OR_MANAGE_YOUR_TRIP; + } + if (!isTravelVerifiedBetaEnabled && travelSettings?.lastTravelSignupRequestTime) { + return CONST.TRAVEL.STEPS.REVIEWING_REQUEST; + } + return CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL; +} + +export {isWorkspaceProvisionedForTravel, hasAcceptedTravelTerms, isNonUSDPolicy, getTravelStep}; diff --git a/src/libs/PolicyUtils/vendor.ts b/src/libs/PolicyUtils/vendor.ts index 2a77c5280549..08ed4c92764b 100644 --- a/src/libs/PolicyUtils/vendor.ts +++ b/src/libs/PolicyUtils/vendor.ts @@ -36,15 +36,12 @@ function isIntacctVendorMatchingActive(policy: OnyxEntry): boolean { * configuration gate — mirrors `Xero::hasVendorFeature` on the PHP side. The `isConfigured` check * matters because Integration-Server clears that flag during a Xero tenant switch while the old * tenant's `data.contacts` lingers until the next sync repopulates it; without the gate the - * Supplier picker would render stale contacts from the previous tenant and a user-pick during + * vendor picker would render stale contacts from the previous tenant and a user-pick during * that window would persist a now-invalid `comment.vendor.externalID` that flips inactive the * moment the new sync completes. * * This is the *eligibility* predicate used by `hasVendorFeature`, NOT the source predicate — on - * dual-connected workspaces QBO/Intacct precedence still applies in `getMatchingVendors`. Use - * `isXeroActiveMatchingSource` when the question is "is Xero the integration whose vendors are - * actually being shown to the user?" (e.g. for the Supplier/Vendor label flip in the expense row, - * picker, and modified-expense fragments). + * dual-connected workspaces QBO/Intacct precedence still applies in `getMatchingVendors`. */ function isXeroVendorMatchingActive(policy: OnyxEntry): boolean { return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]?.config?.isConfigured; @@ -83,18 +80,6 @@ function isCertiniaVendorMatchingActive(policy: OnyxEntry): boolean { return config?.isConfigured === true && config?.hasPSA !== true; } -/** - * True when Xero is the *active* vendor-matching source for the workspace — i.e. Xero is - * connected AND neither QBO nor Intacct is in a vendor-matching export mode. Mirrors the precedence - * in `getActiveVendorMatchingIntegration` (QBO → Intacct → Xero → Rillet → DualEntry → Campfire → Business Central → Certinia) so the UI labels, copy, and - * inactive-vendor guardrail stay bound to whichever integration's vendor list is actually being consulted. - * Without this scoping, a workspace with active QBO matching + a lingering Xero connection would render - * QBO vendors under the "Supplier" label. - */ -function isXeroActiveMatchingSource(policy: OnyxEntry): boolean { - return getActiveVendorMatchingIntegration(policy) === CONST.POLICY.CONNECTIONS.NAME.XERO; -} - /** * Vendor matching feature gate. Returns true when a supported accounting integration is connected * with a non-reimbursable export type that scopes the vendor field. Mirrors the per-integration @@ -271,19 +256,65 @@ function getMatchingVendors(policy: OnyxEntry): Vendor[] { /** * Sorts vendors alphabetically by name using the provided localeCompare. - * Uses vendor id as a stable tie-breaker when names match. + * Uses vendor id or externalID as a stable tie-breaker when names match. * Non-mutating: returns a new sorted array. */ -function sortVendors(vendors: TVendor[], localeCompare: LocaleContextProps['localeCompare']): TVendor[] { +function sortVendors(vendors: TVendor[], localeCompare: LocaleContextProps['localeCompare']): TVendor[] { return [...vendors].sort((a, b) => { const nameComparison = localeCompare(a.name ?? '', b.name ?? ''); if (nameComparison !== 0) { return nameComparison; } - return localeCompare(a.id, b.id); + const keyA = a.id ?? a.externalID; + const keyB = b.id ?? b.externalID; + if (!keyA || !keyB) { + return 0; + } + return localeCompare(keyA, keyB); }); } +/** + * Resolves the configured default vendor ID for the active integration that supports vendor enablement. + * Returns undefined if no default vendor is configured or if the integration does not have an active default fallback. + */ +function getDefaultVendorID(policy: OnyxEntry, origin?: ConnectionName): string | undefined { + const integrationOrigin = origin ?? getActiveVendorMatchingIntegration(policy); + if (!policy?.connections || !integrationOrigin) { + return undefined; + } + + if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.QBO) { + const qboConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.QBO]?.config; + if (qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD) { + return qboConfig.nonReimbursableCreditCardDefaultVendor ?? undefined; + } + } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT) { + const intacctConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]?.config; + return intacctConfig?.export?.nonReimbursableCreditCardChargeDefaultVendor ?? undefined; + } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.XERO) { + const xeroConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.XERO]?.config; + return xeroConfig?.defaultVendor ?? undefined; + } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.RILLET) { + const rilletConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.RILLET]?.config; + return rilletConfig?.export?.defaultVendorID ?? undefined; + } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.DUALENTRY) { + const dualentryConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]?.config; + return dualentryConfig?.export?.defaultVendorID ?? undefined; + } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE) { + const campfireConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]?.config; + return campfireConfig?.export?.defaultVendorID ?? undefined; + } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { + const businessCentralConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL]?.config; + return businessCentralConfig?.export?.defaultVendorID ?? undefined; + } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.CERTINIA) { + const certiniaConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.CERTINIA]?.config; + return certiniaConfig?.export?.vendorAccount ?? undefined; + } + + return undefined; +} + /** * True only when the active vendor-matching integration's vendor list has been written to Onyx — * including the loaded-but-empty case. Lets callers distinguish "vendor not in list" (the @@ -417,8 +448,8 @@ function getVendorEmptyState(policy: OnyxEntry, translate: LocaleContext }; case CONST.POLICY.CONNECTIONS.NAME.XERO: return { - title: translate('workspace.xero.noSuppliersFound'), - subtitle: translate('workspace.xero.noSuppliersFoundDescription'), + title: translate('workspace.xero.noVendorsFound'), + subtitle: translate('workspace.xero.noVendorsFoundDescription'), }; case CONST.POLICY.CONNECTIONS.NAME.RILLET: return { @@ -517,6 +548,7 @@ export { getActiveVendorMatchingIntegration, getMatchingVendorByID, getMatchingVendors, + getDefaultVendorID, sortVendors, getVendorEmptyState, getVendorRuleDisplayValue, @@ -529,7 +561,6 @@ export { isBusinessCentralVendorMatchingActive, isDualEntryVendorMatchingActive, isCertiniaVendorMatchingActive, - isXeroActiveMatchingSource, isXeroVendorMatchingActive, hasVendorFeature, hasVendorFeatureOnAnyPolicy, diff --git a/src/libs/PolicyUtils/workflows.ts b/src/libs/PolicyUtils/workflows.ts new file mode 100644 index 000000000000..c144e0bf5388 --- /dev/null +++ b/src/libs/PolicyUtils/workflows.ts @@ -0,0 +1,73 @@ +/** + * Auto-reporting (scheduled submit) frequency, Submit & Close, and Dynamic External Workflow. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import CONST from '@src/CONST'; +import type {OnyxInputOrEntry, Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +/** + * Checks if policy's scheduled submit / auto reporting frequency is "instant". + * Note: Free policies have "instant" submit always enabled. + */ +function isInstantSubmitEnabled(policy: OnyxInputOrEntry): boolean { + return policy?.autoReporting === true && policy?.autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; +} + +/** + * Checks if policy's scheduled submit / auto reporting frequency is not "instant". + */ +function isDelayedSubmissionEnabled(policy: OnyxInputOrEntry): boolean { + return policy?.autoReporting === true && policy?.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; +} + +/** + * This gets a "corrected" value for autoReportingFrequency. The purpose of this function is to encapsulate some logic around the "immediate" frequency. + * + * - "immediate" is actually not immediate. For that you want "instant". + * - (immediate && harvesting.enabled) === daily + * - (immediate && !harvesting.enabled) === manual + * + * Note that "daily" and "manual" only exist as options for the API, not in the database or Onyx. + */ +function getCorrectedAutoReportingFrequency(policy: OnyxInputOrEntry): ValueOf | undefined { + if (policy?.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE) { + return policy?.autoReportingFrequency; + } + + if (policy?.harvesting?.enabled) { + // This is actually not really "immediate". It's "daily". Surprise! + return CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE; + } + + // "manual" is really just "immediate" (aka "daily") with harvesting disabled + return CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL; +} + +/** + * Checks if policy's approval mode is "optional", a.k.a. "Submit & Close" + */ +function isSubmitAndClose(policy: OnyxInputOrEntry): boolean { + return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL; +} + +/** + * Checks if policy has Dynamic External Workflow enabled + */ +function hasDynamicExternalWorkflow(policy: OnyxEntry): boolean { + return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL; +} + +/** + * Checks if the approval workflow configuration must stay hidden because the workspace's Dynamic External Workflow has + * "Hide People Table Columns" set. The backend only returns `dynamicExternalWorkflowHidePeople` when it is `true`, so an + * absent flag means "show". The approval mode is checked too, so a stale flag can't hide workflows on a policy that no + * longer uses a Dynamic External Workflow. + */ +function shouldHideDynamicExternalWorkflowPeople(policy: OnyxEntry): boolean { + return hasDynamicExternalWorkflow(policy) && !!policy?.dynamicExternalWorkflowHidePeople; +} + +export {isInstantSubmitEnabled, isDelayedSubmissionEnabled, getCorrectedAutoReportingFrequency, isSubmitAndClose, hasDynamicExternalWorkflow, shouldHideDynamicExternalWorkflowPeople}; diff --git a/src/libs/PopoverMenuSections.ts b/src/libs/PopoverMenuSections.ts index 1baddf312db4..41d4ec38e1f4 100644 --- a/src/libs/PopoverMenuSections.ts +++ b/src/libs/PopoverMenuSections.ts @@ -23,7 +23,7 @@ const REPORT_MORE_MENU_SECTIONS = [ ], [REPORT_SECONDARY_ACTIONS.SPLIT, REPORT_SECONDARY_ACTIONS.MERGE, REPORT_SECONDARY_ACTIONS.DUPLICATE_EXPENSE, REPORT_SECONDARY_ACTIONS.MOVE_EXPENSE], [REPORT_SECONDARY_ACTIONS.DUPLICATE_REPORT, REPORT_SECONDARY_ACTIONS.CHANGE_WORKSPACE, REPORT_SECONDARY_ACTIONS.CHANGE_APPROVER], - [REPORT_SECONDARY_ACTIONS.EXPORT, REPORT_SECONDARY_ACTIONS.DOWNLOAD_PDF, REPORT_SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS, REPORT_SECONDARY_ACTIONS.PRINT], + [REPORT_SECONDARY_ACTIONS.EXPORT, REPORT_SECONDARY_ACTIONS.DOWNLOAD_PDF, REPORT_SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS, REPORT_SECONDARY_ACTIONS.SHARE, REPORT_SECONDARY_ACTIONS.PRINT], [REPORT_SECONDARY_ACTIONS.VIEW_DETAILS, REPORT_SECONDARY_ACTIONS.DELETE], ]; diff --git a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts index 8a18ea9ef288..2857ee0ca1d7 100644 --- a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts +++ b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts @@ -19,6 +19,10 @@ import type RegisterPrefetchOnAppStart from './types'; const NITRO_FETCH_TARGET = 'fetch'; const CONTENT_TYPE_HEADER = 'Content-Type'; +type PrefetchFormData = { + getParts: () => Array<{fieldName: string; string: string}>; +}; + function buildAuthenticateBody(credentials: Credentials): string { // NitroFetch token refresh runs outside the JS request pipeline, so we must serialize the // Authenticate body here instead of going through Request.post() and enhanceParameters(). @@ -47,6 +51,29 @@ function buildAuthenticateBody(credentials: Credentials): string { return body.toString(); } +function isPrefetchFormData(body: unknown): body is PrefetchFormData { + return typeof body === 'object' && body !== null && 'getParts' in body && typeof body.getParts === 'function'; +} + +/** + * The saved body can be replayed natively on the next app start, which may already be running a newer version after an update. + * Sending the old `appversion` there would get a 426 and show the Update Required screen, so it is omitted. + */ +function removeAppVersionFromPrefetchBody(body: RequestInit['body']): RequestInit['body'] { + if (!isPrefetchFormData(body)) { + return body; + } + + const prefetchBody = new FormData(); + for (const part of body.getParts()) { + if (part.fieldName === 'appversion') { + continue; + } + prefetchBody.append(part.fieldName, part.string); + } + return prefetchBody; +} + function registerPrefetchTokenRefresh(): void { const credentials = getCredentials(); @@ -80,7 +107,7 @@ const registerPrefetchOnAppStart: RegisterPrefetchOnAppStart = ({prefetchKey, fe } registerPrefetchTokenRefresh(); - prefetchOnAppStart(url, fetchParams).catch((error) => { + prefetchOnAppStart(url, {...fetchParams, body: removeAppVersionFromPrefetchBody(fetchParams.body)}).catch((error) => { Log.warn(`[NitroFetch] prefetchOnAppStart failed for ${command}`, {error, fetchParams, url}); }); }; diff --git a/src/libs/Pusher/EventType.ts b/src/libs/Pusher/EventType.ts index 50dad3dffcd0..e744f89eeb98 100644 --- a/src/libs/Pusher/EventType.ts +++ b/src/libs/Pusher/EventType.ts @@ -8,6 +8,7 @@ export default { USER_IS_LEAVING_ROOM: 'client-userIsLeavingRoom', USER_IS_TYPING: 'client-userIsTyping', MULTIPLE_EVENTS: 'multipleEvents', + AGENT_ZERO_PROCESSING_STATUS: 'agentZeroProcessingStatus', CONCIERGE_REASONING: 'conciergeReasoning', CONCIERGE_DRAFT_EVENTS: 'conciergeDraftEvents', CONCIERGE_DRAFT_STARTED: 'conciergeDraftStarted', diff --git a/src/libs/Pusher/types.ts b/src/libs/Pusher/types.ts index 028fbe3b4d9a..fb9fabde86dc 100644 --- a/src/libs/Pusher/types.ts +++ b/src/libs/Pusher/types.ts @@ -1,4 +1,5 @@ import type CONST from '@src/CONST'; +import type ONYXKEYS from '@src/ONYXKEYS'; import type {AnyOnyxUpdatesFromServer, ReportUserIsTyping} from '@src/types/onyx'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; @@ -29,6 +30,16 @@ type UserIsLeavingRoomEvent = Record & { userLogin?: string; }; +type AgentZeroProcessingStatusEvent = { + updates: Array<{ + onyxMethod: 'merge'; + key: `${typeof ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${string}`; + value: { + agentZeroProcessingRequestIndicator: Record; + }; + }>; +}; + type ConciergeReasoningEvent = { reasoning: string; agentZeroRequestID: string; @@ -68,6 +79,7 @@ type ConciergeDraftEventsEvent = { type PusherEventMap = { [TYPE.USER_IS_TYPING]: UserIsTypingEvent; [TYPE.USER_IS_LEAVING_ROOM]: UserIsLeavingRoomEvent; + [TYPE.AGENT_ZERO_PROCESSING_STATUS]: AgentZeroProcessingStatusEvent; [TYPE.CONCIERGE_REASONING]: ConciergeReasoningEvent; [TYPE.CONCIERGE_DRAFT_EVENTS]: ConciergeDraftEventsEvent; [TYPE.CONCIERGE_DRAFT_STARTED]: ConciergeDraftEvent; diff --git a/src/libs/PusherUtils.ts b/src/libs/PusherUtils.ts index d311abf03649..e89daaf488b3 100644 --- a/src/libs/PusherUtils.ts +++ b/src/libs/PusherUtils.ts @@ -1,9 +1,11 @@ import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; -import type {AnyOnyxUpdatesFromServer, OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer'; +import type {OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer'; import type {OnyxKey} from 'react-native-onyx'; +import type {EventData, PusherEventName} from './Pusher/types'; + import {reconnect} from './actions/Reconnect'; import Log from './Log'; import Pusher from './Pusher'; @@ -35,14 +37,14 @@ function triggerMultiEventHandler(eventType: string, data: /** * Abstraction around subscribing to private user channel events. Handles all logs and errors automatically. */ -function subscribeToPrivateUserChannelEvent(eventName: string, accountID: string, onEvent: (pushJSON: AnyOnyxUpdatesFromServer) => void) { +function subscribeToPrivateUserChannelEvent(eventName: EventName, accountID: string, onEvent: (pushJSON: EventData) => void) { const pusherChannelName = getUserChannelName(accountID); - function logPusherEvent(pushJSON: AnyOnyxUpdatesFromServer) { + function logPusherEvent(pushJSON: EventData) { Log.info(`[Report] Handled ${eventName} event sent by Pusher`, false, pushJSON); } - function onEventPush(pushJSON: AnyOnyxUpdatesFromServer) { + function onEventPush(pushJSON: EventData) { logPusherEvent(pushJSON); onEvent(pushJSON); } diff --git a/src/libs/Reauthentication.ts b/src/libs/Reauthentication.ts index 8d7afc5b2f4c..4684d4f1a6f9 100644 --- a/src/libs/Reauthentication.ts +++ b/src/libs/Reauthentication.ts @@ -38,6 +38,7 @@ type Parameters = { let isAuthenticatingWithShortLivedToken = false; let isSupportAuthTokenUsed = false; let isSupportSession = false; +let isDelegateTokenSession = false; type ReauthenticationResult = { wasSuccessful: boolean; @@ -59,6 +60,7 @@ Onyx.connectWithoutView({ callback: (value) => { isSupportAuthTokenUsed = !!value?.isSupportAuthTokenUsed; isSupportSession = value?.authTokenType === CONST.AUTH_TOKEN_TYPES.SUPPORT; + isDelegateTokenSession = value?.authTokenType === CONST.AUTH_TOKEN_TYPES.DELEGATE; Sentry.setUser({ id: value?.accountID, @@ -212,9 +214,12 @@ function reauthenticate(command = ''): Promise { return hasReadRequiredDataFromStorage().then(() => { // While connected as a delegate, ONYXKEYS.CREDENTIALS is wiped by clearOnyxForDelegateTransition; the original user's creds live in stashedCredentials. - const isDelegate = isConnectedAsDelegate({ - delegatedAccess: account?.delegatedAccess, - }); + // ACCOUNT can lose `delegate` once access is revoked while SESSION still holds the restricted token + const isDelegate = + isDelegateTokenSession || + isConnectedAsDelegate({ + delegatedAccess: account?.delegatedAccess, + }); const credentials = isDelegate ? stashedCredentials : getCredentials(); const {partnerName, partnerPassword} = getPartnerCredentials(credentials?.autoGeneratedLogin); diff --git a/src/libs/ReceiptUploadRetryHandler/buildReplaceReceiptRetryPayload.ts b/src/libs/ReceiptUploadRetryHandler/buildReplaceReceiptRetryPayload.ts index dece2995ddc5..324b9bf87e79 100644 --- a/src/libs/ReceiptUploadRetryHandler/buildReplaceReceiptRetryPayload.ts +++ b/src/libs/ReceiptUploadRetryHandler/buildReplaceReceiptRetryPayload.ts @@ -11,6 +11,7 @@ import type {ValueOf} from 'type-fest'; import type {ReceiptRetryContext} from './types'; +import {parseRetryParams} from './buildRetryPayload'; import isRetrySupported from './isRetrySupported'; type StoredReplaceReceiptRetryParams = Pick; @@ -21,17 +22,6 @@ function isReceiptState(value: unknown): value is ValueOf, currentUserAccountID: const login = getAllPersonalDetails()[participant.accountID]?.login; return login ? {...participant, login} : undefined; } +function parseRetryParams(retryParams: ReceiptError['retryParams']): unknown { + if (typeof retryParams !== 'string') { + return retryParams; + } + try { + return JSON.parse(retryParams); + } catch { + return undefined; + } +} function getMerchantForRetry(merchant: string | undefined): string { if (!merchant || merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT) { @@ -65,6 +75,32 @@ function getMerchantForRetry(merchant: string | undefined): string { return merchant; } +function buildRetryTransactionParams(context: ReceiptRetryContext, receiptFile: FileObject) { + const {transaction} = context; + const details = getTransactionDetails(transaction); + if (!transaction || !details) { + return undefined; + } + const receipt: Receipt = {...receiptFile, source: context.receiptError.source, state: CONST.IOU.RECEIPT_STATE.SCAN_READY}; + + return { + amount: details.amount, + currency: details.currency, + created: details.created, + merchant: getMerchantForRetry(details.merchant), + comment: details.comment, + category: details.category, + tag: details.tag, + taxCode: details.taxCode, + taxAmount: details.taxAmount, + billable: details.billable, + reimbursable: details.reimbursable, + attendees: transaction.comment?.attendees, + isFromGlobalCreate: getIsFromGlobalCreate(transaction), + receipt, + }; +} + /** Checks the action here too, not only in `retryReceiptUpload`, so the button is hidden instead of doing nothing. */ function canBuildRetryPayload(context: ReceiptRetryContext): boolean { const {transaction, iouReport, receiptError, currentUserPersonalDetails} = context; @@ -98,11 +134,10 @@ function buildRetryPayload(context: ReceiptRetryContext, receiptFile: FileObject return undefined; } - const details = getTransactionDetails(transaction); - if (!details) { + const transactionParams = buildRetryTransactionParams(context, receiptFile); + if (!transactionParams) { return undefined; } - const receipt: Receipt = {...receiptFile, source: context.receiptError.source, state: CONST.IOU.RECEIPT_STATE.SCAN_READY}; return { report: iouReport, @@ -112,23 +147,7 @@ function buildRetryPayload(context: ReceiptRetryContext, receiptFile: FileObject participant, }, policyParams, - transactionParams: { - amount: details.amount, - currency: details.currency, - created: details.created, - merchant: getMerchantForRetry(details.merchant), - comment: details.comment, - category: details.category, - tag: details.tag, - taxCode: details.taxCode, - taxAmount: details.taxAmount, - billable: details.billable, - reimbursable: details.reimbursable, - // Read from the transaction because `details` widens this to `string | Attendee[]`. - attendees: transaction.comment?.attendees, - isFromGlobalCreate: getIsFromGlobalCreate(transaction), - receipt, - }, + transactionParams, // Same ID, so if the first attempt reached Auth the retry gets `Transaction already created.` instead of a second expense. optimisticTransactionID: transaction.transactionID, optimisticIOUReportID: iouReport.reportID, @@ -162,4 +181,4 @@ function buildRetryPayload(context: ReceiptRetryContext, receiptFile: FileObject } export default buildRetryPayload; -export {canBuildRetryPayload}; +export {buildRetryTransactionParams, canBuildRetryPayload, isRetryableFlow, parseRetryParams, resolveParticipant}; diff --git a/src/libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload.ts b/src/libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload.ts new file mode 100644 index 000000000000..953f0fb89214 --- /dev/null +++ b/src/libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload.ts @@ -0,0 +1,96 @@ +/** Decides whether a failed `TrackExpense` can be retried and rebuilds the call from Onyx. */ +import {getAllReportActions, isCreatedAction} from '@libs/ReportActionsUtils'; +import {isSelfDM} from '@libs/ReportUtils'; + +import {getAllReports} from '@userActions/IOU'; +import type {CreateTrackExpenseParams} from '@userActions/IOU/types/CreateTrackExpenseParams'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReceiptError} from '@src/types/onyx/Transaction'; +import type {FileObject} from '@src/types/utils/Attachment'; + +import type {ReceiptRetryContext} from './types'; + +import {buildRetryTransactionParams, isRetryableFlow, parseRetryParams, resolveParticipant} from './buildRetryPayload'; + +function getRetryWhisperReportActionID(receiptError: ReceiptError): string | undefined { + const parsed = parseRetryParams(receiptError.retryParams); + if (!parsed || typeof parsed !== 'object' || !('actionableWhisperReportActionID' in parsed) || typeof parsed.actionableWhisperReportActionID !== 'string') { + return undefined; + } + return parsed.actionableWhisperReportActionID; +} + +function canBuildTrackExpenseRetryPayload(context: ReceiptRetryContext): boolean { + const {transaction, iouReport, receiptError, currentUserPersonalDetails} = context; + if (receiptError.action !== CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE || !isSelfDM(iouReport)) { + return false; + } + return isRetryableFlow(context) && !!iouReport?.reportID && !!transaction?.transactionID && !!resolveParticipant(iouReport, currentUserPersonalDetails.accountID); +} + +/** Rebuilds the `TrackExpense` call behind a failed receipt upload from the records the failure left in Onyx. */ +function buildTrackExpenseRetryPayload(context: ReceiptRetryContext, receiptFile: FileObject): CreateTrackExpenseParams | undefined { + const { + transaction, + iouReport, + iouActionID, + transactionThreadReportID, + policyParams, + rules, + conciergeReportID, + isSelfTourViewed, + isASAPSubmitBetaEnabled, + introSelected, + delegateAccountID, + getCurrencyDecimals, + currentUserPersonalDetails: currentUser, + } = context; + const participant = resolveParticipant(iouReport, currentUser.accountID); + if (!canBuildTrackExpenseRetryPayload(context) || !transaction || !iouReport || !participant) { + return undefined; + } + + const transactionParams = buildRetryTransactionParams(context, receiptFile); + if (!transactionParams) { + return undefined; + } + + return { + report: iouReport, + isDraftPolicy: false, + isDraftChatReport: false, + participantParams: { + payeeEmail: currentUser.login, + payeeAccountID: currentUser.accountID, + participant, + }, + policyParams, + transactionParams, + optimisticTransactionID: transaction.transactionID, + currentReportActionID: iouActionID, + existingTransactionThreadReportID: transactionThreadReportID, + existingTransactionThreadCreatedReportActionID: Object.values(getAllReportActions(transactionThreadReportID)).find((action) => isCreatedAction(action))?.reportActionID, + existingActionableWhisperReportActionID: getRetryWhisperReportActionID(context.receiptError), + existingTransaction: transaction, + isASAPSubmitBetaEnabled, + currentUser: { + accountID: currentUser.accountID, + email: currentUser.login ?? '', + }, + introSelected, + conciergeChat: conciergeReportID ? getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`] : undefined, + quickAction: undefined, + recentWaypoints: [], + isSelfTourViewed, + currentUserLocalCurrency: undefined, + delegateAccountID, + reportActionsList: undefined, + getCurrencyDecimals, + rules, + }; +} + +export default buildTrackExpenseRetryPayload; +export {canBuildTrackExpenseRetryPayload}; diff --git a/src/libs/ReceiptUploadRetryHandler/index.ts b/src/libs/ReceiptUploadRetryHandler/index.ts index e3bc6939f3d0..d1113692007e 100644 --- a/src/libs/ReceiptUploadRetryHandler/index.ts +++ b/src/libs/ReceiptUploadRetryHandler/index.ts @@ -2,7 +2,7 @@ import Log from '@libs/Log'; import {replaceReceipt} from '@userActions/IOU/Receipt'; -import {requestMoney} from '@userActions/IOU/TrackExpense'; +import {requestMoney, trackExpense} from '@userActions/IOU/TrackExpense'; import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -11,6 +11,7 @@ import type {ReceiptRetryContext, RetryOutcome} from './types'; import buildReplaceReceiptRetryPayload, {canBuildReplaceReceiptRetryPayload} from './buildReplaceReceiptRetryPayload'; import buildRetryPayload, {canBuildRetryPayload} from './buildRetryPayload'; +import buildTrackExpenseRetryPayload, {canBuildTrackExpenseRetryPayload} from './buildTrackExpenseRetryPayload'; import resolveReceiptFile from './resolveReceiptFile'; type RetryFlow = { @@ -41,6 +42,17 @@ const RETRY_FLOWS: Partial> = { return true; }, }, + [CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE]: { + canBuild: canBuildTrackExpenseRetryPayload, + dispatch: (context, receiptFile) => { + const payload = buildTrackExpenseRetryPayload(context, receiptFile); + if (!payload) { + return false; + } + trackExpense({...payload, isRetry: true, shouldPlaySound: false}); + return true; + }, + }, }; function getRetryFlow(context: ReceiptRetryContext): RetryFlow | undefined { diff --git a/src/libs/ReceiptUploadRetryHandler/types.ts b/src/libs/ReceiptUploadRetryHandler/types.ts index 4e93abee5b49..abdcd1dc38be 100644 --- a/src/libs/ReceiptUploadRetryHandler/types.ts +++ b/src/libs/ReceiptUploadRetryHandler/types.ts @@ -4,7 +4,7 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import type BasePolicyParams from '@userActions/IOU/types/BasePolicyParams'; -import type {Report, Rule, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {IntroSelected, Report, Rule, Transaction, TransactionViolations} from '@src/types/onyx'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -53,6 +53,8 @@ type ReceiptRetryContext = { transactionViolations: OnyxEntry; currentUserPersonalDetails: CurrentUserPersonalDetails; + + introSelected: OnyxEntry; }; type RetryOutcome = 'dispatched' | 'fileMissing' | 'payloadIncomplete' | 'unsupportedAction' | 'dispatchFailed'; diff --git a/src/libs/ReportActionComposeFocusManager.ts b/src/libs/ReportActionComposeFocusManager.ts index 89b48a80f6b4..479a4de97d6a 100644 --- a/src/libs/ReportActionComposeFocusManager.ts +++ b/src/libs/ReportActionComposeFocusManager.ts @@ -24,7 +24,7 @@ const editComposerRef = React.createRef(); /** * There can be 2 composers present at the same time. This ref is for the side panel. */ -const sidePanelComposerRef: RefObject | null> = React.createRef>(); +const sidePanelComposerRef: RefObject | ComposerRef | null> = React.createRef | ComposerRef>(); // There are two types of focus callbacks: priority and general // Priority callback would take priority if it existed diff --git a/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts b/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts index 94cdd16e3803..5facaa874734 100644 --- a/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts +++ b/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts @@ -1,4 +1,4 @@ -import type {AnyNode, Document, Element as DomElement} from 'domhandler'; +import type {AnyNode, Document} from 'domhandler'; import render from 'dom-serializer'; import {ElementType} from 'domelementtype'; @@ -78,17 +78,25 @@ function buildClippedNodes(doc: Document, anchor: Anchor): AnyNode[] { } if (isLeaf) { if (stopNode.type === ElementType.Text && anchor.textEndIdx !== undefined) { - const truncated = {...stopNode, data: stopNode.data.slice(0, anchor.textEndIdx)} as unknown as AnyNode; + const truncated = stopNode.cloneNode(false); + // dom-serializer uses the parent to preserve raw text escaping. + truncated.parent = stopNode.parent; + truncated.data = stopNode.data.slice(0, anchor.textEndIdx); return [...before, truncated]; } return [...before, stopNode]; } - const elem = stopNode as DomElement; - const innerChildren = clip(elem.children as AnyNode[], depth + 1); - const partialElem = {...elem, children: innerChildren} as unknown as AnyNode; + if (stopNode.type !== ElementType.Tag) { + return [...before, stopNode]; + } + const innerChildren = clip(stopNode.children, depth + 1); + const partialElem = stopNode.cloneNode(false); + // dom-serializer uses the parent to preserve HTML inside MathML and SVG. + partialElem.parent = stopNode.parent; + partialElem.children = innerChildren; return [...before, partialElem]; }; - return clip(doc.children as AnyNode[], 0); + return clip(doc.children, 0); } /** diff --git a/src/libs/ReportActionMessageUtils.ts b/src/libs/ReportActionMessageUtils.ts index 3316275afcf5..3dddbe6da1e5 100644 --- a/src/libs/ReportActionMessageUtils.ts +++ b/src/libs/ReportActionMessageUtils.ts @@ -37,7 +37,6 @@ function getReportActionHtml(reportAction: PartialReportAction): string { function getReportActionText(reportAction: PartialReportAction): string { const message = getReportActionMessage(reportAction); // Sometime html can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const text = stripFollowupListFromHtml(message?.html) || (message?.text ?? ''); return text ? Parser.htmlToText(text) : ''; } diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 6ad4b1aa7ec4..e47d9ce19b28 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -77,8 +77,8 @@ import {getOriginalMessage, getReportActionHtml, getReportActionMessage, getRepo import {isActionOfType, isDynamicExternalWorkflowApproveFailedAction, isModifiedExpenseAction, isMoneyRequestAction} from './ReportActionTypeGuards'; import StringUtils from './StringUtils'; import {getWorkArrangementLabel} from './WorkArrangementUtils'; +import {getUnitTranslationKey, getWorkspaceAddressStreetLines} from './WorkspaceDisplayUtils'; import {getReportFieldTypeTranslationKey} from './WorkspaceReportFieldUtils'; -import {getUnitTranslationKey, getWorkspaceAddressStreetLines} from './WorkspacesSettingsUtils'; type LastVisibleMessage = { lastMessageText: string; @@ -238,7 +238,9 @@ function isDeletedAction(reportAction: OnyxInputOrEntry, ): boolean { if (!reportAction) { return false; @@ -1398,7 +1401,7 @@ function shouldReportActionBeVisible( // The isNewDot/shouldShow are baked at write time and can be stale for actions created outside NewDot (e.g OldDot or a background job). // Not applied to REIMBURSED, which carries bank account details PAY doesn't replace. - if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED) && hasSiblingPayReportAction(reportAction, reportAction.reportID ?? reportID)) { + if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED) && hasSiblingPayReportAction(reportAction, reportAction.reportID ?? reportID, reportActions)) { return false; } } @@ -1436,6 +1439,7 @@ function isReportActionVisible( visibleReportActions?: VisibleReportActionsDerivedValue, // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 currentUserAccountID?: number, + reportActions?: OnyxEntry, ): boolean { if (!reportAction?.reportActionID) { return false; @@ -1447,18 +1451,18 @@ function isReportActionVisible( // from what's cached in visibleReportActions (which reflects persisted Onyx data). // We must recalculate visibility at runtime to ensure accuracy for these transient states. if (reportAction.pendingAction) { - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport, reportActions); } if (visibleReportActions && reportID) { const reportCache = visibleReportActions[reportID]; if (!reportCache) { - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport, reportActions); } const staticVisibility = reportCache[reportAction.reportActionID]; // If action is not in derived value cache, fall back to runtime calculation if (staticVisibility === undefined) { - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport, reportActions); } if (!staticVisibility) { return false; @@ -1468,7 +1472,7 @@ function isReportActionVisible( } return true; } - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport, reportActions); } /** @@ -1796,27 +1800,44 @@ function isOlderReportAction(a: ReportAction, b: ReportAction): boolean { } /** - * Returns the ID of the newest Concierge comment that can show the feedback prompt. + * Returns the ID of the author's newest comment that can show the feedback prompt. * The comment has to be in Onyx because the Concierge greeting and the streaming draft are built on the client and cannot hold a reaction. * * @param sortedVisibleReportActions - visible report actions sorted newest first * @param persistedReportActionIDs - IDs of the report actions stored in Onyx + * @param authorAccountID - Concierge or a custom agent */ -function getLatestConciergeFeedbackActionID(sortedVisibleReportActions: ReportAction[], persistedReportActionIDs: string[]): string | undefined { - const latestConciergeComment = sortedVisibleReportActions.find(isConciergeFeedbackCandidate); +function getLatestConciergeFeedbackActionID( + sortedVisibleReportActions: ReportAction[], + persistedReportActionIDs: string[], + authorAccountID: number = CONST.ACCOUNT_ID.CONCIERGE, +): string | undefined { + const latestComment = sortedVisibleReportActions.find((action) => action.actorAccountID === authorAccountID && isConciergeFeedbackCandidate(action)); - if (!latestConciergeComment || !persistedReportActionIDs.includes(latestConciergeComment.reportActionID)) { + if (!latestComment || !persistedReportActionIDs.includes(latestComment.reportActionID)) { return undefined; } - return latestConciergeComment.reportActionID; + return latestComment.reportActionID; +} + +/** + * Returns the IDs of the comments that show the feedback prompt: the newest comment of Concierge and the newest comment of each custom agent. + * + * @param sortedVisibleReportActions - visible report actions sorted newest first + * @param persistedReportActionIDs - IDs of the report actions stored in Onyx + * @param customAgentAccountIDs - accountIDs of the custom agents that wrote in the report + */ +function getFeedbackPromptActionIDs(sortedVisibleReportActions: ReportAction[], persistedReportActionIDs: string[], customAgentAccountIDs: number[]): string[] { + return [CONST.ACCOUNT_ID.CONCIERGE, ...customAgentAccountIDs] + .map((authorAccountID) => getLatestConciergeFeedbackActionID(sortedVisibleReportActions, persistedReportActionIDs, authorAccountID)) + .filter((reportActionID): reportActionID is string => !!reportActionID); } -/** Whether the comment can carry the inline feedback prompt, which needs a Concierge comment the server knows about */ +/** Whether the comment can carry the inline feedback prompt, which needs a comment the server knows about */ function isConciergeFeedbackCandidate(action: ReportAction): boolean { return ( isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT) && - action.actorAccountID === CONST.ACCOUNT_ID.CONCIERGE && !isDeletedAction(action) && !isWhisperAction(action) && // A failed comment does not exist on the server, so a reaction on it cannot be saved @@ -1825,19 +1846,19 @@ function isConciergeFeedbackCandidate(action: ReportAction): boolean { } /** - * Returns the ID of the newest Concierge comment in a report that can show the feedback prompt. + * Returns the ID of the author's newest comment in a report that can show the feedback prompt. * Reading the report's own actions is what lets a thread decide about the message it hangs off, which lives in the parent report. */ -function getLatestConciergeFeedbackActionIDFromReportActions(reportActions: OnyxEntry): string | undefined { - let latestConciergeComment: ReportAction | undefined; +function getLatestConciergeFeedbackActionIDFromReportActions(reportActions: OnyxEntry, authorAccountID: number = CONST.ACCOUNT_ID.CONCIERGE): string | undefined { + let latestComment: ReportAction | undefined; for (const action of Object.values(reportActions ?? {})) { - if (isConciergeFeedbackCandidate(action) && (!latestConciergeComment || action.created > latestConciergeComment.created)) { - latestConciergeComment = action; + if (action.actorAccountID === authorAccountID && isConciergeFeedbackCandidate(action) && (!latestComment || action.created > latestComment.created)) { + latestComment = action; } } - return latestConciergeComment?.reportActionID; + return latestComment?.reportActionID; } /** @@ -1980,24 +2001,13 @@ function isPaymentAttemptBoundary(action: OnyxEntry): boolean { } /** - * Finds MARKED_REIMBURSED actions with a PAY sibling in the same payment attempt. A historical PAY + * Finds the MARKED_REIMBURSED actions that have a PAY sibling in the same payment attempt. A historical PAY * must not hide a later manual reimbursement after that payment was canceled or failed. - * The write-time isNewDot/shouldShow flags can be stale for background jobs (Expensify/Expensify#636674). */ -function hasSiblingPayReportAction(reportAction: OnyxEntry, reportID: string | undefined): boolean { - if (!reportID || !reportAction?.reportActionID) { - return false; - } - - const reportActions = getAllReportActions(reportID); - // An action absent from loaded history cannot be matched. Avoid caching the empty fallback for unknown reports. - if (!reportActions[reportAction.reportActionID]) { - return false; - } - +function getPaySiblingActionIDs(reportActions: ReportActions): Set { const cachedSiblings = paySiblingCache.get(reportActions); if (cachedSiblings) { - return cachedSiblings.has(reportAction.reportActionID); + return cachedSiblings; } const paymentActions = getSortedReportActions( @@ -2027,7 +2037,33 @@ function hasSiblingPayReportAction(reportAction: OnyxEntry, report // Cache all siblings together, but invalidate on any history update: even a positive match can // change if an intervening cancellation arrives later. Weak keys release obsolete snapshots. paySiblingCache.set(reportActions, siblings); - return siblings.has(reportAction.reportActionID); + return siblings; +} + +/** + * Checks whether a MARKED_REIMBURSED action has a PAY sibling in the same payment attempt. + * The write-time isNewDot/shouldShow flags can be stale for background jobs (Expensify/Expensify#636674). + * `callerReportActions` (e.g. a Search snapshot) is used when the loaded Onyx history can't answer, e.g. the report's timeline isn't loaded. + */ +function hasSiblingPayReportAction(reportAction: OnyxEntry, reportID: string | undefined, callerReportActions?: OnyxEntry): boolean { + if (!reportID || !reportAction?.reportActionID) { + return false; + } + + const reportActionID = reportAction.reportActionID; + const loadedReportActions = getAllReportActions(reportID); + // An action absent from a collection cannot be matched. Avoid caching the empty fallback for unknown reports. + if (loadedReportActions[reportActionID]) { + if (getPaySiblingActionIDs(loadedReportActions).has(reportActionID)) { + return true; + } + // A loaded history that contains a PAY also holds the cancellations that separate payment attempts, so its answer is final. + if (Object.values(loadedReportActions).some((action) => isPayAction(action))) { + return false; + } + } + + return !!callerReportActions?.[reportActionID] && getPaySiblingActionIDs(callerReportActions).has(reportActionID); } function isTaskAction(reportAction: OnyxEntry): boolean { @@ -2378,7 +2414,6 @@ function getMessageOfOldDotLegacyAction(legacyAction: PartialReportAction) { } if (legacyAction?.message.length !== 0) { // Sometime html can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return legacyAction?.message?.map((element) => getTextFromHtml(element?.html || element?.text)).join('') ?? ''; } return ''; @@ -2396,6 +2431,10 @@ function getMessageOfOldDotReportAction(translate: LocalizedTranslate, oldDotAct switch (actionName) { case CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD: { const {oldValue, newValue, fieldName} = originalMessage; + if (!newValue) { + // A cleared field has no new value to show, so it must never fall through to `set {field} to ""` + return oldValue ? translate('report.actions.type.changeFieldRemoved', oldValue, fieldName) : translate('report.actions.type.changeFieldCleared', fieldName); + } if (!oldValue) { return translate('report.actions.type.changeFieldEmpty', newValue, fieldName); } @@ -2834,7 +2873,6 @@ function getReportActionMessageText(reportAction: OnyxEntry): stri return getReportActionText(reportAction); } // Sometime html can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return reportAction?.message?.reduce((acc, curr) => `${acc}${getTextFromHtml(curr?.html || curr?.text)}`, '') ?? ''; } @@ -3554,7 +3592,7 @@ function getWorkspaceCustomUnitRateImportedMessage(translate: LocalizedTranslate } function getWorkspaceCustomUnitRateAddedMessage(translate: LocalizedTranslate, dateFnsLocale: DateFnsLocale | undefined, action: ReportAction): string { - const {customUnitName, rateName, rate, currency, unit, startDate, endDate} = + const {customUnitName, rateName, rate, currency, unit, startDate, endDate, taxClaimablePercentage, taxRateName} = getOriginalMessage(action as ReportAction) ?? {}; if (rateName && rate !== undefined && currency && unit) { @@ -3562,17 +3600,20 @@ function getWorkspaceCustomUnitRateAddedMessage(translate: LocalizedTranslate, d const formattedRate = `${convertAmountToDisplayString(rate, currency)}/${unitLabel}`; const formattedStartDate = startDate ? DateUtils.formatToReadableString(startDate, dateFnsLocale) : undefined; const formattedEndDate = endDate ? DateUtils.formatToReadableString(endDate, dateFnsLocale) : undefined; + const taxReclaimableSuffix = + taxClaimablePercentage !== undefined && taxRateName + ? translate('workspaceActions.addCustomUnitRateTaxReclaimableSuffix', `${convertAmountToDisplayString(rate * taxClaimablePercentage, currency)}/${unitLabel}`, taxRateName) + : ''; + let addedRateMessage = translate('workspaceActions.addCustomUnitRateWithAmount', rateName, formattedRate); if (formattedStartDate && formattedEndDate) { - return translate('workspaceActions.addCustomUnitRateWithAmountAndDates', rateName, formattedRate, formattedStartDate, formattedEndDate); - } - if (formattedStartDate) { - return translate('workspaceActions.addCustomUnitRateWithAmountAndStartDate', rateName, formattedRate, formattedStartDate); - } - if (formattedEndDate) { - return translate('workspaceActions.addCustomUnitRateWithAmountAndEndDate', rateName, formattedRate, formattedEndDate); + addedRateMessage = translate('workspaceActions.addCustomUnitRateWithAmountAndDates', rateName, formattedRate, formattedStartDate, formattedEndDate); + } else if (formattedStartDate) { + addedRateMessage = translate('workspaceActions.addCustomUnitRateWithAmountAndStartDate', rateName, formattedRate, formattedStartDate); + } else if (formattedEndDate) { + addedRateMessage = translate('workspaceActions.addCustomUnitRateWithAmountAndEndDate', rateName, formattedRate, formattedEndDate); } - return translate('workspaceActions.addCustomUnitRateWithAmount', rateName, formattedRate); + return `${addedRateMessage}${taxReclaimableSuffix}`; } if (customUnitName && rateName) { @@ -3772,6 +3813,10 @@ function getWorkspaceUpdateFieldMessage(translate: LocalizedTranslate, action: R return translate('workspaceActions.preventSelfApproval', oldValue, newValue); } + if (updatedField && updatedField === CONST.POLICY.EXPENSE_REPORT_RULES.PREVENT_PAYOUT_NON_REIMBURSABLE_REPORTS && typeof oldValue === 'string' && typeof newValue === 'string') { + return translate('workspaceActions.preventPayoutNonReimbursableReports', oldValue, newValue); + } + if ( updatedField && updatedField === CONST.POLICY.EXPENSE_REPORT_RULES.MAX_EXPENSE_AGE && @@ -4016,7 +4061,7 @@ type ForwardsToOriginalMessage = { }; function formatMemberListWithAnd(members: Array<{email: string; name: string}>): string { - const emails = members.map((m) => Str.removeSMSDomain(m.email)); + const emails = members.map((m) => formatPhoneNumber(m.email)); if (emails.length === 1) { return emails.at(0) ?? ''; @@ -4698,7 +4743,7 @@ function getUpdatedCommuterExclusionsMessage(translate: LocalizedTranslate, repo if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_COMMUTER_EXCLUSIONS)) { return getReportActionText(reportAction); } - const {newValue, unit, oldValue, updatedField} = getOriginalMessage(reportAction) ?? {}; + const {newValue, unit, oldValue, oldUnit, updatedField} = getOriginalMessage(reportAction) ?? {}; if (updatedField === CONST.POLICY.COMMUTER_EXCLUSION_TYPE.METHOD) { let previousMethod: string; @@ -4727,7 +4772,8 @@ function getUpdatedCommuterExclusionsMessage(translate: LocalizedTranslate, repo return translate('workspaceActions.commuterExclusions.setFixedDistance', {formattedDistance}); } - const formattedOldDistance = getFormattedDistanceInUnits(Number(oldValue ?? 0), distanceUnit, translate); + const oldDistanceUnit = oldUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS || oldUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? oldUnit : distanceUnit; + const formattedOldDistance = getFormattedDistanceInUnits(Number(oldValue ?? 0), oldDistanceUnit, translate); return translate('workspaceActions.commuterExclusions.changedFixedDistance', {formattedOldDistance, formattedNewDistance: formattedDistance}); } @@ -4779,6 +4825,68 @@ function getUpdatedMemberWorkArrangementMessage(translate: LocalizedTranslate, r return translate('workspaceActions.updatedMemberWorkArrangement', {displayName, newArrangement, oldArrangement}); } +function getAddedOfficeLocationMessage(translate: LocalizedTranslate, reportAction: OnyxEntry): string { + if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION)) { + return getReportActionText(reportAction); + } + const {name = '', address} = getOriginalMessage(reportAction) ?? {}; + return translate('workspaceActions.officeLocation.added', {name, address: formatAddressToString(address)}); +} + +function getUpdatedOfficeLocationMessage(translate: LocalizedTranslate, reportAction: OnyxEntry): string { + if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION)) { + return getReportActionText(reportAction); + } + const {name = '', updatedField, newValue, oldValue, newAddress, oldAddress} = getOriginalMessage(reportAction) ?? {}; + + if (updatedField === CONST.POLICY.OFFICE_LOCATION_FIELD.NAME) { + return translate('workspaceActions.officeLocation.renamed', { + oldName: typeof oldValue === 'string' ? oldValue : '', + newName: typeof newValue === 'string' ? newValue : '', + }); + } + + if (updatedField === CONST.POLICY.OFFICE_LOCATION_FIELD.ADDRESS) { + return translate('workspaceActions.officeLocation.changedAddress', {name, newAddress: formatAddressToString(newAddress), oldAddress: formatAddressToString(oldAddress)}); + } + + if (updatedField === CONST.POLICY.OFFICE_LOCATION_FIELD.IS_DEFAULT) { + // Unsetting the primary office makes the company address the primary one + return newValue === false ? translate('workspaceActions.officeLocation.setCompanyAddressAsPrimary') : translate('workspaceActions.officeLocation.setAsPrimary', {name}); + } + + return getReportActionText(reportAction); +} + +function getDeletedOfficeLocationMessage(translate: LocalizedTranslate, reportAction: OnyxEntry): string { + if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION)) { + return getReportActionText(reportAction); + } + return translate('workspaceActions.officeLocation.removed', {name: getOriginalMessage(reportAction)?.name ?? ''}); +} + +function getUpdatedEmployeeOfficeLocationMessage(translate: LocalizedTranslate, reportAction: OnyxEntry): string { + if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION)) { + return getReportActionText(reportAction); + } + const {name, email, officeID, officeName, oldOfficeID, oldOfficeName} = getOriginalMessage(reportAction) ?? {}; + + // An office logged without a name is the primary office, which a member follows when none is assigned to them or + // the assigned one was removed + const describeOfficeLocation = (loggedOfficeID: string | undefined, loggedOfficeName: string | undefined) => { + if (loggedOfficeID === CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID) { + return translate('workspaceActions.officeLocation.companyAddress'); + } + return loggedOfficeName ? translate('workspaceActions.officeLocation.quotedName', {name: loggedOfficeName}) : translate('workspaceActions.officeLocation.primaryOffice'); + }; + + return translate('workspaceActions.officeLocation.changedMemberOfficeLocation', { + displayName: name ?? (email ? formatPhoneNumber(email) : ''), + officeLocation: describeOfficeLocation(officeID, officeName), + previousOfficeLocation: describeOfficeLocation(oldOfficeID, oldOfficeName), + }); +} + function getUpdatedProhibitedExpensesMessage(translate: LocalizedTranslate, reportAction: OnyxEntry) { const {newProhibitedExpenses, oldProhibitedExpenses} = getOriginalMessage(reportAction as ReportAction) ?? {}; @@ -5411,6 +5519,7 @@ export { getDismissedViolationMessageText, getFirstVisibleReportActionID, getLatestConciergeFeedbackActionID, + getFeedbackPromptActionIDs, getLatestConciergeFeedbackActionIDFromReportActions, getIOUActionForReportID, getIOUActionForTransactionID, @@ -5614,6 +5723,10 @@ export { getUpdatedCommuterExclusionsMessage, getPolicyWorkArrangementMessage, getUpdatedMemberWorkArrangementMessage, + getAddedOfficeLocationMessage, + getUpdatedOfficeLocationMessage, + getDeletedOfficeLocationMessage, + getUpdatedEmployeeOfficeLocationMessage, getWorkspaceTagUpdateMessage, getWorkspaceReportFieldUpdateMessage, getWorkspaceReportFieldDeleteMessage, diff --git a/src/libs/ReportAlternateTextUtils.ts b/src/libs/ReportAlternateTextUtils.ts index 8f01c40407f9..99fcd9360b18 100644 --- a/src/libs/ReportAlternateTextUtils.ts +++ b/src/libs/ReportAlternateTextUtils.ts @@ -5,6 +5,7 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type { + BankAccountList, Card, CardList, PersonalDetails, @@ -14,13 +15,13 @@ import type { Report, ReportAction, ReportActions, - ReportAttributesDerivedValue, ReportMetadata, Rule, Transaction, VisibleReportActionsDerivedValue, WorkspaceCardsList, } from '@src/types/onyx'; +import type {ReportNamesDerivedValue} from '@src/types/onyx/DerivedValues'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {Locale as DateFnsLocale} from 'date-fns'; @@ -48,6 +49,7 @@ import { getAddedBudgetMessage, getAddedCardFeedMessage, getAddedConnectionMessage, + getAddedOfficeLocationMessage, getApprovalLimitUpdateMessage, getAssignedCompanyCardMessage, getAutoCategorizeNewExpensesMessage, @@ -69,6 +71,7 @@ import { getDelegateSubmitMessage, getDeletedApprovalRuleMessage, getDeletedBudgetMessage, + getDeletedOfficeLocationMessage, getDynamicExternalWorkflowRoutedMessage, getExportIntegrationLastMessageText, getForeignCurrencyDefaultTaxUpdateMessage, @@ -133,9 +136,11 @@ import { getUpdatedCardFeedLiabilityMessage, getUpdatedCardFeedStatementPeriodMessage, getUpdatedDefaultTitleMessage, + getUpdatedEmployeeOfficeLocationMessage, getUpdatedIndividualBudgetNotificationMessage, getUpdatedManualApprovalThresholdMessage, getUpdatedMemberWorkArrangementMessage, + getUpdatedOfficeLocationMessage, getUpdatedOwnershipMessage, getUpdatedProhibitedExpensesMessage, getUpdatedReimbursementChoiceMessage, @@ -336,8 +341,7 @@ function getLastActorDisplayName(lastActorDetails: Partial | nu } return lastActorDetails.accountID !== currentUserAccountID - ? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - lastActorDetails.firstName || temporaryGetDisplayNameOrDefault({passedPersonalDetails: lastActorDetails, translate, formatPhoneNumber: formatPhoneNumberPhoneUtils}) + ? lastActorDetails.firstName || temporaryGetDisplayNameOrDefault({passedPersonalDetails: lastActorDetails, translate, formatPhoneNumber: formatPhoneNumberPhoneUtils}) : translate('common.you'); } @@ -405,7 +409,6 @@ function getExpenseReportPreviewText( const formattedAmount = convertToDisplayString(amount, currency); const description = linkedTransaction ? getDescription(linkedTransaction) : ''; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const comment = Parser.htmlToText(description || originalMessage?.comment || '').trim(); return formatReportLastMessageText(translate('iou.expenseAmount', formattedAmount, comment || undefined)); @@ -457,7 +460,6 @@ function getLastActorDisplayNameFromLastVisibleActions( const lastReportAction = lastAction ?? getLastVisibleActionIncludingTransactionThread(reportID, canUserPerformWrite, undefined, visibleReportActionsData, oneTransactionThreadReportID); if (lastReportAction) { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const lastActorAccountID = getReportActionActorAccountID(lastReportAction, undefined, undefined) || report?.lastActorAccountID; let actorDetails: Partial | null = lastActorAccountID ? (personalDetails?.[lastActorAccountID] ?? null) : null; @@ -503,6 +505,8 @@ function getLastMessageTextForReport({ oneTransactionThreadReportID, lastOriginalAction, rules, + bankAccountList, + formatPhoneNumber, }: { translate: LocalizedTranslate; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; @@ -518,18 +522,21 @@ function getLastMessageTextForReport({ reportMetadata?: OnyxEntry; visibleReportActionsDataParam?: VisibleReportActionsDerivedValue; lastAction?: OnyxEntry; - reportAttributesDerived?: ReportAttributesDerivedValue['reports']; + reportAttributesDerived?: ReportNamesDerivedValue; policyTags?: OnyxEntry; currentUserLogin?: string; conciergeReportID: string | undefined; isTrackIntentUser?: boolean; // TODO: Remove optional (?) once all callers pass sortedActions. Refactor issue: https://github.com/Expensify/App/issues/66381 sortedActions?: Record; - // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 - currentUserAccountID?: number; + currentUserAccountID: number; oneTransactionThreadReportID?: string; lastOriginalAction?: OnyxEntry; rules: OnyxCollection; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; + + /** The current user's bank accounts, used to name the account a report was paid with in the preview. */ + bankAccountList?: OnyxEntry; }): string { const reportID = report?.reportID; const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived); @@ -571,9 +578,7 @@ function getLastMessageTextForReport({ let lastMessageTextFromReport = ''; if (isArchivedNonExpenseReport(report, isReportArchived)) { - const archiveReason = - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - (isClosedAction(lastOriginalReportAction) && getOriginalMessage(lastOriginalReportAction)?.reason) || CONST.REPORT.ARCHIVE_REASON.DEFAULT; + const archiveReason = (isClosedAction(lastOriginalReportAction) && getOriginalMessage(lastOriginalReportAction)?.reason) || CONST.REPORT.ARCHIVE_REASON.DEFAULT; switch (archiveReason) { case CONST.REPORT.ARCHIVE_REASON.ACCOUNT_CLOSED: case CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY: @@ -595,11 +600,13 @@ function getLastMessageTextForReport({ } } else if (isMoneyRequestAction(lastReportAction)) { const properSchemaForMoneyRequestMessage = getReportPreviewMessage(translate, convertToDisplayString, { + personalDetails, reportOrID: report, iouReportAction: lastReportAction, shouldConsiderScanningReceiptOrPendingRoute: true, policy, isForListPreview: true, + bankAccountList, }); lastMessageTextFromReport = formatReportLastMessageText(Parser.htmlToText(properSchemaForMoneyRequestMessage)); } else if (isReportPreviewAction(lastReportAction)) { @@ -618,6 +625,7 @@ function getLastMessageTextForReport({ lastMessageTextFromReport = formatReportLastMessageText(reportName); } else { const reportPreviewMessage = getReportPreviewMessage(translate, convertToDisplayString, { + personalDetails, reportOrID: !isEmptyObject(iouReport) ? iouReport : null, iouReportAction: lastIOUMoneyReportAction ?? lastReportAction, shouldConsiderScanningReceiptOrPendingRoute: true, @@ -628,13 +636,14 @@ function getLastMessageTextForReport({ policy, isForListPreview: true, originalReportAction: lastReportAction, + bankAccountList, }); lastMessageTextFromReport = formatReportLastMessageText(Parser.htmlToText(reportPreviewMessage)); } } else if (isReimbursementQueuedAction(lastReportAction)) { lastMessageTextFromReport = getReimbursementQueuedActionMessage({reportAction: lastReportAction, translate, formatPhoneNumber: formatPhoneNumberPhoneUtils, report}); } else if (isReimbursementDeQueuedOrCanceledAction(lastReportAction)) { - lastMessageTextFromReport = getReimbursementDeQueuedOrCanceledActionMessage(translate, lastReportAction, report?.ownerAccountID, convertToDisplayString); + lastMessageTextFromReport = getReimbursementDeQueuedOrCanceledActionMessage(translate, formatPhoneNumber, lastReportAction, report?.ownerAccountID, convertToDisplayString); } else if (isDeletedParentAction(lastReportAction) && reportUtilsIsChatReport(report)) { lastMessageTextFromReport = getDeletedParentActionMessageForChatReport(lastReportAction); } else if (isPendingRemove(lastReportAction) && report?.reportID && isThreadParentMessage(lastReportAction, report.reportID)) { @@ -860,6 +869,18 @@ function getLastMessageTextForReport({ if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT)) { lastMessageTextFromReport = getUpdatedMemberWorkArrangementMessage(translate, lastReportAction); } + if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION)) { + lastMessageTextFromReport = getAddedOfficeLocationMessage(translate, lastReportAction); + } + if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION)) { + lastMessageTextFromReport = getUpdatedOfficeLocationMessage(translate, lastReportAction); + } + if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION)) { + lastMessageTextFromReport = getDeletedOfficeLocationMessage(translate, lastReportAction); + } + if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION)) { + lastMessageTextFromReport = getUpdatedEmployeeOfficeLocationMessage(translate, lastReportAction); + } if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT)) { lastMessageTextFromReport = getAutoReimbursementMessage(translate, lastReportAction, convertToDisplayString); } @@ -973,11 +994,13 @@ function getLastMessageTextForReport({ formatReportLastMessageText( Parser.htmlToText( getReportPreviewMessage(translate, convertToDisplayString, { + personalDetails, reportOrID: report, iouReportAction: lastReportAction, shouldConsiderScanningReceiptOrPendingRoute: true, policy, isForListPreview: true, + bankAccountList, }), ), ) || lastVisibleMessage?.lastMessageText; @@ -1180,7 +1203,7 @@ type GetReportAlternateTextParams = { isReportArchived: boolean | undefined; privateIsArchived: boolean; conciergeReportID: string | undefined; - reportAttributesDerived?: ReportAttributesDerivedValue['reports']; + reportAttributesDerived?: ReportNamesDerivedValue; visibleReportActionsData?: VisibleReportActionsDerivedValue; sortedActions?: Record; oneTransactionThreadReportID?: string; @@ -1195,6 +1218,9 @@ type GetReportAlternateTextParams = { convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; rules: OnyxCollection; + + /** The current user's bank accounts, used to name the account a report was paid with in the preview. */ + bankAccountList?: OnyxEntry; }; /** @@ -1233,6 +1259,7 @@ function getReportAlternateText({ convertToDisplayString, convertToDisplayStringWithoutCurrency, rules, + bankAccountList, }: GetReportAlternateTextParams): string | undefined { let alternateText: string | undefined; const isChatRoomReport = isChatRoom(report); @@ -1248,12 +1275,11 @@ function getReportAlternateText({ if (participantPersonalDetailListExcludeCurrentUserParam) { return participantPersonalDetailListExcludeCurrentUserParam; } - const participantAccountIDs = getParticipantsAccountIDsForDisplay(report); + const participantAccountIDs = getParticipantsAccountIDsForDisplay(report, undefined); const participantAccountIDsExcludeCurrentUser = excludeParticipantsForDisplay(participantAccountIDs, report.participants ?? {}, reportMetadata, {shouldExcludeCurrentUser: true}); return Object.values(getPersonalDetailsForAccountIDs(participantAccountIDsExcludeCurrentUser, personalDetails)); }; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const lastActorAccountID = getReportActionActorAccountID(lastAction, undefined, undefined) || report.lastActorAccountID; // If the last actor's details are not currently saved in Onyx Collection, // then try to get that from the last report action if that action is valid @@ -1272,7 +1298,6 @@ function getReportAlternateText({ const lastActorDisplayName = getLastActorDisplayName(lastActorDetails, currentUserAccountID, translate); // An empty precomputed text means "not computed yet", so `||` (not `??`) keeps the recompute fallback meaningful. const lastMessageTextFromReport = - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing lastMessageTextFromReportProp || getLastMessageTextForReport({ translate, @@ -1297,6 +1322,8 @@ function getReportAlternateText({ oneTransactionThreadReportID: resolvedOneTransactionThreadReportID, lastOriginalAction, rules, + bankAccountList, + formatPhoneNumber, }); const getLastActorDisplayNamePrefix = () => @@ -1678,7 +1705,6 @@ function getReportAlternateText({ derivedReportName: reportAttributesDerived?.[report.reportID]?.reportName, isReportArchived, formatPhoneNumber, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing }).messageText || translate('report.noActivityYet'), ); } diff --git a/src/libs/ReportFieldOptionsListUtils.ts b/src/libs/ReportFieldOptionsListUtils.ts index 610c1bdfe700..912280000169 100644 --- a/src/libs/ReportFieldOptionsListUtils.ts +++ b/src/libs/ReportFieldOptionsListUtils.ts @@ -30,6 +30,8 @@ function getReportFieldOptionsSection({ initiallySelectedValue, searchValue, translate, + shouldPinSelectedOption = true, + shouldShowSectionTitles = true, }: { options: string[]; recentlyUsedOptions: string[]; @@ -37,12 +39,18 @@ function getReportFieldOptionsSection({ initiallySelectedValue: string; searchValue: string; translate: LocalizedTranslate; + + /** Whether the selected value is lifted into its own section at the top of the list */ + shouldPinSelectedOption?: boolean; + + /** Whether the "Recent" and "All" section titles are rendered */ + shouldShowSectionTitles?: boolean; }) { const reportFieldOptionsSections = []; // The live selection drives the checkmark (isSelected)... const selectedKeySet = new Set(selectedOptions.map(({text, keyForList, name}) => text ?? keyForList ?? name ?? '').filter((o) => !!o)); // ...while the frozen initial selection drives the pinned "Selected" section, so selecting a value marks it without reordering the list until reopen. - const pinnedOptionKeys = initiallySelectedValue ? [initiallySelectedValue] : []; + const pinnedOptionKeys = shouldPinSelectedOption && initiallySelectedValue ? [initiallySelectedValue] : []; const pinnedKeySet = new Set(pinnedOptionKeys); if (searchValue) { @@ -81,7 +89,7 @@ function getReportFieldOptionsSection({ if (filteredRecentlyUsedOptions.length > 0) { reportFieldOptionsSections.push({ // "Recent" section - title: translate('common.recent'), + title: shouldShowSectionTitles ? translate('common.recent') : '', sectionIndex: 2, data: getReportFieldOptions(filteredRecentlyUsedOptions, selectedKeySet), }); @@ -89,7 +97,7 @@ function getReportFieldOptionsSection({ reportFieldOptionsSections.push({ // "All" section when items amount more than the threshold - title: translate('common.all'), + title: shouldShowSectionTitles ? translate('common.all') : '', sectionIndex: 3, data: getReportFieldOptions(filteredOptions, selectedKeySet), }); diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index e553172dabdc..baaf46de3ca4 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -37,12 +37,14 @@ import createDynamicRoute from './Navigation/helpers/dynamicRoutesUtils/createDy import {getCurrentUserEmail} from './Network/NetworkStore'; import Parser from './Parser'; import {getPersonalDetailsByID, temporaryGetDisplayNameOrDefault} from './PersonalDetailsUtils'; +import {getPolicyReimburserAccountID, wasPaidWithPolicyBankAccount} from './PolicyPaymentUtils'; import {getCleanedTagName, isPolicyAdmin, isPolicyFieldListEmpty} from './PolicyUtils'; import { getActionableCard3DSTransactionApprovalMessage, getActionableCardFraudAlertResolutionMessage, getAgentPromptUpdatedMessage, getAddedCardFeedMessage, + getAddedOfficeLocationMessage, getApprovalLimitUpdateMessage, getAssignedCompanyCardMessage, getAutoCategorizeNewExpensesMessage, @@ -63,6 +65,7 @@ import { getCustomTaxNameUpdateMessage, getDefaultApproverUpdateMessage, getDelegateSubmitMessage, + getDeletedOfficeLocationMessage, getDismissedViolationMessageText, getDynamicExternalWorkflowApproveFailedActionMessage, getDynamicExternalWorkflowSubmitFailedActionMessage, @@ -90,6 +93,7 @@ import { getPolicyChangeLogMaxExpenseAmountMessage, getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage, getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, + getPolicyWorkArrangementMessage, getReimburserUpdateMessage, getRemovedCardFeedMessage, getRenamedAction, @@ -108,6 +112,9 @@ import { getUpdatedAutoHarvestingMessage, getUpdatedCardFeedLiabilityMessage, getUpdatedCardFeedStatementPeriodMessage, + getUpdatedEmployeeOfficeLocationMessage, + getUpdatedMemberWorkArrangementMessage, + getUpdatedOfficeLocationMessage, getUpdatedProhibitedExpensesMessage, getWorkspaceAttendeeTrackingUpdateMessage, getWorkspaceCategoryUpdateMessage, @@ -649,7 +656,7 @@ function computeReportNameBasedOnReportAction({ } if (isReimbursementDeQueuedOrCanceledAction(parentReportAction)) { - return getReimbursementDeQueuedOrCanceledActionMessage(translate, parentReportAction, parentReport?.ownerAccountID, convertToDisplayString); + return getReimbursementDeQueuedOrCanceledActionMessage(translate, formatPhoneNumber, parentReportAction, parentReport?.ownerAccountID, convertToDisplayString); } if (isRejectedAction(parentReportAction)) { return translate('iou.rejectedThisReport'); @@ -702,6 +709,24 @@ function computeReportNameBasedOnReportAction({ if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_HARVESTING) { return getUpdatedAutoHarvestingMessage(translate, parentReportAction); } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_POLICY_WORK_ARRANGEMENT) { + return getPolicyWorkArrangementMessage(translate, parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT) { + return getUpdatedMemberWorkArrangementMessage(translate, parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION) { + return getAddedOfficeLocationMessage(translate, parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION) { + return getUpdatedOfficeLocationMessage(translate, parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION) { + return getDeletedOfficeLocationMessage(translate, parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION) { + return getUpdatedEmployeeOfficeLocationMessage(translate, parentReportAction); + } if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_REPORT_FIELD) { return getWorkspaceReportFieldAddMessage(translate, parentReportAction); } @@ -887,7 +912,14 @@ function computeReportNameBasedOnReportAction({ if (isMoneyRequestAction(parentReportAction)) { const originalMessage = getOriginalMessage(parentReportAction); - const last4Digits = originalMessage?.accountNumber?.slice(-4) ?? reportPolicy?.achAccount?.accountNumber?.slice(-4) ?? ''; + + // Prefer the account stored on the action: the payer is not always the workspace payer, so the policy's + // ACH account can belong to a different bank account than the one the report was actually paid with, and + // attributing it to a non-payer admin's payment shows a different account to every other viewer. + const policyAccountNumber = wasPaidWithPolicyBankAccount(reportPolicy, parentReportAction?.actorAccountID, getPolicyReimburserAccountID(reportPolicy, personalDetailsList)) + ? reportPolicy?.achAccount?.accountNumber + : undefined; + const last4Digits = (originalMessage?.accountNumber ?? policyAccountNumber)?.slice(-4) ?? ''; if (originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { if (originalMessage.paymentType === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { @@ -1312,7 +1344,7 @@ function computeReportName({ return chatThreadReportName; } - if (isClosedExpenseReportWithNoExpenses(report, transactions)) { + if (isClosedExpenseReportWithNoExpenses(report, (reportTransactions[report.reportID]?.length ?? 0) > 0)) { return translate('parentReportAction.deletedReport'); } diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 9a9565440887..1741e3122864 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -6,14 +6,12 @@ import type {ValueOf} from 'type-fest'; import { arePaymentsEnabled, - canMemberWrite, - getReimbursementChoice, + canAdminPayReport, getSubmitToAccountID, getValidConnectedIntegration, hasDynamicExternalWorkflow, hasIntegrationAutoSync, isArchivedOrPendingDeletePolicy, - isGroupPolicy, isPreferredExporter, isSubmitterApproveBlockedOnSubmitWorkspace, } from './PolicyUtils'; @@ -31,7 +29,7 @@ import { isInvoiceReport, isIOUReport, isOpenReport, - isPayBlockedByArchivedState, + isPayBlocked, isPayer, isProcessingReport, isReportApproved, @@ -136,18 +134,14 @@ function canPay( ) { const isExpense = isExpenseReport(report); - if (isPayBlockedByArchivedState(report, policy, isReportArchived)) { + if (isPayBlocked(report, policy, isReportArchived)) { return false; } const isReportPayer = isPayer(currentUserAccountID, currentUserLogin, report, bankAccountList, policy, false); // The admin pay path is for workspace expense reports. Personal policies should only offer Pay to the actual payer. - const canPayReport = - isReportPayer || - (isGroupPolicy(policy) && - getReimbursementChoice(policy) === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL && - canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)); + const canPayReport = isReportPayer || canAdminPayReport(policy, currentUserLogin); const isPaymentsEnabled = arePaymentsEnabled(policy); const isProcessing = isProcessingReport(report); const isApprovalEnabled = policy ? policy.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL : false; @@ -169,8 +163,8 @@ function canPay( isPaymentsEnabled && isReportFinished && (reimbursableSpend !== 0 || - hasSettledZeroReimbursableSpend(spendBreakdown, report, transactions) || - (nonReimbursableSpend !== 0 && hasOnlyNonReimbursableTransactions(report?.reportID, transactions))) + ((nonReimbursableSpend !== 0 || hasSettledZeroReimbursableSpend(spendBreakdown, report, transactions)) && + !(policy?.preventPayoutNonReimbursableReports && hasOnlyNonReimbursableTransactions(report?.reportID, transactions)))) ) { return !didExportFail; } diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 497cb552a050..efe6ed679952 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -7,9 +7,8 @@ import type {ValueOf} from 'type-fest'; import { arePaymentsEnabled as arePaymentsEnabledUtils, - canMemberWrite, + canAdminPayReport, getManagerAccountID, - getReimbursementChoice, getSubmitToAccountID, getValidConnectedIntegration, hasDynamicExternalWorkflow, @@ -51,7 +50,7 @@ import { isInvoiceReport as isInvoiceReportUtils, isIOUReport as isIOUReportUtils, isOpenReport as isOpenReportUtils, - isPayBlockedByArchivedState, + isPayBlocked, isPayer, isProcessingReport as isProcessingReportUtils, isReportApproved as isReportApprovedUtils, @@ -222,21 +221,14 @@ function isPrimaryPayAction({ }: IsPrimaryPayActionParams) { const isExpenseReport = isExpenseReportUtils(report); - if (isPayBlockedByArchivedState(report, policy, isArchivedReport(reportNameValuePairs) || !!isChatReportArchived)) { + if (isPayBlocked(report, policy, isArchivedReport(reportNameValuePairs) || !!isChatReportArchived)) { return false; } if (isExpenseReport && !isPaidGroupPolicy(policy)) { return false; } const isReportPayer = isPayer(currentUserAccountID, currentUserLogin, report, bankAccountList, policy, false); - - // The admin pay path is for workspace expense reports. Personal policies should only offer Pay to the actual payer. - const canPayReport = - isReportPayer || - (canNonPayerAdminPay && - isGroupPolicy(policy) && - getReimbursementChoice(policy) === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL && - canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)); + const canPayReport = isReportPayer || (!!canNonPayerAdminPay && canAdminPayReport(policy, currentUserLogin)); const arePaymentsEnabled = arePaymentsEnabledUtils(policy); const isReportApproved = isReportApprovedUtils({report}); const isReportClosed = isClosedReportUtils(report); @@ -258,8 +250,8 @@ function isPrimaryPayAction({ arePaymentsEnabled && isReportFinished && (reimbursableSpend !== 0 || - hasSettledZeroReimbursableSpend(spendBreakdown, report, reportTransactions) || - (nonReimbursableSpend !== 0 && hasOnlyNonReimbursableTransactions(report?.reportID, reportTransactions))) + ((nonReimbursableSpend !== 0 || hasSettledZeroReimbursableSpend(spendBreakdown, report, reportTransactions)) && + !(policy?.preventPayoutNonReimbursableReports && hasOnlyNonReimbursableTransactions(report?.reportID, reportTransactions)))) ) { return isSecondaryAction ?? !didExportFail; } @@ -526,6 +518,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY); } - // Mirror the pay gate (canIOUBePaid.canPay): whoever could mark the report paid can cancel it, no admin requirement. - const canCancelPayment = - isPayer || - (getReimbursementChoice(policy) === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL && - canMemberWrite(policy, currentUserEmail, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)); - - if (!canCancelPayment) { - return false; - } - const payActions = getReportPayActions(report.reportID); const latestPayAction = getLatestPayAction(payActions); const latestPaymentType = getPayActionPaymentType(latestPayAction); @@ -497,8 +490,21 @@ function isCancelPaymentAction( // An undetermined payment type (no pay action) is treated as paid elsewhere below so we still surface Cancel. const isPaidViaBankAccount = !!latestPaymentType && latestPaymentType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE; + // Auth computes cancellation permission per viewer. Shared bank accounts have different IDs for each user, + // and persisted pay actions may omit bankAccountID, so local account matching cannot determine access. + if (isPaidViaBankAccount) { + return !!report.canCancelReimbursement; + } + + // Mirror the pay gate for payments marked as paid elsewhere. + const canCancelPayment = isPayer || canAdminPayReport(policy, currentUserEmail); + + if (!canCancelPayment) { + return false; + } + // For reports marked as paid elsewhere or when we can't determine payment type, show cancel button - if (report.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED && !isPaidViaBankAccount) { + if (report.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED) { return true; } @@ -515,6 +521,10 @@ function isReceivedPaymentAction(report: Report, reportTransactions: Transaction return false; } + if (policy?.preventPayoutNonReimbursableReports && hasOnlyNonReimbursableTransactions(report.reportID, reportTransactions)) { + return false; + } + // Submit workspaces allow submitters to mark Outstanding (processing) reports as paid without requiring manager approval. if (isSubmitPolicy(policy) && isProcessingReportUtils(report)) { return true; @@ -732,12 +742,13 @@ function isDeleteAction( reportTransactions: Transaction[], currentUserAccountID: number, rules: OnyxCollection, + reportOwnerLogin: string | undefined, policy: OnyxEntry, cardList: OnyxEntry, reportActions?: ReportAction[], isReportLevelDelete = false, ): boolean { - return canDeleteMoneyRequestReport(report, reportTransactions, reportActions ?? [], currentUserAccountID, rules, policy, cardList, isReportLevelDelete); + return canDeleteMoneyRequestReport(report, reportTransactions, reportActions ?? [], currentUserAccountID, rules, reportOwnerLogin, policy, cardList, isReportLevelDelete); } function shouldShowEditSplitInDeleteAction( @@ -748,6 +759,7 @@ function shouldShowEditSplitInDeleteAction( currentUserAccountID: number, policy: OnyxEntry, rules: OnyxCollection, + reportOwnerLogin: string | undefined, cardList: OnyxEntry, ): boolean { if (reportTransactions.length !== 1) { @@ -762,7 +774,7 @@ function shouldShowEditSplitInDeleteAction( const isSelfDMSplit = isSelfDMReportUtils(report); return ( shouldRedirectDeleteToSplitExpenseEdit(reportTransaction, originalTransaction, isSelfDMSplit) && - isDeleteAction(report, reportTransactions, currentUserAccountID, rules, policy, cardList, reportActions) + isDeleteAction(report, reportTransactions, currentUserAccountID, rules, reportOwnerLogin, policy, cardList, reportActions) ); } @@ -819,7 +831,7 @@ function isReopenAction(report: Report, policy?: Policy): boolean { /** * Checks whether the supplied report supports merging transactions from it. */ -function isMergeAction(parentReport: Report, reportTransactions: Transaction[], rules: OnyxCollection, policy?: Policy): boolean { +function isMergeAction(parentReport: Report, reportTransactions: Transaction[], rules: OnyxCollection, parentReportOwnerLogin: string | undefined, policy?: Policy): boolean { // Do not show merge action if there are more than 2 transactions if (reportTransactions.length > 2) { return false; @@ -846,10 +858,18 @@ function isMergeAction(parentReport: Report, reportTransactions: Transaction[], const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; - return isMoneyRequestReportEligibleForMerge(parentReport.reportID, isAdmin, rules); + return isMoneyRequestReportEligibleForMerge(parentReport.reportID, isAdmin, rules, parentReportOwnerLogin); } -function isMergeActionForSelectedTransactions(transactions: Transaction[], reports: Report[], policies: Policy[], rules: OnyxCollection, currentUserAccountID?: number) { +function isMergeActionForSelectedTransactions( + transactions: Transaction[], + reports: Report[], + policies: Policy[], + rules: OnyxCollection, + // The full list is needed because the loop below resolves a different report owner on each pass. + personalDetails: OnyxEntry, + currentUserAccountID?: number, +) { if ([transactions, reports, policies].some((collection) => collection?.length > 2)) { return false; } @@ -895,7 +915,7 @@ function isMergeActionForSelectedTransactions(transactions: Transaction[], repor if (hasOnlyNonReimbursableTransactions(report.reportID) && isSubmitAndClose(policy) && isInstantSubmitEnabled(policy)) { return false; } - return isMoneyRequestReportEligibleForMerge(report, policy?.role === CONST.POLICY.ROLE.ADMIN, rules); + return isMoneyRequestReportEligibleForMerge(report, policy?.role === CONST.POLICY.ROLE.ADMIN, rules, getLoginByAccountID(report.ownerAccountID, personalDetails)); }); return allReportsEligible && (transactions.length === 1 || areTransactionsEligibleForMerge(transactions.at(0), transactions.at(1))); @@ -1144,12 +1164,12 @@ function getSecondaryReportActions({ if ( isSplitAction(report, reportTransactions, originalTransaction, currentUserLogin, currentUserAccountID, rules, submitterLogin, policy, parentReport) && - !shouldShowEditSplitInDeleteAction(report, reportTransactions, reportActions, originalTransaction, currentUserAccountID, policy, rules, cardList) + !shouldShowEditSplitInDeleteAction(report, reportTransactions, reportActions, originalTransaction, currentUserAccountID, policy, rules, submitterLogin, cardList) ) { options.push(CONST.REPORT.SECONDARY_ACTIONS.SPLIT); } - if (reportTransactions?.length === 1 && isMergeAction(report, reportTransactions, rules, policy)) { + if (reportTransactions?.length === 1 && isMergeAction(report, reportTransactions, rules, submitterLogin, policy)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.MERGE); } @@ -1171,6 +1191,8 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS); } + options.push(CONST.REPORT.SECONDARY_ACTIONS.SHARE); + if (!isOpenReportUtils(report)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.PRINT); } @@ -1207,7 +1229,7 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS); - if (isDeleteAction(report, reportTransactions, currentUserAccountID, rules, policy, cardList, reportActions ?? [], true)) { + if (isDeleteAction(report, reportTransactions, currentUserAccountID, rules, submitterLogin, policy, cardList, reportActions ?? [], true)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.DELETE); } @@ -1298,12 +1320,22 @@ function getSecondaryTransactionThreadActions({ if ( isSplitAction(parentReport, [reportTransaction], originalTransaction, currentUserLogin, currentUserAccountID, rules, parentReportOwnerLogin, policy, grandParentReport) && - !shouldShowEditSplitInDeleteAction(parentReport, [reportTransaction], reportAction ? [reportAction] : [], originalTransaction, currentUserAccountID, policy, rules, cardList) + !shouldShowEditSplitInDeleteAction( + parentReport, + [reportTransaction], + reportAction ? [reportAction] : [], + originalTransaction, + currentUserAccountID, + policy, + rules, + parentReportOwnerLogin, + cardList, + ) ) { options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SPLIT); } - if (isMergeAction(parentReport, [reportTransaction], rules, policy)) { + if (isMergeAction(parentReport, [reportTransaction], rules, parentReportOwnerLogin, policy)) { options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.MERGE); } @@ -1350,7 +1382,7 @@ function getSecondaryTransactionThreadActions({ options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.VIEW_DETAILS); - if (isDeleteAction(parentReport, [reportTransaction], currentUserAccountID, rules, policy, cardList, reportAction ? [reportAction] : [])) { + if (isDeleteAction(parentReport, [reportTransaction], currentUserAccountID, rules, parentReportOwnerLogin, policy, cardList, reportAction ? [reportAction] : [])) { options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.DELETE); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index c6094c6b8030..e461b572f6a3 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -97,20 +97,20 @@ import lodashMaxBy from 'lodash/maxBy'; import Onyx from 'react-native-onyx'; import type {GuidedSetupData, TaskForParameters} from './actions/Report'; -import type {OnboardingCompanySize, OnboardingMessage, OnboardingPurpose, OnboardingTaskLinks} from './actions/Welcome/OnboardingFlow'; import type {AddCommentOrAttachmentParams} from './API/parameters'; +import type {OnboardingCompanySize, OnboardingMessage, OnboardingPurpose, OnboardingTaskLinks} from './buildOnboardingMessages'; import type {FormulaContext, compute as computeFormula, computeWithMetadata as computeFormulaWithMetadata} from './Formula'; import type {MoneyRequestNavigatorParamList, ReportsSplitNavigatorParamList} from './Navigation/types'; import type {PersonalDetailsOnyxUpdate} from './PersonalDetailsUtils'; +import type {PolicyPaymentAttribution} from './PolicyPaymentUtils'; import type {LastVisibleMessage} from './ReportActionsUtils'; import type {AvatarSource} from './UserAvatarUtils'; import {isIntuitEnterpriseSuiteConnection} from './AccountingUtils'; import {getBankAccountFromID} from './actions/BankAccounts'; import hasCreditBankAccount from './actions/ReimbursementAccount/hasCreditBankAccount'; -import {isAnonymousUser as isAnonymousUserSession} from './actions/Session'; -import {getOnboardingMessages} from './actions/Welcome/OnboardingFlow'; import {convertAttendeesToArray, normalizeAttendees} from './AttendeeUtils'; +import {buildOnboardingMessagesWithoutManageTeam} from './buildOnboardingMessages'; import {isCardWithPotentialFraud} from './CardUtils'; import {getCategoryGLCode} from './CategoryUtils'; import {convertToDisplayStringEnLocale} from './CurrencyUtils'; @@ -129,7 +129,7 @@ import getReportURLForCurrentContext from './Navigation/helpers/getReportURLForC import getStateFromPath from './Navigation/helpers/getStateFromPath'; import {isFullScreenName} from './Navigation/helpers/isNavigatorName'; import isSearchTopmostFullScreenRoute from './Navigation/helpers/isSearchTopmostFullScreenRoute'; -import {linkingConfig} from './Navigation/linkingConfig'; +import linkingPrefixes from './Navigation/linkingConfig/prefixes'; import Navigation, {navigationRef} from './Navigation/Navigation'; import {getDBTimeWithSkew, getServerAnchoredDBTime} from './NetworkState'; import {rand64} from './NumberUtils'; @@ -138,6 +138,7 @@ import Parser from './Parser'; import {getParsedMessageWithShortMentions} from './ParsingUtils'; import {getAllPersonalDetailLogins, getAllPersonalDetails, getPersonalDetail} from './PersonalDetailsStore'; import {buildPersonalDetailsUpdate, getAccountIDsByLogins, getLoginByAccountID, getPersonalDetailByEmail, temporaryGetDisplayNameOrDefault} from './PersonalDetailsUtils'; +import {getPolicyReimburserAccountID} from './PolicyPaymentUtils'; import { arePaymentsEnabled, canMemberWrite as canMemberWritePolicyUtils, @@ -150,11 +151,13 @@ import { getPerDiemCustomUnit, getPolicyByCustomUnitID, getPolicyRole, + canAdminPayReport, getReimbursementChoice, getRuleApprovers, getSubmitToAccountID, getTagGLCode, hasDependentTags as hasDependentTagsPolicyUtils, + hasReadOnlyAllReportsAccess, hasDynamicExternalWorkflow, isArchivedOrPendingDeletePolicy, isExpensifyTeam, @@ -296,11 +299,13 @@ import { isManagedCardTransaction, isManualDistanceRequest as isManualDistanceRequestTransactionUtils, isMapDistanceRequest, + isOdometerDistanceRequest, isOnHold as isOnHoldTransactionUtils, isPayAtEndExpense, isPending, isPerDiemRequest, isReceiptBeingScanned, + isReceiptScanBlockingEdit, isScanning, isScanningTransaction, isScanRequest as isScanRequestTransactionUtils, @@ -453,6 +458,12 @@ type BuildOptimisticIOUReportActionParams = { linkedExpenseReportAction?: OnyxEntry; payAsBusiness?: boolean; bankAccountID?: number | undefined; + /** + * Masked number of the bank account the report was actually paid with. Stored on the action so every viewer sees + * the same account, since the payer's account is not present in every viewer's `bankAccountList` and the policy's + * ACH account can belong to a different bank account than the one used to pay. + */ + accountNumber?: string; isPersonalTrackingExpense?: boolean; reportActionID?: string; // TODO: delegateAccountIDParam will be made required when all callers pass the value (https://github.com/Expensify/App/issues/66425) @@ -1024,8 +1035,7 @@ type GetPolicyNameBaseParams = { reports?: Report[]; }; -// TODO: We'll remove the optional (?) modifier from the unavailableTranslation parameter once https://github.com/Expensify/App/issues/66430 is completed. -type GetPolicyNameParams = GetPolicyNameBaseParams & ({returnEmptyIfNotFound: true} | {returnEmptyIfNotFound?: false; unavailableTranslation?: string}); +type GetPolicyNameParams = GetPolicyNameBaseParams & ({returnEmptyIfNotFound: true} | {returnEmptyIfNotFound?: false; unavailableTranslation: string}); type GetReportStatusParams = { stateNum?: number; @@ -1246,7 +1256,6 @@ Onyx.connect({ }); let hiddenTranslation = ''; -let unavailableTranslation = ''; Onyx.connect({ key: ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, @@ -1255,7 +1264,6 @@ Onyx.connect({ return; } hiddenTranslation = translateLocal('common.hidden'); - unavailableTranslation = translateLocal('workspace.common.unavailable'); }, }); @@ -1413,10 +1421,13 @@ function getPolicyType(report: OnyxInputOrEntry, policies: OnyxCollectio /** * Get the policy name from a given report + * + * Callers pass their own `unavailableTranslation` (the "Unavailable workspace" copy) for the case where no policy resolves, unless they + * pass `returnEmptyIfNotFound`. Builders that persist their output pass the English literal, everything else passes `translate('workspace.common.unavailable')`. */ function getPolicyName(params: GetPolicyNameParams): string { const {report, policy, policies, reports} = params; - const noPolicyFound = params.returnEmptyIfNotFound ? '' : (params.unavailableTranslation ?? unavailableTranslation); + const noPolicyFound = params.returnEmptyIfNotFound ? '' : params.unavailableTranslation; const parentReport = report ? getRootParentReport({report, reports}) : undefined; // A caller that explicitly passes `policy` (e.g. a draft workspace's policy from getReportOption) can resolve a name @@ -1436,7 +1447,6 @@ function getPolicyName(params: GetPolicyNameParams): string { // Rooms send back the policy name with the reportSummary, // since they can also be accessed by people who aren't in the workspace - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const policyName = finalPolicy?.name || report?.policyName || report?.oldPolicyName || parentReport?.policyName || parentReport?.oldPolicyName || noPolicyFound; return policyName; @@ -1836,7 +1846,7 @@ function getBankAccountRoute(report: OnyxEntry, areInvoicesEnabled: bool /** * Check if personal detail of accountID is empty or optimistic data - * TODO: Remove this function once callers pass personal details explicitly (https://github.com/Expensify/App/issues/66413) + * TODO: Remove this function in Phase B, once callers pass personal details explicitly. See https://github.com/Expensify/App/issues/66413. */ function isOptimisticPersonalDetail(accountID: number): boolean { const personalDetail = getPersonalDetail(accountID); @@ -1955,7 +1965,6 @@ function getReportNotificationPreference(report: OnyxEntry, currentUserA const participant = accountID ? report?.participants?.[accountID] : undefined; // Empty notification preference should return `hidden` - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return participant?.notificationPreference || CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; } @@ -2084,6 +2093,26 @@ function isOpenOrProcessingReport(report: OnyxEntry): boolean { return isOpenReport(report) || isProcessingReport(report); } +// Instant submit shows Fix until the first approval. A forward leaves the report submitted, so that still counts as approved. +// Every other workspace only shows Fix on a draft. +function isReportEligibleForViolationFix(report: OnyxEntry, policy: OnyxEntry, hasBeenForwardedSinceLastSubmit?: boolean): boolean { + if (isInstantSubmitEnabled(policy)) { + return isOpenOrProcessingReport(report) && !(hasBeenForwardedSinceLastSubmit ?? hasReportBeenForwardedSinceLastSubmit(report)); + } + return isOpenReport(report); +} + +// Violations the submitter can no longer fix once a report is submitted. Inbox and Home both use this list. +function getSubmitterUnfixableViolationNames(report: OnyxEntry): {excludedViolations: ViolationName[]; excludedNotices: ViolationName[]} { + if (!isProcessingReport(report)) { + return {excludedViolations: [], excludedNotices: []}; + } + return { + excludedViolations: [CONST.VIOLATIONS.COMPANY_CARD_REQUIRED], + excludedNotices: [CONST.VIOLATIONS.MODIFIED_AMOUNT], + }; +} + /** * Checks if a report is in an open/unsubmitted state where its transactions can be deleted. * Returns true for: @@ -2128,8 +2157,10 @@ function isAwaitingFirstLevelApproval(report: OnyxEntry, rules: OnyxColl return false; } - // TODO: Callers are threaded in PRs 4a through 4d. Remove this fallback in PR 28 once none of them pass undefined. See https://github.com/Expensify/App/issues/66413. - const resolvedOwnerLogin = reportOwnerLogin ?? getLoginByAccountID(report.ownerAccountID, getAllPersonalDetails()); + // An optimistic personal detail carries an empty login, which would otherwise reach the approval lookup and match no employee. + const providedOwnerLogin = reportOwnerLogin === '' ? undefined : reportOwnerLogin; + // TODO: Callers are threaded in Parts 5 through 13. Remove this fallback in Part 13 once none of them pass undefined. See https://github.com/Expensify/App/issues/66413. + const resolvedOwnerLogin = providedOwnerLogin ?? getLoginByAccountID(report.ownerAccountID, getAllPersonalDetails()); const submitsToAccountID = getSubmitToAccountID(policy, report, resolvedOwnerLogin, rules); return isProcessingReport(report) && submitsToAccountID === report.managerID && !hasReportBeenForwardedSinceLastSubmit(report); @@ -2546,13 +2577,13 @@ function hasExpensifyGuidesEmails(accountIDs: number[], guideAccountIDs: GuideAc return accountIDs.some((accountID) => guideAccountIDs.includes(accountID)); } - // TODO: Remove fallback once all callers pass guideAccountIDs (https://github.com/Expensify/App/issues/66413) + // TODO: Remove this fallback in Phase B, once all callers pass guideAccountIDs. See https://github.com/Expensify/App/issues/66413. return accountIDs.some((accountID) => Str.extractEmailDomain(getPersonalDetail(accountID)?.login ?? '') === CONST.EMAIL.GUIDES_DOMAIN); } -function getMostRecentlyVisitedReport(reports: Array>, lastVisitTimes: Record): OnyxEntry { +function getMostRecentlyVisitedReport(reports: Array>, lastVisitTimes: Record, isAnonymousUser: boolean): OnyxEntry { const filteredReports = reports.filter((report) => { - if (!report?.isPinned && isHiddenForCurrentUser(report) && !(isPublicRoom(report) && isAnonymousUserSession())) { + if (!report?.isPinned && isHiddenForCurrentUser(report) && !(isPublicRoom(report) && isAnonymousUser)) { return false; } return !!report?.reportID && !!(lastVisitTimes?.[report.reportID] ?? report?.lastReadTime); @@ -2579,6 +2610,7 @@ function toLastAccessedReport(report: OnyxEntry): LastAccessedReport | u function findLastAccessedReport( ignoreDomainRooms: boolean, guideAccountIDs: GuideAccountIDsDerivedValue | undefined, + isAnonymousUser: boolean, openOnAdminRoom = false, excludeReportID?: string, reportNameValuePairs?: OnyxCollection, @@ -2610,7 +2642,7 @@ function findLastAccessedReport( // and it prompts the user to use the Concierge chat instead. return !isSystemChat(report) && !isArchivedReport(reportNameValuePairsCollection?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`]); }; - const isVisible = (report: OnyxEntry) => !!report?.isPinned || !isHiddenForCurrentUser(report) || (isPublicRoom(report) && isAnonymousUserSession()); + const isVisible = (report: OnyxEntry) => !!report?.isPinned || !isHiddenForCurrentUser(report) || (isPublicRoom(report) && isAnonymousUser); // Any visited report outranks every unvisited one, so pick the newest eligible visit instead of scanning all reports. let newestVisitTime = ''; @@ -2642,20 +2674,13 @@ function findLastAccessedReport( } return toLastAccessedReport(lodashMaxBy(reportsValues, (a) => a?.lastReadTime ?? '')); } - return toLastAccessedReport(getMostRecentlyVisitedReport(reportsValues, allReportLastVisitTimes)); -} - -/** - * Whether the provided report has expenses - */ -function hasExpenses(reportID?: string, transactions?: OnyxCollection): boolean { - return !!Object.values(transactions ?? deprecatedAllTransactions ?? {}).find((transaction) => transaction?.reportID === reportID); + return toLastAccessedReport(getMostRecentlyVisitedReport(reportsValues, allReportLastVisitTimes, isAnonymousUser)); } /** * Whether the provided report is a closed expense report with no expenses */ -function isClosedExpenseReportWithNoExpenses(report: OnyxEntry, transactions?: OnyxCollection): boolean { +function isClosedExpenseReportWithNoExpenses(report: OnyxEntry, hasLinkedTransactions: boolean): boolean { if (!report?.statusNum || report.statusNum !== CONST.REPORT.STATUS_NUM.CLOSED || !isExpenseReport(report)) { return false; } @@ -2675,7 +2700,7 @@ function isClosedExpenseReportWithNoExpenses(report: OnyxEntry, transact return false; } - return !hasExpenses(report.reportID, transactions); + return !hasLinkedTransactions; } /** @@ -2879,6 +2904,17 @@ function isMoneyRequestReport(reportOrID: OnyxInputOrEntry | string, rep return isIOUReport(report) || isExpenseReport(report); } +/** + * Checks if a thread member is the sender or receiver of the thread's parent IOU report. + * They keep access through the IOU report, so removing them from the thread doesn't stick. + */ +function isThreadMemberProtectedByParentReport(parentReport: OnyxInputOrEntry, accountID: number | undefined): boolean { + if (!parentReport || accountID === undefined || !isIOUReport(parentReport)) { + return false; + } + return accountID === parentReport.ownerAccountID || accountID === parentReport.managerID; +} + /** * Checks if a report aligns its action list to the top (transaction threads, money request / invoice reports). */ @@ -2928,15 +2964,21 @@ function isPayAtEndExpenseReport(report: OnyxEntry, transactions: Transa * Checks if a report is a transaction thread associated with a report that has only one transaction */ // TODO: isOffline will be required eventually. Refactor issue: https://github.com/Expensify/App/issues/66407 -function isOneTransactionThread(report: OnyxEntry, parentReport: OnyxEntry, threadParentReportAction: OnyxEntry, isOffline?: boolean) { +function isOneTransactionThread( + report: OnyxEntry, + parentReport: OnyxEntry, + threadParentReportAction: OnyxEntry, + isOffline?: boolean, + parentReportActions?: OnyxEntry | ReportAction[], +) { if (!report || !parentReport) { return false; } - const parentReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport?.reportID}`] ?? ([] as ReportAction[]); + const resolvedParentReportActions = parentReportActions ?? allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport.reportID}`]; const chatReport = deprecatedAllReports?.[`${ONYXKEYS.COLLECTION.REPORT}${parentReport?.chatReportID}`]; - const transactionThreadReportID = getOneTransactionThreadReportID(parentReport, chatReport, parentReportActions, isOffline); + const transactionThreadReportID = getOneTransactionThreadReportID(parentReport, chatReport, resolvedParentReportActions, isOffline); return report?.reportID === transactionThreadReportID && !isSentMoneyReportAction(threadParentReportAction); } @@ -3108,7 +3150,7 @@ function canAddOrDeleteTransactions(moneyRequestReport: OnyxEntry, rules } if (isProcessingReport(moneyRequestReport) && isExpenseReport(moneyRequestReport)) { - // TODO: Pass reportOwnerLogin in PR 4b, once canAddOrDeleteTransactions takes it first. + // TODO: Pass reportOwnerLogin in Part 13, once canAddOrDeleteTransactions takes it first. // isAwaitingFirstLevelApproval falls back to the personal details store until then. See https://github.com/Expensify/App/issues/66413. return isAwaitingFirstLevelApproval(moneyRequestReport, rules, undefined); } @@ -3174,7 +3216,13 @@ function canDeleteTransaction(moneyRequestReport: OnyxEntry, rules: Onyx * * @returns True if the report is eligible for merging transactions, false otherwise */ -function isMoneyRequestReportEligibleForMerge(reportOrReportID: Report | string, isAdmin: boolean, rules: OnyxCollection, currentUserAccountID?: number): boolean { +function isMoneyRequestReportEligibleForMerge( + reportOrReportID: Report | string, + isAdmin: boolean, + rules: OnyxCollection, + reportOwnerLogin: string | undefined, + currentUserAccountID?: number, +): boolean { const report = typeof reportOrReportID === 'string' ? getReportOrDraftReport(reportOrReportID) : reportOrReportID; if (!isMoneyRequestReport(report) || isIOUReport(report)) { @@ -3189,9 +3237,7 @@ function isMoneyRequestReportEligibleForMerge(reportOrReportID: Report | string, } if (isSubmitter) { - // TODO: Pass reportOwnerLogin in PR 4b, once isMoneyRequestReportEligibleForMerge takes it first. - // isAwaitingFirstLevelApproval falls back to the personal details store until then. See https://github.com/Expensify/App/issues/66413. - return isOpenReport(report) || isAwaitingFirstLevelApproval(report, rules, undefined); + return isOpenReport(report) || isAwaitingFirstLevelApproval(report, rules, reportOwnerLogin); } return isManager && isExpenseReport(report) && isProcessingReport(report); @@ -3225,7 +3271,7 @@ function canSubmitAndIsAwaitingForCurrentUser( !hasAutoRejectedTransactionsForManager && canSubmitReport( iouReport, - // TODO: Callers are threaded in PRs 4c and 23. Remove this fallback in PR 28 once none of them pass undefined. See https://github.com/Expensify/App/issues/66413. + // TODO: Callers are threaded in Parts 8 and 14. Remove this fallback in Part 14 once none of them pass undefined. See https://github.com/Expensify/App/issues/66413. iouReportOwnerLogin ?? getLoginByAccountID(iouReport?.ownerAccountID, getAllPersonalDetails()), policy, transactions, @@ -3306,7 +3352,7 @@ function canIOUBePaid( ) { const iouSettled = isSettled(iouReport); - if (isEmptyObject(iouReport)) { + if (isEmptyObject(iouReport) || iouReport.isWaitingForCancelledReimbursement) { return false; } @@ -3332,11 +3378,7 @@ function canIOUBePaid( const isReportPayer = isPayer(currentUserAccountID, currentUserLogin, iouReport, bankAccountList, policy, onlyShowPayElsewhere); // The admin pay path is for workspace expense reports. Personal policies should only offer Pay to the actual payer. - const canPay = - isReportPayer || - (isGroupPolicyPolicyUtils(policy) && - getReimbursementChoice(policy) === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL && - canMemberWritePolicyUtils(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)); + const canPay = isReportPayer || canAdminPayReport(policy, currentUserLogin); const spendBreakdown = getMoneyRequestSpendBreakdown(iouReport); const {reimbursableSpend, nonReimbursableSpend} = spendBreakdown; @@ -3350,9 +3392,12 @@ function canIOUBePaid( const isReportFinished = (isApproved || isClosed) && !iouReport?.isWaitingOnBankAccount; const isIOU = isIOUReport(iouReport); const canShowMarkedAsPaidForNegativeAmount = onlyShowPayElsewhere && reimbursableSpend < 0; - // Nothing is left to reimburse, e.g. the expenses cancel out, so the report can only be marked as paid to close it out - const canShowMarkedAsPaidForZeroReimbursableSpend = onlyShowPayElsewhere && hasSettledZeroReimbursableSpend(spendBreakdown, iouReport, transactions); - const isOnlyNonReimbursablePayElsewhere = onlyShowPayElsewhere && nonReimbursableSpend !== 0 && hasOnlyNonReimbursableTransactions(iouReport?.reportID, transactions); + + // Nothing is left to reimburse, e.g. the expenses cancel out or not reimbursable, so the report can only be marked as paid to close it out + const canShowMarkedAsPaidForZeroReimbursableSpend = + onlyShowPayElsewhere && + (nonReimbursableSpend !== 0 || hasSettledZeroReimbursableSpend(spendBreakdown, iouReport, transactions)) && + !(policy?.preventPayoutNonReimbursableReports && hasOnlyNonReimbursableTransactions(iouReport?.reportID, transactions)); if (isIOU && canPay && !iouSettled && reimbursableSpend > 0) { return true; @@ -3368,7 +3413,7 @@ function canIOUBePaid( canPay && isReportFinished && !iouSettled && - (reimbursableSpend > 0 || canShowMarkedAsPaidForNegativeAmount || canShowMarkedAsPaidForZeroReimbursableSpend || isOnlyNonReimbursablePayElsewhere) && + (reimbursableSpend > 0 || canShowMarkedAsPaidForNegativeAmount || canShowMarkedAsPaidForZeroReimbursableSpend) && !isPayBlockedByArchivedState(iouReport, policy, isChatReportArchived) && !isAutoReimbursable && !isReportPayAtEnd && @@ -3466,7 +3511,7 @@ function getBadgeFromIOUReport( allViolations, currentUserLogin, currentUserAccountID, - // TODO: Pass iouReportOwnerLogin in PR 23. canSubmitAndIsAwaitingForCurrentUser falls back to the personal details store until then. See https://github.com/Expensify/App/issues/66413. + // TODO: Pass iouReportOwnerLogin in Part 14. canSubmitAndIsAwaitingForCurrentUser falls back to the personal details store until then. See https://github.com/Expensify/App/issues/66413. undefined, iouReportActions, ); @@ -3619,7 +3664,7 @@ function hasOutstandingChildRequest( allTransactionViolations, currentUserEmailParam, currentUserAccountIDParam, - // TODO: Resolve the owner login per iouReport in PR 4d. This loop visits a different iouReport on every iteration, so a single login param on + // TODO: Resolve the owner login per iouReport in Part 8. This loop visits a different iouReport on every iteration, so a single login param on // hasOutstandingChildRequest cannot serve it and a precomputed map of accountID to login is needed instead. // canSubmitAndIsAwaitingForCurrentUser falls back to the personal details store until then. See https://github.com/Expensify/App/issues/66413. undefined, @@ -3670,6 +3715,7 @@ function canDeleteMoneyRequestReport( reportActions: ReportAction[], currentUserAccountID: number, rules: OnyxCollection, + reportOwnerLogin: string | undefined, policy: OnyxEntry, cardList: OnyxEntry, isReportLevelDelete = false, @@ -3721,9 +3767,7 @@ function canDeleteMoneyRequestReport( } if (isExpenseReport(report)) { - // TODO: Pass reportOwnerLogin in PR 4a, once canDeleteMoneyRequestReport takes it first. - // isAwaitingFirstLevelApproval falls back to the personal details store until then. See https://github.com/Expensify/App/issues/66413. - if (!isOpenReport(report) && !(isProcessingReport(report) && isAwaitingFirstLevelApproval(report, rules, undefined))) { + if (!isOpenReport(report) && !(isProcessingReport(report) && isAwaitingFirstLevelApproval(report, rules, reportOwnerLogin))) { return false; } @@ -3750,6 +3794,8 @@ function canDeleteReportAction( childReportActions: OnyxCollection, currentUserAccountID: number, rules: OnyxCollection, + // The full list is needed because the report being checked is resolved below, from the narrowed reportID. + personalDetails: OnyxEntry, cardList: OnyxEntry, ): boolean { const report = getReportOrDraftReport(reportID); @@ -3771,6 +3817,8 @@ function canDeleteReportAction( if (isActionOwner) { if (!isEmptyObject(report) && (isMoneyRequestReport(report) || isInvoiceReport(report))) { + // TODO: Pass reportOwnerLogin in Part 12, once canDeleteTransaction takes it first. + // isAwaitingFirstLevelApproval falls back to the personal details store until then. See https://github.com/Expensify/App/issues/66413. return canDeleteTransaction(report, rules) && canCardTransactionBeDeleted; } if (isTrackExpenseAction(reportAction)) { @@ -3787,6 +3835,7 @@ function canDeleteReportAction( Object.values(childReportActions ?? {}).filter((action): action is ReportAction => !!action), currentUserAccountID, rules, + getLoginByAccountID(report.ownerAccountID, personalDetails), policy, cardList, true, @@ -3971,7 +4020,6 @@ function getWorkspaceIcon(report: OnyxInputOrEntry, translate: Localized const iconFromCache = workSpaceIconsCache.get(cacheKey); const reportPolicy = policy ?? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; const policyAvatarURL = reportPolicy ? reportPolicy?.avatarURL : report?.policyAvatar; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const policyExpenseChatAvatarSource = policyAvatarURL || getDefaultWorkspaceAvatar(workspaceName); const isSameAvatarURL = iconFromCache?.icon?.source === policyExpenseChatAvatarSource; @@ -4147,14 +4195,13 @@ function excludeParticipantsForDisplay( function getParticipantsAccountIDsForDisplay( report: OnyxEntry, + reportMetadata: OnyxEntry, shouldExcludeHidden = false, shouldExcludeDeleted = false, shouldForceExcludeCurrentUser = false, - reportMetadataParam?: OnyxEntry, personalDetails: OnyxEntry = getAllPersonalDetails(), ): number[] { const reportParticipants = report?.participants ?? {}; - const reportMetadata = reportMetadataParam ?? getReportMetadata(report?.reportID); let participantsEntries = Object.entries(reportParticipants); // We should not show participants that have an optimistic entry with the same login in the personal details @@ -4191,7 +4238,7 @@ function getParticipantsAccountIDsForDisplay( function getParticipantsList(report: Report, personalDetails: OnyxEntry, isRoomMembersList = false, reportMetadata: OnyxEntry = undefined): number[] { const shouldExcludeHiddenParticipants = !isGroupChat(report) && !isInvoiceReport(report) && !isMoneyRequestReport(report) && !isMoneyRequest(report) && !isPolicyExpenseChat(report); - const chatParticipants = getParticipantsAccountIDsForDisplay(report, isRoomMembersList || shouldExcludeHiddenParticipants, false, false, reportMetadata, personalDetails); + const chatParticipants = getParticipantsAccountIDsForDisplay(report, reportMetadata, isRoomMembersList || shouldExcludeHiddenParticipants, false, false, personalDetails); return chatParticipants.filter((accountID) => { const details = personalDetails?.[accountID]; @@ -4498,7 +4545,6 @@ function getIconsForGroupChat( return []; } const groupChatIcon = { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing source: report.avatarUrl || getDefaultGroupAvatar(report.reportID), id: -1, type: CONST.ICON_TYPE_AVATAR, @@ -4695,7 +4741,6 @@ function getDisplayNamesWithTooltips( hiddenTranslation: hiddenText, youTranslation: youText, }) || - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing user?.login || ''; const avatar = user && 'avatar' in user ? user.avatar : undefined; @@ -4791,6 +4836,7 @@ function getReimbursementQueuedActionMessage({ */ function getReimbursementDeQueuedOrCanceledActionMessage( translate: LocalizedTranslate, + formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], reportAction: OnyxEntry>, reportOwnerAccountID: number | undefined, convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'], @@ -4806,7 +4852,7 @@ function getReimbursementDeQueuedOrCanceledActionMessage( getDisplayNameForParticipant({ accountID: reportOwnerAccountID, shouldUseShortForm: true, - formatPhoneNumber: formatPhoneNumberPhoneUtils, + formatPhoneNumber, hiddenTranslation: translate('common.hidden'), }) ?? ''; return translate('iou.canceledRequest', formattedAmount, submitterDisplayName); @@ -5025,7 +5071,7 @@ type GetReasonAndReportActionThatRequiresAttentionParams = { allReportActions?: OnyxCollection; reports?: OnyxCollection; policies?: OnyxCollection; - reportMetadata?: OnyxEntry; + reportMetadata: OnyxEntry; cardList?: OnyxEntry; }; @@ -5085,7 +5131,9 @@ function getReasonAndReportActionThatRequiresAttention({ }; } - const optionReportMetadata = reportMetadataParam ?? allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${optionOrReport.reportID}`]; + // TODO: Remove this fallback once every caller threads the metadata, so this stops reading the module-level Onyx.connect. See https://github.com/Expensify/App/issues/66421 + const reportMetadata = reportMetadataParam ?? allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${optionOrReport.reportID}`]; + // Prefer the policies collection callers already have on hand (e.g. reportAttributes.ts's own OnyxDerived // dependency) over the deprecated allPolicies module cache, which is populated by its own independently-timed // Onyx.connect and can still be stale/missing a policy that's already present in the caller's own snapshot. @@ -5099,7 +5147,7 @@ function getReasonAndReportActionThatRequiresAttention({ const {reportAction: iouReportActionToApproveOrPay, actionBadge} = getIOUReportActionWithBadge( optionOrReport, policy, - optionReportMetadata, + reportMetadata, invoiceReceiverPolicy, currentUserLogin, currentUserAccountID, @@ -5137,7 +5185,7 @@ function getReasonAndReportActionThatRequiresAttention({ optionOrReport, undefined, policy, - optionReportMetadata, + reportMetadata, invoiceReceiverPolicy, currentUserLogin, currentUserAccountID, @@ -5241,7 +5289,16 @@ function requiresAttentionFromCurrentUser( parentReportAction?: OnyxEntry, isReportArchived = false, ) { - return !!getReasonAndReportActionThatRequiresAttention({optionOrReport, currentUserLogin, currentUserAccountID, transactionViolations, parentReportAction, isReportArchived}); + return !!getReasonAndReportActionThatRequiresAttention({ + optionOrReport, + currentUserLogin, + currentUserAccountID, + transactionViolations, + parentReportAction, + isReportArchived, + // TODO: Pass the real reportMetadata in the next PR. See https://github.com/Expensify/App/issues/66421 + reportMetadata: undefined, + }); } /** @@ -5469,7 +5526,7 @@ function isReportFieldDisabled(report: OnyxEntry, reportField: OnyxEntry const isTitleField = isReportFieldOfTypeTitle(reportField); const isAdmin = isPolicyAdmin(policy); const isApproved = isReportApproved({report}); - // TODO: Pass reportOwnerLogin in PR 4b, once isReportFieldDisabled takes it first. + // TODO: Pass reportOwnerLogin in Part 6, once isReportFieldDisabled takes it first. // isAwaitingFirstLevelApproval falls back to the personal details store until then. See https://github.com/Expensify/App/issues/66413. const isForwardedForSubmitter = isReportOwner(report) && isExpenseReport(report) && isProcessingReport(report) && !isAwaitingFirstLevelApproval(report, rules, undefined); if (!isAdmin && (isReportSettled || isReportClosed || isApproved || isForwardedForSubmitter)) { @@ -5853,7 +5910,6 @@ function canEditReportPolicy(report: OnyxEntry, reportPolicy: OnyxEntry< const isAdmin = isPolicyAdminPolicyUtils(reportPolicy); const isManager = isReportManager(report); const isSubmitter = isReportOwner(report); - const isReportAuditor = isAuditor(report); const isIOUType = isIOUReport(report); const isInvoiceType = isInvoiceReport(report); const isExpenseType = isExpenseReport(report); @@ -5866,7 +5922,7 @@ function canEditReportPolicy(report: OnyxEntry, reportPolicy: OnyxEntry< } if (isInvoiceType) { - return isOpen && !isReportAuditor; + return isOpen && !hasReadOnlyAllReportsAccess(reportPolicy); } if (isExpenseType) { @@ -6104,9 +6160,9 @@ function canEditFieldOfMoneyRequest({ return ( !isClosedReport(moneyRequestReport) && !isInvoiceReport(moneyRequestReport) && - !isReceiptBeingScanned(transaction) && + !isReceiptScanBlockingEdit(transaction) && !isPerDiemRequest(transaction) && - (!isDistanceRequest(transaction) || isManualDistanceRequestTransactionUtils(transaction)) && + (!isDistanceRequest(transaction) || isManualDistanceRequestTransactionUtils(transaction) || isOdometerDistanceRequest(transaction)) && canEditExpense && (isDeleteAction ? isRequestor : true) ); @@ -6253,7 +6309,7 @@ function canModifyHoldStatus(report: Report, reportAction: OnyxEntry; + /** The current user's bank accounts, used to name the account a report was paid with. */ + bankAccountList?: OnyxEntry; + personalDetails?: OnyxEntry; }; /** @@ -6519,7 +6578,16 @@ function getReportPreviewMessage( convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'], params: GetReportPreviewMessageBaseParams, ): string { - const {reportOrID, iouReportAction = null, shouldConsiderScanningReceiptOrPendingRoute = false, isPreviewMessageForParentChatReport = false, policy, isForListPreview = false} = params; + const { + reportOrID, + iouReportAction = null, + shouldConsiderScanningReceiptOrPendingRoute = false, + isPreviewMessageForParentChatReport = false, + policy, + isForListPreview = false, + bankAccountList, + personalDetails, + } = params; const originalReportAction = params.originalReportAction ?? iouReportAction; const report = typeof reportOrID === 'string' ? getReport(reportOrID, deprecatedAllReports) : reportOrID; const reportActionMessage = getReportActionHtml(iouReportAction); @@ -6641,7 +6709,10 @@ function getReportPreviewMessage( report.isWaitingOnBankAccount ) { translatePhraseKey = 'iou.paidWithExpensify'; - const isFromInvoice = !!originalMessage?.bankAccountID; + + // A paying admin can record `bankAccountID` on a workspace payment too, so the report type is what says + // whether this was an invoice, matching `getIOUReportActionDisplayMessage`. + const isFromInvoice = isInvoiceReport(report) && !!originalMessage?.bankAccountID; if (originalMessage?.automaticAction) { translatePhraseKey = 'iou.automaticallyPaidWithExpensify'; } @@ -6671,7 +6742,14 @@ function getReportPreviewMessage( actualPayerName = actualPayerName && isForListPreview && !isPreviewMessageForParentChatReport ? `${actualPayerName}:` : actualPayerName; const payerDisplayName = isPreviewMessageForParentChatReport ? payerName : actualPayerName; if (translatePhraseKey === 'iou.businessBankAccount') { - const last4Digits = originalMessage?.accountNumber?.slice(-4) ?? policy?.achAccount?.accountNumber?.slice(-4) ?? ''; + const last4Digits = getBankAccountLastFourDigits({ + bankAccountID: originalMessage?.bankAccountID, + bankAccountList, + policy: policy ?? undefined, + accountNumber: originalMessage?.accountNumber, + payerAccountID, + reimburserAccountID: getPolicyReimburserAccountID(policy ?? undefined, personalDetails), + }); const crossBorderMessage = originalMessage ? getCrossBorderReimbursedMessage(translate, originalMessage, convertToDisplayString, last4Digits) : undefined; if (crossBorderMessage) { return crossBorderMessage; @@ -6762,7 +6840,16 @@ function getReportPreviewReportActionMessage( params: Omit & {policy?: OnyxInputOrEntry}, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], ): string { - const {reportOrID, iouReportAction = null, shouldConsiderScanningReceiptOrPendingRoute = false, isPreviewMessageForParentChatReport = false, policy, isForListPreview = false} = params; + const { + reportOrID, + iouReportAction = null, + shouldConsiderScanningReceiptOrPendingRoute = false, + isPreviewMessageForParentChatReport = false, + policy, + isForListPreview = false, + bankAccountList, + personalDetails, + } = params; const originalReportAction = params.originalReportAction ?? iouReportAction; const report = typeof reportOrID === 'string' ? getReport(reportOrID, deprecatedAllReports) : reportOrID; const reportActionMessage = getReportActionHtml(iouReportAction); @@ -6838,10 +6925,18 @@ function getReportPreviewReportActionMessage( const {totalDisplaySpend: totalAmount} = getMoneyRequestSpendBreakdown(report); const parentReport = getParentReport(report); - const policyName = getPolicyName({report: parentReport ?? report, policy}); + // Like the rest of this function, the workspace and participant fallbacks below are the raw English copy: this message is stored on a + // report action and read back by every viewer, so it must not follow the locale of whoever happened to build it. These mirror + // `workspace.common.unavailable` and `common.hidden` in `src/languages/en.ts`, and ReportUtilsTest asserts they do not drift from them. + const policyName = getPolicyName({report: parentReport ?? report, policy, unavailableTranslation: 'Unavailable workspace'}); const payerName = isExpenseReport(report) ? policyName - : getDisplayNameForParticipant({accountID: report.managerID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils}); + : getDisplayNameForParticipant({ + accountID: report.managerID, + shouldUseShortForm: !isPreviewMessageForParentChatReport, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: 'Hidden', + }); const formattedAmount = convertToDisplayStringEnLocale(totalAmount, report.currency, getCurrencyDecimals); @@ -6878,7 +6973,9 @@ function getReportPreviewReportActionMessage( report.isWaitingOnBankAccount ) { translatePhraseKey = 'iou.paidWithExpensify'; - const isFromInvoice = !!originalMessage?.bankAccountID; + // A paying admin can record `bankAccountID` on a workspace payment too, so the report type is what says + // whether this was an invoice, matching `getIOUReportActionDisplayMessage`. + const isFromInvoice = isInvoiceReport(report) && !!originalMessage?.bankAccountID; if (originalMessage?.automaticAction) { translatePhraseKey = 'iou.automaticallyPaidWithExpensify'; } @@ -6898,12 +6995,24 @@ function getReportPreviewReportActionMessage( let actualPayerName = report.managerID === deprecatedCurrentUserAccountID && !isForListPreview ? '' - : getDisplayNameForParticipant({accountID: payerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils}); + : getDisplayNameForParticipant({ + accountID: payerAccountID, + shouldUseShortForm: true, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: 'Hidden', + }); actualPayerName = actualPayerName && isForListPreview && !isPreviewMessageForParentChatReport ? `${actualPayerName}:` : actualPayerName; const payerDisplayName = isPreviewMessageForParentChatReport ? payerName : actualPayerName; if (translatePhraseKey === 'iou.businessBankAccount') { - const last4Digits = originalMessage?.accountNumber?.slice(-4) ?? reportPolicy?.achAccount?.accountNumber?.slice(-4) ?? ''; + const last4Digits = getBankAccountLastFourDigits({ + bankAccountID: originalMessage?.bankAccountID, + bankAccountList, + policy: reportPolicy, + accountNumber: originalMessage?.accountNumber, + payerAccountID, + reimburserAccountID: getPolicyReimburserAccountID(reportPolicy, personalDetails), + }); // This variant returns raw English to match the surrounding non-localized preview strings. if (originalMessage?.creditedAmount && originalMessage.creditedCurrency) { @@ -6931,7 +7040,13 @@ function getReportPreviewReportActionMessage( } if (report.isWaitingOnBankAccount) { - const submitterDisplayName = getDisplayNameForParticipant({accountID: report.ownerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils}) ?? ''; + const submitterDisplayName = + getDisplayNameForParticipant({ + accountID: report.ownerAccountID, + shouldUseShortForm: true, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: 'Hidden', + }) ?? ''; return `started payment, but is waiting for ${submitterDisplayName} to add a personal bank account.`; } @@ -6960,13 +7075,18 @@ function getReportPreviewReportActionMessage( // We only want to show the actor name in the preview if it's not the current user who took the action const requestorName = lastActorID && lastActorID !== deprecatedCurrentUserAccountID - ? getDisplayNameForParticipant({accountID: lastActorID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils}) + ? getDisplayNameForParticipant({ + accountID: lastActorID, + shouldUseShortForm: !isPreviewMessageForParentChatReport, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: 'Hidden', + }) : ''; return `${requestorName ? `${requestorName}: ` : ''}${amountToDisplay}${comment ? ` for ${comment}` : ''}`; } if (containsNonReimbursable) { - const ownerName = getDisplayNameForParticipant({accountID: report.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils}) ?? ''; + const ownerName = getDisplayNameForParticipant({accountID: report.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils, hiddenTranslation: 'Hidden'}) ?? ''; return `${ownerName} spent ${formattedAmount}`; } return `${payerName ?? ''} owes ${formattedAmount}${comment ? ` for ${comment}` : ''}`; @@ -7303,7 +7423,7 @@ function getParentNavigationSubtitle( function navigateToDetailsPage(report: OnyxEntry, isInSidePanel = false) { const isSelfDMReport = isSelfDM(report); const isOneOnOneChatReport = isOneOnOneChat(report); - const participantAccountID = getParticipantsAccountIDsForDisplay(report); + const participantAccountID = getParticipantsAccountIDsForDisplay(report, undefined); const basePath = isInSidePanel && report?.reportID ? ROUTES.REPORT_WITH_ID.getRoute(report.reportID) : undefined; @@ -7322,7 +7442,7 @@ function navigateToDetailsPage(report: OnyxEntry, isInSidePanel = false) */ function goBackToDetailsPage(report: OnyxEntry, backTo?: Route, shouldGoBackToDetailsPage = false) { const isOneOnOneChatReport = isOneOnOneChat(report); - const participantAccountID = getParticipantsAccountIDsForDisplay(report); + const participantAccountID = getParticipantsAccountIDsForDisplay(report, undefined); if (isOneOnOneChatReport) { Navigation.goBack(createDynamicRoute(DYNAMIC_ROUTES.PROFILE.getRoute(participantAccountID.at(0)))); @@ -7367,7 +7487,7 @@ function goBackFromPrivateNotes(report: OnyxEntry, accountID?: number) { } const currentUserPrivateNote = report.privateNotes?.[accountID]?.note ?? ''; if (isEmpty(currentUserPrivateNote)) { - const participantAccountIDs = getParticipantsAccountIDsForDisplay(report); + const participantAccountIDs = getParticipantsAccountIDsForDisplay(report, undefined); if (isOneOnOneChat(report)) { Navigation.goBack(createDynamicRoute(DYNAMIC_ROUTES.PROFILE.getRoute(participantAccountIDs.at(0)))); @@ -7787,7 +7907,6 @@ function buildOptimisticTaskCommentReportAction( function buildOptimisticSelfDMReport(created: string, reportID?: string): Report { return { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing reportID: reportID || generateReportID(), participants: { [deprecatedCurrentUserAccountID ?? CONST.DEFAULT_NUMBER_ID]: { @@ -8042,7 +8161,8 @@ function buildOptimisticExpenseReport({ const storedTotal = total * -1; const storedNonReimbursableTotal = nonReimbursableTotal * -1; const report = chatReportID ? getReportOrDraftReport(chatReportID) : undefined; - const policyName = getPolicyName({report}); + // The report name below is stored in English (see the `reportName` comment), so its workspace fallback is the raw English copy too. + const policyName = getPolicyName({report, unavailableTranslation: 'Unavailable workspace'}); const formattedTotal = convertToDisplayStringEnLocale(storedTotal, currency, getCurrencyDecimals); // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 const policyReal = getPolicy(policyID); @@ -8475,6 +8595,7 @@ function buildOptimisticIOUReportAction(params: BuildOptimisticIOUReportActionPa isPersonalTrackingExpense = false, payAsBusiness, bankAccountID, + accountNumber, reportActionID, delegateAccountIDParam, isSubmitterMarkedPaymentReceived, @@ -8516,6 +8637,11 @@ function buildOptimisticIOUReportAction(params: BuildOptimisticIOUReportActionPa if (isSubmitterMarkedPaymentReceived) { originalMessage.isSubmitterMarkedPaymentReceived = true; } + + // Persist the masked account used to pay so every viewer resolves the same account (see `accountNumber` above). + if (accountNumber) { + originalMessage.accountNumber = accountNumber; + } } if (type !== CONST.IOU.REPORT_ACTION_TYPE.PAY) { @@ -8946,11 +9072,11 @@ function buildOptimisticReportPreview( /** * Builds an optimistic ACTIONABLE_TRACK_EXPENSE_WHISPER action with a randomly generated reportActionID. */ -function buildOptimisticActionableTrackExpenseWhisper(iouAction: OptimisticIOUReportAction, transactionID: string): ReportAction { +function buildOptimisticActionableTrackExpenseWhisper(iouAction: OptimisticIOUReportAction, transactionID: string, optimisticReportActionID?: string): ReportAction { const currentTime = DateUtils.getDBTime(); const targetEmail = CONST.EMAIL.CONCIERGE; const actorAccountID = getAccountIDsByLogins([targetEmail]).at(0); - const reportActionID = rand64(); + const reportActionID = optimisticReportActionID ?? rand64(); return { actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER, actorAccountID, @@ -9462,7 +9588,7 @@ function buildOptimisticRenamedRoomReportAction(newName: string, oldName: string /** * Returns the necessary reportAction onyx data to indicate that the room description has been updated */ -function buildOptimisticRoomDescriptionUpdatedReportAction(description: string): OptimisticRoomDescriptionUpdatedReportAction { +function buildOptimisticRoomDescriptionUpdatedReportAction(description: string, accountIDToName?: Record): OptimisticRoomDescriptionUpdatedReportAction { const now = DateUtils.getDBTime(); return { reportActionID: rand64(), @@ -9472,7 +9598,7 @@ function buildOptimisticRoomDescriptionUpdatedReportAction(description: string): message: [ { type: CONST.REPORT.MESSAGE.TYPE.COMMENT, - text: description ? `set the room description to: ${Parser.htmlToText(description)}` : 'cleared the room description', + text: description ? `set the room description to: ${Parser.htmlToText(description, {accountIDToName})}` : 'cleared the room description', html: description ? `set the room description to: ${description}` : 'cleared the room description', }, ], @@ -10608,20 +10734,17 @@ function getViolatingReportIDForRBRInLHN(report: OnyxEntry, transactionV if (!potentialReport) { return false; } - // Allow both open and processing reports to show RBR for violations - if (!isOpenOrProcessingReport(potentialReport)) { - return false; - } const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${potentialReport.policyID}`]; + // Keeps the RBR in step with Home's "Review X expenses" row. + if (!isReportEligibleForViolationFix(potentialReport, policy)) { + return false; + } // Ignore transactions that are already pending deletion (e.g. a reverted split child) so the LHN RBR stays // consistent with what the opened report renders, which also filters out DELETE-pending transactions. const transactions = getReportTransactions(potentialReport.reportID).filter((transaction) => !isTransactionPendingDelete(transaction)); - // A submitted `companyCardRequired` is not actionable by the submitter, so it must not drive the RBR. It is - // excluded by name because the back end owns its type. - const excludedViolationNamesForLHN: ViolationName[] = isProcessingReport(potentialReport) ? [CONST.VIOLATIONS.COMPANY_CARD_REQUIRED] : []; - const excludedNoticeNamesForLHN: ViolationName[] = isProcessingReport(potentialReport) ? [CONST.VIOLATIONS.MODIFIED_AMOUNT] : []; + const {excludedViolations: excludedViolationNamesForLHN, excludedNotices: excludedNoticeNamesForLHN} = getSubmitterUnfixableViolationNames(potentialReport); return ( !isInvoiceReport(potentialReport) && @@ -11283,6 +11406,29 @@ function getPolicyExpenseChat(ownerAccountID: number | undefined, policyID: stri }); } +/** + * Returns the chat an expense created from the given report should go to. For IOU and expense reports that is their linked chat, + * for any other report it is the report itself. + * An expense report received from another session can arrive without chatReportID, so we recover its workspace chat from its own policy and owner. + */ +function getChatReportFromMoneyRequestReport(report: OnyxEntry, chatReportDraft?: OnyxEntry | EmptyObject): OnyxEntry { + if (!isMoneyRequestReport(report)) { + return report; + } + + const chatReport = getReportOrDraftReport(report?.chatReportID, undefined, undefined, chatReportDraft); + if (chatReport) { + return chatReport; + } + + // Only expense reports can be recovered this way: an IOU report's chat is a DM that no policy points at + if (!isExpenseReport(report)) { + return undefined; + } + + return getPolicyExpenseChat(report?.ownerAccountID, report?.policyID); +} + function getAllPolicyReports(policyID: string): Array> { return Object.values(deprecatedAllReports ?? {}).filter((report) => report?.policyID === policyID); } @@ -11380,7 +11526,7 @@ function getRouteFromLink(url: string | null): string { // Get the reportID from URL let route = url; const localWebAndroidRegEx = /^(https:\/\/([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3}))/; - for (const prefix of linkingConfig.prefixes) { + for (const prefix of linkingPrefixes) { if (route.startsWith(prefix)) { route = route.replace(prefix, ''); } else if (localWebAndroidRegEx.test(route)) { @@ -11479,13 +11625,12 @@ function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, o return false; } - let isOwnPolicyExpenseChat = report?.isOwnPolicyExpenseChat ?? false; - if (isExpenseReport(report) && getParentReport(report)) { - isOwnPolicyExpenseChat = !!getParentReport(report)?.isOwnPolicyExpenseChat; - } + const isOwnPolicyExpenseChat = report?.isOwnPolicyExpenseChat ?? false; - // In case there are no other participants than the current user and it's not user's own policy expense chat, they can't submit expenses from such report - if (otherParticipants.length === 0 && !isOwnPolicyExpenseChat) { + // In case there are no other participants than the current user and it's not user's own policy expense chat, they can't submit expenses from such report. + // Expense reports are exempt because they are gated by canAddTransaction below, the same check the "Add expense" button uses. An empty expense report + // only ever has its owner as a participant, so applying this rule to it would hide the page behind a button that is still rendered. + if (!isExpenseReport(report) && otherParticipants.length === 0 && !isOwnPolicyExpenseChat) { return false; } @@ -12110,7 +12255,6 @@ function getTaskAssigneeChatOnyxData({ // If you're choosing to share the task in the same DM as the assignee then we don't need to create another reportAction indicating that you've been assigned if (assigneeChatReportID !== parentReportID) { const assigneePersonalDetails = getPersonalDetail(assigneeAccountID); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const displayname = assigneePersonalDetails?.displayName || assigneePersonalDetails?.login || ''; optimisticAssigneeAddComment = buildOptimisticTaskCommentReportAction( taskReportID, @@ -12172,9 +12316,10 @@ function getIOUReportActionDisplayMessage( translate: LocalizedTranslate, reportAction: OnyxEntry, convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'], - policyACHAccountNumber: string | undefined, + policy: OnyxEntry, transaction?: OnyxEntry, bankAccountList?: OnyxEntry, + reimburserAccountID?: number, ): string { if (!isMoneyRequestAction(reportAction)) { return ''; @@ -12187,7 +12332,14 @@ function getIOUReportActionDisplayMessage( let translationKey: TranslationPaths; if (originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { - const last4Digits = originalMessage?.accountNumber?.slice(-4) ?? getBankAccountLastFourDigits(originalMessage?.bankAccountID, bankAccountList, policyACHAccountNumber); + const last4Digits = getBankAccountLastFourDigits({ + bankAccountID: originalMessage?.bankAccountID, + bankAccountList, + policy, + accountNumber: originalMessage?.accountNumber, + payerAccountID: reportAction?.actorAccountID, + reimburserAccountID, + }); const crossBorderMessage = getCrossBorderReimbursedMessage(translate, originalMessage, convertToDisplayString, last4Digits); switch (originalMessage.paymentType) { @@ -12334,6 +12486,7 @@ function canEditReportDescription(report: OnyxEntry, policy: OnyxEntry

, policy: OnyxEntry, currentUserAccountID: number | undefined, isReportArchived = false): boolean { +function canLeaveChat(report: OnyxEntry, policy: OnyxEntry, currentUserAccountID: number | undefined, isAnonymousUser: boolean, isReportArchived = false): boolean { if (isRootGroupChat(report, isReportArchived)) { return true; } @@ -12918,7 +13072,7 @@ function canLeaveChat(report: OnyxEntry, policy: OnyxEntry, curr return true; } - if (isPublicRoom(report) && isAnonymousUserSession()) { + if (isPublicRoom(report) && isAnonymousUser) { return false; } @@ -12952,7 +13106,7 @@ function canLeaveChat(report: OnyxEntry, policy: OnyxEntry, curr * Check if a report is forwarded or not */ function isForwardedReport(report: OnyxEntry, rules: OnyxCollection): boolean { - // TODO: Pass reportOwnerLogin in PR 4a, once isForwardedReport takes it first. + // TODO: Pass reportOwnerLogin in Part 9, once isForwardedReport takes it first. // isAwaitingFirstLevelApproval falls back to the personal details store until then. See https://github.com/Expensify/App/issues/66413. return isProcessingReport(report) && !isAwaitingFirstLevelApproval(report, rules, undefined); } @@ -13164,7 +13318,7 @@ function getValidateEmailTaskLink(targetChatReportID: string | undefined, should } function getValidateEmailTaskDescription(workEmail: string, targetChatReportID: string | undefined, shouldResumeAccountMerge: boolean) { - const validateEmailTask = getOnboardingMessages().joinWorkspaceMessages.validateEmail.tasks.find((task) => task.type === CONST.ONBOARDING_TASK_TYPE.VALIDATE_EMAIL); + const validateEmailTask = buildOnboardingMessagesWithoutManageTeam().joinWorkspaceMessages.validateEmail.tasks.find((task) => task.type === CONST.ONBOARDING_TASK_TYPE.VALIDATE_EMAIL); if (!validateEmailTask) { return ''; } @@ -13199,12 +13353,12 @@ function prepareOnboardingOnyxData({ }: PrepareOnboardingOnyxDataParams) { if (engagementChoice === CONST.ONBOARDING_CHOICES.PERSONAL_SPEND) { // eslint-disable-next-line no-param-reassign - onboardingMessage = getOnboardingMessages().onboardingMessages[CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]; + onboardingMessage = buildOnboardingMessagesWithoutManageTeam().onboardingMessages[CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]; } if (engagementChoice === CONST.ONBOARDING_CHOICES.EMPLOYER || engagementChoice === CONST.ONBOARDING_CHOICES.SUBMIT) { // eslint-disable-next-line no-param-reassign - onboardingMessage = shouldSkipConciergeOnboarding ? {message: '', tasks: []} : getOnboardingMessages().onboardingMessages[CONST.ONBOARDING_CHOICES.SUBMIT]; + onboardingMessage = shouldSkipConciergeOnboarding ? {message: '', tasks: []} : buildOnboardingMessagesWithoutManageTeam().onboardingMessages[CONST.ONBOARDING_CHOICES.SUBMIT]; } const shouldPostTasksInAdminsRoom = isPostingTasksInAdminsRoom(engagementChoice); @@ -14027,6 +14181,7 @@ function getIntegrationIcon( | 'RilletSquare' | 'DualEntrySquare' | 'CampfireSquare' + | 'ZohoBooksSquare' | 'BusinessCentralSquare' | 'GustoSquare' | 'IntuitSquare', @@ -14066,6 +14221,9 @@ function getIntegrationIcon( if (connectionName === CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE) { return expensifyIcons?.CampfireSquare; } + if (connectionName === CONST.POLICY.CONNECTIONS.NAME.ZOHO_BOOKS) { + return expensifyIcons?.ZohoBooksSquare; + } if (connectionName === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { return expensifyIcons?.BusinessCentralSquare; } @@ -14084,6 +14242,16 @@ function canBeExported(report: OnyxEntry) { return isExpenseReport(report) && isCorrectState; } +/** + * Whether the current user can email the report to someone. The submitter, the approver and workspace admins can once the report is submitted. + */ +function canShareReport(report: OnyxEntry, policy: OnyxEntry, currentUserAccountID: number): boolean { + if (!isExpenseReport(report) || isOpenReport(report)) { + return false; + } + return isCurrentUserSubmitter(report, currentUserAccountID) || report?.managerID === currentUserAccountID || isPolicyAdmin(policy); +} + function getIntegrationNameFromExportMessage(reportActions: OnyxEntry | ReportAction[]) { if (!reportActions) { return ''; @@ -14137,7 +14305,6 @@ function isExported(reportActions: OnyxEntry | ReportAction[], re const originalMessage = getOriginalMessage(action); const label = originalMessage?.label; // It's possible for originalMessage?.markedManually to be `false`, but the report is still has a valid automatic export. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const isValidExport = originalMessage?.markedManually || (label && validExportLabels.has(label) && originalMessage?.type !== CONST.EXPORT_TEMPLATE); if (isValidExport && action.created > lastSuccessfulExportCreated) { lastSuccessfulExportCreated = action.created; @@ -14505,6 +14672,11 @@ function isPayBlockedByArchivedState(report: OnyxInputOrEntry, policy: O return isExpenseReport(report) ? isArchivedOrPendingDeletePolicy(policy) : isReportOrChatArchived; } +/** Whether Pay is blocked, either by archived state or by a cancelled payment that is still going back to the payer. */ +function isPayBlocked(report: OnyxInputOrEntry, policy: OnyxInputOrEntry, isReportOrChatArchived: boolean): boolean { + return isPayBlockedByArchivedState(report, policy, isReportOrChatArchived) || !!report?.isWaitingForCancelledReimbursement; +} + function hasReportBeenReopened(report: OnyxEntry, reportActions?: OnyxEntry | ReportAction[]): boolean { if (report?.hasReportBeenReopened !== undefined) { return report.hasReportBeenReopened; @@ -14860,13 +15032,12 @@ function getStatusBadgeBackgroundColor(theme: ThemeColors, stateNum?: number, st * Checks if a workspace member is leaving a workspace room * This is used to determine if we need to show special handling when a workspace member leaves a room */ -function isWorkspaceMemberLeavingWorkspaceRoom(report: OnyxEntry, isPolicyEmployee: boolean, isPolicyAdminParam: boolean): boolean { +function isWorkspaceMemberLeavingWorkspaceRoom(report: OnyxEntry, isPolicyEmployee: boolean, isPolicyAdminParam: boolean, isPolicyGuestParam = false): boolean { if (!report) { return false; } - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const hasAccessPolicyExpenseChat = isPolicyExpenseChat(report) && (report.isOwnPolicyExpenseChat || isPolicyAdminParam); - return (report.visibility === CONST.REPORT.VISIBILITY.RESTRICTED || hasAccessPolicyExpenseChat) && isPolicyEmployee; + return (report.visibility === CONST.REPORT.VISIBILITY.RESTRICTED || hasAccessPolicyExpenseChat) && isPolicyEmployee && !isPolicyGuestParam; } /** @@ -14958,6 +15129,7 @@ const sortableColumnNames = [ CONST.SEARCH.TABLE_COLUMNS.TOTAL, CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE, CONST.SEARCH.TABLE_COLUMNS.BILLABLE, + CONST.SEARCH.TABLE_COLUMNS.HELD, CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE, CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.MCC, @@ -15035,6 +15207,8 @@ function getTransactionSortValue( return getReimbursable(transaction) ? 1 : 0; case CONST.SEARCH.TABLE_COLUMNS.BILLABLE: return transaction.billable ? 1 : 0; + case CONST.SEARCH.TABLE_COLUMNS.HELD: + return isOnHoldTransactionUtils(transaction) ? 1 : 0; case CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE: return getExchangeRate(transaction, report?.currency); case CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT: @@ -15107,6 +15281,8 @@ function canMergeReports( selectedReports: Array>, currentUserAccountID: number | undefined, rules: OnyxCollection, + // Every selected report shares an owner, because cross-account merges are rejected below. + reportOwnerLogin: string | undefined, reportNameValuePairs: OnyxCollection, policy: OnyxEntry, ): boolean { @@ -15154,7 +15330,7 @@ function canMergeReports( const isReportArchived = isArchivedReport(reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]); const hasWriteAccess = canUserPerformWriteAction(report, isReportArchived); const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; - const isReportEligibleForMerge = isMoneyRequestReportEligibleForMerge(report, isAdmin, rules, currentUserAccountID); + const isReportEligibleForMerge = isMoneyRequestReportEligibleForMerge(report, isAdmin, rules, reportOwnerLogin, currentUserAccountID); if (!hasWriteAccess || !isReportEligibleForMerge) { return false; @@ -15309,6 +15485,7 @@ export { getPersonalDetailsForAccountID, getPolicyDescriptionText, getPolicyExpenseChat, + getChatReportFromMoneyRequestReport, getPolicyName, getReimbursementDeQueuedOrCanceledActionMessage, getReimbursementQueuedActionMessage, @@ -15424,6 +15601,8 @@ export { isPolicyExpenseChat, isProcessingReport, isOpenReport, + isReportEligibleForViolationFix, + getSubmitterUnfixableViolationNames, isReportIDApproved, isAwaitingFirstLevelApproval, isPublicRoom, @@ -15443,6 +15622,7 @@ export { isSystemChat, isTaskReport, isThread, + isThreadMemberProtectedByParentReport, isTrackExpenseReport, isUnread, isUnreadWithMention, @@ -15492,6 +15672,7 @@ export { findPolicyExpenseChatByPolicyID, getIntegrationIcon, canBeExported, + canShareReport, isExported, hasExpensifyGuidesEmails, hasExportError, @@ -15553,7 +15734,7 @@ export { pushTransactionAutoSelectionsOnyxData, navigateOnDeleteExpense, canRejectReportAction, - isPayBlockedByArchivedState, + isPayBlocked, hasReportBeenRejectedToSubmitter, hasReportBeenReopened, hasReportBeenRetracted, diff --git a/src/libs/RequireFieldsRulesUtils.ts b/src/libs/RequireFieldsRulesUtils.ts index fae4f1488e31..47e9db705549 100644 --- a/src/libs/RequireFieldsRulesUtils.ts +++ b/src/libs/RequireFieldsRulesUtils.ts @@ -10,6 +10,7 @@ import type {Route} from '@src/ROUTES'; import type {RequireFieldsRuleForm, RequireFieldsRuleSettingFieldKey} from '@src/types/form/RequireFieldsRuleForm'; import INPUT_IDS from '@src/types/form/RequireFieldsRuleForm'; import type {Policy, PolicyCategories, PolicyCategory} from '@src/types/onyx'; +import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; @@ -28,6 +29,8 @@ import {setDraftRequireFieldsRule} from './actions/User'; import {getDecodedCategoryName} from './CategoryUtils'; import {isPendingDeleteOrUpdate} from './PolicyRulesUtils'; +const REQUIRE_FIELDS_SETTING_FIELD_KEYS = [INPUT_IDS.DESCRIPTION_SETTING, INPUT_IDS.ATTENDEES_SETTING, INPUT_IDS.RECEIPT_SETTING, INPUT_IDS.ITEMIZED_RECEIPT_SETTING] as const; + type RequireFieldsRuleType = DeepValueOf; type FieldRequirementsDirection = DeepValueOf; @@ -797,6 +800,24 @@ function getRequireFieldsFieldSettingUpdate( }; } +/** Returns the require fields draft for generated values, and the setting fields that the generated settings change */ +function getRequireFieldsDraftFromGeneratedValues(values: GeneratedRuleValues): {draft: GeneratedRuleValues; touchedFieldKeys: Set} { + let draft = values; + const touchedFieldKeys = new Set(); + for (const fieldKey of REQUIRE_FIELDS_SETTING_FIELD_KEYS) { + const setting = values[fieldKey]; + if (setting === undefined) { + continue; + } + const update = getRequireFieldsFieldSettingUpdate(fieldKey, setting); + draft = {...draft, ...update.formUpdate}; + for (const touchedFieldKey of update.touchedFieldKeys) { + touchedFieldKeys.add(touchedFieldKey); + } + } + return {draft, touchedFieldKeys}; +} + /** * Direction currently applied on the category for this field, or undefined when there is no override. * Unlike getRequireFieldsFormFromCategory, unset fields stay undefined instead of defaulting to Don't require. @@ -973,6 +994,7 @@ function getRequireFieldsFieldCouplingTooltipKey( } export { + REQUIRE_FIELDS_SETTING_FIELD_KEYS, canClearRequireFieldsField, categoryHasAnyRequireFieldsRule, deleteRequireFieldsRule, @@ -983,6 +1005,7 @@ export { getRequireFieldsDisplayedSetting, getRequireFieldsFieldClearKeys, getRequireFieldsFieldCouplingTooltipKey, + getRequireFieldsDraftFromGeneratedValues, getRequireFieldsFieldSettingUpdate, getRequireFieldsFormFromCategory, getRequireFieldsPendingActionForCategory, diff --git a/src/libs/ReusableDistanceRoutesUtils.ts b/src/libs/ReusableDistanceRoutesUtils.ts index d69a23e47813..4a1783c0a649 100644 --- a/src/libs/ReusableDistanceRoutesUtils.ts +++ b/src/libs/ReusableDistanceRoutesUtils.ts @@ -1,10 +1,13 @@ import CONST from '@src/CONST'; -import type {ReusableDistanceRoute} from '@src/types/onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReusableDistanceRoute, Transaction} from '@src/types/onyx'; import type {Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; +import type {OnyxCollection} from 'react-native-onyx'; + import DateUtils from './DateUtils'; import tokenizedSearch from './tokenizedSearch'; -import {getWaypointIndex} from './TransactionUtils'; +import {getWaypointIndex, hasPendingDistanceReceiptRegeneration} from './TransactionUtils'; import tryResolveUrlFromApiRoot from './tryResolveUrlFromApiRoot'; /** @@ -57,6 +60,18 @@ function formatLastUsed(inserted: string): string { return DateUtils.formatWithUTCTimeZone(inserted, CONST.DATE.MONTH_DAY_ABBR_FORMAT, undefined); } +/** + * Resolves the reusable routes that can be shown. While online, routes whose source expense still has a pending map + * receipt (just created, or its waypoints/distance edited) are left out until the server generates the new map image, + * the same check the report preview uses before loading a distance receipt. + */ +function getAvailableReusableDistanceRoutes(routes: ReusableDistanceRoute[] | undefined, transactions: OnyxCollection | undefined, isOffline: boolean): ReusableDistanceRoute[] { + if (!routes?.length || isOffline) { + return routes ?? []; + } + return routes.filter((route) => !hasPendingDistanceReceiptRegeneration(transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${route.transactionID}`])); +} + /** * Builds the large thumbnail URL for the source expense receipt. */ @@ -71,4 +86,4 @@ function getRouteThumbnailSource(receiptSource: string | undefined): string | un return `${resolvedSource}.1024.jpg`; } -export {getOrderedWaypoints, getRouteEndpoints, filterRoutes, formatLastUsed, getRouteThumbnailSource, normalizeRouteWaypoints}; +export {filterRoutes, formatLastUsed, getAvailableReusableDistanceRoutes, getOrderedWaypoints, getRouteEndpoints, getRouteThumbnailSource, normalizeRouteWaypoints}; diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index 29fc562c983a..d1655df8114a 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -1,5 +1,5 @@ import type {SubstitutionMap} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; -import type {SearchAutocompleteQueryRange, SearchAutocompleteResult, SearchColumnType, SearchFilterKey} from '@components/Search/types'; +import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types'; import CONST from '@src/CONST'; import type {PolicyCategories, PolicyTagLists, RecentlyUsedCategories, RecentlyUsedTags} from '@src/types/onyx'; @@ -22,13 +22,48 @@ const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS: SearchFilterKey[] = [ CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE, ]; +type AutocompleteParserRange = Pick & {key: string}; +type AutocompleteParserResult = {autocomplete: AutocompleteParserRange | null; ranges: AutocompleteParserRange[]}; + +/** The generated parser also produces non-canonical keys. Only prove the fields used by ordinary query consumers. */ +function isAutocompleteParserResult(value: unknown): value is AutocompleteParserResult { + 'worklet'; + + if (typeof value !== 'object' || value === null || !('autocomplete' in value) || !('ranges' in value) || !Array.isArray(value.ranges)) { + return false; + } + + const parsedRanges: unknown[] = value.ranges; + const ranges = value.autocomplete === null ? parsedRanges : [...parsedRanges, value.autocomplete]; + for (const range of ranges) { + if ( + typeof range !== 'object' || + range === null || + !('key' in range) || + typeof range.key !== 'string' || + !('value' in range) || + typeof range.value !== 'string' || + !('start' in range) || + typeof range.start !== 'number' || + !('length' in range) || + typeof range.length !== 'number' + ) { + return false; + } + } + return true; +} + /** * Parses given query using the autocomplete parser. * This is a smaller and simpler version of search parser used for autocomplete displaying logic. */ function parseForAutocomplete(text: string) { try { - const parsedAutocomplete = parse(text) as SearchAutocompleteResult; + const parsedAutocomplete: unknown = parse(text); + if (!isAutocompleteParserResult(parsedAutocomplete)) { + throw new Error('Invalid autocomplete parser result'); + } return parsedAutocomplete; } catch (e) { console.error(`Error when parsing autocomplete query"`, e); @@ -152,7 +187,7 @@ const userFriendlyStatusList = Object.values({ const userFriendlyColumnList = new Set( Object.entries(CONST.SEARCH.SEARCH_USER_FRIENDLY_VALUES_MAP) - .filter(([key]) => Object.values(CONST.SEARCH.TABLE_COLUMNS).includes(key as SearchColumnType)) + .filter(([key]) => Object.values(CONST.SEARCH.TABLE_COLUMNS).some((column) => column === key)) .map(([, value]) => value), ); @@ -161,7 +196,7 @@ const userFriendlyColumnList = new Set( * Determines if a specific value in the search syntax can/should be highlighted as valid or not */ function filterOutRangesWithCorrectValue( - range: SearchAutocompleteQueryRange, + range: AutocompleteParserRange, substitutionMap: SubstitutionMap, userLogins: SharedValue, currencyList: SharedValue, @@ -251,10 +286,13 @@ function filterOutRangesWithCorrectValue( return viewList.includes(range.value); case CONST.SEARCH.SYNTAX_FILTER_KEYS.BILLABLE: case CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE: + case CONST.SEARCH.SYNTAX_FILTER_KEYS.DECLINED: + case CONST.SEARCH.SYNTAX_FILTER_KEYS.HELD: return booleanList.includes(range.value); case CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE: case CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED: case CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED: + case CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL: case CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID: case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED: case CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWN: @@ -307,7 +345,10 @@ function parseForLiveMarkdown( ): MarkdownRange[] { 'worklet'; - const parsedAutocomplete = parse(input) as SearchAutocompleteResult; + const parsedAutocomplete: unknown = parse(input); + if (!isAutocompleteParserResult(parsedAutocomplete)) { + return []; + } const ranges = parsedAutocomplete.ranges; const typeRange = ranges.find((range) => range.key === CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE); const currentType = typeRange?.value ?? CONST.SEARCH.DATA_TYPES.EXPENSE; diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index a3b269757198..12522b663f5f 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -228,94 +228,99 @@ function peg$parse(input, options) { var peg$c42 = "withdrawalid"; var peg$c43 = "withdrawal-id"; var peg$c44 = "billable"; - var peg$c45 = "reimbursable"; - var peg$c46 = "type"; - var peg$c47 = "status"; - var peg$c48 = "sortBy"; - var peg$c49 = "sort-by"; - var peg$c50 = "sortOrder"; - var peg$c51 = "sort-order"; - var peg$c52 = "policyID"; - var peg$c53 = "workspace"; - var peg$c54 = "submitted"; - var peg$c55 = "approved"; - var peg$c56 = "paid"; - var peg$c57 = "exported"; - var peg$c58 = "posted"; - var peg$c59 = "withdrawn"; - var peg$c60 = "groupby"; - var peg$c61 = "group-by"; - var peg$c62 = "limit"; - var peg$c63 = "feed"; - var peg$c64 = "title"; - var peg$c65 = "submitteruserid"; - var peg$c66 = "submitter-user-id"; - var peg$c67 = "submitterpayrollid"; - var peg$c68 = "submitter-payroll-id"; - var peg$c69 = "orderdealnumbers"; - var peg$c70 = "order-deal-numbers"; - var peg$c71 = "assignee"; - var peg$c72 = "createdby"; - var peg$c73 = "created-by"; - var peg$c74 = "action"; - var peg$c75 = "total"; - var peg$c76 = "has"; - var peg$c77 = "is"; - var peg$c78 = "purchaseamount"; - var peg$c79 = "purchase-amount"; - var peg$c80 = "purchasecurrency"; - var peg$c81 = "purchase-currency"; - var peg$c82 = "amountdebited"; - var peg$c83 = "amount-debited"; - var peg$c84 = "amountreimbursed"; - var peg$c85 = "amount-reimbursed"; - var peg$c86 = "columns"; - var peg$c87 = "view"; - var peg$c88 = "compare"; - var peg$c89 = "per-diem"; - var peg$c90 = "drafts"; - var peg$c91 = "draft"; - var peg$c92 = "tax"; - var peg$c93 = "policy-name"; - var peg$c94 = "long-report-id"; - var peg$c95 = "exported-to"; - var peg$c96 = "exportedto"; - var peg$c97 = "exchange-rate"; - var peg$c98 = "reimbursable-total"; - var peg$c99 = "non-reimbursable-total"; - var peg$c100 = "group-from"; - var peg$c101 = "group-expenses"; - var peg$c102 = "group-total"; - var peg$c103 = "group-card"; - var peg$c104 = "group-feed"; - var peg$c105 = "group-bank-account"; - var peg$c106 = "group-withdrawn"; - var peg$c107 = "group-withdrawal-id"; - var peg$c108 = "group-amount-debited"; - var peg$c109 = "group-amount-reimbursed"; - var peg$c110 = "group-category"; - var peg$c111 = "group-tag"; - var peg$c112 = "group-merchant"; - var peg$c113 = "group-day"; - var peg$c114 = "group-month"; - var peg$c115 = "group-week"; - var peg$c116 = "group-year"; - var peg$c117 = "group-quarter"; - var peg$c118 = "!="; - var peg$c119 = ">="; - var peg$c120 = ">"; - var peg$c121 = "<="; - var peg$c122 = "<"; - var peg$c123 = "\\"; - var peg$c124 = "\u201C"; - var peg$c125 = "\u201D"; - var peg$c126 = "\""; - var peg$c127 = "footercount"; - var peg$c128 = "footer-count"; - var peg$c129 = "footercurrency"; - var peg$c130 = "footer-currency"; - var peg$c131 = "footertotal"; - var peg$c132 = "footer-total"; + var peg$c45 = "declined"; + var peg$c46 = "held"; + var peg$c47 = "reimbursable"; + var peg$c48 = "type"; + var peg$c49 = "status"; + var peg$c50 = "sortBy"; + var peg$c51 = "sort-by"; + var peg$c52 = "sortOrder"; + var peg$c53 = "sort-order"; + var peg$c54 = "policyID"; + var peg$c55 = "workspace"; + var peg$c56 = "submitted"; + var peg$c57 = "approved"; + var peg$c58 = "anyapproval"; + var peg$c59 = "any-approval"; + var peg$c60 = "paid"; + var peg$c61 = "exported"; + var peg$c62 = "posted"; + var peg$c63 = "withdrawn"; + var peg$c64 = "groupby"; + var peg$c65 = "group-by"; + var peg$c66 = "limit"; + var peg$c67 = "feed"; + var peg$c68 = "title"; + var peg$c69 = "submitteruserid"; + var peg$c70 = "submitter-user-id"; + var peg$c71 = "submitterpayrollid"; + var peg$c72 = "submitter-payroll-id"; + var peg$c73 = "orderdealnumbers"; + var peg$c74 = "order-deal-numbers"; + var peg$c75 = "assignee"; + var peg$c76 = "createdby"; + var peg$c77 = "created-by"; + var peg$c78 = "action"; + var peg$c79 = "total"; + var peg$c80 = "has"; + var peg$c81 = "is"; + var peg$c82 = "purchaseamount"; + var peg$c83 = "purchase-amount"; + var peg$c84 = "purchasecurrency"; + var peg$c85 = "purchase-currency"; + var peg$c86 = "amountdebited"; + var peg$c87 = "amount-debited"; + var peg$c88 = "amountreimbursed"; + var peg$c89 = "amount-reimbursed"; + var peg$c90 = "columns"; + var peg$c91 = "view"; + var peg$c92 = "compare"; + var peg$c93 = "per-diem"; + var peg$c94 = "drafts"; + var peg$c95 = "draft"; + var peg$c96 = "tax"; + var peg$c97 = "policy-name"; + var peg$c98 = "long-report-id"; + var peg$c99 = "exported-to"; + var peg$c100 = "exportedto"; + var peg$c101 = "exchange-rate"; + var peg$c102 = "reimbursable-total"; + var peg$c103 = "non-reimbursable-total"; + var peg$c104 = "group-from"; + var peg$c105 = "group-expenses"; + var peg$c106 = "group-total"; + var peg$c107 = "group-card"; + var peg$c108 = "group-feed"; + var peg$c109 = "group-bank-account"; + var peg$c110 = "group-withdrawn"; + var peg$c111 = "group-withdrawal-id"; + var peg$c112 = "group-amount-debited"; + var peg$c113 = "group-amount-reimbursed"; + var peg$c114 = "group-category"; + var peg$c115 = "group-tag"; + var peg$c116 = "group-merchant"; + var peg$c117 = "group-day"; + var peg$c118 = "group-month"; + var peg$c119 = "group-week"; + var peg$c120 = "group-year"; + var peg$c121 = "group-quarter"; + var peg$c122 = "*:"; + var peg$c123 = "!="; + var peg$c124 = ">="; + var peg$c125 = ">"; + var peg$c126 = "<="; + var peg$c127 = "<"; + var peg$c128 = "\\"; + var peg$c129 = "\u201C"; + var peg$c130 = "\u201D"; + var peg$c131 = "\""; + var peg$c132 = "footercount"; + var peg$c133 = "footer-count"; + var peg$c134 = "footercurrency"; + var peg$c135 = "footer-currency"; + var peg$c136 = "footertotal"; + var peg$c137 = "footer-total"; var peg$r0 = /^[ \t\r\n\xA0,:=<>!]/; var peg$r1 = /^[:=]/; @@ -380,111 +385,116 @@ function peg$parse(input, options) { var peg$e45 = peg$literalExpectation("withdrawalID", true); var peg$e46 = peg$literalExpectation("withdrawal-id", true); var peg$e47 = peg$literalExpectation("billable", true); - var peg$e48 = peg$literalExpectation("reimbursable", true); - var peg$e49 = peg$literalExpectation("type", true); - var peg$e50 = peg$literalExpectation("status", true); - var peg$e51 = peg$literalExpectation("sortBy", false); - var peg$e52 = peg$literalExpectation("sort-by", true); - var peg$e53 = peg$literalExpectation("sortOrder", false); - var peg$e54 = peg$literalExpectation("sort-order", true); - var peg$e55 = peg$literalExpectation("policyID", false); - var peg$e56 = peg$literalExpectation("workspace", true); - var peg$e57 = peg$literalExpectation("submitted", true); - var peg$e58 = peg$literalExpectation("approved", true); - var peg$e59 = peg$literalExpectation("paid", true); - var peg$e60 = peg$literalExpectation("exported", true); - var peg$e61 = peg$literalExpectation("posted", true); - var peg$e62 = peg$literalExpectation("withdrawn", true); - var peg$e63 = peg$literalExpectation("groupBy", true); - var peg$e64 = peg$literalExpectation("group-by", true); - var peg$e65 = peg$literalExpectation("limit", true); - var peg$e66 = peg$literalExpectation("feed", true); - var peg$e67 = peg$literalExpectation("title", true); - var peg$e68 = peg$literalExpectation("submitterUserID", true); - var peg$e69 = peg$literalExpectation("submitter-user-id", true); - var peg$e70 = peg$literalExpectation("submitterPayrollID", true); - var peg$e71 = peg$literalExpectation("submitter-payroll-id", true); - var peg$e72 = peg$literalExpectation("orderDealNumbers", true); - var peg$e73 = peg$literalExpectation("order-deal-numbers", true); - var peg$e74 = peg$literalExpectation("assignee", true); - var peg$e75 = peg$literalExpectation("createdBy", true); - var peg$e76 = peg$literalExpectation("created-by", true); - var peg$e77 = peg$literalExpectation("action", true); - var peg$e78 = peg$literalExpectation("total", true); - var peg$e79 = peg$literalExpectation("has", true); - var peg$e80 = peg$literalExpectation("is", true); - var peg$e81 = peg$literalExpectation("purchaseAmount", true); - var peg$e82 = peg$literalExpectation("purchase-amount", true); - var peg$e83 = peg$literalExpectation("purchaseCurrency", true); - var peg$e84 = peg$literalExpectation("purchase-currency", true); - var peg$e85 = peg$literalExpectation("amountDebited", true); - var peg$e86 = peg$literalExpectation("amount-debited", true); - var peg$e87 = peg$literalExpectation("amountReimbursed", true); - var peg$e88 = peg$literalExpectation("amount-reimbursed", true); - var peg$e89 = peg$literalExpectation("columns", true); - var peg$e90 = peg$literalExpectation("view", true); - var peg$e91 = peg$literalExpectation("compare", true); - var peg$e92 = peg$literalExpectation("per-diem", true); - var peg$e93 = peg$literalExpectation("drafts", true); - var peg$e94 = peg$literalExpectation("draft", true); - var peg$e95 = peg$literalExpectation("tax", true); - var peg$e96 = peg$literalExpectation("policy-name", true); - var peg$e97 = peg$literalExpectation("long-report-id", true); - var peg$e98 = peg$literalExpectation("exported-to", true); - var peg$e99 = peg$classExpectation([":", "="], false, false); - var peg$e100 = peg$literalExpectation("exportedTo", true); - var peg$e101 = peg$literalExpectation("exchange-rate", true); - var peg$e102 = peg$literalExpectation("reimbursable-total", true); - var peg$e103 = peg$literalExpectation("non-reimbursable-total", true); - var peg$e104 = peg$literalExpectation("group-from", true); - var peg$e105 = peg$literalExpectation("group-expenses", true); - var peg$e106 = peg$literalExpectation("group-total", true); - var peg$e107 = peg$literalExpectation("group-card", true); - var peg$e108 = peg$literalExpectation("group-feed", true); - var peg$e109 = peg$literalExpectation("group-bank-account", true); - var peg$e110 = peg$literalExpectation("group-withdrawn", true); - var peg$e111 = peg$literalExpectation("group-withdrawal-id", true); - var peg$e112 = peg$literalExpectation("group-amount-debited", true); - var peg$e113 = peg$literalExpectation("group-amount-reimbursed", true); - var peg$e114 = peg$literalExpectation("group-category", true); - var peg$e115 = peg$literalExpectation("group-tag", true); - var peg$e116 = peg$literalExpectation("group-merchant", true); - var peg$e117 = peg$literalExpectation("group-day", true); - var peg$e118 = peg$literalExpectation("group-month", true); - var peg$e119 = peg$literalExpectation("group-week", true); - var peg$e120 = peg$literalExpectation("group-year", true); - var peg$e121 = peg$literalExpectation("group-quarter", true); - var peg$e122 = peg$otherExpectation("operator"); - var peg$e123 = peg$literalExpectation("!=", false); - var peg$e124 = peg$literalExpectation(">=", false); - var peg$e125 = peg$literalExpectation(">", false); - var peg$e126 = peg$literalExpectation("<=", false); - var peg$e127 = peg$literalExpectation("<", false); - var peg$e128 = peg$literalExpectation("\\", false); - var peg$e129 = peg$classExpectation([",", "\"", "\u201D", "\u201C", "\\"], false, false); - var peg$e130 = peg$otherExpectation("word"); - var peg$e131 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e132 = peg$otherExpectation("whitespace"); - var peg$e133 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); - var peg$e134 = peg$otherExpectation("quote"); - var peg$e135 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e136 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); - var peg$e137 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); - var peg$e138 = peg$literalExpectation("\u201C", false); - var peg$e139 = peg$literalExpectation("\u201D", false); - var peg$e140 = peg$literalExpectation("\"", false); - var peg$e141 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e142 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e143 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); - var peg$e144 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); - var peg$e145 = peg$classExpectation([","], false, false); - var peg$e146 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); - var peg$e147 = peg$literalExpectation("footerCount", true); - var peg$e148 = peg$literalExpectation("footer-count", true); - var peg$e149 = peg$literalExpectation("footerCurrency", true); - var peg$e150 = peg$literalExpectation("footer-currency", true); - var peg$e151 = peg$literalExpectation("footerTotal", true); - var peg$e152 = peg$literalExpectation("footer-total", true); + var peg$e48 = peg$literalExpectation("declined", true); + var peg$e49 = peg$literalExpectation("held", true); + var peg$e50 = peg$literalExpectation("reimbursable", true); + var peg$e51 = peg$literalExpectation("type", true); + var peg$e52 = peg$literalExpectation("status", true); + var peg$e53 = peg$literalExpectation("sortBy", false); + var peg$e54 = peg$literalExpectation("sort-by", true); + var peg$e55 = peg$literalExpectation("sortOrder", false); + var peg$e56 = peg$literalExpectation("sort-order", true); + var peg$e57 = peg$literalExpectation("policyID", false); + var peg$e58 = peg$literalExpectation("workspace", true); + var peg$e59 = peg$literalExpectation("submitted", true); + var peg$e60 = peg$literalExpectation("approved", true); + var peg$e61 = peg$literalExpectation("anyApproval", true); + var peg$e62 = peg$literalExpectation("any-approval", true); + var peg$e63 = peg$literalExpectation("paid", true); + var peg$e64 = peg$literalExpectation("exported", true); + var peg$e65 = peg$literalExpectation("posted", true); + var peg$e66 = peg$literalExpectation("withdrawn", true); + var peg$e67 = peg$literalExpectation("groupBy", true); + var peg$e68 = peg$literalExpectation("group-by", true); + var peg$e69 = peg$literalExpectation("limit", true); + var peg$e70 = peg$literalExpectation("feed", true); + var peg$e71 = peg$literalExpectation("title", true); + var peg$e72 = peg$literalExpectation("submitterUserID", true); + var peg$e73 = peg$literalExpectation("submitter-user-id", true); + var peg$e74 = peg$literalExpectation("submitterPayrollID", true); + var peg$e75 = peg$literalExpectation("submitter-payroll-id", true); + var peg$e76 = peg$literalExpectation("orderDealNumbers", true); + var peg$e77 = peg$literalExpectation("order-deal-numbers", true); + var peg$e78 = peg$literalExpectation("assignee", true); + var peg$e79 = peg$literalExpectation("createdBy", true); + var peg$e80 = peg$literalExpectation("created-by", true); + var peg$e81 = peg$literalExpectation("action", true); + var peg$e82 = peg$literalExpectation("total", true); + var peg$e83 = peg$literalExpectation("has", true); + var peg$e84 = peg$literalExpectation("is", true); + var peg$e85 = peg$literalExpectation("purchaseAmount", true); + var peg$e86 = peg$literalExpectation("purchase-amount", true); + var peg$e87 = peg$literalExpectation("purchaseCurrency", true); + var peg$e88 = peg$literalExpectation("purchase-currency", true); + var peg$e89 = peg$literalExpectation("amountDebited", true); + var peg$e90 = peg$literalExpectation("amount-debited", true); + var peg$e91 = peg$literalExpectation("amountReimbursed", true); + var peg$e92 = peg$literalExpectation("amount-reimbursed", true); + var peg$e93 = peg$literalExpectation("columns", true); + var peg$e94 = peg$literalExpectation("view", true); + var peg$e95 = peg$literalExpectation("compare", true); + var peg$e96 = peg$literalExpectation("per-diem", true); + var peg$e97 = peg$literalExpectation("drafts", true); + var peg$e98 = peg$literalExpectation("draft", true); + var peg$e99 = peg$literalExpectation("tax", true); + var peg$e100 = peg$literalExpectation("policy-name", true); + var peg$e101 = peg$literalExpectation("long-report-id", true); + var peg$e102 = peg$literalExpectation("exported-to", true); + var peg$e103 = peg$classExpectation([":", "="], false, false); + var peg$e104 = peg$literalExpectation("exportedTo", true); + var peg$e105 = peg$literalExpectation("exchange-rate", true); + var peg$e106 = peg$literalExpectation("reimbursable-total", true); + var peg$e107 = peg$literalExpectation("non-reimbursable-total", true); + var peg$e108 = peg$literalExpectation("group-from", true); + var peg$e109 = peg$literalExpectation("group-expenses", true); + var peg$e110 = peg$literalExpectation("group-total", true); + var peg$e111 = peg$literalExpectation("group-card", true); + var peg$e112 = peg$literalExpectation("group-feed", true); + var peg$e113 = peg$literalExpectation("group-bank-account", true); + var peg$e114 = peg$literalExpectation("group-withdrawn", true); + var peg$e115 = peg$literalExpectation("group-withdrawal-id", true); + var peg$e116 = peg$literalExpectation("group-amount-debited", true); + var peg$e117 = peg$literalExpectation("group-amount-reimbursed", true); + var peg$e118 = peg$literalExpectation("group-category", true); + var peg$e119 = peg$literalExpectation("group-tag", true); + var peg$e120 = peg$literalExpectation("group-merchant", true); + var peg$e121 = peg$literalExpectation("group-day", true); + var peg$e122 = peg$literalExpectation("group-month", true); + var peg$e123 = peg$literalExpectation("group-week", true); + var peg$e124 = peg$literalExpectation("group-year", true); + var peg$e125 = peg$literalExpectation("group-quarter", true); + var peg$e126 = peg$otherExpectation("operator"); + var peg$e127 = peg$literalExpectation("*:", false); + var peg$e128 = peg$literalExpectation("!=", false); + var peg$e129 = peg$literalExpectation(">=", false); + var peg$e130 = peg$literalExpectation(">", false); + var peg$e131 = peg$literalExpectation("<=", false); + var peg$e132 = peg$literalExpectation("<", false); + var peg$e133 = peg$literalExpectation("\\", false); + var peg$e134 = peg$classExpectation([",", "\"", "\u201D", "\u201C", "\\"], false, false); + var peg$e135 = peg$otherExpectation("word"); + var peg$e136 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e137 = peg$otherExpectation("whitespace"); + var peg$e138 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); + var peg$e139 = peg$otherExpectation("quote"); + var peg$e140 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e141 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e142 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); + var peg$e143 = peg$literalExpectation("\u201C", false); + var peg$e144 = peg$literalExpectation("\u201D", false); + var peg$e145 = peg$literalExpectation("\"", false); + var peg$e146 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e147 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e148 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); + var peg$e149 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); + var peg$e150 = peg$classExpectation([","], false, false); + var peg$e151 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + var peg$e152 = peg$literalExpectation("footerCount", true); + var peg$e153 = peg$literalExpectation("footer-count", true); + var peg$e154 = peg$literalExpectation("footerCurrency", true); + var peg$e155 = peg$literalExpectation("footer-currency", true); + var peg$e156 = peg$literalExpectation("footerTotal", true); + var peg$e157 = peg$literalExpectation("footer-total", true); var peg$f0 = function(ranges) { return { autocomplete, ranges }; }; var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); }; @@ -493,6 +503,12 @@ function peg$parse(input, options) { const isNegated = !!neg && !nonNegatableKeys.has(key); + // The "contains" operator is only supported for the merchant field + if (op === "contains" && (neg || key !== "merchant")) { + autocomplete = null; + return; + } + if (!value) { autocomplete = { key, @@ -577,110 +593,115 @@ function peg$parse(input, options) { var peg$f33 = function() { return "paidStatus"; }; var peg$f34 = function() { return "withdrawalID"; }; var peg$f35 = function() { return "billable"; }; - var peg$f36 = function() { return "reimbursable"; }; - var peg$f37 = function() { return "type"; }; - var peg$f38 = function() { return "status"; }; - var peg$f39 = function() { return "sortBy"; }; - var peg$f40 = function() { return "sortOrder"; }; - var peg$f41 = function() { return "policyID"; }; - var peg$f42 = function() { return "submitted"; }; - var peg$f43 = function() { return "approved"; }; - var peg$f44 = function() { return "paid"; }; - var peg$f45 = function() { return "exported"; }; - var peg$f46 = function() { return "posted"; }; - var peg$f47 = function() { return "withdrawn"; }; - var peg$f48 = function() { return "groupBy"; }; - var peg$f49 = function() { return "limit"; }; - var peg$f50 = function() { return "feed"; }; - var peg$f51 = function() { return "title"; }; - var peg$f52 = function() { return "submitterUserID"; }; - var peg$f53 = function() { return "submitterPayrollID"; }; - var peg$f54 = function() { return "orderDealNumbers"; }; - var peg$f55 = function() { return "assignee"; }; - var peg$f56 = function() { return "createdBy"; }; - var peg$f57 = function() { return "action"; }; - var peg$f58 = function() {return "total"; }; - var peg$f59 = function() {return "has"; }; - var peg$f60 = function() {return "is"; }; - var peg$f61 = function() {return "purchaseAmount"}; - var peg$f62 = function() {return "purchaseCurrency"}; - var peg$f63 = function() {return "amountDebited"}; - var peg$f64 = function() {return "amountReimbursed"}; - var peg$f65 = function() { + var peg$f36 = function() { return "declined"; }; + var peg$f37 = function() { return "held"; }; + var peg$f38 = function() { return "reimbursable"; }; + var peg$f39 = function() { return "type"; }; + var peg$f40 = function() { return "status"; }; + var peg$f41 = function() { return "sortBy"; }; + var peg$f42 = function() { return "sortOrder"; }; + var peg$f43 = function() { return "policyID"; }; + var peg$f44 = function() { return "submitted"; }; + var peg$f45 = function() { return "approved"; }; + var peg$f46 = function() { return "anyApproval"; }; + var peg$f47 = function() { return "anyApproval"; }; + var peg$f48 = function() { return "paid"; }; + var peg$f49 = function() { return "exported"; }; + var peg$f50 = function() { return "posted"; }; + var peg$f51 = function() { return "withdrawn"; }; + var peg$f52 = function() { return "groupBy"; }; + var peg$f53 = function() { return "limit"; }; + var peg$f54 = function() { return "feed"; }; + var peg$f55 = function() { return "title"; }; + var peg$f56 = function() { return "submitterUserID"; }; + var peg$f57 = function() { return "submitterPayrollID"; }; + var peg$f58 = function() { return "orderDealNumbers"; }; + var peg$f59 = function() { return "assignee"; }; + var peg$f60 = function() { return "createdBy"; }; + var peg$f61 = function() { return "action"; }; + var peg$f62 = function() {return "total"; }; + var peg$f63 = function() {return "has"; }; + var peg$f64 = function() {return "is"; }; + var peg$f65 = function() {return "purchaseAmount"}; + var peg$f66 = function() {return "purchaseCurrency"}; + var peg$f67 = function() {return "amountDebited"}; + var peg$f68 = function() {return "amountReimbursed"}; + var peg$f69 = function() { isColumnsContext = true; return "columns"; }; - var peg$f66 = function() {return "view"}; - var peg$f67 = function() {return "compare"}; - var peg$f68 = function() { return isColumnsContext; }; - var peg$f69 = function() { return "perDiem"; }; - var peg$f70 = function() { return "drafts"; }; - var peg$f71 = function() { return "originalamount"; }; - var peg$f72 = function() { return "taxAmount"; }; - var peg$f73 = function() { return "taxrate"; }; - var peg$f74 = function() { return "policyname"; }; - var peg$f75 = function() { return "withdrawalID"; }; - var peg$f76 = function() { return "bankAccount"; }; - var peg$f77 = function() { return "reportID"; }; - var peg$f78 = function() { return "base62ReportID"; }; - var peg$f79 = function() { return "exportedto"; }; - var peg$f80 = function() { return "exportedTo"; }; - var peg$f81 = function() { return "exportedTo"; }; - var peg$f82 = function() { return "exchangeRate"; }; - var peg$f83 = function() { return "reimbursableTotal"; }; - var peg$f84 = function() { return "nonReimbursableTotal"; }; - var peg$f85 = function() { return "groupFrom"; }; - var peg$f86 = function() { return "groupExpenses"; }; - var peg$f87 = function() { return "groupTotal"; }; - var peg$f88 = function() { return "groupCard"; }; - var peg$f89 = function() { return "groupFeed"; }; - var peg$f90 = function() { return "groupBankAccount"; }; - var peg$f91 = function() { return "groupWithdrawn"; }; - var peg$f92 = function() { return "groupWithdrawalID"; }; - var peg$f93 = function() { return "groupAmountDebited"; }; - var peg$f94 = function() { return "groupAmountReimbursed"; }; - var peg$f95 = function() { return "amountDebited"; }; - var peg$f96 = function() { return "amountReimbursed"; }; - var peg$f97 = function() { return "groupCategory"; }; - var peg$f98 = function() { return "groupTag"; }; - var peg$f99 = function() { return "groupMerchant"; }; - var peg$f100 = function() { return "groupday"; }; - var peg$f101 = function() { return "groupmonth"; }; - var peg$f102 = function() { return "groupweek"; }; - var peg$f103 = function() { return "groupyear"; }; - var peg$f104 = function() { return "groupquarter"; }; - var peg$f105 = function() { return "eq"; }; - var peg$f106 = function() { return "neq"; }; - var peg$f107 = function() { return "gte"; }; - var peg$f108 = function() { return "gt"; }; - var peg$f109 = function() { return "lte"; }; - var peg$f110 = function() { return "lt"; }; - var peg$f111 = function(o) { + var peg$f70 = function() {return "view"}; + var peg$f71 = function() {return "compare"}; + var peg$f72 = function() { return isColumnsContext; }; + var peg$f73 = function() { return "perDiem"; }; + var peg$f74 = function() { return "drafts"; }; + var peg$f75 = function() { return "originalamount"; }; + var peg$f76 = function() { return "taxAmount"; }; + var peg$f77 = function() { return "taxrate"; }; + var peg$f78 = function() { return "policyname"; }; + var peg$f79 = function() { return "withdrawalID"; }; + var peg$f80 = function() { return "bankAccount"; }; + var peg$f81 = function() { return "reportID"; }; + var peg$f82 = function() { return "base62ReportID"; }; + var peg$f83 = function() { return "exportedto"; }; + var peg$f84 = function() { return "exportedTo"; }; + var peg$f85 = function() { return "exportedTo"; }; + var peg$f86 = function() { return "exchangeRate"; }; + var peg$f87 = function() { return "reimbursableTotal"; }; + var peg$f88 = function() { return "nonReimbursableTotal"; }; + var peg$f89 = function() { return "groupFrom"; }; + var peg$f90 = function() { return "groupExpenses"; }; + var peg$f91 = function() { return "groupTotal"; }; + var peg$f92 = function() { return "groupCard"; }; + var peg$f93 = function() { return "groupFeed"; }; + var peg$f94 = function() { return "groupBankAccount"; }; + var peg$f95 = function() { return "groupWithdrawn"; }; + var peg$f96 = function() { return "groupWithdrawalID"; }; + var peg$f97 = function() { return "groupAmountDebited"; }; + var peg$f98 = function() { return "groupAmountReimbursed"; }; + var peg$f99 = function() { return "amountDebited"; }; + var peg$f100 = function() { return "amountReimbursed"; }; + var peg$f101 = function() { return "groupCategory"; }; + var peg$f102 = function() { return "groupTag"; }; + var peg$f103 = function() { return "groupMerchant"; }; + var peg$f104 = function() { return "groupday"; }; + var peg$f105 = function() { return "groupmonth"; }; + var peg$f106 = function() { return "groupweek"; }; + var peg$f107 = function() { return "groupyear"; }; + var peg$f108 = function() { return "groupquarter"; }; + var peg$f109 = function() { return "contains"; }; + var peg$f110 = function() { return "eq"; }; + var peg$f111 = function() { return "neq"; }; + var peg$f112 = function() { return "gte"; }; + var peg$f113 = function() { return "gt"; }; + var peg$f114 = function() { return "lte"; }; + var peg$f115 = function() { return "lt"; }; + var peg$f116 = function(o) { if (nameOperator) { - expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found + expectingNestedQuote = (o === "eq" || o === "contains"); } isColumnsContext = false; return o; }; - var peg$f112 = function(c) { return c; }; - var peg$f113 = function(chars) { return chars.join("").trim(); }; - var peg$f114 = function() { + var peg$f117 = function(c) { return c; }; + var peg$f118 = function(chars) { return chars.join("").trim(); }; + var peg$f119 = function() { isColumnsContext = false; return "and"; }; - var peg$f115 = function() { return expectingNestedQuote; }; - var peg$f116 = function(start, inner, end) { //handle no-breaking space + var peg$f120 = function() { return expectingNestedQuote; }; + var peg$f121 = function(start, inner, end) { //handle no-breaking space return [...start, '"', ...inner, '"', ...end].join(""); }; - var peg$f117 = function(start) {return "“"}; - var peg$f118 = function(start) {return "”"}; - var peg$f119 = function(start) {return "\""}; - var peg$f120 = function(start, inner, end) { + var peg$f122 = function(start) {return "“"}; + var peg$f123 = function(start) {return "”"}; + var peg$f124 = function(start) {return "\""}; + var peg$f125 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); }; - var peg$f121 = function() { return "footerCount"; }; - var peg$f122 = function() { return "footerCurrency"; }; - var peg$f123 = function() { return "footerTotal"; }; + var peg$f126 = function() { return "footerCount"; }; + var peg$f127 = function() { return "footerCurrency"; }; + var peg$f128 = function() { return "footerTotal"; }; var peg$currPos = options.peg$currPos | 0; var peg$savedPos = peg$currPos; var peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -1151,67 +1172,76 @@ function peg$parse(input, options) { if (s1 === peg$FAILED) { s1 = peg$parsebillable(); if (s1 === peg$FAILED) { - s1 = peg$parsepolicyID(); + s1 = peg$parsedeclined(); if (s1 === peg$FAILED) { - s1 = peg$parseaction(); + s1 = peg$parseheld(); if (s1 === peg$FAILED) { - s1 = peg$parsedate(); + s1 = peg$parsepolicyID(); if (s1 === peg$FAILED) { - s1 = peg$parsesubmitted(); + s1 = peg$parseaction(); if (s1 === peg$FAILED) { - s1 = peg$parseapproved(); + s1 = peg$parsedate(); if (s1 === peg$FAILED) { - s1 = peg$parsepaid(); + s1 = peg$parsesubmitted(); if (s1 === peg$FAILED) { - s1 = peg$parseexportedTo(); + s1 = peg$parseanyApproval(); if (s1 === peg$FAILED) { - s1 = peg$parseexported(); + s1 = peg$parseapproved(); if (s1 === peg$FAILED) { - s1 = peg$parsewithdrawn(); + s1 = peg$parsepaid(); if (s1 === peg$FAILED) { - s1 = peg$parseposted(); + s1 = peg$parseexportedTo(); if (s1 === peg$FAILED) { - s1 = peg$parsehas(); + s1 = peg$parseexported(); if (s1 === peg$FAILED) { - s1 = peg$parseis(); + s1 = peg$parsewithdrawn(); if (s1 === peg$FAILED) { - s1 = peg$parsepurchaseCurrency(); + s1 = peg$parseposted(); if (s1 === peg$FAILED) { - s1 = peg$parsepurchaseAmount(); + s1 = peg$parsehas(); if (s1 === peg$FAILED) { - s1 = peg$parseamountDebited(); + s1 = peg$parseis(); if (s1 === peg$FAILED) { - s1 = peg$parseamountReimbursed(); + s1 = peg$parsepurchaseCurrency(); if (s1 === peg$FAILED) { - s1 = peg$parseamount(); + s1 = peg$parsepurchaseAmount(); if (s1 === peg$FAILED) { - s1 = peg$parsemerchant(); + s1 = peg$parseamountDebited(); if (s1 === peg$FAILED) { - s1 = peg$parsedescription(); + s1 = peg$parseamountReimbursed(); if (s1 === peg$FAILED) { - s1 = peg$parsereportID(); + s1 = peg$parseamount(); if (s1 === peg$FAILED) { - s1 = peg$parsewithdrawalID(); + s1 = peg$parsemerchant(); if (s1 === peg$FAILED) { - s1 = peg$parsetitle(); + s1 = peg$parsedescription(); if (s1 === peg$FAILED) { - s1 = peg$parsesubmitterUserID(); + s1 = peg$parsereportID(); if (s1 === peg$FAILED) { - s1 = peg$parsesubmitterPayrollID(); + s1 = peg$parsewithdrawalID(); if (s1 === peg$FAILED) { - s1 = peg$parseorderDealNumbers(); + s1 = peg$parsetitle(); if (s1 === peg$FAILED) { - s1 = peg$parsereportFieldDynamic(); + s1 = peg$parsesubmitterUserID(); if (s1 === peg$FAILED) { - s1 = peg$parsecolumns(); + s1 = peg$parsesubmitterPayrollID(); if (s1 === peg$FAILED) { - s1 = peg$parselimit(); + s1 = peg$parseorderDealNumbers(); if (s1 === peg$FAILED) { - s1 = peg$parsefooterCount(); + s1 = peg$parsereportFieldDynamic(); if (s1 === peg$FAILED) { - s1 = peg$parsefooterCurrency(); + s1 = peg$parsecolumns(); if (s1 === peg$FAILED) { - s1 = peg$parsefooterTotal(); + s1 = peg$parselimit(); + if (s1 === peg$FAILED) { + s1 = peg$parsefooterCount(); + if (s1 === peg$FAILED) { + s1 = peg$parsefooterCurrency(); + if (s1 === peg$FAILED) { + s1 = peg$parsefooterTotal(); + } + } + } } } } @@ -2051,13 +2081,13 @@ function peg$parse(input, options) { return s0; } - function peg$parsereimbursable() { + function peg$parsedeclined() { var s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 12); + s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c45) { - peg$currPos += 12; + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e48); } @@ -2071,7 +2101,7 @@ function peg$parse(input, options) { return s0; } - function peg$parsetype() { + function peg$parseheld() { var s0, s1; s0 = peg$currPos; @@ -2091,13 +2121,13 @@ function peg$parse(input, options) { return s0; } - function peg$parsestatus() { + function peg$parsereimbursable() { var s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 6); + s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c47) { - peg$currPos += 6; + peg$currPos += 12; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e50); } @@ -2111,28 +2141,68 @@ function peg$parse(input, options) { return s0; } + function peg$parsetype() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 4); + if (s1.toLowerCase() === peg$c48) { + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e51); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f39(); + } + s0 = s1; + + return s0; + } + + function peg$parsestatus() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 6); + if (s1.toLowerCase() === peg$c49) { + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e52); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f40(); + } + s0 = s1; + + return s0; + } + function peg$parsesortBy() { var s0, s1; - if (input.substr(peg$currPos, 6) === peg$c48) { - s0 = peg$c48; + if (input.substr(peg$currPos, 6) === peg$c50) { + s0 = peg$c50; peg$currPos += 6; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e51); } + if (peg$silentFails === 0) { peg$fail(peg$e53); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 7); - if (s1.toLowerCase() === peg$c49) { + if (s1.toLowerCase() === peg$c51) { peg$currPos += 7; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e52); } + if (peg$silentFails === 0) { peg$fail(peg$e54); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f39(); + s1 = peg$f41(); } s0 = s1; } @@ -2143,25 +2213,25 @@ function peg$parse(input, options) { function peg$parsesortOrder() { var s0, s1; - if (input.substr(peg$currPos, 9) === peg$c50) { - s0 = peg$c50; + if (input.substr(peg$currPos, 9) === peg$c52) { + s0 = peg$c52; peg$currPos += 9; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e53); } + if (peg$silentFails === 0) { peg$fail(peg$e55); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c51) { + if (s1.toLowerCase() === peg$c53) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e54); } + if (peg$silentFails === 0) { peg$fail(peg$e56); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f40(); + s1 = peg$f42(); } s0 = s1; } @@ -2172,25 +2242,25 @@ function peg$parse(input, options) { function peg$parsepolicyID() { var s0, s1; - if (input.substr(peg$currPos, 8) === peg$c52) { - s0 = peg$c52; + if (input.substr(peg$currPos, 8) === peg$c54) { + s0 = peg$c54; peg$currPos += 8; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e55); } + if (peg$silentFails === 0) { peg$fail(peg$e57); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c53) { + if (s1.toLowerCase() === peg$c55) { peg$currPos += 9; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e56); } + if (peg$silentFails === 0) { peg$fail(peg$e58); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f41(); + s1 = peg$f43(); } s0 = s1; } @@ -2203,15 +2273,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c54) { + if (s1.toLowerCase() === peg$c56) { peg$currPos += 9; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e57); } + if (peg$silentFails === 0) { peg$fail(peg$e59); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f42(); + s1 = peg$f44(); } s0 = s1; @@ -2223,35 +2293,70 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c55) { + if (s1.toLowerCase() === peg$c57) { peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e58); } + if (peg$silentFails === 0) { peg$fail(peg$e60); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f43(); + s1 = peg$f45(); } s0 = s1; return s0; } + function peg$parseanyApproval() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c58) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e61); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f46(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 12); + if (s1.toLowerCase() === peg$c59) { + peg$currPos += 12; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e62); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f47(); + } + s0 = s1; + } + + return s0; + } + function peg$parsepaid() { var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c56) { + if (s1.toLowerCase() === peg$c60) { peg$currPos += 4; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e59); } + if (peg$silentFails === 0) { peg$fail(peg$e63); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f44(); + s1 = peg$f48(); } s0 = s1; @@ -2263,15 +2368,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c57) { + if (s1.toLowerCase() === peg$c61) { peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e60); } + if (peg$silentFails === 0) { peg$fail(peg$e64); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f45(); + s1 = peg$f49(); } s0 = s1; @@ -2283,15 +2388,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c58) { + if (s1.toLowerCase() === peg$c62) { peg$currPos += 6; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e61); } + if (peg$silentFails === 0) { peg$fail(peg$e65); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f46(); + s1 = peg$f50(); } s0 = s1; @@ -2303,15 +2408,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c59) { + if (s1.toLowerCase() === peg$c63) { peg$currPos += 9; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e62); } + if (peg$silentFails === 0) { peg$fail(peg$e66); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f47(); + s1 = peg$f51(); } s0 = s1; @@ -2322,24 +2427,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 7); - if (s0.toLowerCase() === peg$c60) { + if (s0.toLowerCase() === peg$c64) { peg$currPos += 7; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e63); } + if (peg$silentFails === 0) { peg$fail(peg$e67); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c61) { + if (s1.toLowerCase() === peg$c65) { peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e64); } + if (peg$silentFails === 0) { peg$fail(peg$e68); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f48(); + s1 = peg$f52(); } s0 = s1; } @@ -2352,15 +2457,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c62) { + if (s1.toLowerCase() === peg$c66) { peg$currPos += 5; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e65); } + if (peg$silentFails === 0) { peg$fail(peg$e69); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f49(); + s1 = peg$f53(); } s0 = s1; @@ -2372,15 +2477,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c63) { + if (s1.toLowerCase() === peg$c67) { peg$currPos += 4; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e66); } + if (peg$silentFails === 0) { peg$fail(peg$e70); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f50(); + s1 = peg$f54(); } s0 = s1; @@ -2392,15 +2497,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c64) { + if (s1.toLowerCase() === peg$c68) { peg$currPos += 5; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e67); } + if (peg$silentFails === 0) { peg$fail(peg$e71); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f51(); + s1 = peg$f55(); } s0 = s1; @@ -2411,24 +2516,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 15); - if (s0.toLowerCase() === peg$c65) { + if (s0.toLowerCase() === peg$c69) { peg$currPos += 15; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e68); } + if (peg$silentFails === 0) { peg$fail(peg$e72); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 17); - if (s1.toLowerCase() === peg$c66) { + if (s1.toLowerCase() === peg$c70) { peg$currPos += 17; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e69); } + if (peg$silentFails === 0) { peg$fail(peg$e73); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f52(); + s1 = peg$f56(); } s0 = s1; } @@ -2440,24 +2545,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 18); - if (s0.toLowerCase() === peg$c67) { + if (s0.toLowerCase() === peg$c71) { peg$currPos += 18; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e70); } + if (peg$silentFails === 0) { peg$fail(peg$e74); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 20); - if (s1.toLowerCase() === peg$c68) { + if (s1.toLowerCase() === peg$c72) { peg$currPos += 20; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e71); } + if (peg$silentFails === 0) { peg$fail(peg$e75); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f53(); + s1 = peg$f57(); } s0 = s1; } @@ -2469,24 +2574,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 16); - if (s0.toLowerCase() === peg$c69) { + if (s0.toLowerCase() === peg$c73) { peg$currPos += 16; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e72); } + if (peg$silentFails === 0) { peg$fail(peg$e76); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 18); - if (s1.toLowerCase() === peg$c70) { + if (s1.toLowerCase() === peg$c74) { peg$currPos += 18; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e73); } + if (peg$silentFails === 0) { peg$fail(peg$e77); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f54(); + s1 = peg$f58(); } s0 = s1; } @@ -2499,15 +2604,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c71) { + if (s1.toLowerCase() === peg$c75) { peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e74); } + if (peg$silentFails === 0) { peg$fail(peg$e78); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f55(); + s1 = peg$f59(); } s0 = s1; @@ -2518,24 +2623,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 9); - if (s0.toLowerCase() === peg$c72) { + if (s0.toLowerCase() === peg$c76) { peg$currPos += 9; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e75); } + if (peg$silentFails === 0) { peg$fail(peg$e79); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c73) { + if (s1.toLowerCase() === peg$c77) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e76); } + if (peg$silentFails === 0) { peg$fail(peg$e80); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f56(); + s1 = peg$f60(); } s0 = s1; } @@ -2548,15 +2653,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c74) { + if (s1.toLowerCase() === peg$c78) { peg$currPos += 6; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e77); } + if (peg$silentFails === 0) { peg$fail(peg$e81); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f57(); + s1 = peg$f61(); } s0 = s1; @@ -2568,15 +2673,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c75) { + if (s1.toLowerCase() === peg$c79) { peg$currPos += 5; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e78); } + if (peg$silentFails === 0) { peg$fail(peg$e82); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f58(); + s1 = peg$f62(); } s0 = s1; @@ -2588,15 +2693,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); - if (s1.toLowerCase() === peg$c76) { + if (s1.toLowerCase() === peg$c80) { peg$currPos += 3; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e79); } + if (peg$silentFails === 0) { peg$fail(peg$e83); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f59(); + s1 = peg$f63(); } s0 = s1; @@ -2608,15 +2713,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 2); - if (s1.toLowerCase() === peg$c77) { + if (s1.toLowerCase() === peg$c81) { peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e80); } + if (peg$silentFails === 0) { peg$fail(peg$e84); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f60(); + s1 = peg$f64(); } s0 = s1; @@ -2627,24 +2732,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 14); - if (s0.toLowerCase() === peg$c78) { + if (s0.toLowerCase() === peg$c82) { peg$currPos += 14; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e81); } + if (peg$silentFails === 0) { peg$fail(peg$e85); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c79) { + if (s1.toLowerCase() === peg$c83) { peg$currPos += 15; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e82); } + if (peg$silentFails === 0) { peg$fail(peg$e86); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f61(); + s1 = peg$f65(); } s0 = s1; } @@ -2656,24 +2761,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 16); - if (s0.toLowerCase() === peg$c80) { + if (s0.toLowerCase() === peg$c84) { peg$currPos += 16; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e83); } + if (peg$silentFails === 0) { peg$fail(peg$e87); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 17); - if (s1.toLowerCase() === peg$c81) { + if (s1.toLowerCase() === peg$c85) { peg$currPos += 17; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e84); } + if (peg$silentFails === 0) { peg$fail(peg$e88); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f62(); + s1 = peg$f66(); } s0 = s1; } @@ -2685,24 +2790,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 13); - if (s0.toLowerCase() === peg$c82) { + if (s0.toLowerCase() === peg$c86) { peg$currPos += 13; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e85); } + if (peg$silentFails === 0) { peg$fail(peg$e89); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c83) { + if (s1.toLowerCase() === peg$c87) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e86); } + if (peg$silentFails === 0) { peg$fail(peg$e90); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f63(); + s1 = peg$f67(); } s0 = s1; } @@ -2714,24 +2819,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 16); - if (s0.toLowerCase() === peg$c84) { + if (s0.toLowerCase() === peg$c88) { peg$currPos += 16; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e87); } + if (peg$silentFails === 0) { peg$fail(peg$e91); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 17); - if (s1.toLowerCase() === peg$c85) { + if (s1.toLowerCase() === peg$c89) { peg$currPos += 17; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e88); } + if (peg$silentFails === 0) { peg$fail(peg$e92); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f64(); + s1 = peg$f68(); } s0 = s1; } @@ -2744,15 +2849,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 7); - if (s1.toLowerCase() === peg$c86) { + if (s1.toLowerCase() === peg$c90) { peg$currPos += 7; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e89); } + if (peg$silentFails === 0) { peg$fail(peg$e93); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f65(); + s1 = peg$f69(); } s0 = s1; @@ -2764,15 +2869,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c87) { + if (s1.toLowerCase() === peg$c91) { peg$currPos += 4; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e90); } + if (peg$silentFails === 0) { peg$fail(peg$e94); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f66(); + s1 = peg$f70(); } s0 = s1; @@ -2784,15 +2889,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 7); - if (s1.toLowerCase() === peg$c88) { + if (s1.toLowerCase() === peg$c92) { peg$currPos += 7; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e91); } + if (peg$silentFails === 0) { peg$fail(peg$e95); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f67(); + s1 = peg$f71(); } s0 = s1; @@ -2804,7 +2909,7 @@ function peg$parse(input, options) { s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f68(); + s1 = peg$f72(); if (s1) { s1 = undefined; } else { @@ -2939,11 +3044,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c89) { + if (s1.toLowerCase() === peg$c93) { peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e92); } + if (peg$silentFails === 0) { peg$fail(peg$e96); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -2958,7 +3063,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f69(); + s0 = peg$f73(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -2976,19 +3081,19 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c90) { + if (s1.toLowerCase() === peg$c94) { peg$currPos += 6; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e93); } + if (peg$silentFails === 0) { peg$fail(peg$e97); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c91) { + if (s1.toLowerCase() === peg$c95) { peg$currPos += 5; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e94); } + if (peg$silentFails === 0) { peg$fail(peg$e98); } } } if (s1 !== peg$FAILED) { @@ -3004,7 +3109,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f70(); + s0 = peg$f74(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3022,11 +3127,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c79) { + if (s1.toLowerCase() === peg$c83) { peg$currPos += 15; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e82); } + if (peg$silentFails === 0) { peg$fail(peg$e86); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3041,7 +3146,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f71(); + s0 = peg$f75(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3059,11 +3164,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); - if (s1.toLowerCase() === peg$c92) { + if (s1.toLowerCase() === peg$c96) { peg$currPos += 3; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e95); } + if (peg$silentFails === 0) { peg$fail(peg$e99); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3078,7 +3183,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f72(); + s0 = peg$f76(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3115,7 +3220,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f73(); + s0 = peg$f77(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3133,11 +3238,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c93) { + if (s1.toLowerCase() === peg$c97) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e96); } + if (peg$silentFails === 0) { peg$fail(peg$e100); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3152,7 +3257,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f74(); + s0 = peg$f78(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3189,7 +3294,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f75(); + s0 = peg$f79(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3226,7 +3331,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f76(); + s0 = peg$f80(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3244,11 +3349,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c94) { + if (s1.toLowerCase() === peg$c98) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e97); } + if (peg$silentFails === 0) { peg$fail(peg$e101); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3263,7 +3368,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f77(); + s0 = peg$f81(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3300,7 +3405,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f78(); + s0 = peg$f82(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3318,11 +3423,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c95) { + if (s1.toLowerCase() === peg$c99) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e98); } + if (peg$silentFails === 0) { peg$fail(peg$e102); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3337,7 +3442,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f79(); + s0 = peg$f83(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3362,7 +3467,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e99); } + if (peg$silentFails === 0) { peg$fail(peg$e103); } } if (s2 !== peg$FAILED) { s1 = [s1, s2]; @@ -3381,11 +3486,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c96) { + if (s1.toLowerCase() === peg$c100) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e100); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3400,7 +3505,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f80(); + s0 = peg$f84(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3412,11 +3517,11 @@ function peg$parse(input, options) { if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c95) { + if (s1.toLowerCase() === peg$c99) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e98); } + if (peg$silentFails === 0) { peg$fail(peg$e102); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3431,7 +3536,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f81(); + s0 = peg$f85(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3450,11 +3555,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); - if (s1.toLowerCase() === peg$c97) { + if (s1.toLowerCase() === peg$c101) { peg$currPos += 13; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3469,7 +3574,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f82(); + s0 = peg$f86(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3487,11 +3592,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 18); - if (s1.toLowerCase() === peg$c98) { + if (s1.toLowerCase() === peg$c102) { peg$currPos += 18; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e102); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3506,7 +3611,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f83(); + s0 = peg$f87(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3524,11 +3629,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 22); - if (s1.toLowerCase() === peg$c99) { + if (s1.toLowerCase() === peg$c103) { peg$currPos += 22; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3543,7 +3648,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f84(); + s0 = peg$f88(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3561,11 +3666,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c100) { + if (s1.toLowerCase() === peg$c104) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3580,7 +3685,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f85(); + s0 = peg$f89(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3598,11 +3703,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c101) { + if (s1.toLowerCase() === peg$c105) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e109); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3617,7 +3722,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f86(); + s0 = peg$f90(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3635,11 +3740,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c102) { + if (s1.toLowerCase() === peg$c106) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3654,7 +3759,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f87(); + s0 = peg$f91(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3672,11 +3777,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c103) { + if (s1.toLowerCase() === peg$c107) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3691,7 +3796,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f88(); + s0 = peg$f92(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3709,11 +3814,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c104) { + if (s1.toLowerCase() === peg$c108) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3728,7 +3833,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f89(); + s0 = peg$f93(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3746,11 +3851,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 18); - if (s1.toLowerCase() === peg$c105) { + if (s1.toLowerCase() === peg$c109) { peg$currPos += 18; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e113); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3765,7 +3870,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f90(); + s0 = peg$f94(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3783,11 +3888,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c106) { + if (s1.toLowerCase() === peg$c110) { peg$currPos += 15; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e114); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3802,7 +3907,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f91(); + s0 = peg$f95(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3820,11 +3925,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 19); - if (s1.toLowerCase() === peg$c107) { + if (s1.toLowerCase() === peg$c111) { peg$currPos += 19; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e115); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3839,7 +3944,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f92(); + s0 = peg$f96(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3857,11 +3962,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 20); - if (s1.toLowerCase() === peg$c108) { + if (s1.toLowerCase() === peg$c112) { peg$currPos += 20; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e116); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3876,7 +3981,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f93(); + s0 = peg$f97(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3894,11 +3999,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 23); - if (s1.toLowerCase() === peg$c109) { + if (s1.toLowerCase() === peg$c113) { peg$currPos += 23; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e117); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3913,7 +4018,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f94(); + s0 = peg$f98(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3931,11 +4036,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c83) { + if (s1.toLowerCase() === peg$c87) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e86); } + if (peg$silentFails === 0) { peg$fail(peg$e90); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3950,7 +4055,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f95(); + s0 = peg$f99(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3968,11 +4073,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 17); - if (s1.toLowerCase() === peg$c85) { + if (s1.toLowerCase() === peg$c89) { peg$currPos += 17; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e88); } + if (peg$silentFails === 0) { peg$fail(peg$e92); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3987,7 +4092,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f96(); + s0 = peg$f100(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4005,11 +4110,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c110) { + if (s1.toLowerCase() === peg$c114) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e114); } + if (peg$silentFails === 0) { peg$fail(peg$e118); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4024,7 +4129,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f97(); + s0 = peg$f101(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4042,11 +4147,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c111) { + if (s1.toLowerCase() === peg$c115) { peg$currPos += 9; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e115); } + if (peg$silentFails === 0) { peg$fail(peg$e119); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4061,7 +4166,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f98(); + s0 = peg$f102(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4079,11 +4184,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c112) { + if (s1.toLowerCase() === peg$c116) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e116); } + if (peg$silentFails === 0) { peg$fail(peg$e120); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4098,7 +4203,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f99(); + s0 = peg$f103(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4116,11 +4221,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c113) { + if (s1.toLowerCase() === peg$c117) { peg$currPos += 9; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e117); } + if (peg$silentFails === 0) { peg$fail(peg$e121); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4135,7 +4240,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f100(); + s0 = peg$f104(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4153,11 +4258,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c114) { + if (s1.toLowerCase() === peg$c118) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e118); } + if (peg$silentFails === 0) { peg$fail(peg$e122); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4172,7 +4277,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f101(); + s0 = peg$f105(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4190,11 +4295,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c115) { + if (s1.toLowerCase() === peg$c119) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e119); } + if (peg$silentFails === 0) { peg$fail(peg$e123); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4209,7 +4314,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f102(); + s0 = peg$f106(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4227,11 +4332,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c116) { + if (s1.toLowerCase() === peg$c120) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e120); } + if (peg$silentFails === 0) { peg$fail(peg$e124); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4246,7 +4351,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f103(); + s0 = peg$f107(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4264,11 +4369,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); - if (s1.toLowerCase() === peg$c117) { + if (s1.toLowerCase() === peg$c121) { peg$currPos += 13; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e121); } + if (peg$silentFails === 0) { peg$fail(peg$e125); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4283,7 +4388,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f104(); + s0 = peg$f108(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4301,88 +4406,103 @@ function peg$parse(input, options) { peg$silentFails++; s0 = peg$currPos; - s1 = input.charAt(peg$currPos); - if (peg$r1.test(s1)) { - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c122) { + s1 = peg$c122; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e99); } + if (peg$silentFails === 0) { peg$fail(peg$e127); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f105(); + s1 = peg$f109(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c118) { - s1 = peg$c118; - peg$currPos += 2; + s1 = input.charAt(peg$currPos); + if (peg$r1.test(s1)) { + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e123); } + if (peg$silentFails === 0) { peg$fail(peg$e103); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f106(); + s1 = peg$f110(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c119) { - s1 = peg$c119; + if (input.substr(peg$currPos, 2) === peg$c123) { + s1 = peg$c123; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e124); } + if (peg$silentFails === 0) { peg$fail(peg$e128); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f107(); + s1 = peg$f111(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 62) { - s1 = peg$c120; - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c124) { + s1 = peg$c124; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e125); } + if (peg$silentFails === 0) { peg$fail(peg$e129); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f108(); + s1 = peg$f112(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c121) { - s1 = peg$c121; - peg$currPos += 2; + if (input.charCodeAt(peg$currPos) === 62) { + s1 = peg$c125; + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e126); } + if (peg$silentFails === 0) { peg$fail(peg$e130); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f109(); + s1 = peg$f113(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 60) { - s1 = peg$c122; - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c126) { + s1 = peg$c126; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e127); } + if (peg$silentFails === 0) { peg$fail(peg$e131); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f110(); + s1 = peg$f114(); } s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 60) { + s1 = peg$c127; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e132); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f115(); + } + s0 = s1; + } } } } @@ -4391,7 +4511,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e122); } + if (peg$silentFails === 0) { peg$fail(peg$e126); } } return s0; @@ -4404,7 +4524,7 @@ function peg$parse(input, options) { s1 = peg$parseoperator(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f111(s1); + s1 = peg$f116(s1); } s0 = s1; @@ -4416,11 +4536,11 @@ function peg$parse(input, options) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c123; + s1 = peg$c128; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e128); } + if (peg$silentFails === 0) { peg$fail(peg$e133); } } if (s1 !== peg$FAILED) { s2 = input.charAt(peg$currPos); @@ -4428,11 +4548,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e129); } + if (peg$silentFails === 0) { peg$fail(peg$e134); } } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f112(s2); + s0 = peg$f117(s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4458,7 +4578,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e131); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } } if (s2 !== peg$FAILED) { @@ -4471,7 +4591,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e131); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } } } @@ -4480,13 +4600,13 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f113(s1); + s1 = peg$f118(s1); } s0 = s1; peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e130); } + if (peg$silentFails === 0) { peg$fail(peg$e135); } } return s0; @@ -4498,7 +4618,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f114(); + s1 = peg$f119(); s0 = s1; return s0; @@ -4514,7 +4634,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -4523,12 +4643,12 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e132); } + if (peg$silentFails === 0) { peg$fail(peg$e137); } return s0; } @@ -4538,7 +4658,7 @@ function peg$parse(input, options) { s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f115(); + s1 = peg$f120(); if (s1) { s1 = undefined; } else { @@ -4575,7 +4695,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4584,7 +4704,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } } s2 = input.charAt(peg$currPos); @@ -4592,7 +4712,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e141); } } if (s2 !== peg$FAILED) { s3 = []; @@ -4603,7 +4723,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } } while (s4 !== peg$FAILED) { @@ -4615,7 +4735,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } } } @@ -4624,7 +4744,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e141); } } if (s4 !== peg$FAILED) { s5 = []; @@ -4633,7 +4753,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e131); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -4642,11 +4762,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e131); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } } peg$savedPos = s0; - s0 = peg$f116(s1, s3, s5); + s0 = peg$f121(s1, s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4658,7 +4778,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e134); } + if (peg$silentFails === 0) { peg$fail(peg$e139); } } return s0; @@ -4675,7 +4795,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4684,7 +4804,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } } s2 = input.charAt(peg$currPos); @@ -4692,7 +4812,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e141); } } if (s2 !== peg$FAILED) { s3 = []; @@ -4703,7 +4823,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -4719,15 +4839,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c124; + s6 = peg$c129; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e143); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f117(s1); + s4 = peg$f122(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4750,15 +4870,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c125; + s6 = peg$c130; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e144); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f118(s1); + s4 = peg$f123(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4781,15 +4901,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c126; + s6 = peg$c131; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e140); } + if (peg$silentFails === 0) { peg$fail(peg$e145); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f119(s1); + s4 = peg$f124(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4811,7 +4931,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -4827,15 +4947,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c124; + s6 = peg$c129; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e143); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f117(s1); + s4 = peg$f122(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4858,15 +4978,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c125; + s6 = peg$c130; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e144); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f118(s1); + s4 = peg$f123(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4889,15 +5009,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c126; + s6 = peg$c131; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e140); } + if (peg$silentFails === 0) { peg$fail(peg$e145); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f119(s1); + s4 = peg$f124(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4914,7 +5034,7 @@ function peg$parse(input, options) { s4 = peg$parseclosingQuote(); if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f120(s1, s3, s4); + s0 = peg$f125(s1, s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4926,7 +5046,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e134); } + if (peg$silentFails === 0) { peg$fail(peg$e139); } } return s0; @@ -4941,7 +5061,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e141); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4979,7 +5099,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e141); } + if (peg$silentFails === 0) { peg$fail(peg$e146); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4988,7 +5108,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e141); } + if (peg$silentFails === 0) { peg$fail(peg$e146); } } } s2 = []; @@ -4997,7 +5117,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e142); } + if (peg$silentFails === 0) { peg$fail(peg$e147); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -5006,7 +5126,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e142); } + if (peg$silentFails === 0) { peg$fail(peg$e147); } } } s3 = []; @@ -5015,7 +5135,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e143); } + if (peg$silentFails === 0) { peg$fail(peg$e148); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -5024,7 +5144,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e143); } + if (peg$silentFails === 0) { peg$fail(peg$e148); } } } s4 = peg$parseoperator(); @@ -5043,7 +5163,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e144); } + if (peg$silentFails === 0) { peg$fail(peg$e149); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -5052,7 +5172,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e144); } + if (peg$silentFails === 0) { peg$fail(peg$e149); } } } s2 = peg$currPos; @@ -5099,7 +5219,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e145); } + if (peg$silentFails === 0) { peg$fail(peg$e150); } } } } @@ -5115,7 +5235,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e146); } + if (peg$silentFails === 0) { peg$fail(peg$e151); } } if (s0 === peg$FAILED) { s0 = peg$currPos; @@ -5144,24 +5264,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c127) { + if (s1.toLowerCase() === peg$c132) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e147); } + if (peg$silentFails === 0) { peg$fail(peg$e152); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c128) { + if (s1.toLowerCase() === peg$c133) { peg$currPos += 12; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e148); } + if (peg$silentFails === 0) { peg$fail(peg$e153); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f121(); + s1 = peg$f126(); } s0 = s1; @@ -5173,24 +5293,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c129) { + if (s1.toLowerCase() === peg$c134) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e149); } + if (peg$silentFails === 0) { peg$fail(peg$e154); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c130) { + if (s1.toLowerCase() === peg$c135) { peg$currPos += 15; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e150); } + if (peg$silentFails === 0) { peg$fail(peg$e155); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f122(); + s1 = peg$f127(); } s0 = s1; @@ -5202,24 +5322,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c131) { + if (s1.toLowerCase() === peg$c136) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e151); } + if (peg$silentFails === 0) { peg$fail(peg$e156); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c132) { + if (s1.toLowerCase() === peg$c137) { peg$currPos += 12; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e152); } + if (peg$silentFails === 0) { peg$fail(peg$e157); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f123(); + s1 = peg$f128(); } s0 = s1; diff --git a/src/libs/SearchParser/autocompleteParser.peggy b/src/libs/SearchParser/autocompleteParser.peggy index 47773b51e148..35dae8ec51d3 100644 --- a/src/libs/SearchParser/autocompleteParser.peggy +++ b/src/libs/SearchParser/autocompleteParser.peggy @@ -40,6 +40,12 @@ defaultFilter const isNegated = !!neg && !nonNegatableKeys.has(key); + // The "contains" operator is only supported for the merchant field + if (op === "contains" && (neg || key !== "merchant")) { + autocomplete = null; + return; + } + if (!value) { autocomplete = { key, @@ -105,10 +111,13 @@ autocompleteKey "key" / view / reimbursable / billable + / declined + / held / policyID / action / date / submitted + / anyApproval / approved / paid / exportedTo diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy index a0e24d1e000e..b89c857c3875 100644 --- a/src/libs/SearchParser/baseRules.peggy +++ b/src/libs/SearchParser/baseRules.peggy @@ -68,6 +68,8 @@ withdrawalID = "withdrawalID"i / "withdrawal-id"i { return "withdrawalID"; } billable = "billable"i { return "billable"; } +declined = "declined"i { return "declined"; } +held = "held"i { return "held"; } reimbursable = "reimbursable"i { return "reimbursable"; } type = "type"i { return "type"; } status = "status"i { return "status"; } @@ -82,6 +84,9 @@ policyID / "workspace"i { return "policyID"; } submitted = "submitted"i { return "submitted"; } approved = "approved"i { return "approved"; } +anyApproval + = "anyApproval"i { return "anyApproval"; } + / "any-approval"i { return "anyApproval"; } paid = "paid"i { return "paid"; } exported = "exported"i { return "exported"; } posted = "posted"i { return "posted"; } @@ -191,7 +196,8 @@ groupQuarter = "group-quarter"i &wordBoundary { return "gr operator "operator" - = (":" / "=") { return "eq"; } + = "*:" { return "contains"; } + / (":" / "=") { return "eq"; } / "!=" { return "neq"; } / ">=" { return "gte"; } / ">" { return "gt"; } @@ -201,7 +207,7 @@ operator "operator" filterOperator = o:operator { if (nameOperator) { - expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found + expectingNestedQuote = (o === "eq" || o === "contains"); } isColumnsContext = false; return o; diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index 68bf5cb29072..c495e910937b 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -229,94 +229,99 @@ function peg$parse(input, options) { var peg$c42 = "withdrawalid"; var peg$c43 = "withdrawal-id"; var peg$c44 = "billable"; - var peg$c45 = "reimbursable"; - var peg$c46 = "type"; - var peg$c47 = "status"; - var peg$c48 = "sortBy"; - var peg$c49 = "sort-by"; - var peg$c50 = "sortOrder"; - var peg$c51 = "sort-order"; - var peg$c52 = "policyID"; - var peg$c53 = "workspace"; - var peg$c54 = "submitted"; - var peg$c55 = "approved"; - var peg$c56 = "paid"; - var peg$c57 = "exported"; - var peg$c58 = "posted"; - var peg$c59 = "withdrawn"; - var peg$c60 = "groupby"; - var peg$c61 = "group-by"; - var peg$c62 = "limit"; - var peg$c63 = "feed"; - var peg$c64 = "title"; - var peg$c65 = "submitteruserid"; - var peg$c66 = "submitter-user-id"; - var peg$c67 = "submitterpayrollid"; - var peg$c68 = "submitter-payroll-id"; - var peg$c69 = "orderdealnumbers"; - var peg$c70 = "order-deal-numbers"; - var peg$c71 = "assignee"; - var peg$c72 = "createdby"; - var peg$c73 = "created-by"; - var peg$c74 = "action"; - var peg$c75 = "total"; - var peg$c76 = "has"; - var peg$c77 = "is"; - var peg$c78 = "purchaseamount"; - var peg$c79 = "purchase-amount"; - var peg$c80 = "purchasecurrency"; - var peg$c81 = "purchase-currency"; - var peg$c82 = "amountdebited"; - var peg$c83 = "amount-debited"; - var peg$c84 = "amountreimbursed"; - var peg$c85 = "amount-reimbursed"; - var peg$c86 = "columns"; - var peg$c87 = "view"; - var peg$c88 = "compare"; - var peg$c89 = "per-diem"; - var peg$c90 = "drafts"; - var peg$c91 = "draft"; - var peg$c92 = "tax"; - var peg$c93 = "policy-name"; - var peg$c94 = "long-report-id"; - var peg$c95 = "exported-to"; - var peg$c96 = "exportedto"; - var peg$c97 = "exchange-rate"; - var peg$c98 = "reimbursable-total"; - var peg$c99 = "non-reimbursable-total"; - var peg$c100 = "group-from"; - var peg$c101 = "group-expenses"; - var peg$c102 = "group-total"; - var peg$c103 = "group-card"; - var peg$c104 = "group-feed"; - var peg$c105 = "group-bank-account"; - var peg$c106 = "group-withdrawn"; - var peg$c107 = "group-withdrawal-id"; - var peg$c108 = "group-amount-debited"; - var peg$c109 = "group-amount-reimbursed"; - var peg$c110 = "group-category"; - var peg$c111 = "group-tag"; - var peg$c112 = "group-merchant"; - var peg$c113 = "group-day"; - var peg$c114 = "group-month"; - var peg$c115 = "group-week"; - var peg$c116 = "group-year"; - var peg$c117 = "group-quarter"; - var peg$c118 = "!="; - var peg$c119 = ">="; - var peg$c120 = ">"; - var peg$c121 = "<="; - var peg$c122 = "<"; - var peg$c123 = "\\"; - var peg$c124 = "\u201C"; - var peg$c125 = "\u201D"; - var peg$c126 = "\""; - var peg$c127 = "footercount"; - var peg$c128 = "footer-count"; - var peg$c129 = "footercurrency"; - var peg$c130 = "footer-currency"; - var peg$c131 = "footertotal"; - var peg$c132 = "footer-total"; + var peg$c45 = "declined"; + var peg$c46 = "held"; + var peg$c47 = "reimbursable"; + var peg$c48 = "type"; + var peg$c49 = "status"; + var peg$c50 = "sortBy"; + var peg$c51 = "sort-by"; + var peg$c52 = "sortOrder"; + var peg$c53 = "sort-order"; + var peg$c54 = "policyID"; + var peg$c55 = "workspace"; + var peg$c56 = "submitted"; + var peg$c57 = "approved"; + var peg$c58 = "anyapproval"; + var peg$c59 = "any-approval"; + var peg$c60 = "paid"; + var peg$c61 = "exported"; + var peg$c62 = "posted"; + var peg$c63 = "withdrawn"; + var peg$c64 = "groupby"; + var peg$c65 = "group-by"; + var peg$c66 = "limit"; + var peg$c67 = "feed"; + var peg$c68 = "title"; + var peg$c69 = "submitteruserid"; + var peg$c70 = "submitter-user-id"; + var peg$c71 = "submitterpayrollid"; + var peg$c72 = "submitter-payroll-id"; + var peg$c73 = "orderdealnumbers"; + var peg$c74 = "order-deal-numbers"; + var peg$c75 = "assignee"; + var peg$c76 = "createdby"; + var peg$c77 = "created-by"; + var peg$c78 = "action"; + var peg$c79 = "total"; + var peg$c80 = "has"; + var peg$c81 = "is"; + var peg$c82 = "purchaseamount"; + var peg$c83 = "purchase-amount"; + var peg$c84 = "purchasecurrency"; + var peg$c85 = "purchase-currency"; + var peg$c86 = "amountdebited"; + var peg$c87 = "amount-debited"; + var peg$c88 = "amountreimbursed"; + var peg$c89 = "amount-reimbursed"; + var peg$c90 = "columns"; + var peg$c91 = "view"; + var peg$c92 = "compare"; + var peg$c93 = "per-diem"; + var peg$c94 = "drafts"; + var peg$c95 = "draft"; + var peg$c96 = "tax"; + var peg$c97 = "policy-name"; + var peg$c98 = "long-report-id"; + var peg$c99 = "exported-to"; + var peg$c100 = "exportedto"; + var peg$c101 = "exchange-rate"; + var peg$c102 = "reimbursable-total"; + var peg$c103 = "non-reimbursable-total"; + var peg$c104 = "group-from"; + var peg$c105 = "group-expenses"; + var peg$c106 = "group-total"; + var peg$c107 = "group-card"; + var peg$c108 = "group-feed"; + var peg$c109 = "group-bank-account"; + var peg$c110 = "group-withdrawn"; + var peg$c111 = "group-withdrawal-id"; + var peg$c112 = "group-amount-debited"; + var peg$c113 = "group-amount-reimbursed"; + var peg$c114 = "group-category"; + var peg$c115 = "group-tag"; + var peg$c116 = "group-merchant"; + var peg$c117 = "group-day"; + var peg$c118 = "group-month"; + var peg$c119 = "group-week"; + var peg$c120 = "group-year"; + var peg$c121 = "group-quarter"; + var peg$c122 = "*:"; + var peg$c123 = "!="; + var peg$c124 = ">="; + var peg$c125 = ">"; + var peg$c126 = "<="; + var peg$c127 = "<"; + var peg$c128 = "\\"; + var peg$c129 = "\u201C"; + var peg$c130 = "\u201D"; + var peg$c131 = "\""; + var peg$c132 = "footercount"; + var peg$c133 = "footer-count"; + var peg$c134 = "footercurrency"; + var peg$c135 = "footer-currency"; + var peg$c136 = "footertotal"; + var peg$c137 = "footer-total"; var peg$r0 = /^[^ \t\r\n\xA0]/; var peg$r1 = /^[ \t\r\n\xA0,:=<>!]/; @@ -384,111 +389,116 @@ function peg$parse(input, options) { var peg$e47 = peg$literalExpectation("withdrawalID", true); var peg$e48 = peg$literalExpectation("withdrawal-id", true); var peg$e49 = peg$literalExpectation("billable", true); - var peg$e50 = peg$literalExpectation("reimbursable", true); - var peg$e51 = peg$literalExpectation("type", true); - var peg$e52 = peg$literalExpectation("status", true); - var peg$e53 = peg$literalExpectation("sortBy", false); - var peg$e54 = peg$literalExpectation("sort-by", true); - var peg$e55 = peg$literalExpectation("sortOrder", false); - var peg$e56 = peg$literalExpectation("sort-order", true); - var peg$e57 = peg$literalExpectation("policyID", false); - var peg$e58 = peg$literalExpectation("workspace", true); - var peg$e59 = peg$literalExpectation("submitted", true); - var peg$e60 = peg$literalExpectation("approved", true); - var peg$e61 = peg$literalExpectation("paid", true); - var peg$e62 = peg$literalExpectation("exported", true); - var peg$e63 = peg$literalExpectation("posted", true); - var peg$e64 = peg$literalExpectation("withdrawn", true); - var peg$e65 = peg$literalExpectation("groupBy", true); - var peg$e66 = peg$literalExpectation("group-by", true); - var peg$e67 = peg$literalExpectation("limit", true); - var peg$e68 = peg$literalExpectation("feed", true); - var peg$e69 = peg$literalExpectation("title", true); - var peg$e70 = peg$literalExpectation("submitterUserID", true); - var peg$e71 = peg$literalExpectation("submitter-user-id", true); - var peg$e72 = peg$literalExpectation("submitterPayrollID", true); - var peg$e73 = peg$literalExpectation("submitter-payroll-id", true); - var peg$e74 = peg$literalExpectation("orderDealNumbers", true); - var peg$e75 = peg$literalExpectation("order-deal-numbers", true); - var peg$e76 = peg$literalExpectation("assignee", true); - var peg$e77 = peg$literalExpectation("createdBy", true); - var peg$e78 = peg$literalExpectation("created-by", true); - var peg$e79 = peg$literalExpectation("action", true); - var peg$e80 = peg$literalExpectation("total", true); - var peg$e81 = peg$literalExpectation("has", true); - var peg$e82 = peg$literalExpectation("is", true); - var peg$e83 = peg$literalExpectation("purchaseAmount", true); - var peg$e84 = peg$literalExpectation("purchase-amount", true); - var peg$e85 = peg$literalExpectation("purchaseCurrency", true); - var peg$e86 = peg$literalExpectation("purchase-currency", true); - var peg$e87 = peg$literalExpectation("amountDebited", true); - var peg$e88 = peg$literalExpectation("amount-debited", true); - var peg$e89 = peg$literalExpectation("amountReimbursed", true); - var peg$e90 = peg$literalExpectation("amount-reimbursed", true); - var peg$e91 = peg$literalExpectation("columns", true); - var peg$e92 = peg$literalExpectation("view", true); - var peg$e93 = peg$literalExpectation("compare", true); - var peg$e94 = peg$literalExpectation("per-diem", true); - var peg$e95 = peg$literalExpectation("drafts", true); - var peg$e96 = peg$literalExpectation("draft", true); - var peg$e97 = peg$literalExpectation("tax", true); - var peg$e98 = peg$literalExpectation("policy-name", true); - var peg$e99 = peg$literalExpectation("long-report-id", true); - var peg$e100 = peg$literalExpectation("exported-to", true); - var peg$e101 = peg$classExpectation([":", "="], false, false); - var peg$e102 = peg$literalExpectation("exportedTo", true); - var peg$e103 = peg$literalExpectation("exchange-rate", true); - var peg$e104 = peg$literalExpectation("reimbursable-total", true); - var peg$e105 = peg$literalExpectation("non-reimbursable-total", true); - var peg$e106 = peg$literalExpectation("group-from", true); - var peg$e107 = peg$literalExpectation("group-expenses", true); - var peg$e108 = peg$literalExpectation("group-total", true); - var peg$e109 = peg$literalExpectation("group-card", true); - var peg$e110 = peg$literalExpectation("group-feed", true); - var peg$e111 = peg$literalExpectation("group-bank-account", true); - var peg$e112 = peg$literalExpectation("group-withdrawn", true); - var peg$e113 = peg$literalExpectation("group-withdrawal-id", true); - var peg$e114 = peg$literalExpectation("group-amount-debited", true); - var peg$e115 = peg$literalExpectation("group-amount-reimbursed", true); - var peg$e116 = peg$literalExpectation("group-category", true); - var peg$e117 = peg$literalExpectation("group-tag", true); - var peg$e118 = peg$literalExpectation("group-merchant", true); - var peg$e119 = peg$literalExpectation("group-day", true); - var peg$e120 = peg$literalExpectation("group-month", true); - var peg$e121 = peg$literalExpectation("group-week", true); - var peg$e122 = peg$literalExpectation("group-year", true); - var peg$e123 = peg$literalExpectation("group-quarter", true); - var peg$e124 = peg$otherExpectation("operator"); - var peg$e125 = peg$literalExpectation("!=", false); - var peg$e126 = peg$literalExpectation(">=", false); - var peg$e127 = peg$literalExpectation(">", false); - var peg$e128 = peg$literalExpectation("<=", false); - var peg$e129 = peg$literalExpectation("<", false); - var peg$e130 = peg$literalExpectation("\\", false); - var peg$e131 = peg$classExpectation([",", "\"", "\u201D", "\u201C", "\\"], false, false); - var peg$e132 = peg$otherExpectation("word"); - var peg$e133 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e134 = peg$otherExpectation("whitespace"); - var peg$e135 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); - var peg$e136 = peg$otherExpectation("quote"); - var peg$e137 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e138 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); - var peg$e139 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); - var peg$e140 = peg$literalExpectation("\u201C", false); - var peg$e141 = peg$literalExpectation("\u201D", false); - var peg$e142 = peg$literalExpectation("\"", false); - var peg$e143 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e144 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e145 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); - var peg$e146 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); - var peg$e147 = peg$classExpectation([","], false, false); - var peg$e148 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); - var peg$e149 = peg$literalExpectation("footerCount", true); - var peg$e150 = peg$literalExpectation("footer-count", true); - var peg$e151 = peg$literalExpectation("footerCurrency", true); - var peg$e152 = peg$literalExpectation("footer-currency", true); - var peg$e153 = peg$literalExpectation("footerTotal", true); - var peg$e154 = peg$literalExpectation("footer-total", true); + var peg$e50 = peg$literalExpectation("declined", true); + var peg$e51 = peg$literalExpectation("held", true); + var peg$e52 = peg$literalExpectation("reimbursable", true); + var peg$e53 = peg$literalExpectation("type", true); + var peg$e54 = peg$literalExpectation("status", true); + var peg$e55 = peg$literalExpectation("sortBy", false); + var peg$e56 = peg$literalExpectation("sort-by", true); + var peg$e57 = peg$literalExpectation("sortOrder", false); + var peg$e58 = peg$literalExpectation("sort-order", true); + var peg$e59 = peg$literalExpectation("policyID", false); + var peg$e60 = peg$literalExpectation("workspace", true); + var peg$e61 = peg$literalExpectation("submitted", true); + var peg$e62 = peg$literalExpectation("approved", true); + var peg$e63 = peg$literalExpectation("anyApproval", true); + var peg$e64 = peg$literalExpectation("any-approval", true); + var peg$e65 = peg$literalExpectation("paid", true); + var peg$e66 = peg$literalExpectation("exported", true); + var peg$e67 = peg$literalExpectation("posted", true); + var peg$e68 = peg$literalExpectation("withdrawn", true); + var peg$e69 = peg$literalExpectation("groupBy", true); + var peg$e70 = peg$literalExpectation("group-by", true); + var peg$e71 = peg$literalExpectation("limit", true); + var peg$e72 = peg$literalExpectation("feed", true); + var peg$e73 = peg$literalExpectation("title", true); + var peg$e74 = peg$literalExpectation("submitterUserID", true); + var peg$e75 = peg$literalExpectation("submitter-user-id", true); + var peg$e76 = peg$literalExpectation("submitterPayrollID", true); + var peg$e77 = peg$literalExpectation("submitter-payroll-id", true); + var peg$e78 = peg$literalExpectation("orderDealNumbers", true); + var peg$e79 = peg$literalExpectation("order-deal-numbers", true); + var peg$e80 = peg$literalExpectation("assignee", true); + var peg$e81 = peg$literalExpectation("createdBy", true); + var peg$e82 = peg$literalExpectation("created-by", true); + var peg$e83 = peg$literalExpectation("action", true); + var peg$e84 = peg$literalExpectation("total", true); + var peg$e85 = peg$literalExpectation("has", true); + var peg$e86 = peg$literalExpectation("is", true); + var peg$e87 = peg$literalExpectation("purchaseAmount", true); + var peg$e88 = peg$literalExpectation("purchase-amount", true); + var peg$e89 = peg$literalExpectation("purchaseCurrency", true); + var peg$e90 = peg$literalExpectation("purchase-currency", true); + var peg$e91 = peg$literalExpectation("amountDebited", true); + var peg$e92 = peg$literalExpectation("amount-debited", true); + var peg$e93 = peg$literalExpectation("amountReimbursed", true); + var peg$e94 = peg$literalExpectation("amount-reimbursed", true); + var peg$e95 = peg$literalExpectation("columns", true); + var peg$e96 = peg$literalExpectation("view", true); + var peg$e97 = peg$literalExpectation("compare", true); + var peg$e98 = peg$literalExpectation("per-diem", true); + var peg$e99 = peg$literalExpectation("drafts", true); + var peg$e100 = peg$literalExpectation("draft", true); + var peg$e101 = peg$literalExpectation("tax", true); + var peg$e102 = peg$literalExpectation("policy-name", true); + var peg$e103 = peg$literalExpectation("long-report-id", true); + var peg$e104 = peg$literalExpectation("exported-to", true); + var peg$e105 = peg$classExpectation([":", "="], false, false); + var peg$e106 = peg$literalExpectation("exportedTo", true); + var peg$e107 = peg$literalExpectation("exchange-rate", true); + var peg$e108 = peg$literalExpectation("reimbursable-total", true); + var peg$e109 = peg$literalExpectation("non-reimbursable-total", true); + var peg$e110 = peg$literalExpectation("group-from", true); + var peg$e111 = peg$literalExpectation("group-expenses", true); + var peg$e112 = peg$literalExpectation("group-total", true); + var peg$e113 = peg$literalExpectation("group-card", true); + var peg$e114 = peg$literalExpectation("group-feed", true); + var peg$e115 = peg$literalExpectation("group-bank-account", true); + var peg$e116 = peg$literalExpectation("group-withdrawn", true); + var peg$e117 = peg$literalExpectation("group-withdrawal-id", true); + var peg$e118 = peg$literalExpectation("group-amount-debited", true); + var peg$e119 = peg$literalExpectation("group-amount-reimbursed", true); + var peg$e120 = peg$literalExpectation("group-category", true); + var peg$e121 = peg$literalExpectation("group-tag", true); + var peg$e122 = peg$literalExpectation("group-merchant", true); + var peg$e123 = peg$literalExpectation("group-day", true); + var peg$e124 = peg$literalExpectation("group-month", true); + var peg$e125 = peg$literalExpectation("group-week", true); + var peg$e126 = peg$literalExpectation("group-year", true); + var peg$e127 = peg$literalExpectation("group-quarter", true); + var peg$e128 = peg$otherExpectation("operator"); + var peg$e129 = peg$literalExpectation("*:", false); + var peg$e130 = peg$literalExpectation("!=", false); + var peg$e131 = peg$literalExpectation(">=", false); + var peg$e132 = peg$literalExpectation(">", false); + var peg$e133 = peg$literalExpectation("<=", false); + var peg$e134 = peg$literalExpectation("<", false); + var peg$e135 = peg$literalExpectation("\\", false); + var peg$e136 = peg$classExpectation([",", "\"", "\u201D", "\u201C", "\\"], false, false); + var peg$e137 = peg$otherExpectation("word"); + var peg$e138 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e139 = peg$otherExpectation("whitespace"); + var peg$e140 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); + var peg$e141 = peg$otherExpectation("quote"); + var peg$e142 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e143 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e144 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); + var peg$e145 = peg$literalExpectation("\u201C", false); + var peg$e146 = peg$literalExpectation("\u201D", false); + var peg$e147 = peg$literalExpectation("\"", false); + var peg$e148 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e149 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e150 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); + var peg$e151 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); + var peg$e152 = peg$classExpectation([","], false, false); + var peg$e153 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + var peg$e154 = peg$literalExpectation("footerCount", true); + var peg$e155 = peg$literalExpectation("footer-count", true); + var peg$e156 = peg$literalExpectation("footerCurrency", true); + var peg$e157 = peg$literalExpectation("footer-currency", true); + var peg$e158 = peg$literalExpectation("footerTotal", true); + var peg$e159 = peg$literalExpectation("footer-total", true); var peg$f0 = function(filters) { return applyDefaults(filters); }; var peg$f1 = function(head, tail) { @@ -527,6 +537,10 @@ function peg$parse(input, options) { ); }; var peg$f2 = function(key, op, value) { + // The "contains" operator is only supported for the merchant field + if (op === "contains") { + return buildFilter("eq", "keyword", text().trim()); + } updateDefaultValues(key, value); return {...buildFilter(op, key, value), isDefault: true}; }; @@ -544,7 +558,17 @@ function peg$parse(input, options) { expectingNestedQuote = false; nameOperator = false; const key = field; - let operator = op; + let operator = op.value; + + // Positive `merchant:` filters predate explicit match types, so they must retain contains semantics. + if (!neg && key === "merchant" && op.token === ":") { + operator = "contains"; + } + + // The "contains" operator is only supported for the merchant field + if (operator === "contains" && (neg || key !== "merchant")) { + return buildFilter("eq", "keyword", text().trim()); + } // Apply negation only when "-" is present and the key can be negated if (neg && !nonNegatableKeys.has(key)) { @@ -555,15 +579,16 @@ function peg$parse(input, options) { return buildFilter(operator, key, values); }; - var peg$f5 = function(rest) { + var peg$f5 = function(value) { return {value, token: text()}; }; + var peg$f6 = function(rest) { const suffix = rest.replaceAll(/^-+/g, ""); return "reportField-" + (suffix ? suffix : ""); }; - var peg$f6 = function(k) { + var peg$f7 = function(k) { nameOperator = (k === "from" || k === "to" || k === "payer" || k === "paidBy" || k === "exporter" || k === "attendee" || k === "createdBy" || k === "assignee"); return k; }; - var peg$f7 = function(parts, empty) { + var peg$f8 = function(parts, empty) { const value = parts.flat().filter(Boolean).map((word) => { if (word.startsWith('"') && word.endsWith('"') && word.length >= 2) { return word.slice(1, -1); @@ -575,138 +600,143 @@ function peg$parse(input, options) { } return value[0]; }; - var peg$f8 = function() { return "date"; }; - var peg$f9 = function() { return "amount"; }; - var peg$f10 = function() { return "merchant"; }; - var peg$f11 = function() { return "description"; }; - var peg$f12 = function() { return "reportID"; }; - var peg$f13 = function() { return "keyword"; }; - var peg$f14 = function() { return "in"; }; - var peg$f15 = function() { return "currency"; }; - var peg$f16 = function() { return "groupCurrency"; }; - var peg$f17 = function() { return "tag"; }; - var peg$f18 = function() { return "category"; }; - var peg$f19 = function() { return "to"; }; - var peg$f20 = function() { return "exporter"; }; - var peg$f21 = function() { return "payer"; }; - var peg$f22 = function() { return "paidBy"; }; - var peg$f23 = function() { return "taxRate"; }; - var peg$f24 = function() { return "cardID"; }; - var peg$f25 = function() { return "bankAccount"; }; - var peg$f26 = function() { return "from"; }; - var peg$f27 = function() {return "attendee"}; - var peg$f28 = function() { return "expenseType"; }; - var peg$f29 = function() { return "receiptType"; }; - var peg$f30 = function() { return "transactionStatus"; }; - var peg$f31 = function() { return "withdrawalType"; }; - var peg$f32 = function() { return "withdrawalStatus"; }; - var peg$f33 = function() { return "paidStatus"; }; - var peg$f34 = function() { return "withdrawalID"; }; - var peg$f35 = function() { return "billable"; }; - var peg$f36 = function() { return "reimbursable"; }; - var peg$f37 = function() { return "type"; }; - var peg$f38 = function() { return "status"; }; - var peg$f39 = function() { return "sortBy"; }; - var peg$f40 = function() { return "sortOrder"; }; - var peg$f41 = function() { return "policyID"; }; - var peg$f42 = function() { return "submitted"; }; - var peg$f43 = function() { return "approved"; }; - var peg$f44 = function() { return "paid"; }; - var peg$f45 = function() { return "exported"; }; - var peg$f46 = function() { return "posted"; }; - var peg$f47 = function() { return "withdrawn"; }; - var peg$f48 = function() { return "groupBy"; }; - var peg$f49 = function() { return "limit"; }; - var peg$f50 = function() { return "feed"; }; - var peg$f51 = function() { return "title"; }; - var peg$f52 = function() { return "submitterUserID"; }; - var peg$f53 = function() { return "submitterPayrollID"; }; - var peg$f54 = function() { return "orderDealNumbers"; }; - var peg$f55 = function() { return "assignee"; }; - var peg$f56 = function() { return "createdBy"; }; - var peg$f57 = function() { return "action"; }; - var peg$f58 = function() {return "total"; }; - var peg$f59 = function() {return "has"; }; - var peg$f60 = function() {return "is"; }; - var peg$f61 = function() {return "purchaseAmount"}; - var peg$f62 = function() {return "purchaseCurrency"}; - var peg$f63 = function() {return "amountDebited"}; - var peg$f64 = function() {return "amountReimbursed"}; - var peg$f65 = function() { + var peg$f9 = function() { return "date"; }; + var peg$f10 = function() { return "amount"; }; + var peg$f11 = function() { return "merchant"; }; + var peg$f12 = function() { return "description"; }; + var peg$f13 = function() { return "reportID"; }; + var peg$f14 = function() { return "keyword"; }; + var peg$f15 = function() { return "in"; }; + var peg$f16 = function() { return "currency"; }; + var peg$f17 = function() { return "groupCurrency"; }; + var peg$f18 = function() { return "tag"; }; + var peg$f19 = function() { return "category"; }; + var peg$f20 = function() { return "to"; }; + var peg$f21 = function() { return "exporter"; }; + var peg$f22 = function() { return "payer"; }; + var peg$f23 = function() { return "paidBy"; }; + var peg$f24 = function() { return "taxRate"; }; + var peg$f25 = function() { return "cardID"; }; + var peg$f26 = function() { return "bankAccount"; }; + var peg$f27 = function() { return "from"; }; + var peg$f28 = function() {return "attendee"}; + var peg$f29 = function() { return "expenseType"; }; + var peg$f30 = function() { return "receiptType"; }; + var peg$f31 = function() { return "transactionStatus"; }; + var peg$f32 = function() { return "withdrawalType"; }; + var peg$f33 = function() { return "withdrawalStatus"; }; + var peg$f34 = function() { return "paidStatus"; }; + var peg$f35 = function() { return "withdrawalID"; }; + var peg$f36 = function() { return "billable"; }; + var peg$f37 = function() { return "declined"; }; + var peg$f38 = function() { return "held"; }; + var peg$f39 = function() { return "reimbursable"; }; + var peg$f40 = function() { return "type"; }; + var peg$f41 = function() { return "status"; }; + var peg$f42 = function() { return "sortBy"; }; + var peg$f43 = function() { return "sortOrder"; }; + var peg$f44 = function() { return "policyID"; }; + var peg$f45 = function() { return "submitted"; }; + var peg$f46 = function() { return "approved"; }; + var peg$f47 = function() { return "anyApproval"; }; + var peg$f48 = function() { return "anyApproval"; }; + var peg$f49 = function() { return "paid"; }; + var peg$f50 = function() { return "exported"; }; + var peg$f51 = function() { return "posted"; }; + var peg$f52 = function() { return "withdrawn"; }; + var peg$f53 = function() { return "groupBy"; }; + var peg$f54 = function() { return "limit"; }; + var peg$f55 = function() { return "feed"; }; + var peg$f56 = function() { return "title"; }; + var peg$f57 = function() { return "submitterUserID"; }; + var peg$f58 = function() { return "submitterPayrollID"; }; + var peg$f59 = function() { return "orderDealNumbers"; }; + var peg$f60 = function() { return "assignee"; }; + var peg$f61 = function() { return "createdBy"; }; + var peg$f62 = function() { return "action"; }; + var peg$f63 = function() {return "total"; }; + var peg$f64 = function() {return "has"; }; + var peg$f65 = function() {return "is"; }; + var peg$f66 = function() {return "purchaseAmount"}; + var peg$f67 = function() {return "purchaseCurrency"}; + var peg$f68 = function() {return "amountDebited"}; + var peg$f69 = function() {return "amountReimbursed"}; + var peg$f70 = function() { isColumnsContext = true; return "columns"; }; - var peg$f66 = function() {return "view"}; - var peg$f67 = function() {return "compare"}; - var peg$f68 = function() { return isColumnsContext; }; - var peg$f69 = function() { return "perDiem"; }; - var peg$f70 = function() { return "drafts"; }; - var peg$f71 = function() { return "originalamount"; }; - var peg$f72 = function() { return "taxAmount"; }; - var peg$f73 = function() { return "taxrate"; }; - var peg$f74 = function() { return "policyname"; }; - var peg$f75 = function() { return "withdrawalID"; }; - var peg$f76 = function() { return "bankAccount"; }; - var peg$f77 = function() { return "reportID"; }; - var peg$f78 = function() { return "base62ReportID"; }; - var peg$f79 = function() { return "exportedto"; }; - var peg$f80 = function() { return "exportedTo"; }; - var peg$f81 = function() { return "exportedTo"; }; - var peg$f82 = function() { return "exchangeRate"; }; - var peg$f83 = function() { return "reimbursableTotal"; }; - var peg$f84 = function() { return "nonReimbursableTotal"; }; - var peg$f85 = function() { return "groupFrom"; }; - var peg$f86 = function() { return "groupExpenses"; }; - var peg$f87 = function() { return "groupTotal"; }; - var peg$f88 = function() { return "groupCard"; }; - var peg$f89 = function() { return "groupFeed"; }; - var peg$f90 = function() { return "groupBankAccount"; }; - var peg$f91 = function() { return "groupWithdrawn"; }; - var peg$f92 = function() { return "groupWithdrawalID"; }; - var peg$f93 = function() { return "groupAmountDebited"; }; - var peg$f94 = function() { return "groupAmountReimbursed"; }; - var peg$f95 = function() { return "amountDebited"; }; - var peg$f96 = function() { return "amountReimbursed"; }; - var peg$f97 = function() { return "groupCategory"; }; - var peg$f98 = function() { return "groupTag"; }; - var peg$f99 = function() { return "groupMerchant"; }; - var peg$f100 = function() { return "groupday"; }; - var peg$f101 = function() { return "groupmonth"; }; - var peg$f102 = function() { return "groupweek"; }; - var peg$f103 = function() { return "groupyear"; }; - var peg$f104 = function() { return "groupquarter"; }; - var peg$f105 = function() { return "eq"; }; - var peg$f106 = function() { return "neq"; }; - var peg$f107 = function() { return "gte"; }; - var peg$f108 = function() { return "gt"; }; - var peg$f109 = function() { return "lte"; }; - var peg$f110 = function() { return "lt"; }; - var peg$f111 = function(o) { + var peg$f71 = function() {return "view"}; + var peg$f72 = function() {return "compare"}; + var peg$f73 = function() { return isColumnsContext; }; + var peg$f74 = function() { return "perDiem"; }; + var peg$f75 = function() { return "drafts"; }; + var peg$f76 = function() { return "originalamount"; }; + var peg$f77 = function() { return "taxAmount"; }; + var peg$f78 = function() { return "taxrate"; }; + var peg$f79 = function() { return "policyname"; }; + var peg$f80 = function() { return "withdrawalID"; }; + var peg$f81 = function() { return "bankAccount"; }; + var peg$f82 = function() { return "reportID"; }; + var peg$f83 = function() { return "base62ReportID"; }; + var peg$f84 = function() { return "exportedto"; }; + var peg$f85 = function() { return "exportedTo"; }; + var peg$f86 = function() { return "exportedTo"; }; + var peg$f87 = function() { return "exchangeRate"; }; + var peg$f88 = function() { return "reimbursableTotal"; }; + var peg$f89 = function() { return "nonReimbursableTotal"; }; + var peg$f90 = function() { return "groupFrom"; }; + var peg$f91 = function() { return "groupExpenses"; }; + var peg$f92 = function() { return "groupTotal"; }; + var peg$f93 = function() { return "groupCard"; }; + var peg$f94 = function() { return "groupFeed"; }; + var peg$f95 = function() { return "groupBankAccount"; }; + var peg$f96 = function() { return "groupWithdrawn"; }; + var peg$f97 = function() { return "groupWithdrawalID"; }; + var peg$f98 = function() { return "groupAmountDebited"; }; + var peg$f99 = function() { return "groupAmountReimbursed"; }; + var peg$f100 = function() { return "amountDebited"; }; + var peg$f101 = function() { return "amountReimbursed"; }; + var peg$f102 = function() { return "groupCategory"; }; + var peg$f103 = function() { return "groupTag"; }; + var peg$f104 = function() { return "groupMerchant"; }; + var peg$f105 = function() { return "groupday"; }; + var peg$f106 = function() { return "groupmonth"; }; + var peg$f107 = function() { return "groupweek"; }; + var peg$f108 = function() { return "groupyear"; }; + var peg$f109 = function() { return "groupquarter"; }; + var peg$f110 = function() { return "contains"; }; + var peg$f111 = function() { return "eq"; }; + var peg$f112 = function() { return "neq"; }; + var peg$f113 = function() { return "gte"; }; + var peg$f114 = function() { return "gt"; }; + var peg$f115 = function() { return "lte"; }; + var peg$f116 = function() { return "lt"; }; + var peg$f117 = function(o) { if (nameOperator) { - expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found + expectingNestedQuote = (o === "eq" || o === "contains"); } isColumnsContext = false; return o; }; - var peg$f112 = function(c) { return c; }; - var peg$f113 = function(chars) { return chars.join("").trim(); }; - var peg$f114 = function() { + var peg$f118 = function(c) { return c; }; + var peg$f119 = function(chars) { return chars.join("").trim(); }; + var peg$f120 = function() { isColumnsContext = false; return "and"; }; - var peg$f115 = function() { return expectingNestedQuote; }; - var peg$f116 = function(start, inner, end) { //handle no-breaking space + var peg$f121 = function() { return expectingNestedQuote; }; + var peg$f122 = function(start, inner, end) { //handle no-breaking space return [...start, '"', ...inner, '"', ...end].join(""); }; - var peg$f117 = function(start) {return "“"}; - var peg$f118 = function(start) {return "”"}; - var peg$f119 = function(start) {return "\""}; - var peg$f120 = function(start, inner, end) { + var peg$f123 = function(start) {return "“"}; + var peg$f124 = function(start) {return "”"}; + var peg$f125 = function(start) {return "\""}; + var peg$f126 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); }; - var peg$f121 = function() { return "footerCount"; }; - var peg$f122 = function() { return "footerCurrency"; }; - var peg$f123 = function() { return "footerTotal"; }; + var peg$f127 = function() { return "footerCount"; }; + var peg$f128 = function() { return "footerCurrency"; }; + var peg$f129 = function() { return "footerTotal"; }; var peg$currPos = options.peg$currPos | 0; var peg$savedPos = peg$currPos; var peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -1043,7 +1073,7 @@ function peg$parse(input, options) { s3 = peg$parsefilterKey(); if (s3 !== peg$FAILED) { s4 = peg$parse_(); - s5 = peg$parsefilterOperator(); + s5 = peg$parsesearchFilterOperator(); if (s5 !== peg$FAILED) { s6 = peg$parse_(); s7 = peg$parseidentifier(); @@ -1066,6 +1096,20 @@ function peg$parse(input, options) { return s0; } + function peg$parsesearchFilterOperator() { + var s0, s1; + + s0 = peg$currPos; + s1 = peg$parsefilterOperator(); + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f5(s1); + } + s0 = s1; + + return s0; + } + function peg$parsereportFieldDynamic() { var s0, s1, s2, s3, s4, s5, s6, s7; @@ -1174,7 +1218,7 @@ function peg$parse(input, options) { } s3 = input.substring(s3, peg$currPos); peg$savedPos = s0; - s0 = peg$f5(s3); + s0 = peg$f6(s3); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -1254,57 +1298,66 @@ function peg$parse(input, options) { if (s1 === peg$FAILED) { s1 = peg$parsesubmitted(); if (s1 === peg$FAILED) { - s1 = peg$parseapproved(); + s1 = peg$parseanyApproval(); if (s1 === peg$FAILED) { - s1 = peg$parsepaid(); + s1 = peg$parseapproved(); if (s1 === peg$FAILED) { - s1 = peg$parseexportedTo(); + s1 = peg$parsepaid(); if (s1 === peg$FAILED) { - s1 = peg$parseexported(); + s1 = peg$parseexportedTo(); if (s1 === peg$FAILED) { - s1 = peg$parseposted(); + s1 = peg$parseexported(); if (s1 === peg$FAILED) { - s1 = peg$parsewithdrawn(); + s1 = peg$parseposted(); if (s1 === peg$FAILED) { - s1 = peg$parsefeed(); + s1 = peg$parsewithdrawn(); if (s1 === peg$FAILED) { - s1 = peg$parsetitle(); + s1 = peg$parsefeed(); if (s1 === peg$FAILED) { - s1 = peg$parsesubmitterUserID(); + s1 = peg$parsetitle(); if (s1 === peg$FAILED) { - s1 = peg$parsesubmitterPayrollID(); + s1 = peg$parsesubmitterUserID(); if (s1 === peg$FAILED) { - s1 = peg$parseorderDealNumbers(); + s1 = peg$parsesubmitterPayrollID(); if (s1 === peg$FAILED) { - s1 = peg$parseassignee(); + s1 = peg$parseorderDealNumbers(); if (s1 === peg$FAILED) { - s1 = peg$parsecreatedBy(); + s1 = peg$parseassignee(); if (s1 === peg$FAILED) { - s1 = peg$parsereimbursable(); + s1 = peg$parsecreatedBy(); if (s1 === peg$FAILED) { - s1 = peg$parsebillable(); + s1 = peg$parsereimbursable(); if (s1 === peg$FAILED) { - s1 = peg$parseaction(); + s1 = peg$parsebillable(); if (s1 === peg$FAILED) { - s1 = peg$parsehas(); + s1 = peg$parsedeclined(); if (s1 === peg$FAILED) { - s1 = peg$parseis(); + s1 = peg$parseheld(); if (s1 === peg$FAILED) { - s1 = peg$parsepurchaseCurrency(); + s1 = peg$parseaction(); if (s1 === peg$FAILED) { - s1 = peg$parsepurchaseAmount(); + s1 = peg$parsehas(); if (s1 === peg$FAILED) { - s1 = peg$parsepolicyID(); + s1 = peg$parseis(); if (s1 === peg$FAILED) { - s1 = peg$parsestatus(); + s1 = peg$parsepurchaseCurrency(); if (s1 === peg$FAILED) { - s1 = peg$parsefooterCount(); + s1 = peg$parsepurchaseAmount(); if (s1 === peg$FAILED) { - s1 = peg$parsefooterCurrency(); + s1 = peg$parsepolicyID(); if (s1 === peg$FAILED) { - s1 = peg$parsefooterTotal(); + s1 = peg$parsestatus(); if (s1 === peg$FAILED) { - s1 = peg$parsereportFieldDynamic(); + s1 = peg$parsefooterCount(); + if (s1 === peg$FAILED) { + s1 = peg$parsefooterCurrency(); + if (s1 === peg$FAILED) { + s1 = peg$parsefooterTotal(); + if (s1 === peg$FAILED) { + s1 = peg$parsereportFieldDynamic(); + } + } + } } } } @@ -1383,7 +1436,7 @@ function peg$parse(input, options) { s1 = peg$parsekey(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f6(s1); + s1 = peg$f7(s1); } s0 = s1; @@ -1546,7 +1599,7 @@ function peg$parse(input, options) { s3 = null; } peg$savedPos = s0; - s0 = peg$f7(s2, s3); + s0 = peg$f8(s2, s3); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -1568,7 +1621,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f8(); + s1 = peg$f9(); } s0 = s1; @@ -1588,7 +1641,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f9(); + s1 = peg$f10(); } s0 = s1; @@ -1608,7 +1661,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f10(); + s1 = peg$f11(); } s0 = s1; @@ -1628,7 +1681,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f11(); + s1 = peg$f12(); } s0 = s1; @@ -1656,7 +1709,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f12(); + s1 = peg$f13(); } s0 = s1; } @@ -1677,7 +1730,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f13(); + s1 = peg$f14(); } s0 = s1; @@ -1697,7 +1750,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f14(); + s1 = peg$f15(); } s0 = s1; @@ -1717,7 +1770,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f15(); + s1 = peg$f16(); } s0 = s1; @@ -1745,7 +1798,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f16(); + s1 = peg$f17(); } s0 = s1; } @@ -1766,7 +1819,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f17(); + s1 = peg$f18(); } s0 = s1; @@ -1786,7 +1839,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f18(); + s1 = peg$f19(); } s0 = s1; @@ -1806,7 +1859,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f19(); + s1 = peg$f20(); } s0 = s1; @@ -1826,7 +1879,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f20(); + s1 = peg$f21(); } s0 = s1; @@ -1846,7 +1899,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f21(); + s1 = peg$f22(); } s0 = s1; @@ -1874,7 +1927,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f22(); + s1 = peg$f23(); } s0 = s1; } @@ -1903,7 +1956,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f23(); + s1 = peg$f24(); } s0 = s1; } @@ -1932,7 +1985,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f24(); + s1 = peg$f25(); } s0 = s1; } @@ -1961,7 +2014,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f25(); + s1 = peg$f26(); } s0 = s1; } @@ -1982,7 +2035,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f26(); + s1 = peg$f27(); } s0 = s1; @@ -2002,7 +2055,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f27(); + s1 = peg$f28(); } s0 = s1; @@ -2030,7 +2083,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f28(); + s1 = peg$f29(); } s0 = s1; } @@ -2059,7 +2112,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f29(); + s1 = peg$f30(); } s0 = s1; } @@ -2088,7 +2141,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f30(); + s1 = peg$f31(); } s0 = s1; } @@ -2117,7 +2170,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f31(); + s1 = peg$f32(); } s0 = s1; } @@ -2146,7 +2199,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f32(); + s1 = peg$f33(); } s0 = s1; } @@ -2175,7 +2228,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f33(); + s1 = peg$f34(); } s0 = s1; } @@ -2204,7 +2257,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f34(); + s1 = peg$f35(); } s0 = s1; } @@ -2225,34 +2278,34 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f35(); + s1 = peg$f36(); } s0 = s1; return s0; } - function peg$parsereimbursable() { + function peg$parsedeclined() { var s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 12); + s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c45) { - peg$currPos += 12; + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e50); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f36(); + s1 = peg$f37(); } s0 = s1; return s0; } - function peg$parsetype() { + function peg$parseheld() { var s0, s1; s0 = peg$currPos; @@ -2265,7 +2318,47 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f37(); + s1 = peg$f38(); + } + s0 = s1; + + return s0; + } + + function peg$parsereimbursable() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 12); + if (s1.toLowerCase() === peg$c47) { + peg$currPos += 12; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e52); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f39(); + } + s0 = s1; + + return s0; + } + + function peg$parsetype() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 4); + if (s1.toLowerCase() === peg$c48) { + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e53); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f40(); } s0 = s1; @@ -2277,15 +2370,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c47) { + if (s1.toLowerCase() === peg$c49) { peg$currPos += 6; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e52); } + if (peg$silentFails === 0) { peg$fail(peg$e54); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f38(); + s1 = peg$f41(); } s0 = s1; @@ -2295,25 +2388,25 @@ function peg$parse(input, options) { function peg$parsesortBy() { var s0, s1; - if (input.substr(peg$currPos, 6) === peg$c48) { - s0 = peg$c48; + if (input.substr(peg$currPos, 6) === peg$c50) { + s0 = peg$c50; peg$currPos += 6; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e53); } + if (peg$silentFails === 0) { peg$fail(peg$e55); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 7); - if (s1.toLowerCase() === peg$c49) { + if (s1.toLowerCase() === peg$c51) { peg$currPos += 7; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e54); } + if (peg$silentFails === 0) { peg$fail(peg$e56); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f39(); + s1 = peg$f42(); } s0 = s1; } @@ -2324,25 +2417,25 @@ function peg$parse(input, options) { function peg$parsesortOrder() { var s0, s1; - if (input.substr(peg$currPos, 9) === peg$c50) { - s0 = peg$c50; + if (input.substr(peg$currPos, 9) === peg$c52) { + s0 = peg$c52; peg$currPos += 9; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e55); } + if (peg$silentFails === 0) { peg$fail(peg$e57); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c51) { + if (s1.toLowerCase() === peg$c53) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e56); } + if (peg$silentFails === 0) { peg$fail(peg$e58); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f40(); + s1 = peg$f43(); } s0 = s1; } @@ -2353,25 +2446,25 @@ function peg$parse(input, options) { function peg$parsepolicyID() { var s0, s1; - if (input.substr(peg$currPos, 8) === peg$c52) { - s0 = peg$c52; + if (input.substr(peg$currPos, 8) === peg$c54) { + s0 = peg$c54; peg$currPos += 8; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e57); } + if (peg$silentFails === 0) { peg$fail(peg$e59); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c53) { + if (s1.toLowerCase() === peg$c55) { peg$currPos += 9; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e58); } + if (peg$silentFails === 0) { peg$fail(peg$e60); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f41(); + s1 = peg$f44(); } s0 = s1; } @@ -2384,15 +2477,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c54) { + if (s1.toLowerCase() === peg$c56) { peg$currPos += 9; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e59); } + if (peg$silentFails === 0) { peg$fail(peg$e61); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f42(); + s1 = peg$f45(); } s0 = s1; @@ -2404,17 +2497,52 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c55) { + if (s1.toLowerCase() === peg$c57) { peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e60); } + if (peg$silentFails === 0) { peg$fail(peg$e62); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f46(); + } + s0 = s1; + + return s0; + } + + function peg$parseanyApproval() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c58) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e63); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f43(); + s1 = peg$f47(); } s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 12); + if (s1.toLowerCase() === peg$c59) { + peg$currPos += 12; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e64); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f48(); + } + s0 = s1; + } return s0; } @@ -2424,15 +2552,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c56) { + if (s1.toLowerCase() === peg$c60) { peg$currPos += 4; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e61); } + if (peg$silentFails === 0) { peg$fail(peg$e65); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f44(); + s1 = peg$f49(); } s0 = s1; @@ -2444,15 +2572,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c57) { + if (s1.toLowerCase() === peg$c61) { peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e62); } + if (peg$silentFails === 0) { peg$fail(peg$e66); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f45(); + s1 = peg$f50(); } s0 = s1; @@ -2464,15 +2592,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c58) { + if (s1.toLowerCase() === peg$c62) { peg$currPos += 6; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e63); } + if (peg$silentFails === 0) { peg$fail(peg$e67); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f46(); + s1 = peg$f51(); } s0 = s1; @@ -2484,15 +2612,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c59) { + if (s1.toLowerCase() === peg$c63) { peg$currPos += 9; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e64); } + if (peg$silentFails === 0) { peg$fail(peg$e68); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f47(); + s1 = peg$f52(); } s0 = s1; @@ -2503,24 +2631,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 7); - if (s0.toLowerCase() === peg$c60) { + if (s0.toLowerCase() === peg$c64) { peg$currPos += 7; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e65); } + if (peg$silentFails === 0) { peg$fail(peg$e69); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c61) { + if (s1.toLowerCase() === peg$c65) { peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e66); } + if (peg$silentFails === 0) { peg$fail(peg$e70); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f48(); + s1 = peg$f53(); } s0 = s1; } @@ -2533,15 +2661,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c62) { + if (s1.toLowerCase() === peg$c66) { peg$currPos += 5; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e67); } + if (peg$silentFails === 0) { peg$fail(peg$e71); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f49(); + s1 = peg$f54(); } s0 = s1; @@ -2553,15 +2681,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c63) { + if (s1.toLowerCase() === peg$c67) { peg$currPos += 4; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e68); } + if (peg$silentFails === 0) { peg$fail(peg$e72); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f50(); + s1 = peg$f55(); } s0 = s1; @@ -2573,15 +2701,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c64) { + if (s1.toLowerCase() === peg$c68) { peg$currPos += 5; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e69); } + if (peg$silentFails === 0) { peg$fail(peg$e73); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f51(); + s1 = peg$f56(); } s0 = s1; @@ -2592,24 +2720,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 15); - if (s0.toLowerCase() === peg$c65) { + if (s0.toLowerCase() === peg$c69) { peg$currPos += 15; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e70); } + if (peg$silentFails === 0) { peg$fail(peg$e74); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 17); - if (s1.toLowerCase() === peg$c66) { + if (s1.toLowerCase() === peg$c70) { peg$currPos += 17; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e71); } + if (peg$silentFails === 0) { peg$fail(peg$e75); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f52(); + s1 = peg$f57(); } s0 = s1; } @@ -2621,24 +2749,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 18); - if (s0.toLowerCase() === peg$c67) { + if (s0.toLowerCase() === peg$c71) { peg$currPos += 18; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e72); } + if (peg$silentFails === 0) { peg$fail(peg$e76); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 20); - if (s1.toLowerCase() === peg$c68) { + if (s1.toLowerCase() === peg$c72) { peg$currPos += 20; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e73); } + if (peg$silentFails === 0) { peg$fail(peg$e77); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f53(); + s1 = peg$f58(); } s0 = s1; } @@ -2650,24 +2778,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 16); - if (s0.toLowerCase() === peg$c69) { + if (s0.toLowerCase() === peg$c73) { peg$currPos += 16; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e74); } + if (peg$silentFails === 0) { peg$fail(peg$e78); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 18); - if (s1.toLowerCase() === peg$c70) { + if (s1.toLowerCase() === peg$c74) { peg$currPos += 18; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e75); } + if (peg$silentFails === 0) { peg$fail(peg$e79); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f54(); + s1 = peg$f59(); } s0 = s1; } @@ -2680,15 +2808,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c71) { + if (s1.toLowerCase() === peg$c75) { peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e76); } + if (peg$silentFails === 0) { peg$fail(peg$e80); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f55(); + s1 = peg$f60(); } s0 = s1; @@ -2699,24 +2827,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 9); - if (s0.toLowerCase() === peg$c72) { + if (s0.toLowerCase() === peg$c76) { peg$currPos += 9; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e77); } + if (peg$silentFails === 0) { peg$fail(peg$e81); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c73) { + if (s1.toLowerCase() === peg$c77) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e78); } + if (peg$silentFails === 0) { peg$fail(peg$e82); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f56(); + s1 = peg$f61(); } s0 = s1; } @@ -2729,15 +2857,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c74) { + if (s1.toLowerCase() === peg$c78) { peg$currPos += 6; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e79); } + if (peg$silentFails === 0) { peg$fail(peg$e83); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f57(); + s1 = peg$f62(); } s0 = s1; @@ -2749,15 +2877,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c75) { + if (s1.toLowerCase() === peg$c79) { peg$currPos += 5; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e80); } + if (peg$silentFails === 0) { peg$fail(peg$e84); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f58(); + s1 = peg$f63(); } s0 = s1; @@ -2769,15 +2897,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); - if (s1.toLowerCase() === peg$c76) { + if (s1.toLowerCase() === peg$c80) { peg$currPos += 3; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e81); } + if (peg$silentFails === 0) { peg$fail(peg$e85); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f59(); + s1 = peg$f64(); } s0 = s1; @@ -2789,15 +2917,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 2); - if (s1.toLowerCase() === peg$c77) { + if (s1.toLowerCase() === peg$c81) { peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e82); } + if (peg$silentFails === 0) { peg$fail(peg$e86); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f60(); + s1 = peg$f65(); } s0 = s1; @@ -2808,24 +2936,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 14); - if (s0.toLowerCase() === peg$c78) { + if (s0.toLowerCase() === peg$c82) { peg$currPos += 14; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e83); } + if (peg$silentFails === 0) { peg$fail(peg$e87); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c79) { + if (s1.toLowerCase() === peg$c83) { peg$currPos += 15; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e84); } + if (peg$silentFails === 0) { peg$fail(peg$e88); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f61(); + s1 = peg$f66(); } s0 = s1; } @@ -2837,24 +2965,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 16); - if (s0.toLowerCase() === peg$c80) { + if (s0.toLowerCase() === peg$c84) { peg$currPos += 16; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e85); } + if (peg$silentFails === 0) { peg$fail(peg$e89); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 17); - if (s1.toLowerCase() === peg$c81) { + if (s1.toLowerCase() === peg$c85) { peg$currPos += 17; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e86); } + if (peg$silentFails === 0) { peg$fail(peg$e90); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f62(); + s1 = peg$f67(); } s0 = s1; } @@ -2866,24 +2994,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 13); - if (s0.toLowerCase() === peg$c82) { + if (s0.toLowerCase() === peg$c86) { peg$currPos += 13; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e87); } + if (peg$silentFails === 0) { peg$fail(peg$e91); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c83) { + if (s1.toLowerCase() === peg$c87) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e88); } + if (peg$silentFails === 0) { peg$fail(peg$e92); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f63(); + s1 = peg$f68(); } s0 = s1; } @@ -2895,24 +3023,24 @@ function peg$parse(input, options) { var s0, s1; s0 = input.substr(peg$currPos, 16); - if (s0.toLowerCase() === peg$c84) { + if (s0.toLowerCase() === peg$c88) { peg$currPos += 16; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e89); } + if (peg$silentFails === 0) { peg$fail(peg$e93); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 17); - if (s1.toLowerCase() === peg$c85) { + if (s1.toLowerCase() === peg$c89) { peg$currPos += 17; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e90); } + if (peg$silentFails === 0) { peg$fail(peg$e94); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f64(); + s1 = peg$f69(); } s0 = s1; } @@ -2925,15 +3053,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 7); - if (s1.toLowerCase() === peg$c86) { + if (s1.toLowerCase() === peg$c90) { peg$currPos += 7; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e91); } + if (peg$silentFails === 0) { peg$fail(peg$e95); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f65(); + s1 = peg$f70(); } s0 = s1; @@ -2945,15 +3073,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c87) { + if (s1.toLowerCase() === peg$c91) { peg$currPos += 4; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e92); } + if (peg$silentFails === 0) { peg$fail(peg$e96); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f66(); + s1 = peg$f71(); } s0 = s1; @@ -2965,15 +3093,15 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 7); - if (s1.toLowerCase() === peg$c88) { + if (s1.toLowerCase() === peg$c92) { peg$currPos += 7; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e93); } + if (peg$silentFails === 0) { peg$fail(peg$e97); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f67(); + s1 = peg$f72(); } s0 = s1; @@ -2985,7 +3113,7 @@ function peg$parse(input, options) { s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f68(); + s1 = peg$f73(); if (s1) { s1 = undefined; } else { @@ -3120,11 +3248,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c89) { + if (s1.toLowerCase() === peg$c93) { peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e94); } + if (peg$silentFails === 0) { peg$fail(peg$e98); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3139,7 +3267,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f69(); + s0 = peg$f74(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3157,19 +3285,19 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c90) { + if (s1.toLowerCase() === peg$c94) { peg$currPos += 6; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e95); } + if (peg$silentFails === 0) { peg$fail(peg$e99); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c91) { + if (s1.toLowerCase() === peg$c95) { peg$currPos += 5; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e96); } + if (peg$silentFails === 0) { peg$fail(peg$e100); } } } if (s1 !== peg$FAILED) { @@ -3185,7 +3313,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f70(); + s0 = peg$f75(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3203,11 +3331,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c79) { + if (s1.toLowerCase() === peg$c83) { peg$currPos += 15; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e84); } + if (peg$silentFails === 0) { peg$fail(peg$e88); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3222,7 +3350,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f71(); + s0 = peg$f76(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3240,11 +3368,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); - if (s1.toLowerCase() === peg$c92) { + if (s1.toLowerCase() === peg$c96) { peg$currPos += 3; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e97); } + if (peg$silentFails === 0) { peg$fail(peg$e101); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3259,7 +3387,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f72(); + s0 = peg$f77(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3296,7 +3424,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f73(); + s0 = peg$f78(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3314,11 +3442,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c93) { + if (s1.toLowerCase() === peg$c97) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e98); } + if (peg$silentFails === 0) { peg$fail(peg$e102); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3333,7 +3461,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f74(); + s0 = peg$f79(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3370,7 +3498,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f75(); + s0 = peg$f80(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3407,7 +3535,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f76(); + s0 = peg$f81(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3425,11 +3553,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c94) { + if (s1.toLowerCase() === peg$c98) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e99); } + if (peg$silentFails === 0) { peg$fail(peg$e103); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3444,7 +3572,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f77(); + s0 = peg$f82(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3481,7 +3609,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f78(); + s0 = peg$f83(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3499,11 +3627,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c95) { + if (s1.toLowerCase() === peg$c99) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e100); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3518,7 +3646,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f79(); + s0 = peg$f84(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3543,7 +3671,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } if (s2 !== peg$FAILED) { s1 = [s1, s2]; @@ -3562,11 +3690,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c96) { + if (s1.toLowerCase() === peg$c100) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e102); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3581,7 +3709,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f80(); + s0 = peg$f85(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3593,11 +3721,11 @@ function peg$parse(input, options) { if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c95) { + if (s1.toLowerCase() === peg$c99) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e100); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3612,7 +3740,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f81(); + s0 = peg$f86(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3631,11 +3759,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); - if (s1.toLowerCase() === peg$c97) { + if (s1.toLowerCase() === peg$c101) { peg$currPos += 13; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3650,7 +3778,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f82(); + s0 = peg$f87(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3668,11 +3796,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 18); - if (s1.toLowerCase() === peg$c98) { + if (s1.toLowerCase() === peg$c102) { peg$currPos += 18; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3687,7 +3815,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f83(); + s0 = peg$f88(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3705,11 +3833,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 22); - if (s1.toLowerCase() === peg$c99) { + if (s1.toLowerCase() === peg$c103) { peg$currPos += 22; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e109); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3724,7 +3852,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f84(); + s0 = peg$f89(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3742,11 +3870,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c100) { + if (s1.toLowerCase() === peg$c104) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3761,7 +3889,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f85(); + s0 = peg$f90(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3779,11 +3907,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c101) { + if (s1.toLowerCase() === peg$c105) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3798,7 +3926,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f86(); + s0 = peg$f91(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3816,11 +3944,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c102) { + if (s1.toLowerCase() === peg$c106) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3835,7 +3963,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f87(); + s0 = peg$f92(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3853,11 +3981,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c103) { + if (s1.toLowerCase() === peg$c107) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e113); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3872,7 +4000,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f88(); + s0 = peg$f93(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3890,11 +4018,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c104) { + if (s1.toLowerCase() === peg$c108) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e114); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3909,7 +4037,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f89(); + s0 = peg$f94(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3927,11 +4055,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 18); - if (s1.toLowerCase() === peg$c105) { + if (s1.toLowerCase() === peg$c109) { peg$currPos += 18; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e115); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3946,7 +4074,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f90(); + s0 = peg$f95(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3964,11 +4092,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c106) { + if (s1.toLowerCase() === peg$c110) { peg$currPos += 15; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e116); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3983,7 +4111,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f91(); + s0 = peg$f96(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4001,11 +4129,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 19); - if (s1.toLowerCase() === peg$c107) { + if (s1.toLowerCase() === peg$c111) { peg$currPos += 19; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e117); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4020,7 +4148,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f92(); + s0 = peg$f97(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4038,11 +4166,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 20); - if (s1.toLowerCase() === peg$c108) { + if (s1.toLowerCase() === peg$c112) { peg$currPos += 20; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e114); } + if (peg$silentFails === 0) { peg$fail(peg$e118); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4057,7 +4185,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f93(); + s0 = peg$f98(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4075,11 +4203,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 23); - if (s1.toLowerCase() === peg$c109) { + if (s1.toLowerCase() === peg$c113) { peg$currPos += 23; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e115); } + if (peg$silentFails === 0) { peg$fail(peg$e119); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4094,7 +4222,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f94(); + s0 = peg$f99(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4112,11 +4240,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c83) { + if (s1.toLowerCase() === peg$c87) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e88); } + if (peg$silentFails === 0) { peg$fail(peg$e92); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4131,7 +4259,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f95(); + s0 = peg$f100(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4149,11 +4277,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 17); - if (s1.toLowerCase() === peg$c85) { + if (s1.toLowerCase() === peg$c89) { peg$currPos += 17; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e90); } + if (peg$silentFails === 0) { peg$fail(peg$e94); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4168,7 +4296,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f96(); + s0 = peg$f101(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4186,11 +4314,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c110) { + if (s1.toLowerCase() === peg$c114) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e116); } + if (peg$silentFails === 0) { peg$fail(peg$e120); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4205,7 +4333,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f97(); + s0 = peg$f102(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4223,11 +4351,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c111) { + if (s1.toLowerCase() === peg$c115) { peg$currPos += 9; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e117); } + if (peg$silentFails === 0) { peg$fail(peg$e121); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4242,7 +4370,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f98(); + s0 = peg$f103(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4260,11 +4388,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c112) { + if (s1.toLowerCase() === peg$c116) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e118); } + if (peg$silentFails === 0) { peg$fail(peg$e122); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4279,7 +4407,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f99(); + s0 = peg$f104(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4297,11 +4425,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c113) { + if (s1.toLowerCase() === peg$c117) { peg$currPos += 9; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e119); } + if (peg$silentFails === 0) { peg$fail(peg$e123); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4316,7 +4444,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f100(); + s0 = peg$f105(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4334,11 +4462,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c114) { + if (s1.toLowerCase() === peg$c118) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e120); } + if (peg$silentFails === 0) { peg$fail(peg$e124); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4353,7 +4481,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f101(); + s0 = peg$f106(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4371,11 +4499,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c115) { + if (s1.toLowerCase() === peg$c119) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e121); } + if (peg$silentFails === 0) { peg$fail(peg$e125); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4390,7 +4518,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f102(); + s0 = peg$f107(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4408,11 +4536,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c116) { + if (s1.toLowerCase() === peg$c120) { peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e122); } + if (peg$silentFails === 0) { peg$fail(peg$e126); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4427,7 +4555,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f103(); + s0 = peg$f108(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4445,11 +4573,11 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); - if (s1.toLowerCase() === peg$c117) { + if (s1.toLowerCase() === peg$c121) { peg$currPos += 13; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e123); } + if (peg$silentFails === 0) { peg$fail(peg$e127); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4464,7 +4592,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f104(); + s0 = peg$f109(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4482,88 +4610,103 @@ function peg$parse(input, options) { peg$silentFails++; s0 = peg$currPos; - s1 = input.charAt(peg$currPos); - if (peg$r2.test(s1)) { - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c122) { + s1 = peg$c122; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e129); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f105(); + s1 = peg$f110(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c118) { - s1 = peg$c118; - peg$currPos += 2; + s1 = input.charAt(peg$currPos); + if (peg$r2.test(s1)) { + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e125); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f106(); + s1 = peg$f111(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c119) { - s1 = peg$c119; + if (input.substr(peg$currPos, 2) === peg$c123) { + s1 = peg$c123; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e126); } + if (peg$silentFails === 0) { peg$fail(peg$e130); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f107(); + s1 = peg$f112(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 62) { - s1 = peg$c120; - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c124) { + s1 = peg$c124; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e127); } + if (peg$silentFails === 0) { peg$fail(peg$e131); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f108(); + s1 = peg$f113(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c121) { - s1 = peg$c121; - peg$currPos += 2; + if (input.charCodeAt(peg$currPos) === 62) { + s1 = peg$c125; + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e128); } + if (peg$silentFails === 0) { peg$fail(peg$e132); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f109(); + s1 = peg$f114(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 60) { - s1 = peg$c122; - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c126) { + s1 = peg$c126; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e129); } + if (peg$silentFails === 0) { peg$fail(peg$e133); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f110(); + s1 = peg$f115(); } s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 60) { + s1 = peg$c127; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e134); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f116(); + } + s0 = s1; + } } } } @@ -4572,7 +4715,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e124); } + if (peg$silentFails === 0) { peg$fail(peg$e128); } } return s0; @@ -4585,7 +4728,7 @@ function peg$parse(input, options) { s1 = peg$parseoperator(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f111(s1); + s1 = peg$f117(s1); } s0 = s1; @@ -4597,11 +4740,11 @@ function peg$parse(input, options) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c123; + s1 = peg$c128; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e130); } + if (peg$silentFails === 0) { peg$fail(peg$e135); } } if (s1 !== peg$FAILED) { s2 = input.charAt(peg$currPos); @@ -4609,11 +4752,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e131); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f112(s2); + s0 = peg$f118(s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4639,7 +4782,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } } if (s2 !== peg$FAILED) { @@ -4652,7 +4795,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } } } @@ -4661,13 +4804,13 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f113(s1); + s1 = peg$f119(s1); } s0 = s1; peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e132); } + if (peg$silentFails === 0) { peg$fail(peg$e137); } } return s0; @@ -4679,7 +4822,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f114(); + s1 = peg$f120(); s0 = s1; return s0; @@ -4695,7 +4838,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -4704,12 +4847,12 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e134); } + if (peg$silentFails === 0) { peg$fail(peg$e139); } return s0; } @@ -4719,7 +4862,7 @@ function peg$parse(input, options) { s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f115(); + s1 = peg$f121(); if (s1) { s1 = undefined; } else { @@ -4756,7 +4899,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4765,7 +4908,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } } s2 = input.charAt(peg$currPos); @@ -4773,7 +4916,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e143); } } if (s2 !== peg$FAILED) { s3 = []; @@ -4784,7 +4927,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e144); } } } while (s4 !== peg$FAILED) { @@ -4796,7 +4939,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e144); } } } } @@ -4805,7 +4948,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e143); } } if (s4 !== peg$FAILED) { s5 = []; @@ -4814,7 +4957,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -4823,11 +4966,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } } peg$savedPos = s0; - s0 = peg$f116(s1, s3, s5); + s0 = peg$f122(s1, s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4839,7 +4982,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e141); } } return s0; @@ -4856,7 +4999,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4865,7 +5008,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } } s2 = input.charAt(peg$currPos); @@ -4873,7 +5016,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e143); } } if (s2 !== peg$FAILED) { s3 = []; @@ -4884,7 +5027,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e144); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -4900,15 +5043,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c124; + s6 = peg$c129; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e140); } + if (peg$silentFails === 0) { peg$fail(peg$e145); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f117(s1); + s4 = peg$f123(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4931,15 +5074,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c125; + s6 = peg$c130; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e141); } + if (peg$silentFails === 0) { peg$fail(peg$e146); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f118(s1); + s4 = peg$f124(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4962,15 +5105,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c126; + s6 = peg$c131; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e142); } + if (peg$silentFails === 0) { peg$fail(peg$e147); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f119(s1); + s4 = peg$f125(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4992,7 +5135,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e144); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -5008,15 +5151,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c124; + s6 = peg$c129; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e140); } + if (peg$silentFails === 0) { peg$fail(peg$e145); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f117(s1); + s4 = peg$f123(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -5039,15 +5182,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c125; + s6 = peg$c130; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e141); } + if (peg$silentFails === 0) { peg$fail(peg$e146); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f118(s1); + s4 = peg$f124(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -5070,15 +5213,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c126; + s6 = peg$c131; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e142); } + if (peg$silentFails === 0) { peg$fail(peg$e147); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f119(s1); + s4 = peg$f125(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -5095,7 +5238,7 @@ function peg$parse(input, options) { s4 = peg$parseclosingQuote(); if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f120(s1, s3, s4); + s0 = peg$f126(s1, s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -5107,7 +5250,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e141); } } return s0; @@ -5122,7 +5265,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e143); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -5160,7 +5303,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e143); } + if (peg$silentFails === 0) { peg$fail(peg$e148); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -5169,7 +5312,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e143); } + if (peg$silentFails === 0) { peg$fail(peg$e148); } } } s2 = []; @@ -5178,7 +5321,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e144); } + if (peg$silentFails === 0) { peg$fail(peg$e149); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -5187,7 +5330,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e144); } + if (peg$silentFails === 0) { peg$fail(peg$e149); } } } s3 = []; @@ -5196,7 +5339,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e145); } + if (peg$silentFails === 0) { peg$fail(peg$e150); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -5205,7 +5348,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e145); } + if (peg$silentFails === 0) { peg$fail(peg$e150); } } } s4 = peg$parseoperator(); @@ -5224,7 +5367,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e146); } + if (peg$silentFails === 0) { peg$fail(peg$e151); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -5233,7 +5376,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e146); } + if (peg$silentFails === 0) { peg$fail(peg$e151); } } } s2 = peg$currPos; @@ -5280,7 +5423,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e147); } + if (peg$silentFails === 0) { peg$fail(peg$e152); } } } } @@ -5296,7 +5439,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e148); } + if (peg$silentFails === 0) { peg$fail(peg$e153); } } if (s0 === peg$FAILED) { s0 = peg$currPos; @@ -5325,24 +5468,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c127) { + if (s1.toLowerCase() === peg$c132) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e149); } + if (peg$silentFails === 0) { peg$fail(peg$e154); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c128) { + if (s1.toLowerCase() === peg$c133) { peg$currPos += 12; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e150); } + if (peg$silentFails === 0) { peg$fail(peg$e155); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f121(); + s1 = peg$f127(); } s0 = s1; @@ -5354,24 +5497,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c129) { + if (s1.toLowerCase() === peg$c134) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e151); } + if (peg$silentFails === 0) { peg$fail(peg$e156); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c130) { + if (s1.toLowerCase() === peg$c135) { peg$currPos += 15; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e152); } + if (peg$silentFails === 0) { peg$fail(peg$e157); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f122(); + s1 = peg$f128(); } s0 = s1; @@ -5383,24 +5526,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c131) { + if (s1.toLowerCase() === peg$c136) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e153); } + if (peg$silentFails === 0) { peg$fail(peg$e158); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c132) { + if (s1.toLowerCase() === peg$c137) { peg$currPos += 12; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e154); } + if (peg$silentFails === 0) { peg$fail(peg$e159); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f123(); + s1 = peg$f129(); } s0 = s1; diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy index 3a469f4bf432..679a79d3a4fb 100644 --- a/src/libs/SearchParser/searchParser.peggy +++ b/src/libs/SearchParser/searchParser.peggy @@ -216,6 +216,10 @@ filter = @(standardFilter / defaultFilter / freeTextFilter) defaultFilter = _ key:defaultKey _ op:operator _ value:identifier { + // The "contains" operator is only supported for the merchant field + if (op === "contains") { + return buildFilter("eq", "keyword", text().trim()); + } updateDefaultValues(key, value); return {...buildFilter(op, key, value), isDefault: true}; } @@ -233,11 +237,21 @@ freeTextFilter } standardFilter - = _ neg:"-"? field:filterKey _ op:filterOperator _ values:identifier { + = _ neg:"-"? field:filterKey _ op:searchFilterOperator _ values:identifier { expectingNestedQuote = false; nameOperator = false; const key = field; - let operator = op; + let operator = op.value; + + // Positive `merchant:` filters predate explicit match types, so they must retain contains semantics. + if (!neg && key === "merchant" && op.token === ":") { + operator = "contains"; + } + + // The "contains" operator is only supported for the merchant field + if (operator === "contains" && (neg || key !== "merchant")) { + return buildFilter("eq", "keyword", text().trim()); + } // Apply negation only when "-" is present and the key can be negated if (neg && !nonNegatableKeys.has(key)) { @@ -249,6 +263,9 @@ standardFilter return buildFilter(operator, key, values); } +searchFilterOperator + = value:filterOperator { return {value, token: text()}; } + reportFieldDynamic = ("report-field"i / "reportfield"i) "-" rest:$((!([ \t\r\n\xA0,:=<>!]) .)*) { const suffix = rest.replaceAll(/^-+/g, ""); @@ -289,6 +306,7 @@ key "key" / paidStatus / withdrawalID / submitted + / anyApproval / approved / paid / exportedTo @@ -304,6 +322,8 @@ key "key" / createdBy / reimbursable / billable + / declined + / held / action / has / is diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index d19de92dfd7a..a948d185a6f2 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -41,6 +41,7 @@ import type { HasFilterValues, IsFilterValue, IsFilterValues, + MerchantMatchType, ReceiptTypeValue, TransactionStatusValue, SearchAdvancedFiltersKey, @@ -104,6 +105,58 @@ const operatorToCharMap = { [CONST.SEARCH.SYNTAX_OPERATORS.AND]: ',' as const, [CONST.SEARCH.SYNTAX_OPERATORS.OR]: ' ' as const, }; +const EXPLICIT_EQUAL_TO_OPERATOR = '=' as const; + +const DEFAULT_MERCHANT_OPERATOR = CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS; + +function getMerchantOperator(operator: MerchantMatchType | undefined): MerchantMatchType { + return operator ?? DEFAULT_MERCHANT_OPERATOR; +} + +/** + * Builds the Merchant part of the query. The form has one Merchant field, so a changed condition updates every Merchant clause from flatFilters. + */ +function buildMerchantFilterQuery( + value: string, + prefix: string, + isNegated: boolean, + isMerchantNegated: boolean, + merchantOperator: MerchantMatchType | undefined, + flatFilters: QueryFilters | undefined, +): string { + const merchantFilters = flatFilters?.filter((filter) => filter.key === FILTER_KEYS.MERCHANT) ?? []; + const positiveMerchantFilters = merchantFilters.filter((filter) => !filter.filters.some((item) => item.operator === CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO)); + const negativeMerchantFilters = merchantFilters.filter((filter) => filter.filters.some((item) => item.operator === CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO)); + const wasMerchantNegated = negativeMerchantFilters.length > 0; + const hasMerchantNegationChanged = wasMerchantNegated !== isMerchantNegated; + const lastVisibleMerchantFilter = (wasMerchantNegated ? negativeMerchantFilters : positiveMerchantFilters).at(-1); + const selectedMerchantOperator = getMerchantOperator(merchantOperator); + const originalMerchantFilters = isNegated ? negativeMerchantFilters : positiveMerchantFilters; + const lastMerchantFilter = hasMerchantNegationChanged ? lastVisibleMerchantFilter : originalMerchantFilters.at(-1); + const isMerchantValueUnchanged = value === lastMerchantFilter?.filters.map((item) => item.value.toString()).join(','); + if (lastMerchantFilter && isMerchantValueUnchanged) { + const currentMerchantOperator = positiveMerchantFilters.at(-1)?.filters.some((item) => item.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) + ? CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO + : DEFAULT_MERCHANT_OPERATOR; + const hasMerchantOperatorChanged = !isMerchantNegated && selectedMerchantOperator !== currentMerchantOperator; + const hasMerchantConditionChanged = hasMerchantNegationChanged || hasMerchantOperatorChanged; + const filtersToUpdate = hasMerchantConditionChanged ? merchantFilters : originalMerchantFilters; + const updatedOperator = isNegated ? CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO : selectedMerchantOperator; + // The single field cannot represent every clause or distinguish lists from names containing commas. + const merchantClauses = filtersToUpdate.map((filter) => { + const clauseFilters = hasMerchantConditionChanged ? filter.filters.map((item) => ({...item, operator: updatedOperator})) : filter.filters; + return buildFilterValuesString(FILTER_KEYS.MERCHANT, clauseFilters).trim(); + }); + // Different operators can become identical clauses after the change. + return [...new Set(merchantClauses)].join(' '); + } + + let operator: ValueOf | typeof EXPLICIT_EQUAL_TO_OPERATOR = operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO]; + if (!isNegated) { + operator = selectedMerchantOperator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO ? EXPLICIT_EQUAL_TO_OPERATOR : operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]; + } + return `${prefix}${CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT}${operator}${sanitizeSearchValue(value)}`; +} // Pre-computed validation Sets for buildFilterFormValuesFromQuery (avoids recreating per filter iteration) const VALID_EXPENSE_TYPES = new Set(Object.values(CONST.SEARCH.TRANSACTION_TYPE)); @@ -387,7 +440,6 @@ function sanitizeIncompleteQuotedValue(str: string) { const sanitized = sanitizeSearchValue(str); return sanitized.startsWith('"') ? sanitized : `"${sanitized}"`; } - /** * Escapes each keyword that would otherwise be re-interpreted as query syntax by wrapping it in quotes. * A keyword that looks like a filter (e.g. `type:expense`) becomes `"type:expense"` so it is matched as a @@ -594,13 +646,16 @@ function buildAmountFilterQuery(filterKey: SearchAmountFilterKeys, filterValues: function buildFilterValuesString(filterName: string, queryFilters: QueryFilter[]) { const delimiter = filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD ? ' ' : ','; const allowedOps = new Set([CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO]); + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { + allowedOps.add(CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); + } let filterValueString = ''; for (const [index, queryFilter] of queryFilters.entries()) { const previousValueHasSameOp = allowedOps.has(queryFilter.operator) && queryFilters?.at(index - 1)?.operator === queryFilter.operator; const nextValueHasSameOp = allowedOps.has(queryFilter.operator) && queryFilters?.at(index + 1)?.operator === queryFilter.operator; - // If the previous queryFilter has the same operator (this rule applies only to eq and neq operators) then append the current value + // If the previous queryFilter has the same operator and that operator supports comma-delimited values, append the current value if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD) { filterValueString += `${delimiter}${escapeKeyword(sanitizeSearchValue(queryFilter.value.toString()))}`; } else if (index !== 0 && (previousValueHasSameOp || nextValueHasSameOp)) { @@ -618,7 +673,10 @@ function buildFilterValuesString(filterName: string, queryFilters: QueryFilter[] filterValueString += ` ${filterName}${operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO]}${sanitizeSearchValue(rangeBoundaries.to)}`; } } else { - const operatorChar = operatorToCharMap[queryFilter.operator]; + const operatorChar = + filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT && queryFilter.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO + ? EXPLICIT_EQUAL_TO_OPERATOR + : operatorToCharMap[queryFilter.operator]; if (!operatorChar) { continue; } @@ -929,12 +987,6 @@ function getQueryHashes(query: SearchQueryJSON) { } const filterSet = new Set(orderedQuery); - const exactMatchFilterKeys = [...(query.exactMatchFilterKeys ?? [])].sort(); - if (exactMatchFilterKeys.length > 0) { - const exactMatchIdentity = `exactMatch:${exactMatchFilterKeys.join(',')}`; - orderedQuery += ` ${exactMatchIdentity}`; - filterSet.add(exactMatchIdentity); - } // Certain filters shouldn't affect whether two searchers are similar or not, since they dont // actually filter out results @@ -1005,17 +1057,6 @@ function getQueryHashes(query: SearchQueryJSON) { return {primaryHash, recentSearchHash, similarSearchHash}; } -function withExactMatchFilterKeys(queryJSON: Readonly, exactMatchFilterKeys: SearchFilterKey[]): SearchQueryJSON { - const queryWithExactMatches = {...queryJSON, exactMatchFilterKeys}; - const {primaryHash, recentSearchHash, similarSearchHash} = getQueryHashes(queryWithExactMatches); - return { - ...queryWithExactMatches, - hash: primaryHash, - recentSearchHash, - similarSearchHash, - }; -} - /** * Returns whether a given string is a date preset (e.g. Last month) */ @@ -1180,7 +1221,14 @@ const NON_FILTER_CHIP_KEYS = new Set([ CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL, CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY, ]); -const NON_SAVABLE_FILTER_KEYS = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY]); +// Keys whose change alone doesn't offer Save. The Spend footer's selections are still saved with a search, but changing +// them is undone from the footer's own Reset, so they offer neither Save nor Reset in the header. +const NON_SAVABLE_FILTER_KEYS = new Set([ + CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_COUNT, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY, +]); function buildQueryStringWithResetFilters(currentQueryJSON: SearchQueryJSON, defaultQueryJSON: SearchQueryJSON | undefined) { const resetFilters = (defaultQueryJSON?.flatFilters ?? []).filter((filter) => !NON_FILTER_CHIP_KEYS.has(filter.key)); @@ -1268,6 +1316,8 @@ type BuildQueryStringOptions = { sortBy?: string; sortOrder?: string; limit?: number; + /** Original query filters, including predicates that cannot fit in a single form field. */ + flatFilters?: QueryFilters; }; /** @@ -1305,7 +1355,7 @@ function buildQueryStringFromFilterFormValues(filterValues: Partial CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); if (keyInCorrectForm) { - return `${prefix}${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}:${sanitizeSearchValue(filterValue as string)}`; + const value = filterValue as string; + if (filterKey === FILTER_KEYS.MERCHANT) { + return buildMerchantFilterQuery(value, prefix, isNegated, !!supportedFilterValues.merchantNot, merchantOperator, options?.flatFilters); + } + return `${prefix}${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}${operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO]}${sanitizeSearchValue(value)}`; } } if ((filterKey === FILTER_KEYS.REPORT_ID || filterKey === FILTER_KEYS.WITHDRAWAL_ID) && filterValue) { @@ -1654,44 +1710,31 @@ function getDateFilterRange(queryJSON: SearchQueryJSON): DateFilterRange { const presetRange = getDateRangeForPreset(equalToValue); start = presetRange.start; end = presetRange.end; - } else if (isValidDate(equalToValue)) { - start = equalToValue; - end = equalToValue; + } else { + start = getInclusiveRangeBoundary(equalToValue); + end = start; } } + // Typed dates can drop leading zeros, like 2026-1-5, so each boundary is zero-padded before it is compared with other dates const greaterThanOrEqualToFilter = dateFilters.find((filter) => filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO); if (greaterThanOrEqualToFilter) { - const filterValue = greaterThanOrEqualToFilter.value.toString(); - if (isValidDate(filterValue)) { - start = getLaterDate(start, filterValue); - } + start = getLaterDate(start, getInclusiveRangeBoundary(greaterThanOrEqualToFilter.value.toString())); } const greaterThanFilter = dateFilters.find((filter) => filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN); if (greaterThanFilter) { - const filterValue = greaterThanFilter.value.toString(); - if (isValidDate(filterValue)) { - const nextDay = format(addDays(parse(filterValue, 'yyyy-MM-dd', new Date()), 1), 'yyyy-MM-dd'); - start = getLaterDate(start, nextDay); - } + start = getLaterDate(start, getInclusiveRangeBoundary(greaterThanFilter.value.toString(), 1)); } const lowerThanOrEqualToFilter = dateFilters.find((filter) => filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO); if (lowerThanOrEqualToFilter) { - const filterValue = lowerThanOrEqualToFilter.value.toString(); - if (isValidDate(filterValue)) { - end = getEarlierDate(end, filterValue); - } + end = getEarlierDate(end, getInclusiveRangeBoundary(lowerThanOrEqualToFilter.value.toString())); } const lowerThanFilter = dateFilters.find((filter) => filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN); if (lowerThanFilter) { - const filterValue = lowerThanFilter.value.toString(); - if (isValidDate(filterValue)) { - const previousDay = format(addDays(parse(filterValue, 'yyyy-MM-dd', new Date()), -1), 'yyyy-MM-dd'); - end = getEarlierDate(end, previousDay); - } + end = getEarlierDate(end, getInclusiveRangeBoundary(lowerThanFilter.value.toString(), -1)); } return {start, end}; @@ -1797,6 +1840,11 @@ function buildFilterFormValuesFromQuery( } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE) { filtersForm[addNegation(filterKey, isNegated)] = filterValues.join(','); + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT && !isNegated) { + filtersForm[FILTER_KEYS.MERCHANT_OPERATOR] = filterList.some((item) => item.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) + ? CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO + : DEFAULT_MERCHANT_OPERATOR; + } } if ( filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTER_USER_ID || @@ -1994,7 +2042,12 @@ function buildFilterFormValuesFromQuery( ?.value.toString() ?? filtersForm[negatedKey]; } - if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.BILLABLE || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE) { + if ( + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.BILLABLE || + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE || + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DECLINED || + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.HELD + ) { const validBooleanTypes = Object.values(CONST.SEARCH.BOOLEAN); filtersForm[addNegation(filterKey, isNegated)] = validBooleanTypes.find((value) => filterValues.at(0) === value); } @@ -2883,7 +2936,7 @@ function shouldResetSortForViewChange({newView, oldView, groupBy}: {newView: str function buildFilterQueryWithSortDefaults( filterValues: Partial, previousState: {view?: string; groupBy?: string}, - currentQueryOptions: {sortBy?: string; sortOrder?: string}, + currentQueryOptions: BuildQueryStringOptions, policies?: OnyxCollection, ): string | undefined { const resetSort = shouldResetSort({ @@ -2902,6 +2955,7 @@ function buildFilterQueryWithSortDefaults( const queryString = buildQueryStringFromFilterFormValues(filterValues, { sortBy: resetSort || resetSortForViewChange ? undefined : currentQueryOptions.sortBy, sortOrder: resetSort || resetSortForViewChange ? undefined : currentQueryOptions.sortOrder, + flatFilters: currentQueryOptions.flatFilters, }); if (!resetSort && !resetSortForViewChange) { @@ -2974,30 +3028,27 @@ function getEmptyDateValues(): SearchDateValues { } /** - * Set of filter keys that represent free-text fields where the default `:` (eq) operator - * should be treated as a substring/partial match (`contains`) when querying the backend. - * This allows searches like `merchant:coffee` to match "Coffee shop". + * Fields where `eq` should still be sent to the backend as `contains`. + * Merchant is excluded because the parser preserves legacy `merchant:` as contains and uses `merchant=` for exact matches. */ function isTextSearchField(key: string): key is SearchFilterKey { - return key === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT || key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION; + return key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION; } /** * Recursively traverses a search AST and replaces the `eq` operator with `contains` - * for free-text filter fields (merchant, description). This enables partial/substring - * matching on the backend for text searches while preserving the user-facing `:` syntax. - * Keys in `exactMatchFilterKeys` keep their original `eq` operator. + * for fields that still use partial matching on the backend (description). + * Merchant is excluded because its parser output already distinguishes legacy contains from explicit exact matches. */ -function applyContainsOperatorToTextFields(node: ASTNode, exactMatchFilterKeys?: ReadonlySet): ASTNode { - const filterKey = typeof node.left === 'string' && isTextSearchField(node.left) ? node.left : undefined; - if (filterKey && !exactMatchFilterKeys?.has(filterKey) && node.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { +function applyContainsOperatorToTextFields(node: ASTNode): ASTNode { + if (typeof node.left === 'string' && isTextSearchField(node.left) && node.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { return {...node, operator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}; } return { ...node, - left: typeof node.left === 'object' && node.left ? applyContainsOperatorToTextFields(node.left, exactMatchFilterKeys) : node.left, - right: typeof node.right === 'object' && !Array.isArray(node.right) && node.right ? applyContainsOperatorToTextFields(node.right, exactMatchFilterKeys) : node.right, + left: typeof node.left === 'object' && node.left ? applyContainsOperatorToTextFields(node.left) : node.left, + right: typeof node.right === 'object' && !Array.isArray(node.right) && node.right ? applyContainsOperatorToTextFields(node.right) : node.right, }; } @@ -3017,16 +3068,15 @@ function getDateModifierTitle(modifier: ValueOf(queryData: T, exactMatchFilterKeys?: ReadonlySet): string { - const normalizedFilters = queryData.filters ? applyContainsOperatorToTextFields(queryData.filters, exactMatchFilterKeys) : queryData.filters; +function serializeQueryJSONForBackend(queryData: T): string { + const normalizedFilters = queryData.filters ? applyContainsOperatorToTextFields(queryData.filters) : queryData.filters; const normalizedRawFilterList = queryData.rawFilterList ? queryData.rawFilterList.map((filter) => { - if (isTextSearchField(filter.key) && !exactMatchFilterKeys?.has(filter.key) && filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { + if (isTextSearchField(filter.key) && filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { return {...filter, operator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}; } return filter; @@ -3066,6 +3116,16 @@ function getFilterFormValues { + return { + ...getFilterFormValues(CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, value, isNegated), + [FILTER_KEYS.MERCHANT_OPERATOR]: isNegated ? undefined : merchantOperator, + }; +} + /** * Checks whether a query still matches a default query: it contains all of the default query's filter keys and has the same type. * Used to detect when a query no longer represents a given default/suggested search (e.g. a filter was removed). @@ -3076,7 +3136,9 @@ function doesQueryMatchDefaultFilterKeysAndType(queryJSON: SearchQueryJSON | und } const queryFilterKeys = new Set(queryJSON.flatFilters.map((filter) => filter.key)); - const defaultQueryFilterKeys = new Set(defaultQueryJSON.flatFilters.map((filter) => filter.key)); + // The Spend footer's keys pick what the footer shows, not which rows match, and a default choice is left out of the + // query. So they never decide whether a last query still belongs to its default one. + const defaultQueryFilterKeys = new Set(defaultQueryJSON.flatFilters.map((filter) => filter.key).filter((key) => !FOOTER_FILTER_KEYS.has(key))); return [...defaultQueryFilterKeys].every((value) => queryFilterKeys.has(value)) && queryJSON.type === defaultQueryJSON.type; } @@ -3118,7 +3180,6 @@ export { hasFooterSelections, hasFiltersChangedFromDefault, isSearchQuerySavable, - withExactMatchFilterKeys, isSearchDatePreset, getDateRangeForPreset, getDateFilterRange, @@ -3169,6 +3230,7 @@ export { isFilterNegatable, removeNegation, getFilterFormValues, + getMerchantFilterFormValues, getFilterFromQuery, getValidLastQuery, doesQueryMatchDefaultFilterKeysAndType, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index b7f78c261878..91229b22e985 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -41,6 +41,7 @@ import type { SearchDateFilterKeys, SearchDatePreset, SearchFilterKey, + SearchFooterCount, SearchFooterTotal, SearchGroupBy, SearchPaidStatus, @@ -119,7 +120,9 @@ import {getCardDescriptionForSearchTable, getFeedNameForDisplay, isPersonalCard} import {getCategoryGLCode, getDecodedCategoryName} from './CategoryUtils'; import DateUtils from './DateUtils'; import getIOUPayerAndReceiver from './getIOUPayerAndReceiver'; +import getTransactionFromTransactionListItem from './getTransactionFromTransactionListItem'; import interceptAnonymousUser from './interceptAnonymousUser'; +import isTransactionGroupListItemType from './isTransactionGroupListItemType'; import memoize from './memoize'; import isSearchTopmostFullScreenRoute from './Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation from './Navigation/Navigation'; @@ -214,7 +217,6 @@ import { isSearchDatePreset, removeNegation, sortOptionsWithEmptyValue, - withExactMatchFilterKeys, } from './SearchQueryUtils'; import {expenseStatusActionMapping, getSuggestedSearches, isEligibleForStatus, SEARCH_TYPE_MENU_ICON_NAMES} from './SearchSuggestionUtils'; import StringUtils from './StringUtils'; @@ -239,6 +241,7 @@ import { getTransactionViolations, hasDisplayableMCC, isDeletedTransaction, + isOnHold, isPending, isScanning, isViolationDismissed, @@ -332,6 +335,7 @@ const transactionColumnNamesToSortingProperty: TransactionSorting = { [CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT]: 'originalAmount' as const, [CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE]: 'reimbursable' as const, [CONST.SEARCH.TABLE_COLUMNS.BILLABLE]: 'billable' as const, + [CONST.SEARCH.TABLE_COLUMNS.HELD]: null, [CONST.SEARCH.TABLE_COLUMNS.MCC]: 'mcc' as const, [CONST.SEARCH.TABLE_COLUMNS.TAX_CODE]: 'taxCode' as const, [CONST.SEARCH.TABLE_COLUMNS.TYPE]: null, @@ -749,7 +753,7 @@ function getSuggestedSearchesVisibility( const isExporter = policy.exporter === currentUserEmail; const isSubmittedTo = - !!currentUserEmail && Object.values(policy.employeeList ?? {}).some((employee) => employee.submitsTo === currentUserEmail || employee.forwardsTo === currentUserEmail); + !!currentUserEmail && Object.values(policy.employeeList ?? {}).some((employee) => employee?.submitsTo === currentUserEmail || employee?.forwardsTo === currentUserEmail); const isUserApprover = !!currentUserEmail && isPolicyApprover(policy, currentUserEmail); const isApprovalEnabled = policy.approvalMode ? policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL : false; @@ -869,6 +873,17 @@ function isReportEntry(key: string): key is ReportKey { return key.startsWith(ONYXKEYS.COLLECTION.REPORT); } +/** The IDs of the expense reports in search data */ +function getExpenseReportIDs(data: OnyxTypes.SearchResults['data']): Record { + const reportIDs: Record = {}; + for (const key of Object.keys(data)) { + if (isReportEntry(key) && isExpenseReportUtil(data[key])) { + reportIDs[key.replace(ONYXKEYS.COLLECTION.REPORT, '')] = true; + } + } + return reportIDs; +} + /** * @private */ @@ -891,13 +906,6 @@ function isTransactionEntry(key: string): key is TransactionKey { return key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION); } -/** - * Type guard that checks if something is a TransactionGroupListItemType - */ -function isTransactionGroupListItemType(item: ListItem): item is TransactionGroupListItemType { - return 'transactions' in item; -} - /** * Type guard that checks if something is a TransactionReportGroupListItemType */ @@ -2579,7 +2587,8 @@ function getReportActionsSections( for (const key in data) { if (isReportActionEntry(key)) { const reportIDFromKey = key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); - const reportActions = Object.values(data[key]); + const reportActionsCollection = data[key]; + const reportActions = Object.values(reportActionsCollection); n += reportActions.length; for (const reportAction of reportActions) { // Always use the container reportID so "In " rows open the parent chat, not a child task/thread report. @@ -2596,7 +2605,7 @@ function getReportActionsSections( const isReportArchived = isArchivedReport(data[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]); if ( !reportID || - !isReportActionVisible(reportAction, reportID, canUserPerformWriteAction(report, isReportArchived), visibleReportActionsData) || + !isReportActionVisible(reportAction, reportID, canUserPerformWriteAction(report, isReportArchived), visibleReportActionsData, undefined, reportActionsCollection) || isDeletedAction(reportAction) || isResolvedActionableWhisper(reportAction) || reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED || @@ -3056,11 +3065,7 @@ function buildSpecificGroupQuery(queryJSON: SearchQueryJSON, filterKey: QueryFil sortOrder: CONST.SEARCH.SORT_ORDER.DESC, flatFilters: newFlatFilters, }; - const specificGroupQueryJSON = buildSearchQueryJSON(buildSearchQueryString(newQueryJSON)); - if (!specificGroupQueryJSON || filterKey !== CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { - return specificGroupQueryJSON; - } - return withExactMatchFilterKeys(specificGroupQueryJSON, [filterKey]); + return buildSearchQueryJSON(buildSearchQueryString(newQueryJSON)); } function getActiveGroupSearchHashes(data: OnyxTypes.SearchResults['data'] | undefined, queryJSON: Readonly | undefined): number[] { @@ -3526,6 +3531,7 @@ function getDaySections( ): [TransactionDayGroupListItemType[], number, boolean] { const daySections: Record = {}; const shouldShowShortLabelYear = doGroupsSpanMultipleYears(data); + const currentYear = new Date().getFullYear(); for (const key in data) { if (!isGroupEntry(key)) { continue; @@ -3544,6 +3550,7 @@ function getDaySections( ...dayGroup, formattedDay: DateUtils.formatToReadableString(dayGroup.day, dateFnsLocale), shortFormattedDay: DateUtils.getShortFormattedDayForSearch(dayGroup.day, dateFnsLocale, shouldShowShortLabelYear), + lineChartFormattedDay: DateUtils.getShortFormattedDayForSearch(dayGroup.day, dateFnsLocale, getYearOfDate(dayGroup.day) !== currentYear), keyForList: key, }; } @@ -3622,6 +3629,7 @@ function getWeekSections( // Years come from the week as trimmed to the date filter, so a filter starting Jan 1 doesn't count the days before it const shouldShowShortLabelYear = new Set(Object.values(weeks).flatMap(({weekStart, weekEnd}) => [getYearOfDate(weekStart), getYearOfDate(weekEnd)])).size > 1; + const currentYear = new Date().getFullYear(); for (const [key, {weekGroup, weekStart, weekEnd, transactionsQueryJSON}] of Object.entries(weeks)) { const formattedWeek = DateUtils.getFormattedDateRangeForSearch(weekStart, weekEnd, dateFnsLocale); const shortFormattedWeek = DateUtils.getShortFormattedDateRangeForSearch(weekStart, weekEnd, dateFnsLocale, shouldShowShortLabelYear); @@ -3633,6 +3641,7 @@ function getWeekSections( ...weekGroup, formattedWeek, shortFormattedWeek, + lineChartFormattedWeek: DateUtils.getShortFormattedDayForSearch(weekStart, dateFnsLocale, getYearOfDate(weekStart) !== currentYear), keyForList: key, }; } @@ -3946,7 +3955,7 @@ function compareValues(a: unknown, b: unknown, sortOrder: SortOrder, sortBy: str /** * @private * Gets the value to use for sorting a transaction by a given property. - * Handles special cases like comments and boolean fields (reimbursable, billable). + * Handles special cases like comments and boolean fields (reimbursable, billable, held). */ function getTransactionSortValue(transaction: TransactionListItemType, sortingProperty: string): unknown { if (sortingProperty === 'comment') { @@ -3958,6 +3967,10 @@ function getTransactionSortValue(transaction: TransactionListItemType, sortingPr return boolValue ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; } + if (sortingProperty === CONST.SEARCH.TABLE_COLUMNS.HELD) { + return isOnHold(transaction) ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; + } + return transaction[sortingProperty as keyof TransactionListItemType]; } @@ -4138,15 +4151,18 @@ function getSortedTransactionData( }); } - if (!sortingProperty) { + // Held has no property of its own on the row, so getTransactionSortValue reads it from the hold on the transaction comment + const sortKey = sortBy === CONST.SEARCH.TABLE_COLUMNS.HELD ? sortBy : sortingProperty; + + if (!sortKey) { return data; } return data.sort((a, b) => { - const aValue = getTransactionSortValue(a, sortingProperty); - const bValue = getTransactionSortValue(b, sortingProperty); + const aValue = getTransactionSortValue(a, sortKey); + const bValue = getTransactionSortValue(b, sortKey); - const primaryComparison = compareValues(aValue, bValue, sortOrder, sortingProperty, localeCompare); + const primaryComparison = compareValues(aValue, bValue, sortOrder, sortKey, localeCompare); if (primaryComparison !== 0) { return primaryComparison; @@ -4521,6 +4537,8 @@ function getSearchColumnTranslationKey(column: SearchSortBy, type?: SearchDataTy return 'common.reimbursable'; case CONST.SEARCH.TABLE_COLUMNS.BILLABLE: return 'common.billable'; + case CONST.SEARCH.TABLE_COLUMNS.HELD: + return 'search.filters.held'; case CONST.SEARCH.TABLE_COLUMNS.MCC: return 'common.mcc'; case CONST.SEARCH.TABLE_COLUMNS.TAX_CODE: @@ -5400,6 +5418,14 @@ function getViewOptions(translate: LocalizedTranslate) { return Object.values(CONST.SEARCH.VIEW).map>((value) => ({text: translate(`search.view.${value}`), value})); } +/** The options the Spend footer's count selector offers, in display order. */ +function getFooterCountItems(translate: LocalizedTranslate) { + return [ + {text: translate('common.expenses'), value: CONST.SEARCH.FOOTER_COUNT.EXPENSES}, + {text: translate('common.reports'), value: CONST.SEARCH.FOOTER_COUNT.REPORTS}, + ] satisfies Array>; +} + /** * The options the Spend footer's total selector offers, in display order. `Total spend` is the default and always * applies. The four aggregates are computed by the backend, so picking one re-runs the search. @@ -5454,6 +5480,7 @@ function getDatePresets(filterKey: SearchDateFilterKeys, hasFeed: boolean): Sear switch (filterKey) { case CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE: + case CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL: return commonPresets; case CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED: return [...commonPresets, CONST.SEARCH.DATE_PRESETS.NEVER, ...(hasFeed ? [CONST.SEARCH.DATE_PRESETS.LAST_STATEMENT] : [])]; @@ -5580,6 +5607,11 @@ const FILTER_TO_SYNTAX_KEY = { [FILTER_KEYS.APPROVED_BEFORE]: CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED, [FILTER_KEYS.APPROVED_RANGE]: CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED, + [FILTER_KEYS.ANY_APPROVAL_ON]: CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, + [FILTER_KEYS.ANY_APPROVAL_AFTER]: CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, + [FILTER_KEYS.ANY_APPROVAL_BEFORE]: CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, + [FILTER_KEYS.ANY_APPROVAL_RANGE]: CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, + [FILTER_KEYS.PAID_ON]: CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID, [FILTER_KEYS.PAID_AFTER]: CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID, [FILTER_KEYS.PAID_BEFORE]: CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID, @@ -5659,6 +5691,10 @@ const FILTER_VIEW_MAP = { labelKey: 'common.currency', icon: 'MoneyCircle', }, + [CONST.SEARCH.SYNTAX_FILTER_KEYS.DECLINED]: { + labelKey: 'search.filters.rejected', + icon: 'ThumbsDown', + }, [CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION]: { labelKey: 'common.description', icon: 'Document', @@ -5691,6 +5727,10 @@ const FILTER_VIEW_MAP = { labelKey: 'search.has', icon: 'Paperclip', }, + [CONST.SEARCH.SYNTAX_FILTER_KEYS.HELD]: { + labelKey: 'search.filters.held', + icon: 'Stopwatch', + }, [CONST.SEARCH.SYNTAX_FILTER_KEYS.IN]: { labelKey: 'common.in', icon: 'Inbox', @@ -5807,6 +5847,10 @@ const FILTER_VIEW_MAP = { labelKey: 'search.filters.approved', icon: 'ThumbsUp', }, + [CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL]: { + labelKey: 'search.filters.anyApproval', + icon: 'ThumbsUp', + }, [CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED]: { labelKey: 'search.filters.submitted', icon: 'Send', @@ -5973,7 +6017,16 @@ function getDisplayValue( return form[key]?.sort(localeCompare).join(', '); } - if (key === FILTER_KEYS.BILLABLE || key === FILTER_KEYS.REIMBURSABLE) { + if ( + key === FILTER_KEYS.BILLABLE || + key === FILTER_KEYS.BILLABLE_NOT || + key === FILTER_KEYS.REIMBURSABLE || + key === FILTER_KEYS.REIMBURSABLE_NOT || + key === FILTER_KEYS.DECLINED || + key === FILTER_KEYS.DECLINED_NOT || + key === FILTER_KEYS.HELD || + key === FILTER_KEYS.HELD_NOT + ) { const formValue = form[key]; return formValue ? translate(`common.${formValue}`) : undefined; } @@ -6047,6 +6100,16 @@ function getDisplayValue( return form[key]?.map((receiptType) => translate(getReceiptTypeTranslationKey(receiptType))).join(', '); } + if (key === FILTER_KEYS.MERCHANT) { + const merchant = form[key]; + if (!merchant) { + return; + } + + const matchTypeKey = form[FILTER_KEYS.MERCHANT_OPERATOR] === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO ? 'search.filters.merchant.equalTo' : 'search.filters.merchant.contains'; + return translate(matchTypeKey, merchant); + } + if (key === FILTER_KEYS.TRANSACTION_STATUS) { const transactionStatus = form[key]; return transactionStatus ? translate(`search.filters.transactionStatus.${transactionStatus}`) : undefined; @@ -6099,6 +6162,11 @@ function hasFilterContentValuesChanged( return true; } + // The Merchant content also reads its match type. + if (baseFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { + return previousValues?.[FILTER_KEYS.MERCHANT_OPERATOR] !== values?.[FILTER_KEYS.MERCHANT_OPERATOR]; + } + if (isTextFilterKey(baseFilterKey)) { return false; } @@ -6262,7 +6330,7 @@ function mapFiltersFormToLabelValueList( } function getSingleSelectFilterOptions(filterKey: SearchAdvancedFiltersKey, translate: LocalizedTranslate) { - if (filterKey === FILTER_KEYS.BILLABLE || filterKey === FILTER_KEYS.REIMBURSABLE) { + if (filterKey === FILTER_KEYS.BILLABLE || filterKey === FILTER_KEYS.REIMBURSABLE || filterKey === FILTER_KEYS.DECLINED || filterKey === FILTER_KEYS.HELD) { return Object.values(CONST.SEARCH.BOOLEAN).map((value) => ({value, text: translate(`common.${value}`)})); } @@ -6639,6 +6707,7 @@ function getColumnsToShow({ [CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT]: false, [CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE]: shouldShowReimbursableColumn, [CONST.SEARCH.TABLE_COLUMNS.BILLABLE]: shouldShowBillableColumn, + [CONST.SEARCH.TABLE_COLUMNS.HELD]: false, [CONST.SEARCH.TABLE_COLUMNS.COMMENTS]: shouldShowCommentsColumn, [CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]: true, [CONST.SEARCH.TABLE_COLUMNS.TOTAL]: false, @@ -6667,6 +6736,7 @@ function getColumnsToShow({ [CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS]: false, [CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE]: false, [CONST.SEARCH.TABLE_COLUMNS.BILLABLE]: false, + [CONST.SEARCH.TABLE_COLUMNS.HELD]: false, [CONST.SEARCH.TABLE_COLUMNS.MCC]: false, [CONST.SEARCH.TABLE_COLUMNS.TAX_CODE]: false, [CONST.SEARCH.TABLE_COLUMNS.TAX_RATE]: false, @@ -7005,39 +7075,6 @@ function getSettlementStatusBadgeProps( } } -/** - * Extracts the core Transaction fields from a TransactionListItemType - * This removes UI-specific fields like formattedFrom, hash, violations, etc. - */ -function getTransactionFromTransactionListItem(item: TransactionListItemType): OnyxTypes.Transaction { - // Extract only the core Transaction fields, excluding UI-specific and search-specific fields - - const { - keyForList, - action, - allActions, - report, - from, - to, - formattedFrom, - formattedTo, - formattedTotal, - formattedMerchant, - date, - shouldShowMerchant, - shouldShowYear: shouldTransactionShowYear, - isAmountColumnWide, - isTaxAmountColumnWide, - violations, - hash, - accountID, - policyID, - ...transaction - } = item; - - return transaction as OnyxTypes.Transaction; -} - /** * Width of the arrow ending each row. The arrow is a row child rather than a column, so it is counted separately: leave * it out and it gets pushed past the edge instead of the table scrolling to reach it. @@ -7106,6 +7143,7 @@ function getTableMinWidth( } else if ( column === CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE || column === CONST.SEARCH.TABLE_COLUMNS.BILLABLE || + column === CONST.SEARCH.TABLE_COLUMNS.HELD || column === CONST.SEARCH.TABLE_COLUMNS.MCC || column === CONST.SEARCH.TABLE_COLUMNS.TAX_CODE ) { @@ -7242,6 +7280,8 @@ function shouldShowDeleteOption( currentSearchResults: SearchResults['data'] | undefined, currentUserAccountID: number, rules: OnyxCollection, + // The full list is needed because each pass below resolves a different report owner. + personalDetails: OnyxEntry, cardList: OnyxEntry, selectedReports: SelectedReports[] = [], searchDataType?: SearchDataTypes, @@ -7268,7 +7308,17 @@ function shouldShowDeleteOption( } } const reportPolicy = currentSearchResults?.[`${ONYXKEYS.COLLECTION.POLICY}${fullReport.policyID}`]; - return canDeleteMoneyRequestReport(fullReport, reportTransactions, reportActionsArray, currentUserAccountID, rules, reportPolicy, cardList, true); + return canDeleteMoneyRequestReport( + fullReport, + reportTransactions, + reportActionsArray, + currentUserAccountID, + rules, + getLoginByAccountID(fullReport.ownerAccountID, personalDetails), + reportPolicy, + cardList, + true, + ); }) : selectedTransactionsKeys.every((id) => { const transaction = currentSearchResults?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`] ?? selectedTransactions[id]?.transaction; @@ -7283,7 +7333,16 @@ function shouldShowDeleteOption( selectedTransactions[id].reportAction; const parentReportPolicy = currentSearchResults?.[`${ONYXKEYS.COLLECTION.POLICY}${parentReport?.policyID}`]; - return canDeleteMoneyRequestReport(parentReport, [transaction], parentReportAction ? [parentReportAction] : [], currentUserAccountID, rules, parentReportPolicy, cardList); + return canDeleteMoneyRequestReport( + parentReport, + [transaction], + parentReportAction ? [parentReportAction] : [], + currentUserAccountID, + rules, + getLoginByAccountID(parentReport?.ownerAccountID, personalDetails), + parentReportPolicy, + cardList, + ); }); } @@ -7405,6 +7464,7 @@ export { isGroupedItemArray, isGroupEntry, isReportEntry, + getExpenseReportIDs, isTransactionEntry, isSearchResultsEmpty, isTransactionListItemType, @@ -7437,6 +7497,7 @@ export { getSortOrderOptions, getGroupBySections, getViewOptions, + getFooterCountItems, getFooterTotalItems, getCurrencyOptions, getFeedOptions, diff --git a/src/libs/SelectionListOrderUtils.ts b/src/libs/SelectionListOrderUtils.ts index e9c2918cce46..fd2228d9dcd6 100644 --- a/src/libs/SelectionListOrderUtils.ts +++ b/src/libs/SelectionListOrderUtils.ts @@ -5,6 +5,10 @@ function shouldMoveInitialSelectionToTop(itemCount: number): boolean { return itemCount >= CONST.STANDARD_LIST_ITEM_LIMIT; } +/** + * Moves the pre-selected items to the top of the list, keeping the rest in their original order. + * No-ops for short lists (below the item-limit threshold) or when nothing is pre-selected. + */ function moveInitialSelectionToTop(items: T[], initialSelectedValues: string[]): T[] { if (initialSelectedValues.length === 0 || !shouldMoveInitialSelectionToTop(items.length)) { return items; diff --git a/src/libs/SelectionScraper/index.native.ts b/src/libs/SelectionScraper/index.native.ts index aeb9c5ae3ce2..db484ce39fea 100644 --- a/src/libs/SelectionScraper/index.native.ts +++ b/src/libs/SelectionScraper/index.native.ts @@ -1,8 +1,16 @@ +import CONST from '@src/CONST'; + import type GetCurrentSelection from './types'; +const COPYABLE_TEXT_DATA_SET = {[CONST.COPYABLE_TEXT_ELEMENT]: true} as const; +const COPYABLE_ROW_DATA_SET = {[CONST.COPYABLE_ROW_ELEMENT]: true} as const; +const COPYABLE_ROW_CONTINUATION_DATA_SET = {...COPYABLE_ROW_DATA_SET, [CONST.COPYABLE_ROW_CONTINUATION_ELEMENT]: true} as const; + // This is a no-op function for native devices because they wouldn't be able to support Selection API like a website. const getCurrentSelection: GetCurrentSelection = () => ''; +export {COPYABLE_ROW_CONTINUATION_DATA_SET, COPYABLE_ROW_DATA_SET, COPYABLE_TEXT_DATA_SET}; + export default { getCurrentSelection, }; diff --git a/src/libs/SelectionScraper/index.ts b/src/libs/SelectionScraper/index.ts index 031f3e3c12cb..6b6153745c90 100644 --- a/src/libs/SelectionScraper/index.ts +++ b/src/libs/SelectionScraper/index.ts @@ -13,6 +13,254 @@ import installTransformedChildren from './installTransformedChildren'; const markdownElements = new Set(['h1', 'strong', 'em', 'del', 'blockquote', 'q', 'code', 'pre', 'a', 'br', 'li', 'ul', 'ol', 'b', 'i', 's', 'mention-user']); const tagAttribute = 'data-testid'; +const hiddenElementAttribute = `data-${CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT}`; +const hiddenElementSelector = `[${hiddenElementAttribute}=true]`; +const COPYABLE_TEXT_SELECTOR = `[data-${CONST.COPYABLE_TEXT_ELEMENT}=true]`; +const COPYABLE_TEXT_DATA_SET = {[CONST.COPYABLE_TEXT_ELEMENT]: true} as const; +const COPYABLE_ROW_SELECTOR = `[data-${CONST.COPYABLE_ROW_ELEMENT}=true]`; +const COPYABLE_ROW_DATA_SET = {[CONST.COPYABLE_ROW_ELEMENT]: true} as const; +const copyableRowContinuationAttribute = `data-${CONST.COPYABLE_ROW_CONTINUATION_ELEMENT}`; +const COPYABLE_ROW_CONTINUATION_DATA_SET = {...COPYABLE_ROW_DATA_SET, [CONST.COPYABLE_ROW_CONTINUATION_ELEMENT]: true} as const; + +function normalizeSelectedContent(content: globalThis.DocumentFragment) { + const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT); + const textNodes: globalThis.Text[] = []; + let textNode = walker.nextNode(); + let hasText = false; + let previousTextEndsWithSpace = false; + + while (textNode) { + if (textNode instanceof globalThis.Text) { + textNode.data = textNode.data.replaceAll(/[\t\n\r ]+/g, ' '); + if (!hasText || previousTextEndsWithSpace) { + textNode.data = textNode.data.trimStart(); + } + if (textNode.data) { + hasText = true; + previousTextEndsWithSpace = textNode.data.endsWith(' '); + } + textNodes.push(textNode); + } + textNode = walker.nextNode(); + } + + const lastTextNode = textNodes.findLast((node) => !!node.data); + if (lastTextNode) { + lastTextNode.data = lastTextNode.data.trimEnd(); + } +} + +function unwrapNonSemanticElements(content: globalThis.DocumentFragment) { + for (const element of content.querySelectorAll('*')) { + const renderedTag = element.getAttribute(tagAttribute); + const nativeTag = element.tagName.toLowerCase(); + if ((renderedTag && markdownElements.has(renderedTag)) || markdownElements.has(nativeTag)) { + continue; + } + + element.replaceWith(...Array.from(element.childNodes)); + } +} + +function getCopyableElementContent(element: globalThis.Element, selection: Selection): globalThis.DocumentFragment { + const elementRange = document.createRange(); + elementRange.selectNodeContents(element); + const selectedContent = document.createDocumentFragment(); + + for (let i = 0; i < selection.rangeCount; i++) { + const intersectionRange = selection.getRangeAt(i).cloneRange(); + if (!intersectionRange.intersectsNode(element)) { + continue; + } + + if (intersectionRange.compareBoundaryPoints(globalThis.Range.START_TO_START, elementRange) < 0) { + intersectionRange.setStart(elementRange.startContainer, elementRange.startOffset); + } + if (intersectionRange.compareBoundaryPoints(globalThis.Range.END_TO_END, elementRange) > 0) { + intersectionRange.setEnd(elementRange.endContainer, elementRange.endOffset); + } + + let content: globalThis.Node = intersectionRange.cloneContents(); + let parent = getElementFromNode(intersectionRange.commonAncestorContainer)?.closest(`[${tagAttribute}]`) ?? null; + while (parent && element.contains(parent)) { + const clonedParent = parent.cloneNode(); + clonedParent.appendChild(content); + content = clonedParent; + parent = parent.parentElement?.closest(`[${tagAttribute}]`) ?? null; + } + + const fragment = document.createDocumentFragment(); + fragment.appendChild(content); + for (const hiddenElement of fragment.querySelectorAll(hiddenElementSelector)) { + hiddenElement.remove(); + } + unwrapNonSemanticElements(fragment); + normalizeSelectedContent(fragment); + if (!fragment.textContent?.trim()) { + continue; + } + + if (selectedContent.textContent) { + selectedContent.appendChild(document.createTextNode(' ')); + } + selectedContent.appendChild(fragment); + } + + return selectedContent; +} + +function replaceElementContentWithLines(element: globalThis.Element, lines: Array<{row: globalThis.Element; content: globalThis.DocumentFragment}>) { + for (const [index, {content}] of lines.entries()) { + if (index > 0) { + const previousRow = lines.at(index - 1)?.row; + const separator = previousRow?.getAttribute(copyableRowContinuationAttribute) === 'true' ? document.createTextNode(' ') : document.createElement('br'); + element.appendChild(separator); + } + element.appendChild(content); + } +} + +function getSelectedTextForTextNode(textNode: globalThis.Text, range: Range): string { + const textNodeRange = document.createRange(); + textNodeRange.selectNodeContents(textNode); + + const intersectionRange = range.cloneRange(); + if (intersectionRange.compareBoundaryPoints(globalThis.Range.START_TO_START, textNodeRange) < 0) { + intersectionRange.setStart(textNodeRange.startContainer, textNodeRange.startOffset); + } + if (intersectionRange.compareBoundaryPoints(globalThis.Range.END_TO_END, textNodeRange) > 0) { + intersectionRange.setEnd(textNodeRange.endContainer, textNodeRange.endOffset); + } + + return intersectionRange.toString(); +} + +function getElementFromNode(node: globalThis.Node): globalThis.Element | null { + if (node instanceof globalThis.Element) { + return node; + } + + return node.parentElement; +} + +function getSelectedRowsForRange(range: Range): globalThis.Element[] { + const rootElement = getElementFromNode(range.commonAncestorContainer); + if (!rootElement) { + return []; + } + + // Use the live selection range so partially selected first/last grouped rows are still included. + return [rootElement.closest(COPYABLE_ROW_SELECTOR), ...Array.from(rootElement.querySelectorAll(COPYABLE_ROW_SELECTOR))].filter( + (row): row is globalThis.Element => !!row && range.intersectsNode(row), + ); +} + +function isTopLevelCopyableElementForRow(copyableElement: globalThis.Element, row: globalThis.Element): boolean { + if (!copyableElement.matches(COPYABLE_TEXT_SELECTOR) || copyableElement.closest(COPYABLE_ROW_SELECTOR) !== row) { + return false; + } + + const copyableAncestor = copyableElement.parentElement?.closest(COPYABLE_TEXT_SELECTOR); + return !copyableAncestor || copyableAncestor.closest(COPYABLE_ROW_SELECTOR) !== row; +} + +function getCopyableElementsForSelectedRow(row: globalThis.Element, range: Range): globalThis.Element[] { + return [row, ...Array.from(row.querySelectorAll(COPYABLE_TEXT_SELECTOR))].filter( + (copyableElement) => isTopLevelCopyableElementForRow(copyableElement, row) && range.intersectsNode(copyableElement), + ); +} + +function selectionContainsTextOutsideCopyableRows(selection: Selection): boolean { + if (typeof document === 'undefined' || typeof NodeFilter === 'undefined') { + return false; + } + + for (let i = 0; i < selection.rangeCount; i++) { + const range = selection.getRangeAt(i); + const rootElement = getElementFromNode(range.commonAncestorContainer); + if (!rootElement) { + continue; + } + + const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_TEXT); + let textNode = walker.nextNode(); + + while (textNode) { + if (textNode instanceof globalThis.Text && range.intersectsNode(textNode) && getSelectedTextForTextNode(textNode, range).trim()) { + const textElement = getElementFromNode(textNode); + if (!textElement?.closest(COPYABLE_ROW_SELECTOR) && !textElement?.closest(hiddenElementSelector)) { + return true; + } + } + + textNode = walker.nextNode(); + } + } + + return false; +} + +function getHTMLOfSelectedCopyableRows(selection: Selection): string { + const selectedRows: globalThis.Element[] = []; + const selectedCopyableElementsByRow = new Map>(); + + // Build an ordered row -> cells map so multi-row copied output follows the visual table order. + for (let i = 0; i < selection.rangeCount; i++) { + const range = selection.getRangeAt(i); + for (const row of getSelectedRowsForRange(range)) { + const copyableElements = getCopyableElementsForSelectedRow(row, range); + if (copyableElements.length === 0) { + continue; + } + + if (!selectedCopyableElementsByRow.has(row)) { + selectedRows.push(row); + selectedCopyableElementsByRow.set(row, new Set()); + } + + const selectedCopyableElements = selectedCopyableElementsByRow.get(row); + for (const copyableElement of copyableElements) { + selectedCopyableElements?.add(copyableElement); + } + } + } + + const selectedCopyableElementCount = Array.from(selectedCopyableElementsByRow.values()).reduce((count, copyableElements) => count + copyableElements.size, 0); + if (selectedCopyableElementCount <= 1) { + // Single-cell selections should preserve the browser's exact selected text instead of forcing full-cell output. + return ''; + } + + const lines = selectedRows + .map((row) => { + const content = document.createDocumentFragment(); + for (const copyableElement of [row, ...Array.from(row.querySelectorAll(COPYABLE_TEXT_SELECTOR))]) { + if (!selectedCopyableElementsByRow.get(row)?.has(copyableElement)) { + continue; + } + + const copyableContent = getCopyableElementContent(copyableElement, selection); + if (!copyableContent.textContent) { + continue; + } + + if (content.textContent) { + content.appendChild(document.createTextNode(' ')); + } + content.appendChild(copyableContent); + } + return {row, content}; + }) + .filter(({content}) => !!content.textContent); + + if (lines.length === 0) { + return ''; + } + + const div = document.createElement('div'); + replaceElementContentWithLines(div, lines); + return div.innerHTML; +} /** * Reads html of selection. If browser doesn't support Selection API, returns empty string. @@ -32,6 +280,12 @@ const getHTMLOfSelection = (): string => { return window.getSelection()?.toString() ?? ''; } + const selectedCopyableRowsHTML = getHTMLOfSelectedCopyableRows(selection); + if (selectedCopyableRowsHTML && !selectionContainsTextOutsideCopyableRows(selection)) { + // Explicitly marked rows need normalized row text before generic selection cleanup. + return selectedCopyableRowsHTML; + } + const div = document.createElement('div'); // HTML tag of markdown comments is in data-testid attribute (em, strong, blockquote..). Our goal here is to @@ -91,12 +345,11 @@ const getHTMLOfSelection = (): string => { } } - // Find and remove the div housing the UnreadActionIndicator because we don't want - // the 'New/Nuevo' text inside it being copied. - const divsToRemove = div.querySelectorAll(`[data-${CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT}=true]`); + // Find and remove content that is intentionally hidden from copied selections. + const hiddenElements = div.querySelectorAll(hiddenElementSelector); - if (divsToRemove && divsToRemove.length > 0) { - for (const element of divsToRemove) { + if (hiddenElements && hiddenElements.length > 0) { + for (const element of hiddenElements) { element.remove(); } } @@ -169,6 +422,8 @@ const getCurrentSelection: GetCurrentSelection = () => { return newHtml || ''; }; +export {COPYABLE_ROW_CONTINUATION_DATA_SET, COPYABLE_ROW_DATA_SET, COPYABLE_TEXT_DATA_SET}; + export default { getCurrentSelection, }; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 456ca5fddf4b..f44fb7bdc67b 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -6,6 +6,7 @@ import type {ReportsToDisplayInLHN} from '@hooks/useSidebarOrderedReports'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type { + BankAccountList, Card, GuideAccountIDsDerivedValue, PersonalDetails, @@ -18,7 +19,7 @@ import type { TransactionViolation, VisibleReportActionsDerivedValue, } from '@src/types/onyx'; -import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; +import type {ReportAttributes, ReportNamesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type Policy from '@src/types/onyx/Policy'; import type PriorityMode from '@src/types/onyx/PriorityMode'; @@ -219,7 +220,6 @@ function shouldDisplayReportInLHN({ (isPublicRoom(report) && isAnonymousUser()) || isSystemChat || !!report.isPinned || - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing requiresAttention || (report.isOwnPolicyExpenseChat && !isReportArchived); @@ -442,7 +442,6 @@ function updateReportsToDisplayInLHN({ if (shouldDisplay) { const requiresAttention = reportAttributes?.[report?.reportID]?.requiresAttention ?? false; const isUnreadReport = getIsUnreadReportForInboxTab(report, isReportArchived, reportAttributes?.[report?.reportID]?.isEmpty); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const hasFlags = requiresAttention || hasErrorsOtherThanFailedReceipt || isUnreadReport; const existingEntry = displayedReports[reportID]; @@ -764,6 +763,7 @@ function getOptionData({ isTrackIntentUser, formatPhoneNumber, rules, + bankAccountList, }: { report: OnyxEntry; oneTransactionThreadReport: OnyxEntry; @@ -788,12 +788,15 @@ function getOptionData({ movedToReport?: OnyxEntry; currentUserAccountID: number; visibleReportActionsData?: VisibleReportActionsDerivedValue; - reportAttributesDerived?: ReportAttributesDerivedValue['reports']; + reportAttributesDerived?: ReportNamesDerivedValue; policyTags?: OnyxEntry; currentUserLogin: string; isTrackIntentUser?: boolean; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; rules: OnyxCollection; + + /** The current user's bank accounts, used to name the account a report was paid with in the preview. */ + bankAccountList?: OnyxEntry; }): OptionData | undefined { // When a user signs out, Onyx is cleared. Due to the lazy rendering with a virtual list, it's possible for // this method to be called after the Onyx data has been cleared out. In that case, it's fine to do @@ -835,7 +838,7 @@ function getOptionData({ isConciergeChat: false, }; const reportMetadata = getReportMetadata(report?.reportID); - const participantAccountIDs = getParticipantsAccountIDsForDisplay(report); + const participantAccountIDs = getParticipantsAccountIDsForDisplay(report, undefined); const participantAccountIDsExcludeCurrentUser = excludeParticipantsForDisplay(participantAccountIDs, report.participants ?? {}, reportMetadata, {shouldExcludeCurrentUser: true}); const participantPersonalDetailListExcludeCurrentUser = Object.values(getPersonalDetailsForAccountIDs(participantAccountIDsExcludeCurrentUser, personalDetails)); @@ -868,9 +871,8 @@ function getOptionData({ result.policyID = report.policyID; result.stateNum = report.stateNum; result.statusNum = report.statusNum; - // When the only message of a report is deleted lastVisibleActionCreated is not reset leading to wrongly - // setting it Unread so we add additional condition here to avoid empty chat LHN from being bold. - result.isUnread = isUnread(report, oneTransactionThreadReport, isReportArchived, reportAttributes?.isEmpty) && !!report.lastActorAccountID; + // isUnread() gates on isEmptyReport, so a chat whose only visible message was deleted is already excluded here. + result.isUnread = isUnread(report, oneTransactionThreadReport, isReportArchived, reportAttributes?.isEmpty); result.isUnreadWithMention = isUnreadWithMention(report); result.isPinned = report.isPinned; result.iouReportID = report.iouReportID; @@ -947,6 +949,7 @@ function getOptionData({ convertToDisplayString, convertToDisplayStringWithoutCurrency, rules, + bankAccountList, }); result.isIOUReportOwner = isIOUOwnedByCurrentUser(result as Report); @@ -974,7 +977,6 @@ function getOptionData({ rawReportName: report.reportName, hasDerivedEntry: !!derivedEntry, derivedReportName: derivedEntry?.reportName, - derivedCount: reportAttributesDerived ? Object.keys(reportAttributesDerived).length : 0, }); } @@ -1022,13 +1024,8 @@ function getOptionData({ * Computed once while building the LHN report set (which is cached/incremental) so the tab filter only reads a flag. */ function getIsUnreadReportForInboxTab(report: Report, isReportArchived: boolean, derivedIsEmptyReport: boolean | undefined): boolean { - // The `lastActorAccountID` guard matches getOptionData: it keeps chats whose only visible message was - // deleted out of the Unread tab even though isUnread() can still be true (lastVisibleActionCreated isn't reset). - return ( - isUnread(report, undefined, isReportArchived, derivedIsEmptyReport) && - !!report.lastActorAccountID && - getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE - ); + // isUnread() gates on isEmptyReport, so a chat whose only visible message was deleted never reaches this tab. + return isUnread(report, undefined, isReportArchived, derivedIsEmptyReport) && getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; } /** Whether a report belongs in the "To-do" Inbox tab: it has an outstanding GBR (requiresAttention) or RBR (errors). */ diff --git a/src/libs/SpendRuleChangeLogUtils.ts b/src/libs/SpendRuleChangeLogUtils.ts index 4a9cf658725d..272067952edc 100644 --- a/src/libs/SpendRuleChangeLogUtils.ts +++ b/src/libs/SpendRuleChangeLogUtils.ts @@ -19,7 +19,6 @@ function getSpendRuleFallbackReportActionText(reportAction: OnyxEntry { + const categories = values.categories?.filter(isSpendRuleCategory); + const cardIDs = isAllCards && eligibleCardIDs ? eligibleCardIDs : currentCardIDs; + return {...values, cardIDs, categories}; +} + +/** Returns whether the spend rule restricts merchants or categories */ +function hasSpendRuleMerchantRestrictions(values: Partial | undefined): boolean { + return !!values?.merchantNames?.length || !!values?.categories?.length; +} + function getSpendRuleFormValuesFromCardRule(cardRule?: ExpensifyCardRule): SpendRuleForm | undefined { if (!cardRule || typeof cardRule !== 'object' || !('filters' in cardRule) || !('action' in cardRule)) { return undefined; @@ -194,6 +210,35 @@ type SpendRuleByCardID = { formValues: SpendRuleForm; }; +function getCardIDsWithSpendRules(cardRules: Record | undefined, currentRuleID?: string): Set { + const cardIDs = new Set(); + if (!cardRules) { + return cardIDs; + } + + for (const [ruleID, rule] of Object.entries(cardRules)) { + if (ruleID === currentRuleID) { + continue; + } + const formValues = getSpendRuleFormValuesFromCardRule(rule); + for (const cardID of formValues?.cardIDs ?? []) { + const numericCardID = Number(cardID); + if (Number.isFinite(numericCardID)) { + cardIDs.add(numericCardID); + } + } + } + + return cardIDs; +} + +/** Returns the cards a spend rule can cover, which are the cards no other spend rule covers */ +function getEligibleSpendRuleCards(cardsList: OnyxEntry, expensifyCardSettings: OnyxEntry, currentRuleID?: string): Card[] { + const {cardList, ...cards} = cardsList ?? {}; + const cardIDsWithSpendRules = getCardIDsWithSpendRules(expensifyCardSettings?.cardRules, currentRuleID); + return Object.values(cards).filter((card: Card) => !cardIDsWithSpendRules.has(card.cardID)); +} + /** At most one custom spend rule should reference a card; returns rule id + form values, or undefined. */ function getSpendRuleByCardID(expensifyCardSettingsCollection: OnyxCollection | undefined, cardID: string): SpendRuleByCardID | undefined { for (const settings of Object.values(expensifyCardSettingsCollection ?? {})) { @@ -356,4 +401,14 @@ function getSpendRuleSummaryText( } export type {SpendRuleSummaryPart}; -export {buildSpendRuleAST, getSpendRuleByCardID, getSpendRuleFormValuesFromCardRule, getSpendRuleSummaryParts, getSpendRuleSummaryText, getTruncatedSpendRuleSummary}; +export { + buildSpendRuleAST, + getEligibleSpendRuleCards, + getSpendRuleByCardID, + getSpendRuleDraftFromGeneratedValues, + getSpendRuleFormValuesFromCardRule, + getSpendRuleSummaryParts, + getSpendRuleSummaryText, + getTruncatedSpendRuleSummary, + hasSpendRuleMerchantRestrictions, +}; diff --git a/src/libs/TagsOptionsListUtils.ts b/src/libs/TagsOptionsListUtils.ts index 750a44b9654f..824ebd39e30c 100644 --- a/src/libs/TagsOptionsListUtils.ts +++ b/src/libs/TagsOptionsListUtils.ts @@ -93,6 +93,7 @@ function getTagListSections({ maxRecentReportsToShow = CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, translate, shouldShowGLCode = false, + searchInputThreshold = CONST.STANDARD_LIST_ITEM_LIMIT, }: { tags: PolicyTags | Array; localeCompare: LocaleContextProps['localeCompare']; @@ -102,6 +103,7 @@ function getTagListSections({ maxRecentReportsToShow?: number; translate: LocalizedTranslate; shouldShowGLCode?: boolean; + searchInputThreshold?: number; }) { const tagSections = []; const sortedTags = sortTags(tags, localeCompare); @@ -157,7 +159,7 @@ function getTagListSections({ return tagSections; } - if (numberOfTags < CONST.STANDARD_LIST_ITEM_LIMIT) { + if (numberOfTags < searchInputThreshold) { tagSections.push({ // "All" section when items amount less than the threshold title: '', diff --git a/src/libs/TransactionUtils/getDistanceInMeters.ts b/src/libs/TransactionUtils/getDistanceInMeters.ts index e76a0722ebbd..df37b753200a 100644 --- a/src/libs/TransactionUtils/getDistanceInMeters.ts +++ b/src/libs/TransactionUtils/getDistanceInMeters.ts @@ -1,4 +1,4 @@ -import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import {convertToDistanceInMeters} from '@libs/DistanceUnitConversionUtils'; import CONST from '@src/CONST'; import type {OnyxInputOrEntry, Transaction} from '@src/types/onyx'; @@ -14,7 +14,7 @@ function getDistanceInMeters(transaction: OnyxInputOrEntry, unit: U // This check takes priority because after a manual distance edit, routes.route0.distance may still // hold a stale route-calculated value while quantity reflects the user's intended distance. if (transaction?.comment?.customUnit?.quantity && unit) { - return DistanceRequestUtils.convertToDistanceInMeters(transaction.comment.customUnit.quantity, unit); + return convertToDistanceInMeters(transaction.comment.customUnit.quantity, unit); } // If we are creating a new distance request, the distance is available in routes.route0.distance and it's already in meters. diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 9165c2264c68..e93040c2809c 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -661,12 +661,10 @@ function getConvertedAmount( * For non-expense reports, returns getOriginalAmount() or Math.abs(amount) or Math.abs(modifiedAmount). */ function getOriginalAmountForDisplay(transaction: Pick, isExpenseReport: boolean): number { - /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ if (isExpenseReport) { return -((transaction.originalAmount || transaction.amount || Number(transaction.modifiedAmount)) ?? 0); } return getOriginalAmount(transaction as Transaction) || Math.abs(transaction.amount ?? 0) || Math.abs(Number(transaction.modifiedAmount ?? 0)); - /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ } /** @@ -674,7 +672,6 @@ function getOriginalAmountForDisplay(transaction: Pick): string { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return transaction.originalCurrency || (transaction.amount === 0 ? transaction.modifiedCurrency : transaction.currency) || CONST.CURRENCY.USD; } @@ -745,7 +742,6 @@ function getMerchantName(transaction: TransactionWithOptionalSearchFields, trans } function getReportOwnerAsAttendee(creatorDetails: OnyxEntry): Attendee | undefined { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const creatorLogin = creatorDetails?.login || creatorDetails?.displayName || ''; if (!creatorLogin) { @@ -803,10 +799,13 @@ function getAttendees(transaction: OnyxInputOrEntry, reportOwnerAsA /** * Returns attendees joined as a display string. Pass `localeCompare` to sort alphabetically (matches the pill sort); * omit it to keep insertion order — used by non-React callers that don't want to thread the comparator. - * Strips the SMS domain so phone-login attendees render the same as in the rendered pills. + * Formats phone-login attendees so they render the same as in the rendered pills. */ -function getAttendeesListDisplayString(attendees: Attendee[], localeCompare?: LocaleContextProps['localeCompare']): string { - const getName = (a: Attendee) => Str.removeSMSDomain(a.displayName ?? a.email ?? ''); +function getAttendeesListDisplayString(attendees: Attendee[], formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], localeCompare?: LocaleContextProps['localeCompare']): string { + const getName = (a: Attendee) => { + const name = a.displayName ?? a.email ?? ''; + return Str.isSMSLogin(name) ? formatPhoneNumber(name) : name; + }; const ordered = localeCompare ? // Lowercase to match sortAlphabetically (the pill sort) so joined string and pill order never disagree on case. [...attendees].sort((a, b) => localeCompare(getName(a).toLowerCase(), getName(b).toLowerCase())) @@ -817,10 +816,15 @@ function getAttendeesListDisplayString(attendees: Attendee[], localeCompare?: Lo /** * Return the list of attendees as a string and modified list of attendees as a string if present. */ -function getFormattedAttendees(modifiedAttendees?: Attendee[], attendees?: Attendee[], localeCompare?: LocaleContextProps['localeCompare']): [string, string] { +function getFormattedAttendees( + formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], + modifiedAttendees?: Attendee[], + attendees?: Attendee[], + localeCompare?: LocaleContextProps['localeCompare'], +): [string, string] { const oldAttendees = modifiedAttendees ?? []; const newAttendees = attendees ?? []; - return [getAttendeesListDisplayString(oldAttendees, localeCompare), getAttendeesListDisplayString(newAttendees, localeCompare)]; + return [getAttendeesListDisplayString(oldAttendees, formatPhoneNumber, localeCompare), getAttendeesListDisplayString(newAttendees, formatPhoneNumber, localeCompare)]; } /** @@ -984,7 +988,6 @@ function getTagForDisplay(transaction: OnyxEntry>, tagI } function getCreated(transaction: OnyxInputOrEntry): string { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return transaction?.modifiedCreated ? transaction.modifiedCreated : transaction?.created || ''; } @@ -1200,6 +1203,14 @@ function isReceiptBeingScanned(transaction: OnyxInputOrEntry): bool return transaction?.receipt?.state === CONST.IOU.RECEIPT_STATE.SCAN_READY || transaction?.receipt?.state === CONST.IOU.RECEIPT_STATE.SCANNING; } +/** + * Manual expenses never show a scan to the user, so a backend scan of their receipt shouldn't block receipt edits. + * This checks iouRequestType directly instead of using getRequestType, which treats a missing type as manual. + */ +function isReceiptScanBlockingEdit(transaction: OnyxInputOrEntry): boolean { + return isReceiptBeingScanned(transaction) && transaction?.iouRequestType !== CONST.IOU.REQUEST_TYPE.MANUAL; +} + /** * Check if category is being analyzed (manual request creation or auto-categorization grace period) */ @@ -1874,6 +1885,7 @@ export { hasEReceipt, hasRoute, isReceiptBeingScanned, + isReceiptScanBlockingEdit, didReceiptScanSucceed, getValidWaypoints, doesMoneyRequestDraftHaveUserInput, diff --git a/src/libs/TransactionUtils/tax.ts b/src/libs/TransactionUtils/tax.ts index 2217aa607841..519fad0d41ad 100644 --- a/src/libs/TransactionUtils/tax.ts +++ b/src/libs/TransactionUtils/tax.ts @@ -153,7 +153,6 @@ function getTaxName(policy: OnyxEntry, transaction: OnyxEntry rate.code === resolvedTaxCode) : undefined; diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts index 4b33d33f3b4c..94ee7a96b526 100644 --- a/src/libs/TripReservationUtils.ts +++ b/src/libs/TripReservationUtils.ts @@ -496,7 +496,7 @@ function getReservationsFromSpotnanaPayload(reportID: string, tripData?: TripDat transactionID: '0', sequenceIndex: 0, reservationIndex: reservationData.reservationIndex, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- using || intentionally: pnrCancelled is boolean, ?? would not fall through on false + // using || intentionally: pnrCancelled is boolean, ?? would not fall through on false isCancelled: pnrCancelled || reservationData.isCancelled || undefined, })); }) diff --git a/src/libs/UnreadIndicatorUpdater/index.ts b/src/libs/UnreadIndicatorUpdater/index.ts index 217287de5f2f..f4572dce6ee1 100644 --- a/src/libs/UnreadIndicatorUpdater/index.ts +++ b/src/libs/UnreadIndicatorUpdater/index.ts @@ -121,7 +121,7 @@ function getUnreadReportsForUnreadIndicator(reports: OnyxCollection, cur draftComment, currentUserLogin, currentUserAccountID, - // TODO: Pass guideAccountIDs once callers are fully migrated — PR 33 (https://github.com/Expensify/App/issues/66413); hasExpensifyGuidesEmails falls back to allPersonalDetails + // TODO: Pass guideAccountIDs in Phase B. hasExpensifyGuidesEmails falls back to the personal details store until then. See https://github.com/Expensify/App/issues/66413. hasGuidesEmails: ReportUtils.isDefaultRoom(report) ? ReportUtils.hasExpensifyGuidesEmails(Object.keys(report?.participants ?? {}).map(Number), undefined) : false, conciergeReportID, requiresAttention, diff --git a/src/libs/UserAvatarUtils.ts b/src/libs/UserAvatarUtils.ts index 370a0370fed7..e6686dae617b 100644 --- a/src/libs/UserAvatarUtils.ts +++ b/src/libs/UserAvatarUtils.ts @@ -525,7 +525,9 @@ type BuildUserIconArgsType = DefaultAvatarsType & { * Shared by `AccountAvatar` and `useReportActionAvatars` so the resolution stays in one place. */ function buildUserIcon({accountID, personalDetails, defaultAvatars, invitedEmail, name, accountEmail}: BuildUserIconArgsType): Icon { - const seededSource = accountEmail ? getDefaultAvatarURL({accountID, accountEmail: addSMSDomainIfPhoneNumber(accountEmail)}) : undefined; + // Invited accounts have no personal details until the invite is submitted, so seed from the invited email to match the letter avatar written on submit. + const seedEmail = accountEmail ?? invitedEmail; + const seededSource = seedEmail ? getDefaultAvatarURL({accountID, accountEmail: addSMSDomainIfPhoneNumber(seedEmail)}) : undefined; return { id: accountID, type: CONST.ICON_TYPE_AVATAR, @@ -544,6 +546,13 @@ function optimizeAvatarSource(source?: AvatarSource): AvatarSource | undefined { return findLocalAvatarForURL(source) ?? source; } +/** Letter avatar for a free-text name, such as a list row title, seeded by the name so the color is stable */ +function getLetterAvatarURLForName(name: string): string | undefined { + const [firstName = '', ...otherNames] = name.trim().split(/\s+/); + const colorSeed = [...name].reduce((sum, character) => sum + character.charCodeAt(0), 0); + return getLetterAvatarURL(colorSeed, firstName, otherNames.at(-1) ?? '', '') || undefined; +} + export { buildUserIcon, getAccountIDFromAvatarID, @@ -557,6 +566,7 @@ export { getSmallSizeAvatar, getLetterAvatarInitials, getLetterAvatarURL, + getLetterAvatarURLForName, getUpdatedLetterAvatarURL, parseLetterAvatarURL, isCatalogAvatar, diff --git a/src/libs/VacationDelegateUtils.ts b/src/libs/VacationDelegateUtils.ts new file mode 100644 index 000000000000..1f7bf9b8ebc8 --- /dev/null +++ b/src/libs/VacationDelegateUtils.ts @@ -0,0 +1,122 @@ +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'; + +import {addMinutes, format, isValid, parse} from 'date-fns'; +import {fromZonedTime} from 'date-fns-tz'; + +import DateUtils from './DateUtils'; + +/** Format of the local clear date and time the user picks, e.g. 2026-10-01 17:00:00 */ +const LOCAL_DATE_TIME_FORMAT = 'yyyy-MM-dd HH:mm:ss'; + +/** Time a clear date ends at when no time is picked, so the delegate stays active for the whole day */ +const END_OF_DAY_TIME = '23:59:59'; + +/** + * Combines the picked clear date (yyyy-MM-dd) and time into the local datetime (yyyy-MM-dd HH:mm:ss) the delegate clears at. + * The time can be a full datetime, as the time picker stores it; only its time part is used. Without a time, the delegate clears + * at the end of the picked day. + */ +function getVacationDelegateLocalClearDateTime(clearDate: string | undefined, clearTime: string | undefined): string { + if (!clearDate) { + return ''; + } + + return (clearTime ? DateUtils.combineDateAndTime(clearTime, clearDate) : '') || `${clearDate} ${END_OF_DAY_TIME}`; +} + +/** + * Converts the clear date (yyyy-MM-dd) and time picked for the vacation delegate into the UTC datetime the backend expects, + * reading them in the given timezone. + */ +function getVacationDelegateClearAfter(clearDate: string | undefined, clearTime: string | undefined, timezone: SelectedTimezone | undefined): string | undefined { + const localClearDateTime = getVacationDelegateLocalClearDateTime(clearDate, clearTime); + if (!localClearDateTime) { + return undefined; + } + + return DateUtils.formatDBTimeWithoutMilliseconds(fromZonedTime(localClearDateTime, timezone ?? CONST.DEFAULT_TIME_ZONE.selected).valueOf()); +} + +/** + * Converts the UTC datetime the vacation delegate clears after back into the local datetime (yyyy-MM-dd HH:mm:ss) it clears at in the given timezone. + */ +function getVacationDelegateClearDateTime(clearAfter: string | undefined, timezone: SelectedTimezone | undefined): string { + if (!clearAfter) { + return ''; + } + + return DateUtils.formatUTCDateTimeToDateInTimezone(clearAfter, timezone ?? CONST.DEFAULT_TIME_ZONE.selected, LOCAL_DATE_TIME_FORMAT); +} + +/** + * 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 { + return getVacationDelegateClearDateTime(clearAfter, timezone).slice(0, CONST.DATE.FNS_FORMAT_STRING.length); +} + +/** + * Formats a local clear datetime (yyyy-MM-dd HH:mm:ss) for display, e.g. "Oct 1, 2026 5:00 PM". + */ +function formatVacationDelegateClearDateTime(clearDateTime: string, dateFnsLocale: DateFnsLocale | undefined): string { + const date = parse(clearDateTime, LOCAL_DATE_TIME_FORMAT, new Date()); + if (!isValid(date)) { + return ''; + } + + return format(date, `${CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT} ${CONST.DATE.LOCAL_TIME_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(); +} + +/** + * 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. + */ +function isVacationDelegateClearDatePassed(clearDate: string, timezone: SelectedTimezone | undefined): boolean { + if (!isValid(parse(clearDate, CONST.DATE.FNS_FORMAT_STRING, new Date()))) { + return true; + } + + return isVacationDelegateExpired(getVacationDelegateClearAfter(clearDate, undefined, timezone)); +} + +/** + * 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, + getVacationDelegateClearDateTime, + getVacationDelegateLocalClearDateTime, + formatVacationDelegateClearDateTime, + isVacationDelegateExpired, + getActiveVacationDelegate, + isVacationDelegateClearDatePassed, + isVacationDelegateClearAfterTooSoon, +}; diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 813f87a80233..ab20c60eb608 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -850,7 +850,26 @@ function isInvalidMerchantValue(merchant?: string): boolean { return merchant === '' || merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || merchant === CONST.TRANSACTION.DEFAULT_MERCHANT; } -type MerchantValidationError = {type: 'required'} | {type: 'invalidValue'} | {type: 'tooLong'; byteLength: number}; +type MerchantValidationError = + | {type: typeof CONST.INPUT_VALIDATION_ERRORS.REQUIRED} + | {type: typeof CONST.INPUT_VALIDATION_ERRORS.INVALID} + | {type: typeof CONST.INPUT_VALIDATION_ERRORS.TOO_LONG; byteLength: number}; + +/** + * Copy for a merchant validation error on the merchant form. + */ +function getMerchantErrorMessage(translate: LocalizedTranslate, error: MerchantValidationError): string { + // Disabling the default-case lint rule here is actually safer as this forces us to make the switch cases exhaustive + // eslint-disable-next-line default-case + switch (error.type) { + case CONST.INPUT_VALIDATION_ERRORS.REQUIRED: + return translate('common.error.fieldRequired'); + case CONST.INPUT_VALIDATION_ERRORS.INVALID: + return translate('iou.error.invalidMerchant'); + case CONST.INPUT_VALIDATION_ERRORS.TOO_LONG: + return translate('common.error.characterLimitExceedCounter', error.byteLength, CONST.MERCHANT_NAME_MAX_BYTES); + } +} /** * Returns the first merchant validation error (required, invalid value, or too long), or `undefined` if the merchant is valid. @@ -859,14 +878,14 @@ function getMerchantError(merchant: string | undefined, isMerchantRequired: bool const trimmedMerchant = merchant?.trim() ?? ''; if (isMerchantRequired && !trimmedMerchant) { - return {type: 'required'}; + return {type: CONST.INPUT_VALIDATION_ERRORS.REQUIRED}; } if (trimmedMerchant && isInvalidMerchantValue(trimmedMerchant)) { - return {type: 'invalidValue'}; + return {type: CONST.INPUT_VALIDATION_ERRORS.INVALID}; } const {isValid: isLengthValid, byteLength} = isValidInputLength(trimmedMerchant, CONST.MERCHANT_NAME_MAX_BYTES); if (!isLengthValid) { - return {type: 'tooLong', byteLength}; + return {type: CONST.INPUT_VALIDATION_ERRORS.TOO_LONG, byteLength}; } return undefined; } @@ -951,6 +970,7 @@ export { isValidTaxIDEINNumber, isInvalidMerchantValue, getMerchantError, + getMerchantErrorMessage, isUntypedPlaceholderMerchant, isValidPIN, containsHtmlTag, diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 5ff8303c087a..97621a4679d5 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -21,7 +21,6 @@ import { isDefaultTagName, isMatchingVendorListLoaded, isTaxTrackingEnabled, - isXeroActiveMatchingSource, resolveCurrentTaxCode, } from '@libs/PolicyUtils'; import {isCurrentUserSubmitter} from '@libs/ReportUtils'; @@ -688,6 +687,7 @@ const ViolationsUtils = { distanceOriginalPolicy, ownerLogin: ownerLoginParam, isVendorMatchingBetaEnabled, + policyVendors: policyVendorsParam, }: { updatedTransaction: Transaction; transactionViolations: TransactionViolation[]; @@ -704,6 +704,7 @@ const ViolationsUtils = { ownerLogin: string | undefined; /** Undefined while the account betas are still loading, which leaves the inactive vendor violation untouched */ isVendorMatchingBetaEnabled: boolean | undefined; + policyVendors?: OnyxEntry; }): OnyxUpdate { const isScanning = TransactionUtils.isScanning(updatedTransaction); const isScanRequest = TransactionUtils.isScanRequest(updatedTransaction); @@ -812,46 +813,18 @@ const ViolationsUtils = { // positive in Onyx, and rejecting it would strip a legitimate one. Leave the // existing violation state untouched until the list arrives. const matchedVendor = getMatchingVendorByID(policy, transactionVendorID); + const isVendorDisabled = policyVendorsParam?.[transactionVendorID]?.enabled === false; + const isVendorInactive = !matchedVendor || isVendorDisabled; + const canConfirmVendorEnabled = policyVendorsParam !== undefined && !isVendorDisabled; - // Stamp Xero-specific copy on the violation so the render site can use the - // "Supplier" wording the rest of the Xero UI uses; QBO/Intacct keep the default - // "Vendor" wording. - const isSupplierViolation = isXeroActiveMatchingSource(policy); - if (!matchedVendor && !hasInactiveVendorViolation) { + if (isVendorInactive && !hasInactiveVendorViolation) { newTransactionViolations.push({ name: CONST.VIOLATIONS.INACTIVE_VENDOR, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true, - ...(isSupplierViolation ? {data: {isSupplierViolation: true}} : {}), }); - } else if (matchedVendor && hasInactiveVendorViolation) { + } else if (!isVendorInactive && canConfirmVendorEnabled && hasInactiveVendorViolation) { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.INACTIVE_VENDOR}); - } else if (!matchedVendor && hasInactiveVendorViolation) { - // Reconcile data.isSupplierViolation with the current active matching source. - // Backfills the flag when Xero is now active (server-fired violation, or - // persisted from before this code path existed). Strips a stale flag when the - // workspace has switched back to QBO/Intacct as the active source — otherwise - // the render layer would keep showing "Supplier" wording even though the picker - // is back to "Vendor". - const needsSupplierFlagReconciliation = newTransactionViolations.some( - (violation) => violation.name === CONST.VIOLATIONS.INACTIVE_VENDOR && (violation.data?.isSupplierViolation === true) !== isSupplierViolation, - ); - if (needsSupplierFlagReconciliation) { - newTransactionViolations = newTransactionViolations.map((violation) => { - if (violation.name !== CONST.VIOLATIONS.INACTIVE_VENDOR) { - return violation; - } - const currentFlag = violation.data?.isSupplierViolation === true; - if (currentFlag === isSupplierViolation) { - return violation; - } - if (isSupplierViolation) { - return {...violation, data: {...violation.data, isSupplierViolation: true}}; - } - const {isSupplierViolation: stripped, ...remainingData} = violation.data ?? {}; - return Object.keys(remainingData).length > 0 ? {...violation, data: remainingData} : {...violation, data: undefined}; - }); - } } } else if (!transactionVendorID && hasInactiveVendorViolation) { // Vendor was cleared while the feature is still active — drop the now-stale violation. @@ -929,7 +902,6 @@ const ViolationsUtils = { const isTaxRateValid = !!taxRate && !taxRate.isDisabled; const amount = hasValidModifiedAmount(updatedTransaction) ? Number(updatedTransaction.modifiedAmount) : updatedTransaction.amount; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const currency = updatedTransaction.modifiedCurrency || updatedTransaction.currency; const canCalculateAmountViolations = policy.outputCurrency === currency; @@ -1215,7 +1187,6 @@ const ViolationsUtils = { message = '', errorIndexes = [], missingFields = [], - isSupplierViolation = false, } = violation.data ?? {}; switch (violation.name) { @@ -1255,7 +1226,7 @@ const ViolationsUtils = { case 'futureDate': return translate('violations.futureDate'); case 'inactiveVendor': - return translate('violations.inactiveVendor', isSupplierViolation); + return translate('violations.inactiveVendor'); case 'invoiceMarkup': return translate('violations.invoiceMarkup', invoiceMarkup); case 'maxAge': diff --git a/src/libs/Wallet/WalletProvisioningError.ts b/src/libs/Wallet/WalletProvisioningError.ts new file mode 100644 index 000000000000..216c4d6e272d --- /dev/null +++ b/src/libs/Wallet/WalletProvisioningError.ts @@ -0,0 +1,10 @@ +/** Carries only the normalized failure, never encrypted card data or a challenge. */ +import type {MultifactorAuthenticationScenarioResponse} from '@components/MultifactorAuthentication/config/types'; + +class WalletProvisioningError extends Error { + constructor(readonly response: MultifactorAuthenticationScenarioResponse) { + super('Digital wallet provisioning failed'); + } +} + +export default WalletProvisioningError; diff --git a/src/libs/Wallet/index.android.ts b/src/libs/Wallet/index.android.ts index d5bc4498a723..dfa06f8ce126 100644 --- a/src/libs/Wallet/index.android.ts +++ b/src/libs/Wallet/index.android.ts @@ -7,14 +7,27 @@ import type {AndroidCardData, AndroidWalletData, CardStatus, TokenizationStatus} import {addCardToGoogleWallet, checkWalletAvailability, getCardStatusBySuffix, getSecureWalletInfo} from '@expensify/react-native-wallet'; +import type {AddCardToWalletOptions} from './types'; + function checkIfWalletIsAvailable(): Promise { return checkWalletAvailability(); } -function handleAddCardToWallet(card: Card, cardHolderName: string): Promise { - return getSecureWalletInfo().then((walletData: AndroidWalletData) => - createDigitalGoogleWallet({cardID: card.cardID, cardHolderName, ...walletData}).then((cardData: AndroidCardData) => addCardToGoogleWallet(cardData)), - ); +function handleAddCardToWallet(card: Card, cardHolderName: string, _cardDescription: string, {authentication, signal}: AddCardToWalletOptions = {}): Promise { + if (signal?.aborted) { + return Promise.resolve('canceled'); + } + return getSecureWalletInfo().then((walletData: AndroidWalletData) => { + if (signal?.aborted) { + return 'canceled'; + } + return createDigitalGoogleWallet({cardID: card.cardID, cardHolderName, ...walletData}, authentication).then((cardData: AndroidCardData) => { + if (signal?.aborted) { + return 'canceled'; + } + return addCardToGoogleWallet(cardData); + }); + }); } function isCardInWallet(card: Card): Promise { diff --git a/src/libs/Wallet/index.ios.ts b/src/libs/Wallet/index.ios.ts index ea99f021f162..56b2606aebe2 100644 --- a/src/libs/Wallet/index.ios.ts +++ b/src/libs/Wallet/index.ios.ts @@ -8,11 +8,13 @@ import type {IOSCardData, TokenizationStatus} from '@expensify/react-native-wall import {addCardToAppleWallet, checkWalletAvailability, getCardStatusByIdentifier, getCardStatusBySuffix} from '@expensify/react-native-wallet'; +import type {AddCardToWalletOptions} from './types'; + function checkIfWalletIsAvailable(): Promise { return checkWalletAvailability(); } -function handleAddCardToWallet(card: Card, cardHolderName: string, cardDescription: string): Promise { +async function handleAddCardToWallet(card: Card, cardHolderName: string, cardDescription: string, {authentication, signal}: AddCardToWalletOptions = {}): Promise { const data = { network: CONST.COMPANY_CARDS.CARD_TYPE.VISA, lastDigits: card.lastFourPAN, @@ -20,7 +22,46 @@ function handleAddCardToWallet(card: Card, cardHolderName: string, cardDescripti cardHolderName, } as IOSCardData; - return addCardToAppleWallet(data, (nonce, nonceSignature, certificates) => issuerEncryptPayloadCallback(nonce, nonceSignature, certificates, card.cardID)); + if (!authentication) { + return addCardToAppleWallet(data, (nonce, nonceSignature, certificates) => issuerEncryptPayloadCallback(nonce, nonceSignature, certificates, card.cardID)); + } + + if (signal?.aborted) { + return 'canceled'; + } + + let didRequestProvisioning = false; + let provisioningFailure: unknown; + try { + const status = await addCardToAppleWallet(data, async (nonce, nonceSignature, certificates) => { + try { + // Apple's Try Again callback must not replay a consumed signed challenge. + // Wallet 0.1.22 has no public abort API. Rejection cannot dismiss its sheet. + if (didRequestProvisioning || signal?.aborted) { + throw new Error('Wallet attempt requires fresh authentication'); + } + didRequestProvisioning = true; + const payload = await issuerEncryptPayloadCallback(nonce, nonceSignature, certificates, card.cardID, authentication); + if (signal?.aborted) { + throw new Error('Wallet attempt canceled'); + } + return payload; + } catch (error) { + provisioningFailure ??= error; + throw error; + } + }); + if (provisioningFailure) { + throw provisioningFailure; + } + if (status === 'success' && !didRequestProvisioning) { + throw new Error('Wallet completed without provisioning'); + } + return signal?.aborted ? 'canceled' : status; + } catch (error) { + // A subsequent native rejection must not replace the original server failure. + throw provisioningFailure ?? error; + } } function isCardInWallet(card: Card): Promise { diff --git a/src/libs/Wallet/index.ts b/src/libs/Wallet/index.ts index b8b2ba7a45c7..2e362b9edcbd 100644 --- a/src/libs/Wallet/index.ts +++ b/src/libs/Wallet/index.ts @@ -2,12 +2,14 @@ import type {Card} from '@src/types/onyx'; import type {TokenizationStatus} from '@expensify/react-native-wallet'; +import type {AddCardToWalletOptions} from './types'; + function checkIfWalletIsAvailable(): Promise { return Promise.resolve(false); } // eslint-disable-next-line @typescript-eslint/no-unused-vars -function handleAddCardToWallet(_card: Card, _cardHolderName: string, _cardDescription: string, _onFinished?: () => void): Promise { +function handleAddCardToWallet(_card: Card, _cardHolderName: string, _cardDescription: string, _options?: AddCardToWalletOptions): Promise { return Promise.reject(new Error('Add to wallet is not supported on this platform')); } diff --git a/src/libs/Wallet/types.ts b/src/libs/Wallet/types.ts new file mode 100644 index 000000000000..a2a2f4a0a229 --- /dev/null +++ b/src/libs/Wallet/types.ts @@ -0,0 +1,15 @@ +/** Transient authorization for one native wallet provisioning attempt. */ +import type {SignedChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; +import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types'; + +type WalletAuthentication = { + signedChallenge: SignedChallenge; + authenticationMethod: AuthTypeInfo['marqetaValue']; +}; + +type AddCardToWalletOptions = { + authentication?: WalletAuthentication; + signal?: AbortSignal; +}; + +export type {AddCardToWalletOptions, WalletAuthentication}; diff --git a/src/libs/WorkArrangementUtils.ts b/src/libs/WorkArrangementUtils.ts index f8f885aa3e9d..7cec2a55baf7 100644 --- a/src/libs/WorkArrangementUtils.ts +++ b/src/libs/WorkArrangementUtils.ts @@ -1,14 +1,37 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {Str} from 'expensify-common'; + +import {generateAccountID} from './UserUtils'; + /** - * Whether a member commutes to an office + * Whether a member commutes to an office. When neither the member nor workspace has an explicit arrangement, + * the server treats the member as office-based by default. */ -function getEffectiveWorkArrangement(memberArrangement: boolean | undefined, workspaceArrangement: boolean | undefined): boolean { - return memberArrangement ?? workspaceArrangement ?? true; +function getEffectiveWorkArrangement(memberArrangement: boolean | undefined, workspaceArrangement: boolean | undefined, fallbackArrangement = true): boolean { + return memberArrangement ?? workspaceArrangement ?? fallbackArrangement; } function getWorkArrangementLabel(translate: LocalizedTranslate, isOfficeBased: boolean): string { return translate(isOfficeBased ? 'workspace.people.officeBased' : 'workspace.people.noRegularWorkspace'); } -export {getEffectiveWorkArrangement, getWorkArrangementLabel}; +/** + * Resolve a workspace member login from a route accountID, including IDs generated optimistically + * while an invite is pending. Once the invite syncs, the route can keep that temporary ID even + * though the employee entry remains available by login in the policy. + */ +function getMemberLoginByAccountID(policy: OnyxEntry, accountID: number): string { + const matchesAccountID = (login: string) => generateAccountID(login) === accountID || generateAccountID(Str.removeSMSDomain(login)) === accountID; + const primaryLoginOfInvitedSecondary = Object.entries(policy?.primaryLoginsInvited ?? {}).find(([secondaryLogin]) => matchesAccountID(secondaryLogin))?.[1]; + if (primaryLoginOfInvitedSecondary) { + return primaryLoginOfInvitedSecondary; + } + return Object.keys(policy?.employeeList ?? {}).find(matchesAccountID) ?? ''; +} + +export {getEffectiveWorkArrangement, getMemberLoginByAccountID, getWorkArrangementLabel}; diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index d4942a674f0a..5122c8169bf8 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -233,6 +233,11 @@ function convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, fir const hrAdvancedModeFinalApproverEmail = getHRAdvancedModeFinalApprover(policy); for (const employee of Object.values(employees)) { + // The type doesn't allow it, but `employeeList` can hold a null entry at runtime, so skip it rather than crash + if (!employee) { + continue; + } + const {email, submitsTo, pendingAction} = employee; if (!email) { continue; @@ -818,7 +823,8 @@ function mergeWorkflowMembersWithAvailableMembers(workflowMembers: Member[], all */ function includesEveryWorkspaceMember(memberEmails: Array, employeeList: PolicyEmployeeList | undefined): boolean { const memberEmailSet = new Set(memberEmails); - const workspaceMembers = Object.values(employeeList ?? {}).filter((employee) => !!employee.email && employee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + // `employeeList` can hold a null entry at runtime, so read it optionally + const workspaceMembers = Object.values(employeeList ?? {}).filter((employee) => !!employee?.email && employee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); return workspaceMembers.length > 0 && workspaceMembers.every((employee) => memberEmailSet.has(employee.email)); } @@ -1656,6 +1662,11 @@ function convertApprovalWorkflowRulesToWorkflows({ const availableMembers: Member[] = []; for (const employee of Object.values(employees)) { + // The type doesn't allow it, but `employeeList` can hold a null entry at runtime, so skip it rather than crash + if (!employee) { + continue; + } + const {email, submitsTo, pendingAction} = employee; if (!email) { continue; @@ -1839,8 +1850,9 @@ function hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin, rul const hasNonMemberRoute = isNonMemberApprover(policy, getHRFinalApprover(policy) ?? getDefaultApprover(policy)) || Object.values(policy?.employeeList ?? {}).some( + // `employeeList` can hold a null entry at runtime, so read it optionally (employee) => - isNonMemberApprover(policy, employee.submitsTo) || isNonMemberApprover(policy, employee.forwardsTo) || isNonMemberApprover(policy, employee.overLimitForwardsTo), + isNonMemberApprover(policy, employee?.submitsTo) || isNonMemberApprover(policy, employee?.forwardsTo) || isNonMemberApprover(policy, employee?.overLimitForwardsTo), ); if (!hasNonMemberRoute) { return false; diff --git a/src/libs/WorkspaceDisplayUtils.ts b/src/libs/WorkspaceDisplayUtils.ts new file mode 100644 index 000000000000..462a478f19d9 --- /dev/null +++ b/src/libs/WorkspaceDisplayUtils.ts @@ -0,0 +1,36 @@ +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import type {CompanyAddress, Unit} from '@src/types/onyx/Policy'; + +type WorkspaceAddressStreetLines = { + streetLineOne: string; + streetLineTwo: string; +}; + +/** + * Resolve workspace street lines while supporting both legacy newline street format and explicit street line 2. + */ +function getWorkspaceAddressStreetLines(addressStreet: CompanyAddress['addressStreet'] = '', addressStreet2?: CompanyAddress['addressStreet2']): WorkspaceAddressStreetLines { + const [legacyStreetLineOne, legacyStreetLineTwo] = (addressStreet ?? '').split('\n'); + const trimmedStreetLineTwo = addressStreet2?.trim(); + return { + streetLineOne: legacyStreetLineOne?.trim() ?? '', + // nullish coalescing cannot be used if explicit line 2 can be an empty string + streetLineTwo: trimmedStreetLineTwo || legacyStreetLineTwo?.trim() || '', + }; +} + +/** + * @param unit Unit + * @returns translation key for the unit + */ +function getUnitTranslationKey(unit: Unit): TranslationPaths { + const unitTranslationKeysStrategy: Record = { + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS]: 'common.kilometers', + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES]: 'common.miles', + }; + + return unitTranslationKeysStrategy[unit]; +} + +export {getUnitTranslationKey, getWorkspaceAddressStreetLines}; diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts index 1251494f5065..649b7d2ca9df 100644 --- a/src/libs/WorkspacesSettingsUtils.ts +++ b/src/libs/WorkspacesSettingsUtils.ts @@ -3,9 +3,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import type {Policy, ReportAttributesDerivedValue} from '@src/types/onyx'; -import type {CompanyAddress, Unit} from '@src/types/onyx/Policy'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -14,11 +12,6 @@ import {getConnectionExporters, isPolicyAdmin, isPolicyApprover, isPolicyAuditor type BrickRoad = ValueOf | undefined; -type WorkspaceAddressStreetLines = { - streetLineOne: string; - streetLineTwo: string; -}; - /** * @returns BrickRoad for the given reportID using reportAttributes */ @@ -51,32 +44,6 @@ function getChatTabBrickRoad(orderedReportIDs: string[], reportAttributes: Repor return reportID ? getBrickRoadForPolicy(reportID, reportAttributes) : undefined; } -/** - * Resolve workspace street lines while supporting both legacy newline street format and explicit street line 2. - */ -function getWorkspaceAddressStreetLines(addressStreet: CompanyAddress['addressStreet'] = '', addressStreet2?: CompanyAddress['addressStreet2']): WorkspaceAddressStreetLines { - const [legacyStreetLineOne, legacyStreetLineTwo] = (addressStreet ?? '').split('\n'); - const trimmedStreetLineTwo = addressStreet2?.trim(); - return { - streetLineOne: legacyStreetLineOne?.trim() ?? '', - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing cannot be used if explicit line 2 can be an empty string - streetLineTwo: trimmedStreetLineTwo || legacyStreetLineTwo?.trim() || '', - }; -} - -/** - * @param unit Unit - * @returns translation key for the unit - */ -function getUnitTranslationKey(unit: Unit): TranslationPaths { - const unitTranslationKeysStrategy: Record = { - [CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS]: 'common.kilometers', - [CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES]: 'common.miles', - }; - - return unitTranslationKeysStrategy[unit]; -} - /** * @param error workspace change owner error * @param translate translation function @@ -176,13 +143,5 @@ function getLeaveWorkspaceConfirmationPrompt( return translate('common.leaveWorkspaceConfirmation'); } -export { - getChatTabBrickRoadReportID, - getBrickRoadForPolicy, - getChatTabBrickRoad, - getUnitTranslationKey, - getOwnershipChecksDisplayText, - getWorkspaceAddressStreetLines, - getLeaveWorkspaceConfirmationPrompt, -}; +export {getChatTabBrickRoadReportID, getBrickRoadForPolicy, getChatTabBrickRoad, getOwnershipChecksDisplayText, getLeaveWorkspaceConfirmationPrompt}; export type {BrickRoad}; diff --git a/src/libs/actions/Agent.ts b/src/libs/actions/Agent.ts index 5be435da4e24..8b53c3784ae0 100644 --- a/src/libs/actions/Agent.ts +++ b/src/libs/actions/Agent.ts @@ -14,12 +14,14 @@ import type {AvatarSource} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; import type NewAgentTemplate from '@src/types/onyx/NewAgentTemplate'; import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; import type {AnyOnyxUpdate} from '@src/types/onyx/Request'; import type {OnyxCollection, OnyxCollectionInputValue, OnyxUpdate} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; @@ -39,15 +41,14 @@ function openProfilePage() { read(READ_COMMANDS.OPEN_PROFILE_PAGE, null); } -function createAgent( - firstName: string | undefined, +function prepareNewAgentOnyxData( + currentUserAccountID: number, + currentUserLogin: string | undefined, prompt: string, - ownerAccountID: number, - ownerLogin: string | undefined, + policy?: Policy, + firstName?: string, customExpensifyAvatarID?: string, - file?: File | CustomRNImageManipulatorResult, optimisticAvatarURI?: string, - policyID?: string, ) { const optimisticAccountID = Number(generateReportID()); @@ -58,7 +59,7 @@ function createAgent( avatarURI = optimisticAvatarURI; } - // Optimistic row for Settings > Agents until CREATE_AGENT responds. Omit `login` (server-assigned). + // Optimistic row for Settings > Agents until BE responds. Omit `login` (server-assigned). const optimisticPersonalDetail = { accountID: optimisticAccountID, displayName: firstName, @@ -70,24 +71,29 @@ function createAgent( // Generate the DM report ID client-side so it can be written to Onyx and opened immediately. const optimisticReportID = generateReportID(); const optimisticChatReport = buildOptimisticChatReport({ - participantList: [ownerAccountID, optimisticAccountID], - ownerAccountID, + participantList: [currentUserAccountID, optimisticAccountID], + ownerAccountID: currentUserAccountID, optimisticReportID, - currentUserAccountID: ownerAccountID, + currentUserAccountID, }); + // Reuse the client-generated CREATED action ID when CreateAgent creates the DM. const createdReportActionID = rand64(); const optimisticCreatedAction = buildOptimisticCreatedReportAction({ - emailCreatingAction: ownerLogin ?? CONST.REPORT.OWNER_EMAIL_FAKE, - currentUserAccountID: ownerAccountID, + emailCreatingAction: currentUserLogin ?? CONST.REPORT.OWNER_EMAIL_FAKE, + currentUserAccountID, optimisticReportActionID: createdReportActionID, }); const optimisticData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, - value: {prompt, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + key: `${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, + value: { + prompt, + ownerAccountID: policy?.policyAccountID, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, }, { onyxMethod: Onyx.METHOD.SET, @@ -125,10 +131,8 @@ function createAgent( buildPersonalDetailsUpdate({[optimisticAccountID]: optimisticPersonalDetail}), { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, value: { - prompt, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, errors: getMicroSecondOnyxErrorWithTranslationKey('agentsPage.error.genericAdd'), }, }, @@ -149,11 +153,63 @@ function createAgent( }, ]; + return {optimisticData, successData, failureData, optimisticAccountID, avatarURI, optimisticReportID, createdReportActionID, optimisticPersonalDetailPromise}; +} + +function createAgent( + firstName: string | undefined, + prompt: string, + currentUserAccountID: number, + currentUserLogin: string | undefined, + customExpensifyAvatarID?: string, + file?: File | CustomRNImageManipulatorResult, + optimisticAvatarURI?: string, +) { + const {optimisticData, successData, failureData, optimisticAccountID, avatarURI, optimisticReportID, createdReportActionID, optimisticPersonalDetailPromise} = prepareNewAgentOnyxData( + currentUserAccountID, + currentUserLogin, + prompt, + undefined, + firstName, + customExpensifyAvatarID, + optimisticAvatarURI, + ); + write( // Flag this as the user's personal agent; the backend makes personal agents a full co-pilot of the creator. // optimisticReportID is the owner<->agent DM's optimistic reportID; CreateAgent creates the DM under this exact ID. WRITE_COMMANDS.CREATE_AGENT, - {firstName, prompt, customExpensifyAvatarID, file, policyID, optimisticAccountID: String(optimisticAccountID), isPersonalAgent: true, optimisticReportID, createdReportActionID}, + {firstName, prompt, customExpensifyAvatarID, file, optimisticAccountID: String(optimisticAccountID), isPersonalAgent: true, optimisticReportID, createdReportActionID}, + {optimisticData, successData, failureData}, + ); + + return {optimisticAccountID, avatarURI, optimisticReportID, optimisticPersonalDetailPromise}; +} + +function createCompanyAgent( + currentUserAccountID: number, + currentUserLogin: string | undefined, + prompt: string, + policy: Policy, + role: ValueOf, + firstName?: string, + customExpensifyAvatarID?: string, + file?: File | CustomRNImageManipulatorResult, + optimisticAvatarURI?: string, +) { + const {optimisticData, successData, failureData, optimisticAccountID, avatarURI, optimisticReportID, createdReportActionID, optimisticPersonalDetailPromise} = prepareNewAgentOnyxData( + currentUserAccountID, + currentUserLogin, + prompt, + policy, + firstName, + customExpensifyAvatarID, + optimisticAvatarURI, + ); + + write( + WRITE_COMMANDS.CREATE_COMPANY_AGENT, + {prompt, policyID: policy.id, role, firstName, customExpensifyAvatarID, file, optimisticAccountID, optimisticReportID, createdReportActionID}, {optimisticData, successData, failureData}, ); @@ -173,23 +229,23 @@ function clearNewAgentTemplate() { function clearAgentError(optimisticAccountID: number) { Onyx.update([buildPersonalDetailsUpdate({[optimisticAccountID]: null})]); - Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, null); + Onyx.set(`${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, null); } function clearAgentUpdateError(accountID: number) { - Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, {errors: null, nameErrors: null, promptErrors: null, avatarErrors: null}); + Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, {errors: null, nameErrors: null, promptErrors: null, avatarErrors: null}); } function clearAgentNameUpdateError(accountID: number) { - Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, {nameErrors: null}); + Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, {nameErrors: null}); } function clearAgentPromptUpdateError(accountID: number) { - Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, {promptErrors: null}); + Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, {promptErrors: null}); } function clearAgentDeleteError(accountID: number) { - Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, {pendingAction: null, errors: null}); + Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, {pendingAction: null, errors: null}); } function updateAgentName(accountID: number, firstName: string, originalFirstName: string) { @@ -199,7 +255,7 @@ function updateAgentName(accountID: number, firstName: string, originalFirstName buildPersonalDetailsUpdate({[agentAccountID]: {displayName: firstName}}), { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: null, nameErrors: null}, }, ]; @@ -207,7 +263,7 @@ function updateAgentName(accountID: number, firstName: string, originalFirstName const successData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: {pendingAction: null, nameErrors: null}, }, ]; @@ -216,7 +272,7 @@ function updateAgentName(accountID: number, firstName: string, originalFirstName buildPersonalDetailsUpdate({[agentAccountID]: {displayName: originalFirstName}}), { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: {pendingAction: null, nameErrors: getMicroSecondOnyxErrorWithTranslationKey('agentsPage.error.updateName')}, }, ]; @@ -227,7 +283,7 @@ function updateAgentName(accountID: number, firstName: string, originalFirstName function updateAgentPrompt(accountID: number, prompt: string, originalPrompt: string) { // The caller may still hold an optimistic accountID that was already reconciled while its screen was open. const agentAccountID = resolveAgentAccountID(accountID); - const onyxKey = `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`; + const onyxKey = `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`; const optimisticData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -256,7 +312,7 @@ function updateAgentPrompt(accountID: number, prompt: string, originalPrompt: st } function clearAgentAvatarUpdateError(accountID: number) { - Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, {avatarErrors: null}); + Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, {avatarErrors: null}); } function updateAgentAvatar( @@ -279,7 +335,7 @@ function updateAgentAvatar( }), { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: null, avatarErrors: null}, }, ]; @@ -293,7 +349,7 @@ function updateAgentAvatar( }), { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: {pendingAction: null, avatarErrors: null}, }, ]; @@ -309,7 +365,7 @@ function updateAgentAvatar( }), { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: {pendingAction: null, avatarErrors: getMicroSecondOnyxErrorWithTranslationKey('agentsPage.error.updateAvatar')}, }, ]; @@ -319,13 +375,13 @@ function updateAgentAvatar( write(WRITE_COMMANDS.UPDATE_AGENT_AVATAR, params, {optimisticData, successData, failureData}); } -function deleteAgent(accountID: number, agentLogin?: string, allPolicies?: OnyxCollection, shouldNavigateBack = true) { +function deleteAgent(accountID: number, agentLogin?: string, allPolicies?: OnyxCollection, shouldNavigateBack = true, backPath?: Route) { // The caller may still hold an optimistic accountID that was already reconciled while its screen was open. const agentAccountID = resolveAgentAccountID(accountID); const optimisticData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, }, ]; @@ -333,7 +389,7 @@ function deleteAgent(accountID: number, agentLogin?: string, allPolicies?: OnyxC const successData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: null, }, buildPersonalDetailsUpdate({[agentAccountID]: null}), @@ -342,7 +398,7 @@ function deleteAgent(accountID: number, agentLogin?: string, allPolicies?: OnyxC const failureData: AnyOnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${agentAccountID}`, + key: `${ONYXKEYS.COLLECTION.AGENT}${agentAccountID}`, value: { pendingAction: null, errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), @@ -380,7 +436,7 @@ function deleteAgent(accountID: number, agentLogin?: string, allPolicies?: OnyxC // Callers that end the copilot session right after deleting (e.g. deleting the agent you're copiloting into) // don't want the extra navigation, since the delegate transition resets navigation on its own. if (shouldNavigateBack) { - Navigation.goBack(ROUTES.SETTINGS_AGENTS); + Navigation.goBack(backPath ?? ROUTES.SETTINGS_AGENTS); } } @@ -432,6 +488,7 @@ export { openAgentsPage, openProfilePage, createAgent, + createCompanyAgent, setNewAgentTemplate, clearNewAgentTemplate, clearAgentError, diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index 5e0b27f08d08..78398a246ce3 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -153,6 +153,11 @@ const KEYS_TO_PRESERVE: OnyxKey[] = [ ONYXKEYS.STASHED_SESSION, ONYXKEYS.STASHED_CREDENTIALS, ONYXKEYS.NVP_LAST_DISMISSED_MARKETING_WINDOW, + // Signing out must not erase the record of when we prompted for an app review. nvp_appReview is + // server-owned and its lastPrompt is not refreshed on every response, so if this record went away + // the next sign-in would re-show the prompt the user already answered - every time, forever. + // The record carries the accountID it belongs to, so a different user signing in here ignores it. + ONYXKEYS.APP_REVIEW_LAST_PROMPT, // Preserve IS_USING_IMPORTED_STATE so that when the app restarts (especially in HybridApp mode), // we know if we're in imported state mode and should skip API calls that would cause infinite loading ONYXKEYS.IS_USING_IMPORTED_STATE, diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index eed2dbb908e2..169ba1b85b0e 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -52,7 +52,7 @@ import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import {getMakeDefaultPaymentOnyxData} from './PaymentMethods'; +import getMakeDefaultPaymentOnyxData from './getMakeDefaultPaymentOnyxData'; import {setBankAccountSubStep} from './ReimbursementAccount'; /** Loads the reimbursement countries of the user's policies, which decide whether to collect local or wire details. */ @@ -1242,7 +1242,7 @@ function sendReminderForCorpaySignerInformation(parameters: SendReminderForCorpa } function enableGlobalReimbursementsForUSDBankAccount(parameters: EnableGlobalReimbursementsForUSDBankAccountParams) { - const onyxData: OnyxData = { + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1262,6 +1262,11 @@ function enableGlobalReimbursementsForUSDBankAccount(parameters: EnableGlobalRei isSuccess: true, }, }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT, + value: null, + }, ], failureData: [ { diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 80c6e0c26942..1c123a37b475 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -30,6 +30,7 @@ import {getDisplayedExpensifyCardLimitType, getTranslationKeyForLimitType} from import {convertToShortDisplayString} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; +import escapeCsvField from '@libs/escapeCsvField'; import localFileDownload from '@libs/localFileDownload'; import Log from '@libs/Log'; import deferNavigate from '@libs/Navigation/deferNavigate'; @@ -1987,13 +1988,6 @@ function resolveFraudAlert( API.write(WRITE_COMMANDS.RESOLVE_FRAUD_ALERT, parameters, {optimisticData, successData, failureData}); } -function escapeCsvField(value: string): string { - if (value.includes('"') || value.includes(',') || value.includes('\n') || value.includes('\r')) { - return `"${value.replaceAll('"', '""')}"`; - } - return value; -} - function getOwnerEmailForCard(card: Card, personalDetailsList: PersonalDetailsList | undefined): string { const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID; return personalDetailsList?.[String(accountID)]?.login ?? ''; diff --git a/src/libs/actions/Chronos.ts b/src/libs/actions/Chronos.ts index 1a9436049e00..2499961f2405 100644 --- a/src/libs/actions/Chronos.ts +++ b/src/libs/actions/Chronos.ts @@ -91,7 +91,7 @@ const startOrStopChronosTimer = (report: Report, currentUserAccountID: number, p const lastMessageText = formatReportLastMessageText(getReportActionMessage(reportAction)?.text ?? ''); // Capture the report's current last-visible message so we can restore it if the optimistic comment fails to send. - const previousLastVisibleAction = getLastVisibleAction(reportID); + const previousLastVisibleAction = getLastVisibleAction(reportID, undefined, {}, undefined, undefined, currentUserAccountID); const {lastMessageText: previousLastMessageText = ''} = getLastVisibleMessage(reportID); const failureReport: Partial = previousLastMessageText ? {lastMessageText: previousLastMessageText, lastVisibleActionCreated: previousLastVisibleAction?.created, lastActorAccountID: previousLastVisibleAction?.actorAccountID} diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index 22933490a251..20ae256cfda3 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -21,7 +21,8 @@ import type {Delegate, DelegatedAccess, DelegateRole} from '@src/types/onyx/Acco import type Credentials from '@src/types/onyx/Credentials'; import type Session from '@src/types/onyx/Session'; -import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {OnyxEntry, OnyxMultiSetInput, OnyxUpdate} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import HybridAppModule from '@expensify/react-native-hybrid-app'; import Onyx from 'react-native-onyx'; @@ -39,6 +40,7 @@ const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [ ONYXKEYS.STASHED_SESSION, ONYXKEYS.HAS_LOADED_APP, ONYXKEYS.STASHED_CREDENTIALS, + ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL, ONYXKEYS.HYBRID_APP, // Otherwise it's wiped and refetched by OpenApp, letting the product-marketing window flash back in @@ -61,9 +63,37 @@ const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [ ONYXKEYS.PERSONAL_DETAILS_LIST, ]; -/** Atomically reset Onyx for a delegate-access transition. */ -function clearOnyxForDelegateTransition(): Promise { - return clearOnyxAndSeedFullReconnect(KEYS_TO_PRESERVE_DELEGATE_ACCESS); +/** Atomically reset Onyx for a delegate-access transition. `extraSeeds` are written in the same step as the clear. */ +function clearOnyxForDelegateTransition(extraSeeds?: OnyxMultiSetInput): Promise { + return clearOnyxAndSeedFullReconnect(KEYS_TO_PRESERVE_DELEGATE_ACCESS, extraSeeds); +} + +type LoadAccountAfterDelegateTransitionParams = { + /** Account to reinitialize Pusher for */ + accountID: number | undefined; + + /** Email to reinitialize Pusher for */ + email: string | undefined; + + /** Account to switch OldDot to, omitted to leave OldDot as is */ + hybridAppAccount?: Parameters[0]; +}; + +/** + * Loads the account after a delegate-access transition and syncs OldDot to it. Pusher is not awaited: on Android it can + * stall right after the transition's disconnect, which would block the switch. See Expensify/App#93458. + */ +function loadAccountAfterDelegateTransition({accountID, email, hybridAppAccount}: LoadAccountAfterDelegateTransitionParams): Promise { + return openApp().then(() => { + requestPusherReinitialize({accountID, email}).catch((error: unknown) => { + Log.hmmm('[Delegate] Failed to reinitialize Pusher after a delegate-access transition', {error: ErrorUtils.getErrorMessage(error)}); + }); + + if (!CONFIG.IS_HYBRID_APP || !hybridAppAccount) { + return; + } + HybridAppModule.switchAccount(hybridAppAccount); + }); } type WithDelegatedAccess = { @@ -92,10 +122,6 @@ type WithFieldName = { fieldName: 'addDelegate' | 'updateDelegateRole' | 'connect' | 'removeDelegator' | 'removeDelegate'; }; -type WithOldDotFlag = { - isFromOldDot?: boolean; -}; - type WithStashedCredentials = { stashedCredentials: Credentials | undefined; }; @@ -114,6 +140,22 @@ type WithActivePolicyID = { type DisconnectParams = WithStashedCredentials & WithStashedSession; +const DISCONNECT_RESULT = { + /** Session ended, original session restored with fresh tokens */ + RESTORED: 'restored', + + /** Incomplete response, original session restored from the stash */ + RESTORED_FROM_STASH: 'restoredFromStash', + + /** Session ended but the restore threw partway. SESSION is written first, so it is most likely restored. */ + RESTORE_INTERRUPTED: 'restoreInterrupted', + + /** Request failed, copilot session still active */ + FAILED: 'failed', +} as const; + +type DisconnectResult = ValueOf; + // Clear delegator-level errors type ClearDelegatorErrorsParams = WithDelegatedAccess; @@ -133,17 +175,13 @@ type UpdateDelegateRoleParams = WithEmail & WithRole & WithValidateCode & WithDe type IsConnectedAsDelegateParams = WithDelegatedAccess; // Connect as delegate -type ConnectParams = WithEmail & WithDelegatedAccess & WithOldDotFlag & WithCredentials & WithSession & WithActivePolicyID; +type ConnectParams = WithEmail & WithDelegatedAccess & WithCredentials & WithSession & WithActivePolicyID; /** * Connects the user as a delegate to another account. * Returns a Promise that resolves to true on success, false on failure, or undefined if not applicable. */ -function connect({email, delegatedAccess, credentials, session, activePolicyID, isFromOldDot = false}: ConnectParams) { - if (!delegatedAccess?.delegators && !isFromOldDot) { - return; - } - +function connect({email, delegatedAccess, credentials, session, activePolicyID}: ConnectParams) { if (isConnectedAsDelegate({delegatedAccess})) { Log.info('[Delegate] Already connected as a delegate, skipping connect'); return; @@ -238,22 +276,20 @@ function connect({email, delegatedAccess, credentials, session, activePolicyID, Pusher.disconnect(); return clearOnyxForDelegateTransition(); }) - .then(() => { - return openApp() - .then(() => requestPusherReinitialize({accountID: response.accountID, email: response.email})) - .then(() => { - if (!CONFIG.IS_HYBRID_APP || !policyID) { - return true; - } - HybridAppModule.switchAccount({ - newDotCurrentAccountEmail: email, - authToken: restrictedToken, - policyID, - accountID: String(session?.accountID ?? CONST.DEFAULT_NUMBER_ID), - }); - return true; - }); - }); + .then(() => + loadAccountAfterDelegateTransition({ + accountID: response.accountID, + email: response.email, + hybridAppAccount: policyID + ? { + newDotCurrentAccountEmail: email, + authToken: restrictedToken, + policyID, + accountID: String(session?.accountID ?? CONST.DEFAULT_NUMBER_ID), + } + : undefined, + }).then(() => true), + ); }) .catch((error) => { Log.alert('[Delegate] Error connecting as delegate', {error}); @@ -262,7 +298,7 @@ function connect({email, delegatedAccess, credentials, session, activePolicyID, }); } -function disconnect({stashedCredentials, stashedSession}: DisconnectParams) { +function disconnect({stashedCredentials, stashedSession}: DisconnectParams): Promise { const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -302,11 +338,10 @@ function disconnect({stashedCredentials, stashedSession}: DisconnectParams) { ]; // We need to access the authToken directly from the response to update the session - // The promise resolves to true only once the original session is fully restored, so callers can gate - // follow-up work that must run under the original user's identity (e.g. deleting the agent). + // Resolves to a DISCONNECT_RESULT so callers can gate work that must run as the original user (e.g. deleting the agent) // eslint-disable-next-line rulesdir/no-api-side-effects-method - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.DISCONNECT_AS_DELEGATE, {}, {optimisticData, successData, failureData}) - .then((response) => { + return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.DISCONNECT_AS_DELEGATE, {}, {optimisticData, successData, failureData}).then( + (response) => { const restoreToStashed = () => restoreDelegateSession({ authToken: stashedSession?.authToken, @@ -318,37 +353,46 @@ function disconnect({stashedCredentials, stashedSession}: DisconnectParams) { creationDate: stashedSession?.creationDate, }); - if (!response?.authToken || !response?.encryptedAuthToken) { - Log.alert('[Delegate] No auth token returned while disconnecting as a delegate'); - return restoreToStashed().then(() => false); - } - - if (!response?.requesterID || !response?.requesterEmail) { - Log.alert('[Delegate] No requester data returned while disconnecting as a delegate'); - return restoreToStashed().then(() => false); - } + const restoreOriginalSession = (): Promise => { + if (!response?.authToken || !response?.encryptedAuthToken) { + Log.alert('[Delegate] No auth token returned while disconnecting as a delegate'); + return restoreToStashed().then(() => DISCONNECT_RESULT.RESTORED_FROM_STASH); + } - clearPreservedSearchNavigatorStates(); + if (!response?.requesterID || !response?.requesterEmail) { + Log.alert('[Delegate] No requester data returned while disconnecting as a delegate'); + return restoreToStashed().then(() => DISCONNECT_RESULT.RESTORED_FROM_STASH); + } - const requesterEmail = response.requesterEmail; - const authToken = response.authToken; - return SequentialQueue.waitForIdle() - .then(() => - restoreDelegateSession({ - authToken, - encryptedAuthToken: response.encryptedAuthToken, - accountID: response.requesterID, - email: requesterEmail, - stashedCredentials, - stashedSession, - }), - ) - .then(() => true); - }) - .catch((error) => { + clearPreservedSearchNavigatorStates(); + + const requesterEmail = response.requesterEmail; + const authToken = response.authToken; + return SequentialQueue.waitForIdle() + .then(() => + restoreDelegateSession({ + authToken, + encryptedAuthToken: response.encryptedAuthToken, + accountID: response.requesterID, + email: requesterEmail, + stashedCredentials, + stashedSession, + }), + ) + .then(() => DISCONNECT_RESULT.RESTORED); + }; + + return restoreOriginalSession().catch((error) => { + Log.alert('[Delegate] Error restoring the original session after disconnecting as a delegate', {error}); + return DISCONNECT_RESULT.RESTORE_INTERRUPTED; + }); + }, + // Only a failed request leaves the copilot session active + (error) => { Log.alert('[Delegate] Error disconnecting as a delegate', {error}); - return false; - }); + return DISCONNECT_RESULT.FAILED; + }, + ); } function clearDelegatorErrors({delegatedAccess}: ClearDelegatorErrorsParams) { @@ -688,6 +732,127 @@ function removeDelegator({email, delegatedAccess}: RemoveDelegateParams) { }); } +type RemoveSelfAsDelegateParams = WithStashedCredentials & + WithStashedSession & { + /** Delegator the copilot is acting as */ + delegatorEmail: string; + + /** Acting copilot, whose row shows the error if the session can't be ended */ + delegateEmail: string; + }; + +// Set while removeSelfAsDelegate runs, so resuming a pending removal can't queue it twice +let isRemovingSelfAsDelegate = false; + +/** Queues the restored copilot's removal from the delegator's copilots, clearing the pending removal with it */ +function queueSelfRemovalFromDelegator(delegatorEmail: string) { + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + errorFields: { + removeDelegator: { + [delegatorEmail]: null, + }, + }, + }, + }, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + errorFields: { + removeDelegator: { + [delegatorEmail]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError'), + }, + }, + }, + }, + }, + ]; + + const parameters: APIRemoveDelegatorParams = {delegatorEmail}; + + // The restored account's delegators aren't known here, so refetch them after the removal + API.write(WRITE_COMMANDS.REMOVE_DELEGATOR, parameters, { + optimisticData, + failureData, + }); + openSecuritySettingsPage(); +} + +/** + * Removes the acting copilot from the delegator's copilots, restoring the original session first: revoking first + * invalidates the token the app runs on, and a kill before the restore persists it. See Expensify/App#93458. + * The removal is saved as pending first, so a kill before it is queued doesn't lose it. + */ +function removeSelfAsDelegate({delegatorEmail, delegateEmail, stashedCredentials, stashedSession}: RemoveSelfAsDelegateParams) { + isRemovingSelfAsDelegate = true; + return Onyx.set(ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL, delegatorEmail) + .then(() => disconnect({stashedCredentials, stashedSession})) + .then((disconnectResult) => { + // Only a failed request leaves the copilot on the delegator's account + if (disconnectResult === DISCONNECT_RESULT.FAILED) { + Log.alert('[Delegate] Unable to restore the original session, skipping copilot removal'); + Onyx.set(ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL, null); + + // Still acting as the delegator, so show the error on the copilot's own row + Onyx.merge(ONYXKEYS.ACCOUNT, { + delegatedAccess: { + errorFields: { + removeDelegate: { + [delegateEmail]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('delegate.genericError'), + }, + }, + }, + }); + return; + } + + queueSelfRemovalFromDelegator(delegatorEmail); + }) + .finally(() => { + isRemovingSelfAsDelegate = false; + }); +} + +type ResumeSelfRemovalAsDelegateParams = { + /** Delegator saved as pending by an interrupted removeSelfAsDelegate */ + delegatorEmail: string; + + /** Whether the session still holds the delegator's restricted token */ + isDelegateSession: boolean; +}; + +/** + * Finishes a removeSelfAsDelegate a kill interrupted. Back on their own account, the copilot's removal is queued; + * still acting as the delegator, the restore never happened, so the removal counts as cancelled. + */ +function resumeSelfRemovalAsDelegate({delegatorEmail, isDelegateSession}: ResumeSelfRemovalAsDelegateParams) { + if (isRemovingSelfAsDelegate) { + return; + } + + if (isDelegateSession) { + Onyx.set(ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL, null); + return; + } + + queueSelfRemovalFromDelegator(delegatorEmail); +} + function clearDelegateErrorsByField({email, fieldName, delegatedAccess}: ClearDelegateErrorsByFieldParams) { if (!delegatedAccess) { return; @@ -836,50 +1001,41 @@ function restoreDelegateSession({authToken, encryptedAuthToken, accountID, email // Write SESSION before the clear: SESSION is in KEYS_TO_PRESERVE_DELEGATE_ACCESS, so clearing // preserves the value at clear time. If we cleared first, an interrupted flow would leave // SESSION holding the prior delegate-restricted auth token. See Expensify/App#80073. - return ( - Onyx.set(ONYXKEYS.SESSION, { - ...(stashedSession ?? {}), - accountID, - email, - authToken, - encryptedAuthToken, - creationDate, - }) - .then(() => { - NetworkStore.setAuthToken(authToken ?? null); - // Disconnect Pusher before the transition so it cannot keep pushing updates for the delegate - // account; it is reinitialized for the restored account after `openApp()`. See Expensify/App#93265. - Pusher.disconnect(); - return clearOnyxForDelegateTransition(); - }) - .then(() => { - Onyx.set(ONYXKEYS.CREDENTIALS, { + return Onyx.set(ONYXKEYS.SESSION, { + ...(stashedSession ?? {}), + accountID, + email, + authToken, + encryptedAuthToken, + creationDate, + }) + .then(() => { + NetworkStore.setAuthToken(authToken ?? null); + // Disconnect Pusher before the transition so it cannot keep pushing updates for the delegate + // account; it is reinitialized for the restored account after `openApp()`. See Expensify/App#93265. + Pusher.disconnect(); + + // Seeded with the clear: written after it, an interrupted restore would leave the session without credentials + return clearOnyxForDelegateTransition({ + [ONYXKEYS.CREDENTIALS]: { ...(stashedCredentials ?? {}), accountID, - }); - Onyx.set(ONYXKEYS.STASHED_CREDENTIALS, {}); - Onyx.set(ONYXKEYS.STASHED_SESSION, {}); - - NetworkStore.setIsAuthenticating(false); - - return openApp(); - }) - // Reinitialize Pusher for the restored account so realtime updates resume on the correct session. - .then(() => requestPusherReinitialize({accountID, email})) - .then(() => { - // Sync the account switch to the classic mobile app experience of HybridApp; otherwise the - // two experiences disagree about which account is active after a reauth-driven restoration. - if (!CONFIG.IS_HYBRID_APP || !email || !authToken) { - return; - } - HybridAppModule.switchAccount({ - newDotCurrentAccountEmail: email, - authToken, - policyID: '', - accountID: '', - }); - }) - ); + }, + [ONYXKEYS.STASHED_CREDENTIALS]: {}, + [ONYXKEYS.STASHED_SESSION]: {}, + }); + }) + .then(() => { + NetworkStore.setIsAuthenticating(false); + + // Sync the account switch to the classic mobile app experience of HybridApp; otherwise the + // two experiences disagree about which account is active after a reauth-driven restoration. + return loadAccountAfterDelegateTransition({ + accountID, + email, + hybridAppAccount: email && authToken ? {newDotCurrentAccountEmail: email, authToken, policyID: '', accountID: ''} : undefined, + }); + }); } function openSecuritySettingsPage() { @@ -888,6 +1044,7 @@ function openSecuritySettingsPage() { export { KEYS_TO_PRESERVE_DELEGATE_ACCESS, + DISCONNECT_RESULT, connect, disconnect, clearDelegatorErrors, @@ -898,6 +1055,8 @@ export { updateDelegateRole, removeDelegate, removeDelegator, + removeSelfAsDelegate, + resumeSelfRemovalAsDelegate, openSecuritySettingsPage, clearOnyxForDelegateTransition, }; diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index d950eb15f721..6e8052df45b5 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -42,7 +42,7 @@ import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type PrefixedRecord from '@src/types/utils/PrefixedRecord'; -import type {NullishDeep, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxMergeInput, OnyxUpdate} from 'react-native-onyx'; import {Num} from 'expensify-common'; import Onyx from 'react-native-onyx'; @@ -347,9 +347,13 @@ async function getScimToken(domainName: string): Promise { }; } + if (!('SCIMToken' in response) || typeof response.SCIMToken !== 'string') { + return {state: ScimTokenState.ERROR, error: genericError}; + } + return { state: ScimTokenState.VALUE, - value: (response as {SCIMToken: string}).SCIMToken, + value: response.SCIMToken, }; } catch (error) { return { @@ -1257,8 +1261,10 @@ function clearDomainErrors(domainAccountID: number) { * @param defaultSecurityGroupID Security group ID to be used for optimistic updates */ function addMemberToDomain(domainAccountID: number, email: string, defaultSecurityGroupID: string) { - const DOMAIN_SECURITY_GROUP = `${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}${defaultSecurityGroupID}`; + const DOMAIN_SECURITY_GROUP: SecurityGroupKey = `${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}${defaultSecurityGroupID}`; const optimisticAccountID = generateAccountID(email); + const optimisticMembership: NonNullable> = {}; + optimisticMembership[DOMAIN_SECURITY_GROUP] = {shared: {[optimisticAccountID]: 'read'}}; const optimisticData: Array< | OnyxUpdate< @@ -1272,13 +1278,7 @@ function addMemberToDomain(domainAccountID: number, email: string, defaultSecuri { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, - value: { - [DOMAIN_SECURITY_GROUP]: { - shared: { - [optimisticAccountID]: 'read', - }, - }, - } as PrefixedRecord>, + value: optimisticMembership, }, buildPersonalDetailsUpdate({ [optimisticAccountID]: { @@ -1660,18 +1660,32 @@ 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 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, - previousDelegate: vacationDelegate?.delegate, - }, - } as PrefixedRecord, + [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: optimisticVacationDelegate, + } as PrefixedRecord>, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -1704,6 +1718,7 @@ function setDomainVacationDelegate(domainAccountID: number, domainMemberAccountI value: { [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: { previousDelegate: null, + previousClearAfter: null, }, } as PrefixedRecord>, }, @@ -1760,6 +1775,7 @@ function setDomainVacationDelegate(domainAccountID: number, domainMemberAccountI creator, vacationerEmail: vacationer, vacationDelegateEmail: delegate, + clearAfter, overridePolicyDiffWarning: true, domainAccountID, }; @@ -1776,7 +1792,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 +1882,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>); @@ -2161,23 +2180,15 @@ function changeDomainSecurityGroup( targetSecurityGroupKey: SecurityGroupKey, ) { const accountIDStr = String(accountID); + const optimisticMembership: NonNullable> = {}; + optimisticMembership[currentSecurityGroupKey] = {shared: {[accountIDStr]: null}}; + optimisticMembership[targetSecurityGroupKey] = {shared: {[accountIDStr]: 'read'}}; const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, - value: { - [currentSecurityGroupKey]: { - shared: { - [accountIDStr]: null, - }, - }, - [targetSecurityGroupKey]: { - shared: { - [accountIDStr]: 'read', - }, - }, - } as PrefixedRecord>, + value: optimisticMembership, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -2212,22 +2223,16 @@ function changeDomainSecurityGroup( }, ]; + const rollbackMembership: NonNullable> = {}; + // The target assignment must win when both keys name the same group. + rollbackMembership[currentSecurityGroupKey] = {shared: {[accountIDStr]: currentSecurityGroup.shared?.[accountIDStr]}}; + rollbackMembership[targetSecurityGroupKey] = {shared: {[accountIDStr]: null}}; + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, - value: { - [currentSecurityGroupKey]: { - shared: { - [accountIDStr]: currentSecurityGroup.shared?.[accountIDStr], - }, - }, - [targetSecurityGroupKey]: { - shared: { - [accountIDStr]: null, - }, - }, - } as PrefixedRecord>, + value: rollbackMembership, }, { onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/libs/actions/DynamicForm.ts b/src/libs/actions/DynamicForm.ts new file mode 100644 index 000000000000..a328c508cbd1 --- /dev/null +++ b/src/libs/actions/DynamicForm.ts @@ -0,0 +1,38 @@ +import type {DynamicFormValues} from '@components/DynamicForm/types'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +/** A dynamic form's keys come from its schema, so they are merged into the draft without its Onyx type listing them */ +function saveDraftAnswers(formID: OnyxFormKey, answers: DynamicFormValues) { + Onyx.merge(`${formID}Draft`, answers); +} + +/** Sensitive answers live in memory only, so they reach the next page of the flow but are never written to the draft */ +function saveSensitiveAnswers(formID: OnyxFormKey, answers: DynamicFormValues) { + Onyx.merge(ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS, {[formID]: answers}); +} + +/** Drops some sensitive answers, such as those of a list entry the user removed */ +function forgetSensitiveAnswers(formID: OnyxFormKey, keys: string[]) { + Onyx.merge(ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS, {[formID]: Object.fromEntries(keys.map((key) => [key, null]))}); +} + +/** Fills the list entry editor with the entry's answers, or empties it for a new entry */ +function startListItemEdit(answers: DynamicFormValues): Promise { + return Onyx.set(ONYXKEYS.FORMS.DYNAMIC_FORM_LIST_ITEM_FORM_DRAFT, answers); +} + +/** Empties the list entry editor once it closes, so an entry's answers are not left in its draft */ +function clearListItemEdit() { + Onyx.set(ONYXKEYS.FORMS.DYNAMIC_FORM_LIST_ITEM_FORM_DRAFT, null); +} + +/** Call once the form is submitted or abandoned, so sensitive answers do not outlive the visit */ +function clearSensitiveAnswers(formID: OnyxFormKey) { + Onyx.merge(ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS, {[formID]: null}); +} + +export {clearListItemEdit, clearSensitiveAnswers, forgetSensitiveAnswers, saveDraftAnswers, saveSensitiveAnswers, startListItemEdit}; diff --git a/src/libs/actions/IOU/BulkEdit.ts b/src/libs/actions/IOU/BulkEdit.ts index 35aa362fd381..f3847deb5ad5 100644 --- a/src/libs/actions/IOU/BulkEdit.ts +++ b/src/libs/actions/IOU/BulkEdit.ts @@ -617,7 +617,6 @@ function updateMultipleMoneyRequests({ updatedAttendees?.map(({avatarUrl, displayName, email}) => ({avatarUrl, displayName, ...(email ? {email} : {})})) ?? [], getRecentAttendees(), // Use || so empty-string emails fall back to displayName for the union key - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing (attendee) => attendee.email || attendee.displayName, ).slice(0, CONST.IOU.MAX_RECENT_ATTENDEES), }, diff --git a/src/libs/actions/IOU/DeleteMoneyRequest.ts b/src/libs/actions/IOU/DeleteMoneyRequest.ts index 8c1e4b72a146..395135e36353 100644 --- a/src/libs/actions/IOU/DeleteMoneyRequest.ts +++ b/src/libs/actions/IOU/DeleteMoneyRequest.ts @@ -746,7 +746,7 @@ function getCleanUpTransactionThreadReportOnyxData({ canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(chatReport, isChatIOUReportArchived ?? isArchivedExpenseReport); } - const lastVisibleAction = getLastVisibleAction(iouReportID, canUserPerformWriteAction); + const lastVisibleAction = getLastVisibleAction(iouReportID, canUserPerformWriteAction, {}, undefined, undefined, currentUserAccountID); const {childVisibleActionCount, childCommenterCount, childLastVisibleActionCreated, childOldestFourAccountIDs} = updateOptimisticParentReportAction( reportPreviewAction, @@ -963,7 +963,12 @@ function deleteMoneyRequest({ } const optimisticReportActions = reportPreviewAction?.reportActionID ? {[reportPreviewAction.reportActionID]: null} : {}; - const optimisticLastReportData = optimisticReportLastData(iouReport?.chatReportID ?? String(CONST.DEFAULT_NUMBER_ID), optimisticReportActions, canUserPerformWriteAction); + const optimisticLastReportData = optimisticReportLastData( + iouReport?.chatReportID ?? String(CONST.DEFAULT_NUMBER_ID), + currentUserAccountID, + optimisticReportActions, + canUserPerformWriteAction, + ); if (chatReport) { optimisticData.push({ diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 7d775bf0ccf4..d5c0c488eebe 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -734,6 +734,9 @@ function createExpenseByType({ const distanceParams: CreateDistanceRequestInformation = { ...params, participants, + // params.report is always a chat report here (never an IOU/expense report), so createDistanceRequest's + // one-to-two transition check short-circuits on isMoneyRequestReport and never reads this list. + moneyRequestReportTransactions: [], currentUserLogin: params.currentUserEmailParam, currentUserAccountID: params.currentUserAccountIDParam, existingTransaction: { diff --git a/src/libs/actions/IOU/Hold.ts b/src/libs/actions/IOU/Hold.ts index a63c4531714b..910be9612f00 100644 --- a/src/libs/actions/IOU/Hold.ts +++ b/src/libs/actions/IOU/Hold.ts @@ -90,15 +90,13 @@ function putOnHold({ const newViolation = {name: CONST.VIOLATIONS.HOLD, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}; const updatedViolations = [...(transactionViolations ?? []), newViolation]; const iouAction = getIOUActionForReportID(transaction?.reportID, transactionID); - let transactionThreadReport: OnyxTypes.Report; + let createdThread: OnyxTypes.Report | undefined; // If there is no existing transaction thread report, we should create one // This way we ensure every held request has a dedicated thread for comments - if (initialReportID) { - transactionThreadReport = initialReport ?? ({} as OnyxTypes.Report); - } else { + if (!initialReportID) { const moneyRequestReport = getReportOrDraftReport(transaction?.reportID); - transactionThreadReport = buildTransactionThread(iouAction, moneyRequestReport, currentUserAccountID, undefined, reportID); + createdThread = buildTransactionThread(iouAction, moneyRequestReport, currentUserAccountID, undefined, reportID); } const optimisticCreatedAction = buildOptimisticCreatedReportAction({emailCreatingAction: currentUserLogin}); @@ -222,7 +220,7 @@ function putOnHold({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { - lastVisibleActionCreated: transactionThreadReport.lastVisibleActionCreated, + lastVisibleActionCreated: (createdThread ?? initialReport)?.lastVisibleActionCreated, }, }, { @@ -233,15 +231,15 @@ function putOnHold({ ]; // If the transaction thread report wasn't created before, we create it optimistically - if (!initialReportID) { - transactionThreadReport.pendingFields = { + if (createdThread) { + createdThread.pendingFields = { createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }; optimisticData.push( { onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - value: transactionThreadReport, + value: createdThread, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -261,13 +259,13 @@ function putOnHold({ // We link the IOU action to the new transaction thread by setting childReportID optimistically optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.parentReportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${createdThread.parentReportID}`, value: {[iouAction?.reportActionID]: {childReportID: reportID, childType: CONST.REPORT.TYPE.CHAT}}, }); // We reset the childReportID if the request fails failureData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.parentReportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${createdThread.parentReportID}`, value: {[iouAction?.reportActionID]: {childReportID: null, childType: null}}, }); } @@ -637,7 +635,11 @@ function getHoldReportActionsAndTransactions( const holdTransactions: OnyxTypes.Transaction[] = []; for (const action of Object.values(iouReportActions)) { - const transactionID = isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined; + if (!isMoneyRequestAction(action)) { + continue; + } + + const transactionID = getOriginalMessage(action)?.IOUTransactionID; const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; if (!transaction) { @@ -648,7 +650,7 @@ function getHoldReportActionsAndTransactions( const isScanFailed = shouldMoveScanFailedTransactions && isScanFailedTransactionMovedOnPayment(transaction, iouReport); if (isHeld || isScanFailed) { - holdReportActions.push(action as OnyxTypes.ReportAction); + holdReportActions.push(action); holdTransactions.push(transaction); } } @@ -857,8 +859,7 @@ function getReportFromHoldRequestsOnyxData({ const optimisticHoldReportExpenseActionIDs: OptimisticHoldReportExpenseActionID[] = []; for (const holdReportAction of holdReportActions) { - // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - const originalMessage = getOriginalMessage(holdReportAction) as OnyxTypes.OriginalMessageIOU; + const originalMessage = getOriginalMessage(holdReportAction); deleteHoldReportActions[holdReportAction.reportActionID] = { message: [ diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 2d14fb0f3c55..c0b85bba9bab 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -12,13 +12,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import {getCustomUnitID} from '@libs/PerDiemRequestUtils'; import {getDistanceRateCustomUnit, isTaxTrackingEnabled, resolveCurrentTaxCode} from '@libs/PolicyUtils'; -import { - getReportOrDraftReport, - isInvoiceRoom, - isMoneyRequestReport as isMoneyRequestReportReportUtils, - isPolicyExpenseChat as isPolicyExpenseChatReportUtil, - isSelfDM, -} from '@libs/ReportUtils'; +import {getChatReportFromMoneyRequestReport, isInvoiceRoom, isPolicyExpenseChat as isPolicyExpenseChatReportUtil, isSelfDM} from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; import {startSpan} from '@libs/telemetry/activeSpans'; import {logReceiptSubmitted} from '@libs/telemetry/ReceiptObservability'; @@ -287,6 +281,7 @@ type GetMoneyRequestParticipantOptionsParams = { translate: LocalizedTranslate; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; dateFnsLocale: DateFnsLocale | undefined; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; }; function getMoneyRequestParticipantOptions({ @@ -302,6 +297,7 @@ function getMoneyRequestParticipantOptions({ translate, convertToDisplayString, dateFnsLocale, + formatPhoneNumber, }: GetMoneyRequestParticipantOptionsParams): Array { const selectedParticipants = getMoneyRequestParticipantsFromReport(report, currentUserAccountID); return selectedParticipants.map((participant) => { @@ -321,6 +317,7 @@ function getMoneyRequestParticipantOptions({ translate, dateFnsLocale, convertToDisplayString, + formatPhoneNumber, }, rules, // Passing pendingDeleteMemberAccountIDs as undefined is intentional, getMoneyRequestParticipantsFromReport only report-backs policy expense chats, self DMs and invoice rooms. @@ -597,7 +594,7 @@ function setMoneyRequestParticipants(transactionID: string, participants: Partic */ function getMoneyRequestParticipantsFromReport(report: OnyxEntry, currentUserAccountID?: number): Participant[] { // If the report is iou or expense report, we should get the chat report to set participant for request money - const chatReport = isMoneyRequestReportReportUtils(report) ? getReportOrDraftReport(report?.chatReportID) : report; + const chatReport = getChatReportFromMoneyRequestReport(report); const isSelfDMChat = !isEmptyObject(chatReport) && isSelfDM(chatReport); const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(chatReport); let participants: Participant[] = []; diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index 9a9b15a23817..99ce2bd94319 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -84,7 +84,7 @@ import type {ReplaceReceiptRetryParams} from './Receipt'; import type {StartSplitBilActionParams} from './Split'; import type BasePolicyParams from './types/BasePolicyParams'; import type BaseTransactionParams from './types/BaseTransactionParams'; -import type {CreateTrackExpenseParams} from './types/CreateTrackExpenseParams'; +import type {CreateTrackExpenseParams, TrackExpenseRetryParams} from './types/CreateTrackExpenseParams'; import type RequestMoneyParticipantParams from './types/RequestMoneyParticipantParams'; import type {GPSPoint} from './types/TrackExpenseTransactionParams'; @@ -108,7 +108,8 @@ type BuildOnyxDataForMoneyRequestKeys = | typeof ONYXKEYS.PERSONAL_DETAILS_LIST | typeof ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING | typeof ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE - | typeof ONYXKEYS.COLLECTION.SNAPSHOT; + | typeof ONYXKEYS.COLLECTION.SNAPSHOT + | typeof ONYXKEYS.REUSABLE_DISTANCE_ROUTES; type MoneyRequestInformation = { payerAccountID: number; @@ -381,7 +382,7 @@ function getReceiptError( isScanRequest = true, errorKey?: number, action?: IOUActionParams, - retryParams?: StartSplitBilActionParams | CreateTrackExpenseParams | RequestMoneyInformation | ReplaceReceiptRetryParams, + retryParams?: StartSplitBilActionParams | CreateTrackExpenseParams | RequestMoneyInformation | ReplaceReceiptRetryParams | TrackExpenseRetryParams, ): Errors | ErrorFields { const formattedRetryParams = typeof retryParams === 'string' ? retryParams : JSON.stringify(retryParams); @@ -1506,7 +1507,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma } } else if (isCurrencyMatching) { // Reimbursable transaction: reflect the change in the freshly tracked reimbursableTotal too. - iouReport.reimbursableTotal = previousReimbursableTotal - reportAmount; + iouReport.reimbursableTotal = newReportTotal ? newReportTotal - (iouReport.nonReimbursableTotal ?? 0) : previousReimbursableTotal - reportAmount; } // The reimbursable totals are what the report preview and details actually read, so they are @@ -1527,7 +1528,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma iouReport.unheldTotal -= reportAmount; } if (reimbursable && newUnheldReimbursableTotal === undefined && isCurrencyMatching) { - iouReport.unheldReimbursableTotal = previousUnheldReimbursableTotal - reportAmount; + iouReport.unheldReimbursableTotal = newReportTotal ? newReportTotal - (iouReport.unheldNonReimbursableTotal ?? 0) : previousUnheldReimbursableTotal - reportAmount; } } } diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index 56f7675d202f..3dd956001462 100644 --- a/src/libs/actions/IOU/PayMoneyRequest.ts +++ b/src/libs/actions/IOU/PayMoneyRequest.ts @@ -17,7 +17,6 @@ import { buildOptimisticCancelPaymentReportAction, buildOptimisticIOUReportAction, getReimbursableTotal, - getReportTransactions, getUnheldReimbursableTotal, hasHeldExpenses as hasHeldExpensesReportUtils, hasOutstandingChildRequest, @@ -63,6 +62,7 @@ type PayInvoiceArgs = { paymentMethodType: PaymentMethodType; chatReport: OnyxTypes.Report; invoiceReport: OnyxEntry; + invoiceReportTransactions: OnyxTypes.Transaction[]; introSelected: OnyxEntry; currentUserAccountIDParam: number; currentUserEmailParam: string; @@ -82,6 +82,7 @@ type PayInvoiceArgs = { delegateAccountID: number | undefined; isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + bankAccountList: OnyxEntry; rules: OnyxCollection; }; @@ -102,6 +103,7 @@ type PayMoneyRequestFunctionParams = { paymentType: PaymentMethodType; chatReport: OnyxTypes.Report; iouReport: OnyxEntry; + iouReportTransactions: OnyxTypes.Transaction[]; introSelected: OnyxEntry; currentUserAccountID: number; currentUserLogin: string; @@ -125,18 +127,21 @@ type PayMoneyRequestFunctionParams = { isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; isFallbackChatReport?: boolean; + bankAccountList: OnyxEntry; rules: OnyxCollection; }; function getPayMoneyRequestParams({ initialChatReport, iouReport, + iouReportTransactions, recipient, paymentMethodType, full, reportPolicy, payAsBusiness, bankAccountID, + bankAccountList, currentUserAccountIDParam, currentUserEmailParam, introSelected, @@ -158,6 +163,7 @@ function getPayMoneyRequestParams({ }: { initialChatReport: OnyxTypes.Report; iouReport: OnyxEntry; + iouReportTransactions: OnyxTypes.Transaction[]; recipient: Participant; paymentMethodType: PaymentMethodType; full: boolean; @@ -181,6 +187,7 @@ function getPayMoneyRequestParams({ isTrackIntentUser: boolean | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; isFallbackChatReport?: boolean; + bankAccountList: OnyxEntry; rules: OnyxCollection; }): PayMoneyRequestData { // TODO: https://github.com/Expensify/App/issues/66512 @@ -254,14 +261,20 @@ function getPayMoneyRequestParams({ chatReport = existingB2BInvoiceReport; } - const reportTransactions = getReportTransactions(iouReport?.reportID); const unheldReimbursableTotal = getUnheldReimbursableTotal(iouReport); let total = getReimbursableTotal(iouReport); - if (hasHeldExpensesReportUtils(reportTransactions) && !full && !!unheldReimbursableTotal) { + if (hasHeldExpensesReportUtils(iouReportTransactions) && !full && !!unheldReimbursableTotal) { total = unheldReimbursableTotal; } - const shouldMoveScanFailedTransactions = !!full && isExpenseReport(iouReport) && shouldSplitScanFailedTransactions(reportTransactions, iouReport); + const shouldMoveScanFailedTransactions = !!full && isExpenseReport(iouReport) && shouldSplitScanFailedTransactions(iouReportTransactions, iouReport); + + // Store the masked account actually paid with on the action itself, so every viewer resolves the same account. + // The paying admin may not be the workspace payer, so the account can be their own (looked up in `bankAccountList`) + // rather than the policy's ACH account. We fall back to the policy account when it is the one being used. + const paidWithBankAccount = bankAccountID ? bankAccountList?.[bankAccountID] : undefined; + const paidAccountNumber = + paidWithBankAccount?.accountData?.accountNumber ?? (bankAccountID === reportPolicy?.achAccount?.bankAccountID ? reportPolicy?.achAccount?.accountNumber : undefined); const optimisticIOUReportAction = buildOptimisticIOUReportAction({ type: CONST.IOU.REPORT_ACTION_TYPE.PAY, @@ -275,6 +288,7 @@ function getPayMoneyRequestParams({ isSettlingUp: true, payAsBusiness, bankAccountID, + accountNumber: paidAccountNumber, delegateAccountIDParam: delegateAccountID, getCurrencyDecimals, }); @@ -439,7 +453,6 @@ function getPayMoneyRequestParams({ // Optimistically unhold all transactions if we pay all requests if (full) { - const iouReportTransactions = getReportTransactions(iouReport?.reportID); for (const transaction of iouReportTransactions) { onyxData.optimisticData?.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -868,6 +881,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { paymentType, chatReport, iouReport, + iouReportTransactions, introSelected, currentUserAccountID, currentUserLogin, @@ -892,6 +906,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { getCurrencyDecimals, isFallbackChatReport, rules, + bankAccountList, } = params; const policyForBillingRestriction = chatReportPolicy ?? (policy?.id === chatReport.policyID ? policy : undefined); if ( @@ -910,6 +925,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { const {params: payMoneyRequestParams, onyxData} = getPayMoneyRequestParams({ initialChatReport: chatReport, iouReport, + iouReportTransactions, recipient, paymentMethodType: paymentType, full, @@ -924,6 +940,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { isASAPSubmitBetaEnabled, isSelfTourViewed, bankAccountID: paymentType === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + bankAccountList, delegateAccountID, chatReportActions, isTrackIntentUser, @@ -1116,6 +1133,7 @@ function payInvoice({ paymentMethodType, chatReport, invoiceReport, + invoiceReportTransactions, introSelected, currentUserAccountIDParam, currentUserEmailParam, @@ -1136,6 +1154,7 @@ function payInvoice({ isTrackIntentUser, getCurrencyDecimals, rules, + bankAccountList, }: PayInvoiceArgs) { const recipient = {accountID: invoiceReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID}; const { @@ -1155,11 +1174,13 @@ function payInvoice({ } = getPayMoneyRequestParams({ initialChatReport: chatReport, iouReport: invoiceReport, + iouReportTransactions: invoiceReportTransactions, recipient, paymentMethodType, full: true, payAsBusiness, bankAccountID: methodID, + bankAccountList, existingB2BInvoiceReport, activePolicy, conciergeChat, diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index 100373b9eb71..0ae5c3f4f498 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -27,9 +27,9 @@ import { buildOptimisticSelfDMReport, generateReportID, getChatByParticipants, + getChatReportFromMoneyRequestReport, getNewestOutstandingReportForUser, getReimbursableTotal, - getReportOrDraftReport, getUnheldReimbursableTotal, isExpenseReport, isMoneyRequestReport as isMoneyRequestReportReportUtils, @@ -72,12 +72,16 @@ import resolveWriteBarrier from './resolveWriteBarrier'; import signalExpenseAddedGrowl from './signalExpenseAddedGrowl'; function removeSubrate(transaction: OnyxEntry, currentIndex: string) { + if (!transaction) { + return; + } + // Index comes from the route params and is a string const index = Number(currentIndex); if (index === -1) { return; } - const existingSubrates = transaction?.comment?.customUnit?.subRates ?? []; + const existingSubrates = transaction.comment?.customUnit?.subRates ?? []; const newSubrates = [...existingSubrates]; newSubrates.splice(index, 1); @@ -86,28 +90,31 @@ function removeSubrate(transaction: OnyxEntry, currentInd // to remove nested keys while also preserving other object keys // Doing a deep clone of the transaction to avoid mutating the original object and running into a cache issue when using Onyx.set const newTransaction: OnyxTypes.Transaction = { - // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - ...(transaction as OnyxTypes.Transaction), + ...transaction, comment: { - ...transaction?.comment, + ...transaction.comment, customUnit: { - ...transaction?.comment?.customUnit, + ...transaction.comment?.customUnit, subRates: newSubrates, quantity: null, }, }, }; - Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction?.transactionID}`, newTransaction); + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction); } function updateSubrate(transaction: OnyxEntry, currentIndex: string, quantity: number, id: string, name: string, rate: number) { + if (!transaction) { + return; + } + // Index comes from the route params and is a string const index = Number(currentIndex); if (index === -1) { return; } - const existingSubrates = transaction?.comment?.customUnit?.subRates ?? []; + const existingSubrates = transaction.comment?.customUnit?.subRates ?? []; if (index >= existingSubrates.length) { return; @@ -120,19 +127,18 @@ function updateSubrate(transaction: OnyxEntry, currentInd // to remove nested keys while also preserving other object keys // Doing a deep clone of the transaction to avoid mutating the original object and running into a cache issue when using Onyx.set const newTransaction: OnyxTypes.Transaction = { - // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - ...(transaction as OnyxTypes.Transaction), + ...transaction, comment: { - ...transaction?.comment, + ...transaction.comment, customUnit: { - ...transaction?.comment?.customUnit, + ...transaction.comment?.customUnit, subRates: newSubrates, quantity: null, }, }, }; - Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction?.transactionID}`, newTransaction); + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction); } function clearSubrates(transactionID: string) { @@ -140,12 +146,16 @@ function clearSubrates(transactionID: string) { } function addSubrate(transaction: OnyxEntry, currentIndex: string, quantity: number, id: string, name: string, rate: number) { + if (!transaction) { + return; + } + // Index comes from the route params and is a string const index = Number(currentIndex); if (index === -1) { return; } - const existingSubrates = transaction?.comment?.customUnit?.subRates ?? []; + const existingSubrates = transaction.comment?.customUnit?.subRates ?? []; if (index !== existingSubrates.length) { return; @@ -158,19 +168,18 @@ function addSubrate(transaction: OnyxEntry, currentIndex: // to remove nested keys while also preserving other object keys // Doing a deep clone of the transaction to avoid mutating the original object and running into a cache issue when using Onyx.set const newTransaction: OnyxTypes.Transaction = { - // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - ...(transaction as OnyxTypes.Transaction), + ...transaction, comment: { - ...transaction?.comment, + ...transaction.comment, customUnit: { - ...transaction?.comment?.customUnit, + ...transaction.comment?.customUnit, subRates: newSubrates, quantity: null, }, }, }; - Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction?.transactionID}`, newTransaction); + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction); } function computePerDiemExpenseAmount(customUnit: TransactionCustomUnit) { @@ -367,7 +376,7 @@ function getPerDiemExpensePolicyID({ const allReports = getAllReports(); const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); - const parentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report?.chatReportID) : report; + const parentChatReport = getChatReportFromMoneyRequestReport(report); const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; const parentChatReportCandidate = parentChatReport?.reportID ? parentChatReport : null; @@ -643,7 +652,6 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI [payerAccountID]: { accountID: payerAccountID, // Disabling this line since participant.displayName can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing displayName: formatPhoneNumber(participant.displayName || payerEmail), login: participant.login, isOptimisticPersonalDetail: true, @@ -1103,7 +1111,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf // If the report is iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); - const currentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report?.chatReportID) : report; + const currentChatReport = getChatReportFromMoneyRequestReport(report); const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; const { diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 8c092881822e..2225dab6fa7c 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -41,7 +41,6 @@ import { getReportTransactions, getUnheldReimbursableTotal, hasHeldExpenses as hasHeldExpensesReportUtils, - hasOnlyHeldExpenses, hasOutstandingChildRequest, } from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; @@ -91,6 +90,7 @@ type ApproveMoneyRequestFunctionParams = { type SubmitReportFunctionParams = { expenseReport: OnyxEntry; + expenseReportTransactions: OnyxTypes.Transaction[]; policy: OnyxEntry; rules: OnyxCollection; currentUserAccountIDParam: number; @@ -993,6 +993,7 @@ function unapproveExpenseReport( function submitReport({ expenseReport, + expenseReportTransactions, policy, rules, currentUserAccountIDParam, @@ -1038,11 +1039,10 @@ function submitReport({ const isCurrentUserManager = currentUserAccountIDParam === managerID; // unheldTotal already uses the same sign convention as total, so it can be used directly here without conversion. - const reportTransactions = getReportTransactions(expenseReport.reportID); - const heldTransactions = reportTransactions.filter((transaction) => isOnHold(transaction)); + const heldTransactions = expenseReportTransactions.filter((transaction) => isOnHold(transaction)); const hasHeldExpenses = heldTransactions.length > 0; - if (hasOnlyHeldExpenses(reportTransactions)) { + if (hasHeldExpenses && heldTransactions.length === expenseReportTransactions.length) { return; } diff --git a/src/libs/actions/IOU/SearchUpdate.ts b/src/libs/actions/IOU/SearchUpdate.ts index 02cd1c9f9df6..6a971aeacd3c 100644 --- a/src/libs/actions/IOU/SearchUpdate.ts +++ b/src/libs/actions/IOU/SearchUpdate.ts @@ -91,7 +91,6 @@ function shouldOptimisticallyUpdateSearch( const validSearchTypes = (!isInvoice && currentSearchQueryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE) || - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing (isInvoice && currentSearchQueryJSON.type === CONST.SEARCH.DATA_TYPES.INVOICE) || (iouReport?.type === CONST.REPORT.TYPE.EXPENSE && currentSearchQueryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT); diff --git a/src/libs/actions/IOU/SendMoney.ts b/src/libs/actions/IOU/SendMoney.ts index ac07f8b64758..5d1c20db3be4 100644 --- a/src/libs/actions/IOU/SendMoney.ts +++ b/src/libs/actions/IOU/SendMoney.ts @@ -274,7 +274,6 @@ function getSendMoneyParams({ [recipientAccountID]: { accountID: recipientAccountID, // Disabling this line since participant.displayName can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing displayName: recipient.displayName || recipient.login, login: recipient.login, }, diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 8ef1c03fa83e..f377d1510851 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -9,6 +9,7 @@ import type {WriteReadyBarrier} from '@libs/API'; import type {CompleteSplitBillParams, CreateDistanceRequestParams, SplitBillParams, StartSplitBillParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {calculateAmount as calculateIOUAmount, updateIOUOwnerAndTotal} from '@libs/IOUUtils'; import * as Localize from '@libs/Localize'; @@ -28,11 +29,10 @@ import { buildOptimisticReportPreview, generateReportID, getChatByParticipants, + getChatReportFromMoneyRequestReport, getParsedComment, getReimbursableTotal, getReportNotificationPreference, - getReportOrDraftReport, - getReportTransactions, getTransactionDetails, getUnheldReimbursableTotal, hasViolations as hasViolationsReportUtils, @@ -51,11 +51,13 @@ import { getUpdatedTransaction, isDistanceExpenseType, isDistanceRequest as isDistanceRequestTransactionUtils, + isMapDistanceRequest, isScanRequest as isScanRequestTransactionUtils, } from '@libs/TransactionUtils'; import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; import {notifyNewAction} from '@userActions/Report/reportActionSubscribers'; +import {getLocallyCreatedRouteOnyxData} from '@userActions/ReusableDistanceRoutes'; import {sanitizeWaypointsForAPI} from '@userActions/Transaction'; import CONST from '@src/CONST'; @@ -162,6 +164,10 @@ type CreateDistanceRequestInformation = { writeBarrier?: WriteReadyBarrier; rules: OnyxCollection; isVendorMatchingBetaEnabled: boolean | undefined; + moneyRequestReportTransactions: OnyxTypes.Transaction[]; + + /** Current "Reuse route" list value, so the just used route can be saved locally */ + reusableDistanceRoutes?: OnyxTypes.ReusableDistanceRoute[]; }; type CreateSplitsTransactionParams = BaseTransactionParams & { @@ -778,7 +784,6 @@ function startSplitBill({ for (const participant of participants) { // Disabling this line since participant.login can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const email = participant.isOwnPolicyExpenseChat ? '' : addSMSDomainIfPhoneNumber(participant.login || participant.text || '').toLowerCase(); const accountID = participant.isOwnPolicyExpenseChat ? 0 : Number(participant.accountID); if (email === currentUserEmailForIOUSplit) { @@ -801,10 +806,8 @@ function startSplitBill({ [accountID]: { accountID, // Disabling this line since participant.displayName can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing displayName: formatPhoneNumber(participant.displayName || email), // Disabling this line since participant.login can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing login: participant.login || participant.text, isOptimisticPersonalDetail: true, }, @@ -1912,7 +1915,6 @@ function createSplitsAndOnyxData({ [accountID]: { accountID, // Disabling this line since participant.displayName can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing displayName: formatPhoneNumber(participant.displayName || email), login: participant.login, isOptimisticPersonalDetail: true, @@ -2078,6 +2080,8 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest writeBarrier, rules, isVendorMatchingBetaEnabled, + moneyRequestReportTransactions, + reusableDistanceRoutes, } = distanceRequestInformation; const {policy, policyCategories, policyTagList, policyRecentlyUsedCategories, policyRecentlyUsedTags} = policyParams; const parsedComment = getParsedComment(transactionParams.comment); @@ -2114,7 +2118,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest // If the report is an iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); - const currentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report?.chatReportID) : report; + const currentChatReport = getChatReportFromMoneyRequestReport(report); const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; const isManualDistanceRequest = isEmptyObject(validWaypoints); @@ -2343,6 +2347,32 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest }; } + const isMapDistance = + (distanceRequestType ? distanceRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP : isMapDistanceRequest(existingTransaction)) || + (!distanceRequestType && !gpsCoordinates && odometerStart === undefined && odometerEnd === undefined && !isManualDistanceRequest); + + if (isMapDistance && validWaypoints && reusableDistanceRoutes !== undefined) { + const distanceUnit = DistanceRequestUtils.getUpdatedDistanceUnit({transaction: existingTransaction, policy}); + const effectiveDistance = + modifiedDistance ?? + distance ?? + existingTransaction?.comment?.customUnit?.quantity ?? + (selectedRouteDistance ? Number(DistanceRequestUtils.convertDistanceUnit(selectedRouteDistance, distanceUnit).toFixed(CONST.DISTANCE_DECIMAL_PLACES)) : 0); + + const reusableRouteOnyxData = getLocallyCreatedRouteOnyxData( + { + transactionID: parameters.transactionID, + waypoints: validWaypoints, + distance: effectiveDistance, + routeDistanceMeters: selectedRouteDistance, + inserted: created ?? DateUtils.getDBTime(), + }, + reusableDistanceRoutes ?? [], + ); + onyxData?.optimisticData?.push(...reusableRouteOnyxData.optimisticData); + onyxData?.failureData?.push(...reusableRouteOnyxData.failureData); + } + if (previousOdometerDraft !== undefined && (odometerStart !== undefined || odometerEnd !== undefined)) { onyxData?.optimisticData?.push({ onyxMethod: Onyx.METHOD.SET, @@ -2369,7 +2399,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest const activeReportID = isMoneyRequestReport && report?.reportID ? report.reportID : parameters.chatReportID; - if (isOneToTwoTransactionTransition(isMoneyRequestReport, getReportTransactions(moneyRequestReportID))) { + if (isOneToTwoTransactionTransition(isMoneyRequestReport, moneyRequestReportTransactions)) { addPendingNewTransactionIDs(activeReportID, parameters.transactionID); } diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 3d58b5fe5a27..0de0c6be45da 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -187,6 +187,79 @@ function hasEditableSplitExpenseLeft( }); } +/** + * Whether the transaction sits in the given report and isn't already pending deletion. Transactions pending deletion + * don't keep a report alive. + */ +function isActiveTransactionInReport(transaction: OnyxEntry, reportID: string): boolean { + return transaction?.reportID === reportID && transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; +} + +/** + * Whether saving the split leaves the given expense report without any transactions: every transaction still active in + * it is this expense or one of its splits, and none of the saved splits stays in it. + */ +function willSplitUpdateEmptyExpenseReport( + expenseReportID: string | undefined, + originalTransactionID: string, + splitExpenses: SplitExpense[], + allTransactionsList: OnyxCollection, +): boolean { + if (!expenseReportID) { + return false; + } + const activeExpenseReportTransactions = Object.values(allTransactionsList ?? {}).filter((itemTransaction) => isActiveTransactionInReport(itemTransaction, expenseReportID)); + if (activeExpenseReportTransactions.length === 0) { + return false; + } + + // The original expense leaves the report too: it moves to the split report, or on a reverse split to the remaining split's report. + const isPartOfThisSplit = (itemTransaction: OnyxEntry) => + itemTransaction?.transactionID === originalTransactionID || itemTransaction?.comment?.originalTransactionID === originalTransactionID; + if (!activeExpenseReportTransactions.every(isPartOfThisSplit)) { + return false; + } + return !splitExpenses.some((splitExpense) => { + // A split with no known destination stays in the expense report, so treat it as keeping the report alive. + const splitDestinationReportID = splitExpense.reportID ?? allTransactionsList?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${splitExpense.transactionID}`]?.reportID; + return !splitDestinationReportID || splitDestinationReportID === expenseReportID; + }); +} + +/** + * IDs of the expense reports that saving the split leaves without any transactions: the report the split flow was opened + * from, plus every other expense report a split is removed from or moved out of. + */ +function getExpenseReportIDsEmptiedBySplitUpdate( + expenseReportID: string | undefined, + originalTransactionID: string, + splitExpenses: SplitExpense[], + allTransactionsList: OnyxCollection, + allReportsList: OnyxCollection, +): string[] { + const candidateReportIDs = new Set(); + if (expenseReportID) { + candidateReportIDs.add(expenseReportID); + } + for (const childTransaction of getChildTransactions(allTransactionsList, originalTransactionID)) { + const sourceReportID = childTransaction?.reportID; + if (!sourceReportID) { + continue; + } + const savedSplit = splitExpenses.find((splitExpense) => splitExpense.transactionID === childTransaction?.transactionID); + const isLeavingSourceReport = !savedSplit || (!!savedSplit.reportID && savedSplit.reportID !== sourceReportID); + if (isLeavingSourceReport) { + candidateReportIDs.add(sourceReportID); + } + } + + return [...candidateReportIDs].filter((reportID) => { + // Only expense reports are removed, never the selfDM or a chat an unreported split lives in. + const isExpenseReport = reportID === expenseReportID || allReportsList?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.type === CONST.REPORT.TYPE.EXPENSE; + return isExpenseReport && willSplitUpdateEmptyExpenseReport(reportID, originalTransactionID, splitExpenses, allTransactionsList); + }); +} + function updateSplitTransactions({ allTransactionsList, allReportsList, @@ -426,7 +499,7 @@ function updateSplitTransactions({ const expenseReportNameValuePairs = allReportNameValuePairsList?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${expenseReport?.reportID}`]; const isArchivedExpenseReport = isArchivedReport(expenseReportNameValuePairs); const canUserPerformWriteAction = chatReport ? !!canUserPerformWriteActionReportUtils(chatReport, isArchivedExpenseReport) : true; - const lastVisibleAction = getLastVisibleAction(expenseReport?.reportID, canUserPerformWriteAction); + const lastVisibleAction = getLastVisibleAction(expenseReport?.reportID, canUserPerformWriteAction, {}, undefined, undefined, currentUserPersonalDetails.accountID); const isTransactionOnHold = isOnHold(originalTransaction); const holdReportAction = getReportAction(firstIOU?.childReportID, `${originalTransaction?.comment?.hold ?? ''}`); @@ -533,6 +606,64 @@ function updateSplitTransactions({ childOldestFourAccountIDs, }; }; + + // Optimistically removes an expense report, together with its report preview, once none of its transactions are left in it. + const pushExpenseReportDeletionToOnyxData = (reportID: string) => { + const reportToDelete = reportID === expenseReportID ? expenseReport : allReportsList?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + reportID: null, + pendingFields: { + preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }, + }, + }); + onyxData.successData?.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: null, + }); + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + reportID, + pendingFields: null, + }, + }); + if (reportToDelete?.parentReportID && reportToDelete?.parentReportActionID) { + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToDelete?.parentReportID}`, + value: { + [reportToDelete?.parentReportActionID]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }, + }, + }); + onyxData.successData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToDelete?.parentReportID}`, + value: { + [reportToDelete?.parentReportActionID]: { + pendingAction: null, + }, + }, + }); + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToDelete?.parentReportID}`, + value: { + [reportToDelete?.parentReportActionID]: { + pendingAction: null, + }, + }, + }); + } + }; const pushUpdatedReportPreviewActionToOnyxData = () => { if (!updatedReportPreviewAction) { return; @@ -944,7 +1075,10 @@ function updateSplitTransactions({ // For confirmed workspace transactions viewed from selfDM context, we must use the // real workspace reports instead of splitExpense.reportID (which points to selfDM). const existingTransactionThreadID = isConfirmedWorkspaceTransaction ? currentReportAction?.childReportID : undefined; - const workspaceExpenseReportID = isConfirmedWorkspaceTransaction ? (workspaceChatReport?.iouReportID ?? splitTransaction?.reportID) : undefined; + + // The split stays on its own report. The workspace chat's iouReportID is only its latest open report, which can + // belong to another split, so preferring it moved this split there whenever its amount changed. + const workspaceExpenseReportID = isConfirmedWorkspaceTransaction ? (splitTransaction?.reportID ?? workspaceChatReport?.iouReportID) : undefined; const transactionThreadReportKey = isReverseSplitOperation ? splitExpense?.reportID : (existingTransactionThreadID ?? transactionThreadReportID); const transactionThreadReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportKey}`]; const iouReportID = workspaceExpenseReportID ?? splitExpense?.reportID ?? transactionThreadReport?.parentReportID; @@ -1947,67 +2081,6 @@ function updateSplitTransactions({ }); pushUpdatedReportPreviewActionToOnyxData(); - // Skip only when the reverse split's restored transaction stays in expenseReportID — that - // report isn't becoming empty. If the surviving split lives in a different report (e.g. it - // was moved elsewhere), expenseReportID can still genuinely lose its last transaction. - const reverseSplitKeepsOriginalInThisReport = isReverseSplitOperation && splitExpenses.at(0)?.reportID === expenseReportID; - const isLastTransactionInReport = - !reverseSplitKeepsOriginalInThisReport && Object.values(allTransactionsList ?? {}).filter((itemTransaction) => itemTransaction?.reportID === expenseReportID).length === 1; - if (isLastTransactionInReport && expenseReportID) { - onyxData.optimisticData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, - value: { - reportID: null, - pendingFields: { - preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - }, - }, - }); - onyxData.successData?.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, - value: null, - }); - onyxData.failureData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, - value: { - reportID: expenseReportID, - pendingFields: null, - }, - }); - if (expenseReport?.parentReportID && expenseReport?.parentReportActionID) { - onyxData.optimisticData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.parentReportID}`, - value: { - [expenseReport?.parentReportActionID]: { - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - }, - }, - }); - onyxData.successData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.parentReportID}`, - value: { - [expenseReport?.parentReportActionID]: { - pendingAction: null, - }, - }, - }); - onyxData.failureData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.parentReportID}`, - value: { - [expenseReport?.parentReportActionID]: { - pendingAction: null, - }, - }, - }); - } - } - const originalTransactionThreadReportID = splits.at(0)?.transactionThreadReportID; if (splitThreadComments.length > 0 && originalTransactionThreadReportID && splitTransactionThreadReportID) { const optimisticMovedComments: Record = {}; @@ -2062,6 +2135,16 @@ function updateSplitTransactions({ } } + // Removing every split that lived in an expense report empties it, both for a regular update and a reverse split. Without + // this the report and its preview stay behind holding only pending-delete splits, and opening it shows the Not Found page. + // Pushed after both branches so the updated report preview action above can't overwrite the preview's pending deletion. + const emptiedExpenseReportIDs = isCreationOfSplits + ? [] + : getExpenseReportIDsEmptiedBySplitUpdate(expenseReportID, originalTransactionID, splitExpenses, allTransactionsList, allReportsList); + for (const emptiedExpenseReportID of emptiedExpenseReportIDs) { + pushExpenseReportDeletionToOnyxData(emptiedExpenseReportID); + } + if (!isCreationOfSplits && !isReverseSplitOperation) { const splitUpdateFailureDataKeys: Array<`${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}` | `${typeof ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${string}`> = [ `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, @@ -2175,5 +2258,5 @@ function updateSplitTransactions({ TransitionTracker.runAfterTransitions({callback: () => removeDraftSplitTransaction(originalTransactionID), waitForUpcomingTransition: true}); } -export {hasEditableSplitExpenseLeft, updateSplitTransactions}; +export {getExpenseReportIDsEmptiedBySplitUpdate, hasEditableSplitExpenseLeft, updateSplitTransactions, willSplitUpdateEmptyExpenseReport}; export type {UpdateSplitTransactionsParams}; diff --git a/src/libs/actions/IOU/StartExpenseFlows.ts b/src/libs/actions/IOU/StartExpenseFlows.ts index 01b7c63749af..10db3a9329c4 100644 --- a/src/libs/actions/IOU/StartExpenseFlows.ts +++ b/src/libs/actions/IOU/StartExpenseFlows.ts @@ -183,17 +183,22 @@ function createDraftWorkspaceAndNavigateToConfirmationScreen( }, ]); setMoneyRequestReportID(transactionID, expenseChatReportID); - if (isCategorizing) { - Navigation.navigate( - createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({action: actionName, iouType: CONST.IOU.TYPE.SUBMIT, transactionID, reportID: expenseChatReportID})), - ); - } else { + // The participants and reportID above are written with Onyx.merge, which lands a microtask after this press handler. + // Navigating synchronously would render the confirmation screen before the merge, so it would see no participants and + // auto-open the Choose recipient picker. Defer navigation by a microtask plus an animation frame so the merge is applied first. + Navigation.setNavigationActionToMicrotaskQueue(() => { + if (isCategorizing) { + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({action: actionName, iouType: CONST.IOU.TYPE.SUBMIT, transactionID, reportID: expenseChatReportID})), + ); + return; + } // The confirmation route needs a `:reportID`, so it points at the (draft) workspace expense chat to resolve the // draft policy/destination. That report only lives in REPORT_DRAFT. Send back to the origin report (e.g. the // self-DM the track expense came from) so navigateBack returns there instead of the draft report's start step. const backTo = backToReportID ? ROUTES.REPORT_WITH_ID.getRoute(backToReportID) : undefined; Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(actionName, CONST.IOU.TYPE.SUBMIT, transactionID, expenseChatReportID, undefined, true, backTo)); - } + }); } type CreateDraftTransactionParams = { diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index b65ab435e85c..00d0293d6006 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -48,6 +48,7 @@ import { canUserPerformWriteAction as canUserPerformWriteActionReportUtils, findSelfDMReportID, generateReportID, + getChatReportFromMoneyRequestReport, getDefaultNotificationPreferenceForReport, getParsedComment, getReportOrDraftReport, @@ -87,6 +88,7 @@ import type {BuildPolicyDataKeys} from '@userActions/Policy/Policy'; import type {GuidedSetupData} from '@userActions/Report'; import {buildInviteToRoomOnyxData} from '@userActions/Report'; import {notifyNewAction} from '@userActions/Report/reportActionSubscribers'; +import {getLocallyCreatedRouteOnyxData} from '@userActions/ReusableDistanceRoutes'; import {stringifyWaypointsForAPI} from '@userActions/Transaction'; import {getOnboardingMessages} from '@userActions/Welcome/OnboardingFlow'; @@ -114,7 +116,7 @@ import type {BuildOnyxDataForMoneyRequestKeys, RequestMoneyInformation} from './ import type {ReplaceReceiptRetryParams} from './Receipt'; import type {StartSplitBilActionParams} from './Split'; import type BasePolicyParams from './types/BasePolicyParams'; -import type {CreateTrackExpenseParams} from './types/CreateTrackExpenseParams'; +import type {CreateTrackExpenseParams, TrackExpenseRetryParams} from './types/CreateTrackExpenseParams'; import type { BuildOnyxDataForTrackExpenseKeys, TrackedExpenseParams, @@ -192,6 +194,10 @@ type GetTrackExpenseInformationParams = { existingTransaction?: OnyxEntry; existingTransactionID?: string; optimisticTransactionID?: string; + currentReportActionID?: string; + existingTransactionThreadReportID?: string; + existingTransactionThreadCreatedReportActionID?: string; + existingActionableWhisperReportActionID?: string; participantParams: GetTrackExpenseInformationParticipantParams; policyParams: BasePolicyParams; transactionParams: GetTrackExpenseInformationTransactionParams; @@ -426,6 +432,7 @@ function buildOnyxDataForTrackExpense({ value: { ...transactionThreadReport, pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + errorFields: {createChat: null}, }, }, { @@ -552,6 +559,10 @@ function buildOnyxDataForTrackExpense({ value: quickAction ?? null, }); + // The failure deletes the whisper, so its ID goes with the receipt error. A retry reuses it instead of adding a second whisper. + const receiptErrorRetryParams: TrackExpenseRetryParams | undefined = + actionableTrackExpenseWhisper && !iouReport ? {actionableWhisperReportActionID: actionableTrackExpenseWhisper.reportActionID} : undefined; + if (iouReport) { onyxData.failureData?.push( { @@ -591,7 +602,7 @@ function buildOnyxDataForTrackExpense({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, value: { [iouAction.reportActionID]: { - errors: getReceiptError(transaction.receipt, transaction.receipt?.filename, isScanRequest, undefined, CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE), + errors: getReceiptError(transaction.receipt, transaction.receipt?.filename, isScanRequest, undefined, CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE, receiptErrorRetryParams), }, }, }); @@ -623,7 +634,7 @@ function buildOnyxDataForTrackExpense({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: { - errors: getReceiptError(transaction.receipt, transaction.receipt?.filename, isScanRequest, undefined, CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE), + errors: getReceiptError(transaction.receipt, transaction.receipt?.filename, isScanRequest, undefined, CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE, receiptErrorRetryParams), pendingFields: clearedPendingFields, }, }, @@ -881,6 +892,10 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T existingTransaction, existingTransactionID, optimisticTransactionID: providedOptimisticTransactionID, + currentReportActionID, + existingTransactionThreadReportID, + existingTransactionThreadCreatedReportActionID, + existingActionableWhisperReportActionID, participantParams, policyParams, transactionParams, @@ -1167,7 +1182,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T // 2. IOU action for the iouReport (if tracking in the Expense chat), otherwise – for chatReport // 3. The transaction thread, which requires the iouAction, and CREATED action for the transaction thread // 4. REPORT_PREVIEW action for the chatReport (if tracking in the Expense chat) - const [, optimisticCreatedActionForIOUReport, iouAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = buildOptimisticMoneyRequestEntities({ + const [, optimisticCreatedActionForIOUReport, iouAction, optimisticTransactionThread, newCreatedActionForTransactionThread] = buildOptimisticMoneyRequestEntities({ getCurrencyDecimals, iouReport: shouldUseMoneyReport && iouReport ? iouReport : chatReport, type: CONST.IOU.REPORT_ACTION_TYPE.TRACK, @@ -1178,12 +1193,18 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T participants: [participant], transactionID: optimisticTransaction.transactionID, isPersonalTrackingExpense: !shouldUseMoneyReport, - existingTransactionThreadReportID: linkedTrackedExpenseReportAction?.childReportID, + existingTransactionThreadReportID: existingTransactionThreadReportID ?? linkedTrackedExpenseReportAction?.childReportID, linkedTrackedExpenseReportAction, + reportActionID: currentReportActionID, currentUserAccountID: currentUserAccountIDParam, delegateAccountIDParam: delegateAccountID, }); + const optimisticCreatedActionForTransactionThread = + existingTransactionThreadReportID && existingTransactionThreadCreatedReportActionID + ? buildOptimisticCreatedReportAction({emailCreatingAction: payeeEmail, optimisticReportActionID: existingTransactionThreadCreatedReportActionID}) + : newCreatedActionForTransactionThread; + let reportPreviewAction: OnyxInputValue> = null; if (shouldUseMoneyReport && iouReport) { reportPreviewAction = shouldCreateNewMoneyRequestReport ? null : getReportPreviewReportAction(chatReport.reportID, iouReport.reportID); @@ -1200,7 +1221,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T let actionableTrackExpenseWhisper: OnyxInputValue = null; if (!isPolicyExpenseChat) { - actionableTrackExpenseWhisper = buildOptimisticActionableTrackExpenseWhisper(iouAction, optimisticTransaction.transactionID); + actionableTrackExpenseWhisper = buildOptimisticActionableTrackExpenseWhisper(iouAction, optimisticTransaction.transactionID, existingActionableWhisperReportActionID); } // STEP 5: Build Onyx Data @@ -1214,6 +1235,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T threadReport: optimisticTransactionThread ?? {}, }, shouldCreateNewMoneyRequestReport, + existingTransactionThreadReportID, actionableTrackExpenseWhisper, retryParams, isASAPSubmitBetaEnabled, @@ -1739,7 +1761,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep // If the report is iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); - const currentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report?.chatReportID) : report; + const currentChatReport = getChatReportFromMoneyRequestReport(report); const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; const isMovingTransactionFromTrackExpense = isMovingTransactionFromTrackExpenseIOUUtils(action); const existingTransactionID = existingTransactionDraft?.transactionID; @@ -2490,6 +2512,10 @@ function trackExpense(params: CreateTrackExpenseParams) { shouldPlaySound = true, optimisticChatReportID, optimisticTransactionID, + currentReportActionID, + existingTransactionThreadReportID, + existingTransactionThreadCreatedReportActionID, + existingActionableWhisperReportActionID, isASAPSubmitBetaEnabled, currentUser, introSelected, @@ -2508,6 +2534,7 @@ function trackExpense(params: CreateTrackExpenseParams) { writeBarrier, rules, personalDetailsByLogins, + reusableDistanceRoutes, } = params; const {accountID: currentUserAccountIDParam, email: currentUserEmailParam = ''} = currentUser; const {participant, payeeAccountID, payeeEmail} = participantParams; @@ -2546,7 +2573,7 @@ function trackExpense(params: CreateTrackExpenseParams) { selectedRouteDistance, } = transactionData; const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); - const currentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report?.chatReportID) : report; + const currentChatReport = getChatReportFromMoneyRequestReport(report); const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; const isMovingTransactionFromTrackExpense = isMovingTransactionFromTrackExpenseIOUUtils(action); @@ -2627,6 +2654,10 @@ function trackExpense(params: CreateTrackExpenseParams) { moneyRequestReportID, existingTransaction, optimisticTransactionID, + currentReportActionID, + existingTransactionThreadReportID, + existingTransactionThreadCreatedReportActionID, + existingActionableWhisperReportActionID, existingTransactionID: isMovingTransactionFromTrackExpense && linkedTrackedExpenseReportAction && isMoneyRequestAction(linkedTrackedExpenseReportAction) ? getOriginalMessage(linkedTrackedExpenseReportAction)?.IOUTransactionID @@ -2953,6 +2984,32 @@ function trackExpense(params: CreateTrackExpenseParams) { parameters.actionableWhisperReportActionID = actionableWhisperReportActionIDParam; } + const isMapDistance = + (distanceRequestType ? distanceRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP : isMapDistanceRequest(existingTransaction)) || + (!distanceRequestType && !gpsCoordinates && odometerStart === undefined && odometerEnd === undefined); + + if (isMapDistance && validWaypoints && transaction?.transactionID && reusableDistanceRoutes !== undefined) { + const distanceUnit = DistanceRequestUtils.getUpdatedDistanceUnit({transaction: existingTransaction, policy}); + const effectiveDistance = + modifiedDistance ?? + distance ?? + existingTransaction?.comment?.customUnit?.quantity ?? + (selectedRouteDistance ? Number(DistanceRequestUtils.convertDistanceUnit(selectedRouteDistance, distanceUnit).toFixed(CONST.DISTANCE_DECIMAL_PLACES)) : 0); + + const reusableRouteOnyxData = getLocallyCreatedRouteOnyxData( + { + transactionID: transaction.transactionID, + waypoints: validWaypoints, + distance: effectiveDistance, + routeDistanceMeters: selectedRouteDistance, + inserted: created ?? DateUtils.getDBTime(), + }, + reusableDistanceRoutes ?? [], + ); + onyxData?.optimisticData?.push(...reusableRouteOnyxData.optimisticData); + onyxData?.failureData?.push(...reusableRouteOnyxData.failureData); + } + API.writeWhenReady( WRITE_COMMANDS.TRACK_EXPENSE, parameters, diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 81152ff8065a..121d2995f934 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -864,7 +864,7 @@ function updateMoneyRequestVendor({ } // Optimistically clear any existing inactive-vendor violation. This is the user-driven write - // path: the vendor selector RHP only offers vendors from `getMatchingVendors(policy)`, so a user + // path: the vendor selector RHP only offers vendors that are enabled on the policy, so a user // pick is always a valid vendor (resolving the violation); clearing the vendor likewise // resolves it (no vendor → no inactive-vendor). Without this, the stale violation persists // in Onyx until some unrelated recalculation fires, keeping the expense incorrectly flagged. @@ -1456,6 +1456,7 @@ function updateMoneyRequestDescription({ getCurrencySymbol, rules, isVendorMatchingBetaEnabled, + policyVendors, }: { isVendorMatchingBetaEnabled: boolean | undefined; transactionID: string; @@ -1469,6 +1470,7 @@ function updateMoneyRequestDescription({ policy: OnyxEntry; policyTagList: OnyxEntry; policyCategories: OnyxEntry; + policyVendors?: OnyxEntry; currentUserAccountIDParam: number; currentUserEmailParam: string; isASAPSubmitBetaEnabled: boolean; @@ -1511,6 +1513,7 @@ function updateMoneyRequestDescription({ policyTagList, reportPolicyTags, policyCategories, + policyVendors, currentUserAccountIDParam, currentUserEmailParam, isASAPSubmitBetaEnabled, @@ -1830,6 +1833,7 @@ type GetUpdateMoneyRequestParamsType = { getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; rules: OnyxCollection; isVendorMatchingBetaEnabled: boolean | undefined; + policyVendors?: OnyxEntry; }; type UpdateMoneyRequestDataKeys = @@ -1883,6 +1887,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U getCurrencySymbol, rules, isVendorMatchingBetaEnabled, + policyVendors: policyVendorsParam, } = params; const optimisticData: Array< OnyxUpdate< @@ -2286,7 +2291,6 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U value: lodashUnionBy( transactionChanges.attendees?.map(({avatarUrl, displayName, email}) => ({avatarUrl, displayName, ...(email ? {email} : {})})) ?? [], getRecentAttendees(), - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing (attendee) => attendee.email || attendee.displayName, ).slice(0, CONST.IOU.MAX_RECENT_ATTENDEES), }); @@ -2407,6 +2411,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U isFromExpenseReport, distanceOriginalPolicy, isVendorMatchingBetaEnabled, + policyVendors: policyVendorsParam, }); optimisticData.push(violationsOnyxData); failureData.push({ diff --git a/src/libs/actions/IOU/resolveWriteBarrier.ts b/src/libs/actions/IOU/resolveWriteBarrier.ts index 4466b4e63bcf..24a9f8fd4526 100644 --- a/src/libs/actions/IOU/resolveWriteBarrier.ts +++ b/src/libs/actions/IOU/resolveWriteBarrier.ts @@ -22,7 +22,10 @@ const IMMEDIATE: WriteReadyBarrier = () => Promise.resolve(); /** Barriers that resolve immediately. Writes behind them are not counted as deferred in telemetry. */ const immediateBarriers = new WeakSet([IMMEDIATE]); -/** Use for a barrier that wraps `IMMEDIATE` and only adds a callback, so its write is not counted as deferred. */ +/** + * Use for a barrier that wraps `IMMEDIATE` and only adds a callback, so its write is not counted as deferred. + * Submit-expense flow only, other deferred writes should use the primitives in DEFER_AFTER_PAINT.md. + */ function markBarrierAsImmediate(barrier: TBarrier): TBarrier { immediateBarriers.add(barrier); return barrier; @@ -46,7 +49,8 @@ type ResolveWriteBarrierParams = { * Picks what a submit write waits on before applying its optimistic data: an explicit barrier from the * caller, Search's pending-write signal, or neither. Callers pass their own barrier or nothing; whether * the write also has to wait on Search's signal is decided here. Records the deferral for the - * submit-expense telemetry span. + * submit-expense telemetry span. Submit-expense flow only, other deferred writes should use the primitives in + * DEFER_AFTER_PAINT.md. */ function resolveWriteBarrier({writeBarrier, optimisticWatchKey, isRetry = false}: ResolveWriteBarrierParams = {}): WriteReadyBarrier { if (writeBarrier) { diff --git a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts index 995614776d6a..15480269d0fc 100644 --- a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts +++ b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts @@ -39,6 +39,10 @@ type CreateTrackExpenseParams = { draftTransactionIDs?: string[]; optimisticChatReportID?: string; optimisticTransactionID?: string; + currentReportActionID?: string; + existingTransactionThreadReportID?: string; + existingTransactionThreadCreatedReportActionID?: string; + existingActionableWhisperReportActionID?: string; isASAPSubmitBetaEnabled: boolean; currentUser: CurrentUser; introSelected: OnyxEntry; @@ -58,6 +62,14 @@ type CreateTrackExpenseParams = { writeBarrier?: WriteReadyBarrier; rules: OnyxCollection; personalDetailsByLogins?: PersonalDetailsByLogin; + + /** Current "Reuse route" list value, so the just used route can be saved locally */ + reusableDistanceRoutes?: OnyxTypes.ReusableDistanceRoute[]; +}; + +/** Saved with a failed track's receipt error, because the failure deletes the whisper and a retry needs its ID to rebuild it. */ +type TrackExpenseRetryParams = { + actionableWhisperReportActionID: string; }; -export type {CreateTrackExpenseParams, TrackExpenseAccountantParams}; +export type {CreateTrackExpenseParams, TrackExpenseAccountantParams, TrackExpenseRetryParams}; diff --git a/src/libs/actions/IOU/types/TrackedExpenseParams.ts b/src/libs/actions/IOU/types/TrackedExpenseParams.ts index 71ad91043fbf..fc20a2528c52 100644 --- a/src/libs/actions/IOU/types/TrackedExpenseParams.ts +++ b/src/libs/actions/IOU/types/TrackedExpenseParams.ts @@ -36,7 +36,8 @@ type BuildOnyxDataForTrackExpenseKeys = | typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE | typeof ONYXKEYS.COLLECTION.SNAPSHOT - | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS; + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.REUSABLE_DISTANCE_ROUTES; type TrackedExpenseTransactionParams = Omit & { waypoints?: string; diff --git a/src/libs/actions/ImportSpreadsheet.ts b/src/libs/actions/ImportSpreadsheet.ts index 918968161781..2e1e10ebb9b9 100644 --- a/src/libs/actions/ImportSpreadsheet.ts +++ b/src/libs/actions/ImportSpreadsheet.ts @@ -19,6 +19,7 @@ function setSpreadsheetData( fileName: string, isImportingMultiLevelTags: boolean, importTransactionSettings?: ImportTransactionSettings, + isAppendingToExistingLists = false, ): Promise { // Validate that data is a non-empty array if (!Array.isArray(data) || data.length === 0) { @@ -55,6 +56,7 @@ function setSpreadsheetData( fileType, fileName, isImportingMultiLevelTags, + isAppendingToExistingLists, // Preserve transaction import settings that were configured before file upload importTransactionSettings, containsHeader: true, diff --git a/src/libs/actions/InputFocus/index.ts b/src/libs/actions/InputFocus/index.ts index 658ac2977d78..86a335b1c47c 100644 --- a/src/libs/actions/InputFocus/index.ts +++ b/src/libs/actions/InputFocus/index.ts @@ -1,8 +1,10 @@ +import type {ComposerRef} from '@components/Composer/types'; + import type {Modal} from '@src/types/onyx'; // eslint-disable-next-line @typescript-eslint/no-unused-vars function inputFocusChange(focus: boolean) {} // eslint-disable-next-line @typescript-eslint/no-unused-vars -function composerFocusKeepFocusOn(ref: HTMLElement, isFocused: boolean, modal: Modal, onyxFocused: boolean) {} +function composerFocusKeepFocusOn(ref: Pick | null, isFocused: boolean, modal: Modal, onyxFocused: boolean) {} export {composerFocusKeepFocusOn, inputFocusChange}; diff --git a/src/libs/actions/InputFocus/index.web.ts b/src/libs/actions/InputFocus/index.web.ts index c3010a15909e..aae5246c8772 100644 --- a/src/libs/actions/InputFocus/index.web.ts +++ b/src/libs/actions/InputFocus/index.web.ts @@ -1,3 +1,5 @@ +import type {ComposerRef} from '@components/Composer/types'; + import * as Browser from '@libs/Browser'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; @@ -11,11 +13,11 @@ function inputFocusChange(focus: boolean) { Onyx.set(ONYXKEYS.INPUT_FOCUSED, focus); } -let refSave: HTMLElement | undefined; -function composerFocusKeepFocusOn(ref: HTMLElement, isFocused: boolean, modal: Modal, onyxFocused: boolean) { +let refSave: Pick | null | undefined; +function composerFocusKeepFocusOn(ref: Pick | null, isFocused: boolean, modal: Modal, onyxFocused: boolean) { if (isFocused && !onyxFocused) { inputFocusChange(true); - ref.focus(); + ref?.focus(); } if (isFocused) { refSave = ref; diff --git a/src/libs/actions/Insights.ts b/src/libs/actions/Insights.ts index 785bd6edfc72..69ce36a5b6b5 100644 --- a/src/libs/actions/Insights.ts +++ b/src/libs/actions/Insights.ts @@ -1,5 +1,7 @@ -import {makeRequestWithSideEffects, waitForWrites} from '@libs/API'; -import {READ_COMMANDS} from '@libs/API/types'; +import type {SearchQueryString} from '@components/Search/types'; + +import {makeRequestWithSideEffects, waitForWrites, write} from '@libs/API'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import Log from '@libs/Log'; @@ -71,8 +73,21 @@ function getInsights(dashboard: InsightsDashboardID, hash: number, jsonQuery: st ); } -function setInsightsFilters(searchKey: InsightsSearchKey, query: string) { +function setInsightsFilters(searchKey: InsightsSearchKey, query: SearchQueryString) { Onyx.merge(ONYXKEYS.SEARCH_FILTERS, {[searchKey]: {query}}); } -export {getInsights, setInsightsFilters}; +/** Stores the dashboard's filters locally and on the server, for changes that don't trigger a GetInsights call to save them */ +function saveInsightsFilters(searchKey: InsightsSearchKey, query: SearchQueryString) { + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SEARCH_FILTERS, + value: {[searchKey]: {query}}, + }, + ]; + + write(WRITE_COMMANDS.SAVE_SEARCH_FILTERS, {jsonQuery: JSON.stringify({searchKey, inputQuery: query})}, {optimisticData}); +} + +export {getInsights, saveInsightsFilters, setInsightsFilters}; diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index bacb1ac64eff..8adfc8a4dfa2 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -6,6 +6,7 @@ import buildOldDotURL from '@libs/buildOldDotURL'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import isPublicScreenRoute from '@libs/isPublicScreenRoute'; import Log from '@libs/Log'; +import {setDeepLinkToOpenAfterOnboarding} from '@libs/navigateAfterOnboarding'; import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; import {hasJoinWorkspaceTaskParam} from '@libs/Navigation/helpers/isJoinWorkspaceTaskPath'; import {isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName'; @@ -433,6 +434,7 @@ function openReportFromDeepLink( conciergeReportID: string | undefined, introSelected: OnyxEntry, isSelfTourViewed: boolean | undefined, + hasCompletedGuidedSetupFlow: boolean | undefined, callerAccountID: number, reportNameValuePairs: OnyxCollection, conciergePersonalDetails: OnyxEntry, @@ -567,35 +569,10 @@ function openReportFromDeepLink( const state = navigationRef.getRootState(); const currentFocusedRoute = findFocusedRoute(state); - if (isOnboardingFlowName(currentFocusedRoute?.name)) { - if (hasCompletedGuidedSetupFlowSelector(val) === false) { - setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); - } - return; - } - - if (shouldSkipDeepLinkNavigation(route)) { - return; - } - - if (currentFocusedRoute?.name !== SCREENS.HOME && route === ROUTES.HOME) { - return; - } - - // Drop a deep link captured before onboarding finished: navigateAfterOnboarding owns the - // post-onboarding destination and overrides it anyway, so honoring it only risks the flash (#91437). - if (initialHasCompletedGuidedSetupFlow === false) { - return; - } - - // Navigation for signed users is handled by react-navigation. - if (isAuthenticated) { - return; - } + const deeplinkRoute = route as Route; const navigateHandler = (reportParam?: OnyxEntry) => { // Skip if the user already is in the deeplinked route. - const deeplinkRoute = route as Route; if (deeplinkRoute && Navigation.isActiveRoute(deeplinkRoute)) { return; } @@ -604,14 +581,22 @@ function openReportFromDeepLink( const report = reportParam ?? reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; // If the report does not exist, navigate to the last accessed report or Concierge chat if (reportID && (!report?.reportID || report.errorFields?.notFound)) { - // TODO: Pass guideAccountIDs once callers are fully migrated — PR 33 (https://github.com/Expensify/App/issues/66413); findLastAccessedReport falls back to hasExpensifyGuidesEmails → allPersonalDetails - const lastAccessedReportID = findLastAccessedReport(false, undefined, shouldOpenOnAdminRoom(), reportID, reportNameValuePairs)?.reportID; + // TODO: Pass guideAccountIDs in Phase B. findLastAccessedReport reaches hasExpensifyGuidesEmails, which falls back to the personal details store until then. See https://github.com/Expensify/App/issues/66413. + const lastAccessedReportID = findLastAccessedReport(false, undefined, isAnonymousUser(), shouldOpenOnAdminRoom(), reportID, reportNameValuePairs)?.reportID; if (lastAccessedReportID) { const lastAccessedReportRoute = ROUTES.REPORT_WITH_ID.getRoute(lastAccessedReportID); Navigation.navigate(lastAccessedReportRoute, {forceReplace: Navigation.getTopmostReportId() === reportID, waitForTransition: true}); return; } - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); + navigateToConciergeChat({ + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + shouldDismissModal: false, + }); return; } @@ -619,22 +604,96 @@ function openReportFromDeepLink( const forceReplace = navigationRef.getRootState().routes.at(-1)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; Navigation.navigate(deeplinkRoute, {forceReplace, waitForTransition: true}); }; - // If we log with deeplink with reportID and data for this report is not available yet, - // then we will wait for Onyx to completely merge data from OpenReport API with OpenApp API in AuthScreens - if (reportID && !isAuthenticated && !reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportID) { - const reportConnection = Onyx.connectWithoutView({ - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - // eslint-disable-next-line rulesdir/prefer-early-return - callback: (report) => { - if (report?.errorFields?.notFound || report?.reportID || (report === undefined && CONST.REGEX.NON_NUMERIC.test(reportID))) { + + const openDeepLink = () => { + // OpenApp and OpenReport may not have written this report yet, and navigateHandler would read the missing + // report as "does not exist" and send the user to the last accessed report or Concierge instead. + if (reportID && !isAuthenticated && !reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportID) { + const reportConnection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + callback: (report) => { + if (!report?.errorFields?.notFound && !report?.reportID && !(report === undefined && CONST.REGEX.NON_NUMERIC.test(reportID))) { + return; + } Onyx.disconnect(reportConnection); navigateHandler(report); - } - }, - }); - } else { + }, + }); + return; + } + navigateHandler(); + }; + + // Shared with the onboarding branch below, which sits above this check and would otherwise let a parked link + // replay past it. + const shouldDropDeepLink = () => shouldSkipDeepLinkNavigation(route) || (currentFocusedRoute?.name !== SCREENS.HOME && route === ROUTES.HOME); + + // Opening the link while onboarding still owns the screen flashes the "Not here" page, so hand it to + // navigateAfterOnboarding, which runs after the modal is gone and would otherwise lose it. + const deferUntilAfterOnboarding = () => { + if (initialHasCompletedGuidedSetupFlow !== false || shouldDropDeepLink()) { + return false; + } + + setDeepLinkToOpenAfterOnboarding(() => { + // A new account can finish onboarding before OpenApp does. Opening the link then shows its loading + // skeleton, so wait until report data has loaded. This runs in navigation code, outside React, so + // useOnyx is not available. + const loadingConnection = Onyx.connectWithoutView({ + key: ONYXKEYS.IS_LOADING_REPORT_DATA, + callback: (isLoadingReportData) => { + if (isLoadingReportData) { + return; + } + Onyx.disconnect(loadingConnection); + + // The concierge route is a URL entry page that redirects itself, and on wide layouts it slides in as a + // panel first, so open the chat the way the rest of the app does instead. + if (findFocusedRoute(getStateFromPath(deeplinkRoute))?.name === SCREENS.CONCIERGE) { + navigateToConciergeChat({ + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow: hasCompletedGuidedSetupFlowSelector(val), + conciergePersonalDetails, + }); + return; + } + openDeepLink(); + }, + }); + return true; + }); + return true; + }; + + if (isOnboardingFlowName(currentFocusedRoute?.name)) { + if (deferUntilAfterOnboarding()) { + return; + } + + if (hasCompletedGuidedSetupFlowSelector(val) === false) { + setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); + } + return; + } + + if (shouldDropDeepLink()) { + return; } + + // Navigation for signed users is handled by react-navigation. + if (isAuthenticated) { + return; + } + + if (deferUntilAfterOnboarding()) { + return; + } + + openDeepLink(); }; if (hasCompletedGuidedSetupFlowSelector(val) || isAnonymousUser()) { diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 2854b98a6b4c..b121a52d02f7 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -18,12 +18,12 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import findAllMatchingDynamicSuffixes from '@libs/Navigation/helpers/dynamicRoutesUtils/findAllMatchingDynamicSuffixes'; import getPathWithoutDynamicSuffix from '@libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix'; import Navigation from '@libs/Navigation/Navigation'; +import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; import {isPaidGroupPolicy, isPolicyAdmin, resolveCurrentTaxCode} from '@libs/PolicyUtils'; import {getIOUActionForReportID, getReportAction, getTrackExpenseActionableWhisper} from '@libs/ReportActionsUtils'; import { buildOptimisticIOUReportAction, getReportOrDraftReport, - getReportTransactions, getTransactionDetails, isCurrentUserSubmitter, isMoneyRequestReportEligibleForMerge, @@ -31,7 +31,7 @@ import { } from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import {getReportOwnerAccountIDAsAttendee, getReportOwnerAsAttendee, isDistanceRequest, isTransactionPendingDelete} from '@src/libs/TransactionUtils'; +import {isDistanceRequest, isTransactionPendingDelete} from '@src/libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type { @@ -48,7 +48,6 @@ import type { Transaction, TransactionViolations, } from '@src/types/onyx'; -import type {Attendee} from '@src/types/onyx/IOU'; import type {OnyxCollection, OnyxEntry, OnyxMergeInput, OnyxUpdate} from 'react-native-onyx'; @@ -61,20 +60,6 @@ import {getCleanUpTransactionThreadReportOnyxData} from './IOU/DeleteMoneyReques import {getDeleteTrackExpenseInformation} from './IOU/TrackExpense'; import {getUpdateMoneyRequestParams, getUpdateTrackExpenseParams} from './IOU/UpdateMoneyRequest'; -/** - * Resolve a transaction's report owner as a default attendee, mirroring the `useReportOwnerAsAttendee` hook so the - * non-React merge entry point can apply the same empty-attendee fallback the details/receipt pages use. The Onyx data - * is passed in by the caller rather than read from a module subscription. - */ -function getReportOwnerAsAttendeeForTransaction( - transaction: OnyxEntry, - currentUserAccountID: number | undefined, - personalDetails: OnyxEntry, -): Attendee | undefined { - const ownerAccountID = getReportOwnerAccountIDAsAttendee(transaction, currentUserAccountID); - return getReportOwnerAsAttendee(ownerAccountID ? (personalDetails?.[ownerAccountID] ?? undefined) : undefined); -} - /** * Setup merge transaction data for merging flow */ @@ -119,8 +104,6 @@ function setupMergeTransactionDataAndNavigate( isSelectingSourceTransaction?: boolean, isOnSearch?: boolean, policies?: Array>, - currentUserAccountID?: number, - personalDetails?: OnyxEntry, ) { if (!transactions.length || transactions.length > 2) { return; @@ -169,8 +152,6 @@ function setupMergeTransactionDataAndNavigate( searchReports, targetTransactionPolicy, sourceTransactionPolicy, - getReportOwnerAsAttendeeForTransaction(targetTransaction, currentUserAccountID, personalDetails), - getReportOwnerAsAttendeeForTransaction(sourceTransaction, currentUserAccountID, personalDetails), ); if (!conflictFields.length) { // If there are no conflict fields, we should set mergeable data and navigate to the confirmation page @@ -195,10 +176,24 @@ function getTransactionsForMergingFromAPI(transactionID: string) { } /** - * Fetches eligible transactions for merging locally - * This is FE version of READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING API call + * Fetches eligible transactions for merging locally. + * This is the offline FE equivalent of the READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING API call. + * Per-candidate admin access is checked against each candidate report’s own workspace so that + * cross-workspace admin merges are not granted based on the target’s workspace alone. + * When the candidate’s policy is not in the local cache the candidate is excluded rather than + * assuming access. */ -function getTransactionsForMergingLocally(transactionID: string, targetTransaction: Transaction, transactions: OnyxCollection, rules: OnyxCollection, isAdmin = false) { +function getTransactionsForMergingLocally( + transactionID: string, + targetTransaction: Transaction, + transactions: OnyxCollection, + rules: OnyxCollection, + // The full list is needed because eligibility is checked against a different report owner per candidate transaction. + personalDetails: OnyxEntry, + allPolicies: OnyxCollection, + currentUserLogin: string | undefined, + allReports?: OnyxCollection, +) { const transactionsArray = Object.values(transactions ?? {}); const eligibleTransactions = transactionsArray.filter((transaction): transaction is Transaction => { @@ -207,10 +202,29 @@ function getTransactionsForMergingLocally(transactionID: string, targetTransacti } const isUnreportedExpense = !transaction?.reportID || transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + if (isUnreportedExpense) { + return areTransactionsEligibleForMerge(targetTransaction, transaction) && !isTransactionPendingDelete(transaction); + } + + // Resolve admin status against the candidate’s own workspace. If the policy is absent from + // the local cache we cannot verify access, so we exclude the candidate rather than assuming + // admin rights. + const candidateReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`] ?? getReportOrDraftReport(transaction.reportID); + if (!candidateReport) { + return false; + } + const candidatePolicyKey = `${ONYXKEYS.COLLECTION.POLICY}${candidateReport?.policyID}`; + const candidatePolicy = candidateReport?.policyID ? (allPolicies?.[candidatePolicyKey] ?? null) : null; + const isCandidatePolicyMissing = !!candidateReport?.policyID && candidatePolicy === null; + if (isCandidatePolicyMissing) { + return false; + } + const isCandidateAdmin = isPolicyAdmin(candidatePolicy, currentUserLogin); + return ( areTransactionsEligibleForMerge(targetTransaction, transaction) && !isTransactionPendingDelete(transaction) && - (isUnreportedExpense || (!!transaction.reportID && isMoneyRequestReportEligibleForMerge(transaction.reportID, isAdmin, rules))) + isMoneyRequestReportEligibleForMerge(candidateReport, isCandidateAdmin, rules, getLoginByAccountID(candidateReport.ownerAccountID, personalDetails)) ); }); @@ -228,6 +242,9 @@ function getTransactionsForMerging({ report, currentUserLogin, rules, + personalDetails, + allPolicies, + allReports, }: { isOffline: boolean; targetTransaction: Transaction; @@ -238,22 +255,29 @@ function getTransactionsForMerging({ currentUserLogin: string | undefined; cardList?: CardList; rules: OnyxCollection; + /** The full list is needed because eligibility is checked against a different report owner per candidate transaction. */ + personalDetails: OnyxEntry; + allPolicies?: OnyxCollection; + allReports?: OnyxCollection; }) { const transactionID = targetTransaction.transactionID; if (!transactionID) { return; } - // Collect/Control workspaces: - // - Admins and approvers: The list of eligible expenses will only contain the expenses from the report that the admin/approver triggered the merge from. This is intentionally limited since they’ll only be reviewing one report at a time. - // - Submitters will see all their editable expenses, including their IOUs/unreported expenses + // Candidate discovery: + // - Admins: See expenses from the same submitter across the submitter's open reports and submitted reports pending + // final approval that the admin can access, so admins can merge a card and cash expense on different reports. + // - Managers (non-admin approvers): Limited to same-report transactions on the processing report they triggered + // the merge from, as they review one report at a time. + // - Submitters will see all their editable expenses, including their IOUs/unreported expenses. // IOU: // - There are no admins/approvers outside of the submitter in these cases, so there’s no consideration for different roles. - // - The submitter, who is also the admin, will see all their editable expenses, including their IOUs/unreported expenses + // - The submitter, who is also the admin, will see all their editable expenses, including their IOUs/unreported expenses. const isAdmin = isPolicyAdmin(policy, currentUserLogin); const isManager = isReportManager(report); - if (isPaidGroupPolicy(policy) && (isAdmin || isManager) && !isCurrentUserSubmitter(report)) { + if (isPaidGroupPolicy(policy) && isManager && !isAdmin && !isCurrentUserSubmitter(report)) { const eligibleTransactions = reportTransactions.filter((transaction): transaction is Transaction => { if (!transaction || transaction.transactionID === transactionID) { return false; @@ -269,7 +293,7 @@ function getTransactionsForMerging({ } if (isOffline) { - getTransactionsForMergingLocally(transactionID, targetTransaction, transactions, rules, isAdmin); + getTransactionsForMergingLocally(transactionID, targetTransaction, transactions, rules, personalDetails, allPolicies, currentUserLogin, allReports); } else { getTransactionsForMergingFromAPI(transactionID); } @@ -412,6 +436,7 @@ type MergeTransactionRequestParams = { targetTransaction: Transaction; allTransactionViolations: OnyxCollection; sourceTransaction: Transaction; + transactionsOfDeletableReportCollection: Record; targetTransactionThreadReport: OnyxEntry; targetTransactionThreadParentReport: OnyxEntry; iouReportOwnerLogin: string | undefined; @@ -450,6 +475,7 @@ function mergeTransactionRequest({ mergeTransaction, targetTransaction, sourceTransaction, + transactionsOfDeletableReportCollection, targetTransactionThreadReport, targetTransactionThreadParentReport, iouReportOwnerLogin, @@ -554,7 +580,7 @@ function mergeTransactionRequest({ }; const transactionToDelete = mergeTransaction.reportID === targetTransaction.reportID ? sourceTransaction : targetTransaction; - const transactionsOfDeletableReport = getReportTransactions(transactionToDelete.reportID); + const transactionsOfDeletableReport = Object.values(transactionsOfDeletableReportCollection); const deletableReport = getReportOrDraftReport(transactionToDelete.reportID); const reportPreviewActionIDOfDeletableReport = deletableReport?.parentReportActionID; const optimisticReportDeletionData: Array> = diff --git a/src/libs/actions/MultifactorAuthentication/processing.ts b/src/libs/actions/MultifactorAuthentication/processing.ts index 848d029abfdf..f2d6cb7ad81f 100644 --- a/src/libs/actions/MultifactorAuthentication/processing.ts +++ b/src/libs/actions/MultifactorAuthentication/processing.ts @@ -33,6 +33,7 @@ async function processRegistration(params: RegistrationParams): Promise[0], + context?: {signal: AbortSignal}, ): Promise> { if (!params.signedChallenge) { return { @@ -41,7 +42,7 @@ async function processScenarioAction( }; } - const {httpStatusCode, reason, message, body} = await action(params); + const {httpStatusCode, reason, message, body} = await (context ? action(params, context) : action(params)); if (isHttpSuccess(httpStatusCode)) { return { diff --git a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts index c475b058144d..9d6173058431 100644 --- a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts +++ b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts @@ -5,7 +5,7 @@ import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDer import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; +import type {ReportAction, ReportActions} from '@src/types/onyx'; import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; /** @@ -21,9 +21,13 @@ function shouldSkipCachingAction(action: ReportAction): boolean { * actions. Rebuilding the whole map rather than updating individual entries keeps deletions correct: * a removed action is absent from `reportActions`, so it drops out of the result. */ -function computeReportVisibility(reportActions: ReportActions, currentUserAccountID: number | undefined, report: Report | undefined): Record { +function computeReportVisibility( + reportActions: ReportActions, + currentUserAccountID: number | undefined, + isSupportTicketReport: boolean, + isReplacedSupportTicket: boolean, +): Record { const reportVisibility: Record = {}; - const isSupportTicketReport = isSupportTicket(report); for (const [actionID, action] of Object.entries(reportActions)) { if (!action) { @@ -37,6 +41,11 @@ function computeReportVisibility(reportActions: ReportActions, currentUserAccoun if (shouldSkipCachingAction(action)) { continue; } + // A replacement ticket has its own active survey, so the original ticket's survey is stale. + if (isReplacedSupportTicket && isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY)) { + reportVisibility[action.reportActionID] = false; + continue; + } reportVisibility[action.reportActionID] = shouldReportActionBeVisible(action, actionID, undefined, currentUserAccountID, undefined, isSupportTicketReport); } @@ -49,26 +58,35 @@ export default createOnyxDerivedValueConfig({ // (for UNREPORTED_TRANSACTION/MOVED_TRANSACTION visibility) AND to provide the current // report collection to the visibility check, avoiding stale data from global connections. // SESSION dependency is needed for whisper targeting when user changes. - dependencies: [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.SESSION, ONYXKEYS.COLLECTION.REPORT], - compute: ([allReportActions, session, allReports], {sourceValues, currentValue, triggeredKeys}): VisibleReportActionsDerivedValue => { + dependencies: [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.SESSION, ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS], + compute: ([allReportActions, session, allReports, allReportNameValuePairs], {sourceValues, currentValue, triggeredKeys}): VisibleReportActionsDerivedValue => { if (!allReportActions) { return {}; } const currentUserAccountID = session?.accountID; const reportActionsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_ACTIONS]; + const reportNameValuePairsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]; - // Recompute only the reports whose actions changed when we have a usable delta. Otherwise - // recompute everything: on first load, when there's no delta, or on a SESSION change (the - // user changed, which affects whisper targeting for every report). SESSION is checked via - // triggeredKeys, not sourceValues, so a session cleared to `undefined` still forces the full recompute. - const isIncremental = !!reportActionsUpdates && !triggeredKeys?.has(ONYXKEYS.SESSION) && !triggeredKeys?.has(ONYXKEYS.COLLECTION.REPORT) && !!currentValue; + // Recompute only the reports whose actions or NVPs changed when we have a usable delta. + // Otherwise recompute everything: on first load, when there's no delta, or on a SESSION or + // REPORT change. SESSION is checked via triggeredKeys, not sourceValues, so a session cleared + // to `undefined` still forces the full recompute. + const isIncremental = + !!(reportActionsUpdates ?? reportNameValuePairsUpdates) && !triggeredKeys?.has(ONYXKEYS.SESSION) && !triggeredKeys?.has(ONYXKEYS.COLLECTION.REPORT) && !!currentValue; const result: VisibleReportActionsDerivedValue = isIncremental ? {...currentValue} : {}; - const reportActionsKeysToProcess = isIncremental ? Object.keys(reportActionsUpdates) : Object.keys(allReportActions); + const reportIDsToProcess = isIncremental + ? new Set([ + ...Object.keys(reportActionsUpdates ?? {}).map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, '')), + ...Object.keys(reportNameValuePairsUpdates ?? {}) + .map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, '')) + .filter((reportID) => isSupportTicket(allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`])), + ]) + : new Set(Object.keys(allReportActions).map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''))); - for (const reportActionsKey of reportActionsKeysToProcess) { - const reportID = reportActionsKey.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); + for (const reportID of reportIDsToProcess) { + const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`; const reportActions = allReportActions[reportActionsKey]; // The member was removed entirely — drop the report from the result. @@ -77,7 +95,10 @@ export default createOnyxDerivedValueConfig({ continue; } - result[reportID] = computeReportVisibility(reportActions, currentUserAccountID, allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]); + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const isSupportTicketReport = isSupportTicket(report); + const isReplacedSupportTicket = isSupportTicketReport && !!allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]?.reopenedAsReportID; + result[reportID] = computeReportVisibility(reportActions, currentUserAccountID, isSupportTicketReport, isReplacedSupportTicket); } return result; diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts index 442e4181f7d7..c757e569c662 100644 --- a/src/libs/actions/PaymentMethods.ts +++ b/src/libs/actions/PaymentMethods.ts @@ -12,19 +12,20 @@ import type { UpdateBillingCurrencyParams, } from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {getLastFourDigits} from '@libs/BankAccountUtils'; import * as CardUtils from '@libs/CardUtils'; import GoogleTagManager from '@libs/GoogleTagManager'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {getCurrentUserEmail} from '@libs/Network/NetworkStore'; -import {isPolicyUser} from '@libs/PolicyUtils'; +import {arePaymentsEnabled, isPolicyAdmin, isPolicyUser} from '@libs/PolicyUtils'; import {getCardForSubscriptionBilling} from '@libs/SubscriptionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/AddPaymentCardForm'; -import type {BankAccountList, CardList, FundList} from '@src/types/onyx'; +import type {BankAccountList, CardList, FundList, PolicyMemberBankAccounts} from '@src/types/onyx'; import type PaymentMethod from '@src/types/onyx/PaymentMethod'; import type Policy from '@src/types/onyx/Policy'; import type Session from '@src/types/onyx/Session'; @@ -36,6 +37,8 @@ import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; +import getMakeDefaultPaymentOnyxData from './getMakeDefaultPaymentOnyxData'; + /** * When we successfully add a payment method or pass the KYC checks we will continue with our setup action if we have one set. */ @@ -87,76 +90,49 @@ function getPaymentMethods(params?: OpenPaymentsPageParams) { ); } -function getMakeDefaultPaymentOnyxData( - bankAccountID: number, - fundID?: number, - previousPaymentMethod?: PaymentMethod, - currentPaymentMethod?: PaymentMethod, - isOptimisticData = true, -): Array> { - const onyxData: Array> = [ - isOptimisticData - ? { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.USER_WALLET, - value: { - walletLinkedAccountID: bankAccountID || fundID, - walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD, - // Only clear the error if this is optimistic data. If this is failure data, we do not want to clear the error that came from the server. - errors: null, - }, - } - : { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.USER_WALLET, - value: { - walletLinkedAccountID: bankAccountID || fundID, - walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD, - }, - }, - ]; - - if (previousPaymentMethod?.methodID) { - onyxData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST, - value: { - [previousPaymentMethod.methodID]: { - isDefault: !isOptimisticData, - }, - }, - }); - } - - if (currentPaymentMethod?.methodID) { - onyxData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST, - value: { - [currentPaymentMethod.methodID]: { - isDefault: isOptimisticData, - }, - }, - }); - } - - return onyxData; -} - /** * Sets the default bank account or debit card for an Expensify Wallet * */ -function makeDefaultPaymentMethod(bankAccountID: number, fundID: number, previousPaymentMethod?: PaymentMethod, currentPaymentMethod?: PaymentMethod) { +function makeDefaultPaymentMethod( + bankAccountID: number, + fundID: number, + previousPaymentMethod?: PaymentMethod, + currentPaymentMethod?: PaymentMethod, + policies?: OnyxCollection, + policyMemberBankAccounts?: OnyxCollection, + accountID?: number, +) { const parameters: MakeDefaultPaymentMethodParams = { bankAccountID, fundID, }; - API.write(WRITE_COMMANDS.MAKE_DEFAULT_PAYMENT_METHOD, parameters, { - optimisticData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, true), - failureData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, false), - }); + const optimisticData: Array< + OnyxUpdate + > = getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, true); + const failureData: Array< + OnyxUpdate + > = getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, false); + + // Admins see their own default bank account on the Members page of workspaces with payments on + const accountData = currentPaymentMethod?.accountData; + if (bankAccountID && accountID) { + const memberBankAccount = { + bankName: accountData?.additionalData?.bankName, + bankAccountLastFour: getLastFourDigits(accountData && 'accountNumber' in accountData ? (accountData.accountNumber ?? '') : ''), + }; + for (const policy of Object.values(policies ?? {})) { + if (!policy?.id || !isPolicyAdmin(policy) || !arePaymentsEnabled(policy)) { + continue; + } + const key = `${ONYXKEYS.COLLECTION.POLICY_MEMBER_BANK_ACCOUNTS}${policy.id}` as const; + optimisticData.push({onyxMethod: Onyx.METHOD.MERGE, key, value: {[accountID]: memberBankAccount}}); + failureData.push({onyxMethod: Onyx.METHOD.MERGE, key, value: {[accountID]: policyMemberBankAccounts?.[key]?.[accountID] ?? null}}); + } + } + + API.write(WRITE_COMMANDS.MAKE_DEFAULT_PAYMENT_METHOD, parameters, {optimisticData, failureData}); } function buildAddPaymentCardFormLoadingOnyxData() { @@ -639,7 +615,6 @@ export { addPaymentCard, getPaymentMethods, makeDefaultPaymentMethod, - getMakeDefaultPaymentOnyxData, continueSetup, addSubscriptionPaymentCard, clearAddPaymentCardDraftCurrency, diff --git a/src/libs/actions/PersistedRequests.ts b/src/libs/actions/PersistedRequests.ts index 080bc69329b9..9607896d4df8 100644 --- a/src/libs/actions/PersistedRequests.ts +++ b/src/libs/actions/PersistedRequests.ts @@ -62,6 +62,10 @@ Onyx.connectWithoutView({ callback: (val) => { Log.info('[PersistedRequests] hit Onyx connect callback', false, {isValNullish: val == null, isInitialized}); + if (isInitialized && val == null && persistedRequests.length > 0) { + Log.hmmm('[PersistedRequests] Onyx.clear dropped queued requests', {droppedCommands: getCommands(persistedRequests)}); + } + // After initialization, in-memory is authoritative — ignore stale disk // callbacks to prevent out-of-order Onyx.set() from overwriting the // correct in-memory state (Bug #80759 Issue 4). diff --git a/src/libs/actions/Policy/CopyPolicySettings.ts b/src/libs/actions/Policy/CopyPolicySettings.ts index fc3c27aad433..d0788c942372 100644 --- a/src/libs/actions/Policy/CopyPolicySettings.ts +++ b/src/libs/actions/Policy/CopyPolicySettings.ts @@ -58,6 +58,7 @@ const PARTS_TO_POLICY_FIELDS = { 'eReceipts', 'isAttendeeTrackingEnabled', 'preventSelfApproval', + 'preventPayoutNonReimbursableReports', 'disabledFields', 'glCodes', 'showTagGLCodes', diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index 9f0694258c26..8f84878638b4 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -1,8 +1,8 @@ -import type {LocalizedTranslate} from '@components/LocaleContextProvider'; - import * as API from '@libs/API'; import type { + AddOfficeLocationParams, CreatePolicyDistanceRateParams, + DeleteOfficeLocationParams, DeletePolicyDistanceRatesParams, DisablePolicyCommuterExclusionsParams, EnablePolicyDistanceRatesParams, @@ -14,25 +14,25 @@ import type { SetPolicyRequireMapOrGPSParams, SetPolicyWorkArrangementParams, SetWorkspaceDistanceAutoUpdateParams, + UpdateEmployeeOfficeLocationParams, + UpdateOfficeLocationParams, UpdatePolicyDistanceRateParams, UpdatePolicyDistanceRateValueParams, } from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; -import {rand64} from '@libs/NumberUtils'; +import {generateHexadecimalValue} from '@libs/NumberUtils'; import {buildOnyxDataForGovernmentRateAutoUpdate, buildOnyxDataForPolicyDistanceRateUpdates} from '@libs/PolicyDistanceRatesUtils'; import {goBackWhenEnableFeature, removePendingFieldsFromCustomUnit} from '@libs/PolicyUtils'; -import {getRoom} from '@libs/ReportUtils'; -import {getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {GovernmentMileageRate, PersonalDetailsList, Policy, PolicyEmployee, ReportAction, TransactionViolation} from '@src/types/onyx'; -import type {ErrorFields} from '@src/types/onyx/OnyxCommon'; -import type {CommuterExclusions, CustomUnit, Rate} from '@src/types/onyx/Policy'; +import type {GovernmentMileageRate, PersonalDetailsList, Policy, PolicyEmployee, TransactionViolation} from '@src/types/onyx'; +import type {ErrorFields, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {CommuterExclusions, CompanyAddress, CustomUnit, OfficeLocation, Rate} from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -258,7 +258,12 @@ function clearPolicyDistanceRateErrorFields(policyID: string, customUnitID: stri }); } -function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit) { +function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit, previousCommuterExclusions?: CommuterExclusions) { + const newDistanceUnit = newCustomUnit.attributes?.unit; + const shouldUpdateCommuterExclusionUnit = + previousCommuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE && typeof previousCommuterExclusions.fixedDistance === 'number' && !!newDistanceUnit; + const optimisticCommuterExclusions = shouldUpdateCommuterExclusionUnit ? {...previousCommuterExclusions, fixedDistanceUnit: newDistanceUnit} : undefined; + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -270,6 +275,13 @@ function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomU pendingFields: {attributes: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, }, }, + ...(optimisticCommuterExclusions + ? { + commuterExclusions: optimisticCommuterExclusions, + pendingFields: {commuterExclusions: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {commuterExclusions: null}, + } + : {}), }, }, ]; @@ -284,6 +296,7 @@ function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomU pendingFields: {attributes: null}, }, }, + ...(optimisticCommuterExclusions ? {pendingFields: {commuterExclusions: null}} : {}), }, }, ]; @@ -300,6 +313,16 @@ function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomU pendingFields: {attributes: null}, }, }, + ...(optimisticCommuterExclusions + ? { + commuterExclusions: { + ...previousCommuterExclusions, + fixedDistanceUnit: previousCommuterExclusions?.fixedDistanceUnit ?? null, + }, + pendingFields: {commuterExclusions: null}, + errorFields: {commuterExclusions: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + } + : {}), }, }, ]; @@ -703,36 +726,27 @@ function disablePolicyCommuterExclusions(policyID: string, previousCommuterExclu type WorkArrangementMemberUpdate = { /** The account ID of the workspace member being updated. */ accountID: number; - /** The member's login, used as the employeeList key and in the changelog action. */ + /** The member's login, used as the employeeList key. */ email: string; - /** The member's display name, used in the changelog message. */ - name: string; /** The member's previous office arrangement, used to restore it if the update fails. */ previousHasOfficeWorkArrangement: boolean | undefined; - /** The ID assigned to this member's optimistic changelog action. */ - optimisticReportActionID: string; + /** The member may still be an optimistic invite, so changing the arrangement must not clear its pending action. */ + previousPendingAction: PolicyEmployee['pendingAction']; }; /** * Set the work arrangement (office-based or no regular workspace) for one or more policy members. * The single command covers both individual and bulk updates: employeeAccountIDList carries every - * affected accountID, and one optimistic member work arrangement changelog action is - * created per member in the workspace admins room. + * affected accountID. The server logs each change in the workspace admins room, where it can land in a thread, so no + * change log is added optimistically. */ -function setEmployeeWorkArrangement( - policy: OnyxEntry, - employeeAccountIDList: number[], - isOffice: boolean, - personalDetails: OnyxEntry, - translate: LocalizedTranslate, -) { +function setEmployeeWorkArrangement(policy: OnyxEntry, employeeAccountIDList: number[], isOffice: boolean, personalDetails: OnyxEntry) { const policyID = policy?.id; if (!policyID) { return; } const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; - const newLabel = getWorkArrangementLabel(translate, isOffice); const updates: WorkArrangementMemberUpdate[] = []; for (const accountID of employeeAccountIDList) { const personalDetail = personalDetails?.[accountID]; @@ -751,9 +765,8 @@ function setEmployeeWorkArrangement( updates.push({ accountID, email: login, - name: personalDetail?.displayName ?? login, previousHasOfficeWorkArrangement, - optimisticReportActionID: rand64(), + previousPendingAction: employee.pendingAction, }); } @@ -761,35 +774,130 @@ function setEmployeeWorkArrangement( return; } - const created = DateUtils.getDBTime(); const employeeListOptimisticUpdate: Record> = {}; const employeeListSuccessUpdate: Record> = {}; const employeeListFailureUpdate: Record> = {}; for (const update of updates) { - employeeListOptimisticUpdate[update.email] = {hasOfficeWorkArrangement: isOffice, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; - employeeListSuccessUpdate[update.email] = {pendingAction: null}; + employeeListOptimisticUpdate[update.email] = { + hasOfficeWorkArrangement: isOffice, + pendingAction: update.previousPendingAction ?? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }; + if (!update.previousPendingAction) { + employeeListSuccessUpdate[update.email] = {pendingAction: null}; + } employeeListFailureUpdate[update.email] = { hasOfficeWorkArrangement: update.previousHasOfficeWorkArrangement ?? null, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + ...(!update.previousPendingAction && {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'), + }; + } + + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: {employeeList: employeeListOptimisticUpdate}, + }, + ]; + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: {employeeList: employeeListSuccessUpdate}, + }, + ]; + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: {employeeList: employeeListFailureUpdate}, + }, + ]; + + const parameters: SetEmployeeWorkArrangementParams = { + policyID, + employeeAccountIDList: updates.map((update) => update.accountID).join(','), + employeeLoginList: updates.map((update) => update.email).join(','), + isOffice, + }; + const onyxData: OnyxData = {optimisticData, successData, failureData}; + API.write(WRITE_COMMANDS.SET_EMPLOYEE_WORK_ARRANGEMENT, parameters, onyxData); +} + +type OfficeLocationMemberUpdate = { + /** The member's login, used as the employeeList key and to match the member on the server. */ + email: string; + /** The office the member was assigned to, used to restore it if the update fails. */ + previousOfficeID: string | undefined; + /** The member may still be an optimistic invite, so changing the office must not clear its pending action. */ + previousPendingAction: PolicyEmployee['pendingAction']; +}; + +/** + * Assign one or more workspace members to an office, keyed in the workspace's officeLocations, or to the company + * address with CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID. The single command covers both individual and bulk updates: + * employeeLoginList carries the login of every affected member. The server logs each change in the workspace admins + * room, where it can land in a thread, so no change log is added optimistically. + */ +function updateEmployeeOfficeLocation(policy: OnyxEntry, employeeAccountIDList: number[], officeID: string, personalDetails: OnyxEntry) { + const policyID = policy?.id; + if (!policyID) { + return; + } + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + + const updates: OfficeLocationMemberUpdate[] = []; + for (const accountID of employeeAccountIDList) { + const personalDetail = personalDetails?.[accountID]; + const login = personalDetail?.login; + if (!login) { + continue; + } + const employee = policy?.employeeList?.[login]; + if (!employee || employee.officeID === officeID) { + continue; + } + updates.push({ + email: login, + previousOfficeID: employee.officeID, + previousPendingAction: employee.pendingAction, + }); + } + + if (updates.length === 0) { + return; + } + + const employeeListOptimisticUpdate: Record> = {}; + const employeeListSuccessUpdate: Record> = {}; + const employeeListFailureUpdate: Record> = {}; + for (const update of updates) { + employeeListOptimisticUpdate[update.email] = {officeID, pendingAction: update.previousPendingAction ?? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; + if (!update.previousPendingAction) { + employeeListSuccessUpdate[update.email] = {pendingAction: null}; + } + employeeListFailureUpdate[update.email] = { + officeID: update.previousOfficeID ?? null, + ...(!update.previousPendingAction && {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'), }; } - const optimisticData: Array> = [ + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: policyKey, value: {employeeList: employeeListOptimisticUpdate}, }, ]; - const successData: Array> = [ + const successData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: policyKey, value: {employeeList: employeeListSuccessUpdate}, }, ]; - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: policyKey, @@ -797,48 +905,199 @@ function setEmployeeWorkArrangement( }, ]; - const adminsRoom = getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, policyID); - if (adminsRoom?.reportID) { - const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsRoom.reportID}` as const; - const optimisticReportActions: Record = {}; - const successReportActions: Record> = {}; - const failureReportActions: Record = {}; - for (const update of updates) { - const previousLabel = getWorkArrangementLabel(translate, update.previousHasOfficeWorkArrangement ?? false); - const text = translate('workspaceActions.updatedMemberWorkArrangement', {displayName: update.name, newArrangement: newLabel, oldArrangement: previousLabel}); - optimisticReportActions[update.optimisticReportActionID] = { - reportActionID: update.optimisticReportActionID, - actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT, - created, - shouldShow: true, - automatic: false, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - message: [ - { - type: CONST.REPORT.MESSAGE.TYPE.COMMENT, - html: `${text}`, - text, + const parameters: UpdateEmployeeOfficeLocationParams = { + policyID, + employeeLoginList: updates.map((update) => update.email).join(','), + officeID, + }; + const onyxData: OnyxData = {optimisticData, successData, failureData}; + API.write(WRITE_COMMANDS.UPDATE_EMPLOYEE_OFFICE_LOCATION, parameters, onyxData); +} + +/** + * Add an office to a workspace. Making it the default clears the flag on the current default office, so callers + * pass the workspace's current `officeLocations` for the failure path to restore it. An office added without a `name` + * is named by the server, and shows `defaultName` until then. + */ +function addOfficeLocation(policyID: string, officeLocations: Record | undefined, address: CompanyAddress, isDefault: boolean, name: string, defaultName: string) { + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + const officeID = generateHexadecimalValue(16); + const previousDefaultOfficeID = isDefault + ? Object.entries(officeLocations ?? {}).find(([, officeLocation]) => officeLocation.isDefault && officeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)?.[0] + : undefined; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: { + ...(previousDefaultOfficeID ? {[previousDefaultOfficeID]: {isDefault: false}} : {}), + [officeID]: {name: name || defaultName, address, isDefault, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, errors: null}, }, - ], - originalMessage: { - accountID: update.accountID, - email: update.email, - name: update.name, - newValue: isOffice, - oldValue: update.previousHasOfficeWorkArrangement ?? false, }, - }; - successReportActions[update.optimisticReportActionID] = {pendingAction: null}; - failureReportActions[update.optimisticReportActionID] = null; - } - optimisticData.push({onyxMethod: Onyx.METHOD.MERGE, key: reportActionsKey, value: optimisticReportActions}); - successData.push({onyxMethod: Onyx.METHOD.MERGE, key: reportActionsKey, value: successReportActions}); - failureData.push({onyxMethod: Onyx.METHOD.MERGE, key: reportActionsKey, value: failureReportActions}); + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: {[officeID]: {pendingAction: null}}, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: { + ...(previousDefaultOfficeID ? {[previousDefaultOfficeID]: {isDefault: true}} : {}), + [officeID]: {isDefault: false, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + }, + ], + }; + + const parameters: AddOfficeLocationParams = {policyID, officeID, officeName: name || undefined, address: JSON.stringify(address), isDefault}; + API.write(WRITE_COMMANDS.ADD_OFFICE_LOCATION, parameters, onyxData); +} + +/** + * Update the name, address or default flag of a workspace office. Unsetting the default flag makes the company address + * the default. Callers pass the workspace's current `officeLocations` so the failure path can restore the office, and + * the default office it replaced. + */ +function updateOfficeLocation( + policyID: string, + officeLocations: Record | undefined, + officeID: string, + changes: Partial>, +) { + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + const officeLocation = officeLocations?.[officeID]; + const previousDefaultOfficeID = changes.isDefault + ? Object.entries(officeLocations ?? {}).find( + ([, otherOfficeLocation]) => otherOfficeLocation.isDefault && otherOfficeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + )?.[0] + : undefined; + + // An office that hasn't reached the server yet stays pending addition, so dismissing a failed addition still removes it + const pendingAction = officeLocation?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: { + ...(previousDefaultOfficeID ? {[previousDefaultOfficeID]: {isDefault: false}} : {}), + [officeID]: {...changes, pendingAction, errors: null}, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: {[officeID]: {pendingAction: null}}, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: { + ...(previousDefaultOfficeID ? {[previousDefaultOfficeID]: {isDefault: true}} : {}), + [officeID]: { + name: officeLocation?.name, + address: officeLocation?.address, + isDefault: officeLocation?.isDefault ?? false, + pendingAction: officeLocation?.pendingAction ?? null, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + ], + }; + + const parameters: UpdateOfficeLocationParams = { + policyID, + officeID, + officeName: changes.name, + address: changes.address ? JSON.stringify(changes.address) : undefined, + isDefault: changes.isDefault, + }; + API.write(WRITE_COMMANDS.UPDATE_OFFICE_LOCATION, parameters, onyxData); +} + +/** + * Delete a workspace office. Deleting the default office makes the company address the default, or, in a workspace + * without a company address, the server makes another office the default. An office whose addition failed was never + * stored by the server, so it is only removed locally. + */ +function deleteOfficeLocation(policyID: string, officeID: string, officeLocation: OfficeLocation | undefined) { + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + if (officeLocation?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && !isEmptyObject(officeLocation.errors)) { + Onyx.merge(policyKey, {officeLocations: {[officeID]: null}}); + return; } - const parameters: SetEmployeeWorkArrangementParams = {policyID, employeeAccountIDList: updates.map((update) => update.accountID).join(','), isOffice}; - const onyxData: OnyxData = {optimisticData, successData, failureData}; - API.write(WRITE_COMMANDS.SET_EMPLOYEE_WORK_ARRANGEMENT, parameters, onyxData); + // An office that hasn't reached the server yet stays pending addition, so dismissing the error still removes it + const failedPendingAction = officeLocation?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: {[officeID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, errors: null}}, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: {[officeID]: null}, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + officeLocations: {[officeID]: {pendingAction: failedPendingAction, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}}, + }, + }, + ], + }; + + const parameters: DeleteOfficeLocationParams = {policyID, officeID}; + API.write(WRITE_COMMANDS.DELETE_OFFICE_LOCATION, parameters, onyxData); +} + +/** + * Dismiss the error on a workspace office. An office whose addition failed is removed, since the server never stored it. + */ +function clearOfficeLocationErrors(policyID: string, officeID: string, pendingAction: PendingAction | undefined) { + if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {officeLocations: {[officeID]: null}}); + return; + } + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {officeLocations: {[officeID]: {errors: null}}}); } /** @@ -963,6 +1222,11 @@ export { disablePolicyCommuterExclusions, clearPolicyCommuterExclusionsErrors, setEmployeeWorkArrangement, + updateEmployeeOfficeLocation, + addOfficeLocation, + updateOfficeLocation, + deleteOfficeLocation, + clearOfficeLocationErrors, setPolicyRequireMapOrGPS, clearPolicyRequireMapOrGPSErrors, setWorkspaceDistanceAutoUpdate, diff --git a/src/libs/actions/Policy/InlineEdit.ts b/src/libs/actions/Policy/InlineEdit.ts index 4839fc85e09e..6eca722692d3 100644 --- a/src/libs/actions/Policy/InlineEdit.ts +++ b/src/libs/actions/Policy/InlineEdit.ts @@ -3,16 +3,26 @@ * Table pages call these instead of the canonical Policy/Card actions so sanitization * and no-op checks stay in one place. Persistence and rollback stay in those actions. */ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; + import type PolicyData from '@hooks/usePolicyData/types'; -import {getCardNameError, getExpensifyCardLimitError, getExpensifyCardNewAvailableSpend, shouldShowExpensifyCardFixedLimitType} from '@libs/CardUtils'; -import {getCategoryNameError, getDecodedCategoryName} from '@libs/CategoryUtils'; +import { + getCardNameError, + getCardNameErrorMessage, + getExpensifyCardLimitError, + getExpensifyCardLimitErrorMessage, + getExpensifyCardNewAvailableSpend, + shouldShowExpensifyCardFixedLimitType, +} from '@libs/CardUtils'; +import {getCategoryNameError, getCategoryNameErrorMessage, getDecodedCategoryName} from '@libs/CategoryUtils'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; -import {getDistanceRateNameError, getDistanceRateValueError} from '@libs/PolicyDistanceRatesUtils'; -import {getPerDiemAmountError, getPerDiemNameError} from '@libs/PolicyPerDiemUtils'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import {getDistanceRateNameError, getDistanceRateNameErrorMessage, getDistanceRateValueError, getDistanceRateValueErrorMessage} from '@libs/PolicyDistanceRatesUtils'; +import {getPerDiemAmountError, getPerDiemAmountErrorMessage, getPerDiemNameError, getPerDiemNameErrorMessage} from '@libs/PolicyPerDiemUtils'; import {getCleanedTagName, getTagList} from '@libs/PolicyUtils'; import StringUtils from '@libs/StringUtils'; -import {getTagNameError} from '@libs/TagUtils'; +import {getTagNameError, getTagNameErrorMessage} from '@libs/TagUtils'; import {updateExpensifyCardLimit, updateExpensifyCardLimitType} from '@userActions/Card'; import {updateCompanyCardName} from '@userActions/CompanyCards'; @@ -32,54 +42,97 @@ import {updateWorkspaceMembersRole} from './Member'; import {editPerDiemRateAmount, editPerDiemRateDestination, editPerDiemRateSubrate} from './PerDiem'; import {renamePolicyTag} from './Tag'; -function renameCategoryInline(policyData: PolicyData, currentName: string, newName: string, isVendorMatchingBetaEnabled: boolean | undefined): void { +function renameCategoryInline( + translate: LocalizedTranslate, + policyData: PolicyData, + currentName: string, + newName: string, + isVendorMatchingBetaEnabled: boolean | undefined, +): string | undefined { const sanitized = StringUtils.sanitizeName(newName); const currentDisplayName = getDecodedCategoryName(currentName); - if (sanitized === currentDisplayName || getCategoryNameError(policyData.categories, newName, currentDisplayName)) { + if (sanitized === currentDisplayName) { return; } + const categoryError = getCategoryNameError(policyData.categories, newName, currentDisplayName); + if (categoryError) { + return getCategoryNameErrorMessage(translate, categoryError, newName); + } + renamePolicyCategory(policyData, {oldName: currentName, newName: sanitized}, isVendorMatchingBetaEnabled); } -function renameTagInline(policyData: PolicyData, oldName: string, newName: string, isVendorMatchingBetaEnabled: boolean | undefined): void { +function renameTagInline(translate: LocalizedTranslate, policyData: PolicyData, oldName: string, newName: string, isVendorMatchingBetaEnabled: boolean | undefined): string | undefined { const sanitized = StringUtils.sanitizeName(newName); const currentDisplayName = getCleanedTagName(oldName); const {tags} = getTagList(policyData.tags, 0); - if (sanitized === currentDisplayName || getTagNameError(tags, newName, currentDisplayName)) { + if (sanitized === currentDisplayName) { return; } + const tagError = getTagNameError(tags, newName, currentDisplayName); + if (tagError) { + return getTagNameErrorMessage(translate, tagError, newName); + } + renamePolicyTag(policyData, {oldName, newName: sanitized}, 0, isVendorMatchingBetaEnabled); } -function renameCompanyCardInline(domainOrWorkspaceAccountID: number, cardID: string, newName: string, bankName: CompanyCardFeedWithNumber, displayedName: string, storedName?: string): void { +function renameCompanyCardInline( + translate: LocalizedTranslate, + domainOrWorkspaceAccountID: number, + cardID: string, + newName: string, + bankName: CompanyCardFeedWithNumber, + displayedName: string, + storedName?: string, +): string | undefined { const sanitized = StringUtils.sanitizeName(newName); - if (sanitized === displayedName || getCardNameError(newName)) { + if (sanitized === displayedName) { return; } + const cardNameError = getCardNameError(newName); + if (cardNameError) { + return getCardNameErrorMessage(translate, cardNameError, newName); + } + updateCompanyCardName(domainOrWorkspaceAccountID, cardID, sanitized, bankName, storedName); } -function renameDistanceRateInline(policyID: string, customUnit: CustomUnit, rate: Rate, newName: string): void { - const sanitized = StringUtils.sanitizeName(newName); +function renameDistanceRateInline(translate: LocalizedTranslate, policyID: string, customUnit: CustomUnit, rate: Rate, newName: string): string | undefined { + // The cell edits the name without the hidden date suffix, so check and save the full name that gets stored + const nameToSave = DistanceRequestUtils.getRateNameWithDateSuffix(rate, StringUtils.sanitizeName(newName)); const currentName = rate.name ?? ''; const existingRateNames = Object.values(customUnit.rates ?? {}).map((existingRate) => existingRate.name ?? ''); - if (sanitized === currentName || getDistanceRateNameError(existingRateNames, newName, currentName)) { + if (nameToSave === currentName) { return; } - updatePolicyDistanceRateName(policyID, customUnit, [{...rate, name: sanitized}]); + const nameError = getDistanceRateNameError(existingRateNames, nameToSave, currentName); + if (nameError) { + return getDistanceRateNameErrorMessage(translate, nameError, nameToSave); + } + + updatePolicyDistanceRateName(policyID, customUnit, [{...rate, name: nameToSave}]); } -function updateDistanceRateValueInline(policyID: string, customUnit: CustomUnit, rate: Rate, newRate: string, toLocaleDigit: (arg: string) => string): void { - if (getDistanceRateValueError(newRate, toLocaleDigit)) { - return; +function updateDistanceRateValueInline( + translate: LocalizedTranslate, + policyID: string, + customUnit: CustomUnit, + rate: Rate, + newRate: string, + toLocaleDigit: (arg: string) => string, +): string | undefined { + const rateError = getDistanceRateValueError(newRate, toLocaleDigit); + if (rateError) { + return getDistanceRateValueErrorMessage(translate, rateError); } const currentRateValue = (parseFloat((rate.rate ?? 0).toString()) / CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET).toFixed(CONST.MAX_TAX_RATE_DECIMAL_PLACES); @@ -146,6 +199,15 @@ function getExpensifyCardLimitInlineUpdate(card: Card, newLimit: string): number return nextLimit; } +/** User-facing message for an invalid Expensify card limit, or undefined when the limit can be saved. */ +function getExpensifyCardLimitInlineEditError(translate: LocalizedTranslate, newLimit: string): string | undefined { + const limitError = getExpensifyCardLimitError(newLimit); + if (!limitError) { + return; + } + return getExpensifyCardLimitErrorMessage(translate, limitError); +} + /** * Updates an Expensify card limit from an inline table edit. `newLimit` is the dollar amount as a string. */ @@ -166,32 +228,66 @@ function updateExpensifyCardLimitInline(workspaceAccountID: number, card: Card, ); } -function renamePerDiemDestinationInline(policyID: string, rateID: string, customUnit: CustomUnit | undefined, currentName: string, newName: string): void { +function renamePerDiemDestinationInline( + translate: LocalizedTranslate, + policyID: string, + rateID: string, + customUnit: CustomUnit | undefined, + currentName: string, + newName: string, +): string | undefined { const sanitized = StringUtils.sanitizeName(newName); - if (sanitized === currentName || getPerDiemNameError(newName)) { + if (sanitized === currentName) { return; } + const nameError = getPerDiemNameError(newName); + if (nameError) { + return getPerDiemNameErrorMessage(translate, nameError, newName); + } + editPerDiemRateDestination(policyID, rateID, customUnit, sanitized); } -function renamePerDiemSubrateInline(policyID: string, rateID: string, subRateID: string, customUnit: CustomUnit | undefined, currentName: string, newName: string): void { +function renamePerDiemSubrateInline( + translate: LocalizedTranslate, + policyID: string, + rateID: string, + subRateID: string, + customUnit: CustomUnit | undefined, + currentName: string, + newName: string, +): string | undefined { const sanitized = StringUtils.sanitizeName(newName); - if (sanitized === currentName || getPerDiemNameError(newName)) { + if (sanitized === currentName) { return; } + const nameError = getPerDiemNameError(newName); + if (nameError) { + return getPerDiemNameErrorMessage(translate, nameError, newName); + } + editPerDiemRateSubrate(policyID, rateID, subRateID, customUnit, sanitized); } /** * Updates a per diem amount from an inline table edit. `newAmount` is the frontend dollar string. */ -function updatePerDiemAmountInline(policyID: string, rateID: string, subRateID: string, customUnit: CustomUnit | undefined, currentRate: number, newAmount: string): void { - if (getPerDiemAmountError(newAmount)) { - return; +function updatePerDiemAmountInline( + translate: LocalizedTranslate, + policyID: string, + rateID: string, + subRateID: string, + customUnit: CustomUnit | undefined, + currentRate: number, + newAmount: string, +): string | undefined { + const amountError = getPerDiemAmountError(newAmount); + if (amountError) { + return getPerDiemAmountErrorMessage(translate, amountError); } const backendAmount = convertToBackendAmount(Number(newAmount)); @@ -210,6 +306,7 @@ export { updateDistanceRateValueInline, updateMemberRoleInline, canUpdateExpensifyCardLimitTypeInline, + getExpensifyCardLimitInlineEditError, getExpensifyCardLimitInlineUpdate, updateExpensifyCardLimitTypeInline, updateExpensifyCardLimitInline, diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 05086f61b9f9..a54d0e330422 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -843,6 +843,7 @@ function buildAddMembersToWorkspaceOnyxData( reportActionsList: OnyxCollection | undefined, approverEmail?: string, policyExpenseChatNotificationPreference?: NotificationPreference, + hasOfficeWorkArrangement?: boolean, ) { const policyID = policy.id; const logins = Object.keys(invitedEmailsToAccountIDs).map((memberLogin) => PhoneNumber.addSMSDomainIfPhoneNumber(memberLogin)); @@ -893,6 +894,7 @@ function buildAddMembersToWorkspaceOnyxData( pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, role: effectiveRole, submitsTo: approverEmail ?? getDefaultApprover(policy), + ...(hasOfficeWorkArrangement !== undefined ? {hasOfficeWorkArrangement} : {}), }; successMembersState[email] = {pendingAction: null}; failureMembersState[email] = { @@ -991,6 +993,7 @@ function addMembersToWorkspace( currentUser: CurrentUser, reportActionsList: OnyxCollection, approverEmail?: string, + hasOfficeWorkArrangement?: boolean, ) { if (!policy?.id) { Log.warn('addMembersToWorkspace: Policy ID is undefined'); @@ -1007,6 +1010,8 @@ function addMembersToWorkspace( currentUser, reportActionsList, approverEmail, + undefined, + hasOfficeWorkArrangement, ); const params: AddMembersToWorkspaceParams = { @@ -1017,6 +1022,7 @@ function addMembersToWorkspace( shouldEscapeText: false, }), policyID: policy.id, + ...(hasOfficeWorkArrangement !== undefined ? {hasOfficeWorkArrangement} : {}), }; if (!isEmptyObject(membersChats.reportCreationData)) { params.reportCreationData = JSON.stringify(membersChats.reportCreationData); @@ -1304,6 +1310,29 @@ function openPolicyMemberProfilePage(policyID: string, accountID: number) { function setWorkspaceInviteMembersDraft(policyID: string, invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs) { Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, invitedEmailsToAccountIDs); + // A changed invitee selection starts a new invite draft. Do not carry a previous invite's + // arrangement choice into this one, including when the new flow is open in another tab. + clearWorkspaceInviteWorkArrangementDraft(policyID); +} + +function setMembersSelectedForRoleChange(policyID: string, memberLogins: string[]) { + Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, {policyID, logins: memberLogins}); +} + +/** + * Empties the selection in place, which is how the members table learns the change was saved and drops the rows it + * still has selected. + */ +function clearMembersSelectedForRoleChange(policyID: string) { + Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, {policyID, logins: []}); +} + +/** + * Drops the selection entirely, for leaving the role screen without saving. The members table keeps its rows selected, + * since an empty selection is reserved for a saved change. + */ +function discardMembersSelectedForRoleChange() { + Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, null); } function setWorkspaceInviteRoleDraft(policyID: string, role: ValueOf) { @@ -1314,6 +1343,14 @@ function clearWorkspaceInviteRoleDraft(policyID: string) { Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_ROLE_DRAFT}${policyID}`, null); } +function setWorkspaceInviteWorkArrangementDraft(policyID: string, hasOfficeWorkArrangement: boolean) { + Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_WORK_ARRANGEMENT_DRAFT}${policyID}`, hasOfficeWorkArrangement); +} + +function clearWorkspaceInviteWorkArrangementDraft(policyID: string) { + Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_WORK_ARRANGEMENT_DRAFT}${policyID}`, null); +} + function setWorkspaceInviteApproverDraft(policyID: string, approverEmail: string) { if (!approverEmail) { return; @@ -1531,6 +1568,14 @@ function clearImportedSpreadsheetMemberData() { Onyx.set(ONYXKEYS.IMPORTED_SPREADSHEET_MEMBER_ROLE, null); } +function setWorkspaceMembersSelectedForBulkEdit(policyID: string, accountIDs: number[]) { + Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, {policyID, accountIDs}); +} + +function clearWorkspaceMembersSelectedForBulkEdit() { + Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, null); +} + export { removeMembers, buildUpdateWorkspaceMembersRoleOnyxData, @@ -1555,11 +1600,18 @@ export { buildRoomMembersOnyxData, openPolicyMemberProfilePage, setWorkspaceInviteRoleDraft, + setMembersSelectedForRoleChange, + clearMembersSelectedForRoleChange, + discardMembersSelectedForRoleChange, clearWorkspaceInviteRoleDraft, + setWorkspaceInviteWorkArrangementDraft, + clearWorkspaceInviteWorkArrangementDraft, setWorkspaceInviteApproverDraft, clearWorkspaceInviteApproverDraft, setImportedSpreadsheetMemberData, setImportedSpreadsheetMemberRole, clearImportedSpreadsheetMemberData, + setWorkspaceMembersSelectedForBulkEdit, + clearWorkspaceMembersSelectedForBulkEdit, updatePolicyMemberDisplayName, }; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 8b11a738eb27..4412b3f81fb4 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -79,6 +79,7 @@ import type { UpdateWorkspaceDescriptionParams, UpdateWorkspaceGeneralSettingsParams, UpgradeToCorporateParams, + SetPolicyPreventPayoutNonReimbursableReportsParams, } from '@libs/API/parameters'; import type SetPolicyCashExpenseModeParams from '@libs/API/parameters/SetPolicyCashExpenseModeParams'; import type UpdatePolicyMembersCustomFieldsParams from '@libs/API/parameters/UpdatePolicyMembersCustomFieldsParams'; @@ -4398,7 +4399,7 @@ function createWorkspaceFromIOUPayment({ const policyID = generatePolicyID(); const workspaceName = generateDefaultWorkspaceName(currentUserEmail, currentUserDisplayName, lastWorkspaceNumber, localeTranslate); const employeeAccountID = iouReport?.ownerAccountID; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as iouReport?.currency could be an empty string + // `iouReport?.currency` can be an empty string const {customUnits, customUnitID, customUnitRateID} = buildOptimisticDistanceRateCustomUnits(iouReport?.currency || currentUserLocalCurrency); const oldPersonalPolicyID = iouReport?.policyID; const iouReportID = iouReport?.reportID; @@ -6038,8 +6039,9 @@ function setForeignCurrencyDefault(policyID: string, taxCode: string, currentTax * Optimistically setting DEFAULT_MAX_* for those caused a brief "90 days" / "$2,000" flash (#74401). * * Receipt thresholds: UpgradeToCorporate responds with DISABLED and wipes existing values, then they - * come back shortly after (enable Rules / sync). Re-apply prior values (or Control defaults) in - * successData so the Rules row does not flash "Don't require receipts" between those updates. + * come back shortly after (enable Rules / sync). Re-apply prior values in successData so the Rules + * row does not flash "Don't require receipts" between those updates. Never fall back to Control + * defaults: the API does not persist them, so they would vanish on the next policy fetch (#101524). */ function getCorporateUpgradeReceiptThresholds(policy: OnyxEntry) { const receiptAmount = policy?.maxExpenseAmountNoReceipt; @@ -6050,8 +6052,8 @@ function getCorporateUpgradeReceiptThresholds(policy: OnyxEntry) { const isReceiptThresholdEnabled = (amount: number | undefined): amount is number => amount !== undefined && amount !== CONST.DISABLED_MAX_EXPENSE_VALUE && amount !== 0; return { - maxExpenseAmountNoReceipt: isReceiptThresholdEnabled(receiptAmount) ? receiptAmount : CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT, - maxExpenseAmountNoItemizedReceipt: isReceiptThresholdEnabled(itemizedAmount) ? itemizedAmount : CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT, + ...(isReceiptThresholdEnabled(receiptAmount) ? {maxExpenseAmountNoReceipt: receiptAmount} : {}), + ...(isReceiptThresholdEnabled(itemizedAmount) ? {maxExpenseAmountNoItemizedReceipt: itemizedAmount} : {}), }; } @@ -6876,15 +6878,22 @@ function getCashExpenseReimbursableMode(policy: OnyxEntry): PolicyCashEx return undefined; } - if (policy.defaultReimbursable && !policy.disabledFields?.reimbursable) { + // `defaultReimbursable` is optional on the policy blob. An absent value means "reimbursable", + // which is what the backend and the expense creation path both assume, so read it the same way + // here. Treating an absent value as `false` made this page claim "always non-reimbursable" on + // workspaces that actually create reimbursable expenses. + const defaultReimbursable = policy.defaultReimbursable ?? true; + const isReimbursableDisabled = !!policy.disabledFields?.reimbursable; + + if (defaultReimbursable && !isReimbursableDisabled) { return CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.REIMBURSABLE_DEFAULT; } - if (!policy.disabledFields?.reimbursable && !policy.defaultReimbursable) { + if (!defaultReimbursable && !isReimbursableDisabled) { return CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.NON_REIMBURSABLE_DEFAULT; } - if (policy.defaultReimbursable && policy.disabledFields?.reimbursable) { + if (defaultReimbursable && isReimbursableDisabled) { return CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.ALWAYS_REIMBURSABLE; } @@ -7445,6 +7454,71 @@ function setPolicyPreventSelfApproval( API.write(WRITE_COMMANDS.SET_POLICY_PREVENT_SELF_APPROVAL, parameters, withReviewWorkspaceSettingsTaskData({optimisticData, successData, failureData}, reviewWorkspaceSettingsTaskData)); } +/** + * Call the API to control whether non-reimbursable reports can be marked as paid + * @param policyID - ID of the policy to update + * @param preventPayoutNonReimbursableReports - Whether non-reimbursable reports can be marked as paid. Set to true to prevent payout, false to allow it. + * @param currentPreventPayoutNonReimbursableReports - current value of preventPayoutNonReimbursableReports + */ +function setPolicyPreventPayoutNonReimbursableReports(policyID: string, preventPayoutNonReimbursableReports: boolean, currentPreventPayoutNonReimbursableReports: boolean | undefined) { + if (preventPayoutNonReimbursableReports === currentPreventPayoutNonReimbursableReports) { + return; + } + + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + preventPayoutNonReimbursableReports, + pendingFields: { + preventPayoutNonReimbursableReports: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + preventPayoutNonReimbursableReports: null, + }, + errorFields: null, + }, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + preventPayoutNonReimbursableReports: currentPreventPayoutNonReimbursableReports ?? null, + pendingFields: { + preventPayoutNonReimbursableReports: null, + }, + errorFields: { + preventPayoutNonReimbursableReports: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyPreventPayoutNonReimbursableReportsParams = { + policyID, + enabled: preventPayoutNonReimbursableReports, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_PREVENT_PAYOUT_NON_REIMBURSABLE_REPORTS, parameters, { + optimisticData, + successData, + failureData, + }); +} + /** * Call the API to apply automatic approval limit for the given policy * @param policyID - id of the policy to apply the limit to @@ -8219,6 +8293,7 @@ export { clearQBDErrorField, setPolicyPreventMemberCreatedTitle, setPolicyPreventSelfApproval, + setPolicyPreventPayoutNonReimbursableReports, setPolicyAutomaticApprovalLimit, setPolicyAutomaticApprovalRate, setPolicyAutoReimbursementLimit, diff --git a/src/libs/actions/Policy/PolicyDraft.ts b/src/libs/actions/Policy/PolicyDraft.ts index 0d3c28c802ae..0082726081b9 100644 --- a/src/libs/actions/Policy/PolicyDraft.ts +++ b/src/libs/actions/Policy/PolicyDraft.ts @@ -84,7 +84,6 @@ function generateCustomUnitID(): string { } function buildOptimisticDistanceRateCustomUnits(currencyParam: string | undefined): OptimisticCustomUnits { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null const currency = currencyParam || CONST.CURRENCY.USD; const customUnitID = generateCustomUnitID(); const customUnitRateID = generateCustomUnitID(); diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index f8d94fc18ae5..c899012c7a64 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -6,17 +6,16 @@ import * as API from '@libs/API'; import type { AddPolicyAgentRuleParams, DeletePolicyAgentRuleParams, + DeleteRuleParams, GenerateRuleParams, GetAgentRuleSuggestionsParams, ImportMerchantRulesSpreadsheetParams, + SetRuleParams, UpdatePolicyAgentRuleParams, } from '@libs/API/parameters'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; -import type SetPolicyCodingRuleParams from '@libs/API/parameters/SetPolicyCodingRuleParams'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; -import {buildMerchantRule, isExpenseDefaultTaxValue} from '@libs/ExpenseDefaultRuleUtils'; -import type {BuiltMerchantRule, MerchantRuleFormValues} from '@libs/ExpenseDefaultRuleUtils'; import Log from '@libs/Log'; import {getIsOffline} from '@libs/NetworkState'; import {rand64} from '@libs/NumberUtils'; @@ -24,13 +23,13 @@ import {rand64} from '@libs/NumberUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ExpenseDefaultAction} from '@src/types/onyx/ExpenseDefaultRules'; import type {GeneratedRuleState, GeneratedRuleType} from '@src/types/onyx/GeneratedRule'; import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; import type Policy from '@src/types/onyx/Policy'; import type {AgentRule, CodingRule, CodingRuleFilter} from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; import type Rule from '@src/types/onyx/Rule'; +import type {RuleBody} from '@src/types/onyx/Rule'; import type {OnyxUpdate} from 'react-native-onyx'; @@ -39,69 +38,6 @@ import Onyx from 'react-native-onyx'; /** A coding rule parsed from an imported spreadsheet row, keyed by a client-generated ruleID */ type ImportedMerchantRule = Omit; -/** - * Builds the `codingRuleValue` sent to `SetPolicyCodingRule`. Writes go through the legacy command rather than - * `SetRule` because it dual-writes into both `policy.rules.codingRules` and the `rules_` collection, while - * `SetRule` only writes the new collection. Older clients read `codingRules`, so writing only the new collection - * would silently stop them seeing rules created or edited on a newer client. This whole path goes away once the - * minimum supported version reads the `rules_` collection. - * - * This reads the built rule rather than the form so the optimistic Onyx value and the saved rule share one - * definition of what an empty field is. Reading the form separately let a padded merchant, a whitespace-only - * description, or a tax selected before the rates loaded reach the server after the rule had already dropped it. - */ -function buildLegacyCodingRule(ruleValue: BuiltMerchantRule, ruleID: string, created: string): Partial { - const {FIELD} = CONST.RULES.EXPENSE_DEFAULT; - const {ACTIONS: ACTION} = CONST.RULES; - - const valuesByField = new Map(); - for (const action of Object.values(ruleValue.actions)) { - if (action.name !== ACTION.SET) { - continue; - } - valuesByField.set(action.field, action.value); - } - - const getStringValue = (field: string): string | undefined => { - const value = valuesByField.get(field); - return typeof value === 'string' ? value : undefined; - }; - const getBooleanValue = (field: string): boolean | undefined => { - const value = valuesByField.get(field); - return typeof value === 'boolean' ? value : undefined; - }; - - const taxValue = valuesByField.get(FIELD.TAX); - const tax = isExpenseDefaultTaxValue(taxValue) ? taxValue : undefined; - const merchant = getStringValue(FIELD.MERCHANT); - const category = getStringValue(FIELD.CATEGORY); - const tag = getStringValue(FIELD.TAG); - const vendorID = getStringValue(FIELD.VENDOR_ID); - const comment = getStringValue(FIELD.COMMENT); - const reimbursable = getBooleanValue(FIELD.REIMBURSABLE); - const billable = getBooleanValue(FIELD.BILLABLE); - - const {left, operator, right} = ruleValue.filters; - - return { - ruleID, - filters: { - left, - operator, - right: typeof right === 'string' ? right : String(right), - }, - ...(merchant && {merchant}), - ...(category && {category}), - ...(tag && {tag}), - ...(tax && {tax}), - ...(vendorID && {vendorID}), - ...(comment && {comment}), - ...(reimbursable !== undefined && {reimbursable}), - ...(billable !== undefined && {billable}), - created, - }; -} - /** How long a `GetRules` read may stay marked in flight before the flag is treated as stale. */ const RULES_FETCH_TIMEOUT_MS = 30 * 1000; @@ -232,26 +168,17 @@ function getAgentRuleSuggestions(policyID: string | undefined) { } /** - * Creates or updates a merchant rule. Editing a rule reuses its `ruleID`, since the rules engine has no separate update command. + * Creates or updates a rule of any kind. Editing a rule reuses its `ruleID`, since the rules engine has no separate update command. * @param policyID - The ID of the policy the rule belongs to - * @param formValues - The merchant rule editor's values - * @param policy - Used to resolve the selected tax rate + * @param ruleBody - The triggers, filters and actions to store, built by whichever editor owns this kind of rule + * @param priority - Determines the order rules are applied in when more than one matches * @param ruleID - The ID of the rule being edited, or undefined to create one * @param existingRule - The rule being edited, restored on failure * @param shouldUpdateMatchingTransactions - Whether to apply the rule to transactions that already match it */ -function setMerchantRule( - policyID: string, - formValues: Partial, - policy: Policy | undefined, - ruleID?: string, - existingRule?: Rule, - shouldUpdateMatchingTransactions = false, -) { - const ruleValue = buildMerchantRule(formValues, policy); - - if (!policyID || !ruleValue) { - Log.warn('Invalid params for setMerchantRule', {policyID, merchantToMatch: formValues.merchantToMatch}); +function setRule(policyID: string, ruleBody: RuleBody | undefined, priority: number, ruleID?: string, existingRule?: Rule, shouldUpdateMatchingTransactions = false) { + if (!policyID || !ruleBody) { + Log.warn('Invalid params for setRule', {policyID, ruleID}); return; } @@ -260,11 +187,12 @@ function setMerchantRule( const ruleKey = `${ONYXKEYS.COLLECTION.RULE}${targetRuleID}` as const; const created = existingRule?.created ?? new Date().toISOString(); + // Shared between the optimistic rule and the request so the rule shown while offline matches what the server saves. + const ruleScope = {scope: CONST.RULES.SCOPE.POLICY, scopeID: policyID, priority}; + const optimisticRule: Rule = { - ...ruleValue, - scope: CONST.RULES.SCOPE.POLICY, - scopeID: policyID, - priority: CONST.RULES.EXPENSE_DEFAULT.PRIORITY, + ...ruleBody, + ...ruleScope, created, pendingAction: isEditing ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }; @@ -287,14 +215,15 @@ function setMerchantRule( ], }; - const parameters: SetPolicyCodingRuleParams = { - policyID, - codingRuleID: targetRuleID, - codingRuleValue: JSON.stringify(buildLegacyCodingRule(ruleValue, targetRuleID, created)), + const parameters: SetRuleParams = { + ...ruleScope, + ruleID: targetRuleID, + // FormData cannot carry an object, so the rule body goes over the wire as a string. + value: JSON.stringify(ruleBody), shouldUpdateMatchingTransactions, }; - API.write(WRITE_COMMANDS.SET_POLICY_CODING_RULE, parameters, onyxData); + API.write(WRITE_COMMANDS.SET_RULE, parameters, onyxData); } /** @@ -367,14 +296,13 @@ function getTransactionsMatchingCodingRule(policyID: string, filters: CodingRule } /** - * Deletes a merchant rule - * @param policyID - The ID of the policy the rule belongs to + * Deletes a rule of any kind * @param ruleID - The ID of the rule to delete * @param rule - The rule being deleted, restored on failure */ -function deleteMerchantRule(policyID: string, ruleID: string, rule: Rule | undefined) { - if (!policyID || !ruleID) { - Log.warn('Invalid params for deleteMerchantRule', {policyID, ruleID}); +function deleteRule(ruleID: string, rule: Rule | undefined) { + if (!ruleID) { + Log.warn('Invalid params for deleteRule', {ruleID}); return; } @@ -392,29 +320,25 @@ function deleteMerchantRule(policyID: string, ruleID: string, rule: Rule | undef ], }; - // An empty codingRuleValue tells SetPolicyCodingRule to delete rather than upsert the rule. - const parameters: SetPolicyCodingRuleParams = { - policyID, - codingRuleID: ruleID, - codingRuleValue: '', - shouldUpdateMatchingTransactions: false, - }; + const parameters: DeleteRuleParams = {ruleID}; - API.write(WRITE_COMMANDS.SET_POLICY_CODING_RULE, parameters, onyxData); + API.write(WRITE_COMMANDS.DELETE_RULE, parameters, onyxData); } const PROMPT_ERROR_BY_STATE: Partial> = { [CONST.GENERATED_RULE.STATE.UNSUPPORTED]: 'workspace.rules.newRule.promptErrors.unsupported', [CONST.GENERATED_RULE.STATE.MULTIPLE_RULES]: 'workspace.rules.newRule.promptErrors.multipleRules', [CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE]: 'workspace.rules.newRule.promptErrors.unintelligible', + [CONST.GENERATED_RULE.STATE.AGENT_RULE]: 'workspace.rules.newRule.promptErrors.unsupported', }; /** - * Asks Concierge to turn a description into values for the given rule form. The response only confirms the job was - * queued. The answer arrives later under `ONYXKEYS.GENERATED_RULE`, so the form stays loading until it does. + * Asks Concierge to turn a description into rule form values. The answer carries its own rule type, which can differ + * from `ruleType`. The response only confirms that the server queued the job. The answer arrives later under + * `ONYXKEYS.GENERATED_RULE`. Therefore the form stays in the loading state until the answer arrives. * @returns the generationID the answer carries */ -function generateRule(policyID: string, ruleType: GeneratedRuleType, prompt: string): string { +function generateRule(policyID: string, prompt: string, ruleType?: GeneratedRuleType): string { const generationID = rand64(); type GenerateRuleKey = typeof ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM | typeof ONYXKEYS.GENERATED_RULE; @@ -447,9 +371,26 @@ function generateRule(policyID: string, ruleType: GeneratedRuleType, prompt: str /** Shows on the prompt form why a description did not become a rule */ function setNewRulePromptError(state: GeneratedRuleState) { + setNewRulePromptErrorMessage(PROMPT_ERROR_BY_STATE[state] ?? 'common.genericErrorMessage'); +} + +/** Shows an error on the prompt form */ +function setNewRulePromptErrorMessage(translationKey: TranslationPaths) { Onyx.merge(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, { isLoading: false, - errors: getMicroSecondOnyxErrorWithTranslationKey(PROMPT_ERROR_BY_STATE[state] ?? 'common.genericErrorMessage'), + errors: getMicroSecondOnyxErrorWithTranslationKey(translationKey), + }); +} + +/** Stops the loading state of the prompt form when the answer is shown in the form and not as an error */ +function stopNewRulePromptLoading() { + Onyx.merge(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, {isLoading: false}); +} + +/** Shows the empty description error above the submit button of the new rule prompt form */ +function setNewRulePromptEmptyError() { + Onyx.merge(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, { + errors: getMicroSecondOnyxErrorWithTranslationKey('workspace.rules.newRule.selectTemplateOrDescribe'), }); } @@ -457,10 +398,13 @@ function clearGeneratedRule() { Onyx.set(ONYXKEYS.GENERATED_RULE, null); } +/** + * Clears the prompt form and its draft. The answer stays, because a page that opens after the form closes reads it by + * its generationID. The next `generateRule` call clears it. + */ function clearNewRulePrompt() { Onyx.set(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, null); clearDraftValues(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); - clearGeneratedRule(); } function addPolicyAgentRule(policyID: string, agentRuleID: string, prompt: string) { @@ -721,15 +665,18 @@ export { getAgentRuleSuggestions, getRules, resetRulesFetchState, - setMerchantRule, + setRule, importMerchantRulesSpreadsheet, - deleteMerchantRule, + deleteRule, getTransactionsMatchingCodingRule, addPolicyAgentRule, generateRule, + setNewRulePromptEmptyError, setNewRulePromptError, + setNewRulePromptErrorMessage, clearGeneratedRule, clearNewRulePrompt, + stopNewRulePromptLoading, updatePolicyAgentRule, deletePolicyAgentRule, clearMerchantRuleErrors, diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index d15048ef973d..15935c950ce1 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -901,6 +901,10 @@ function setImportedSpreadsheetIsImportingIndependentMultiLevelTags(isImportingI Onyx.merge(ONYXKEYS.IMPORTED_SPREADSHEET, {isImportingIndependentMultiLevelTags}); } +function setImportedSpreadsheetIsAppendingToExistingLists(isAppendingToExistingLists: boolean) { + Onyx.merge(ONYXKEYS.IMPORTED_SPREADSHEET, {isAppendingToExistingLists}); +} + function setImportedSpreadsheetIsFirstLineHeader(containsHeader: boolean) { Onyx.merge(ONYXKEYS.IMPORTED_SPREADSHEET, {containsHeader}); } @@ -916,6 +920,19 @@ function importMultiLevelTags(policyID: string, spreadsheet: ImportedSpreadsheet const importFinalModalID = getImportFinalModalID(); const importFinalModalResult = waitForImportFinalModal(importFinalModalID); + const failureData: Array> = [ + getImportFinalModalOnyxData(importFinalModalID, getImportFailedFinalModal()), + ]; + // When appending, the accounting connection tag lists are kept, so a failed append shouldn't change whether the policy has multiple tag lists + if (!spreadsheet.isAppendingToExistingLists) { + failureData.unshift({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + hasMultipleTagLists: false, + }, + }); + } const onyxData: OnyxData = { successData: [ { @@ -927,16 +944,7 @@ function importMultiLevelTags(policyID: string, spreadsheet: ImportedSpreadsheet }, getImportFinalModalOnyxData(importFinalModalID, getImportMultiLevelTagsFinalModal()), ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - hasMultipleTagLists: false, - }, - }, - getImportFinalModalOnyxData(importFinalModalID, getImportFailedFinalModal()), - ], + failureData, }; return new Promise((resolve) => { @@ -949,6 +957,7 @@ function importMultiLevelTags(policyID: string, spreadsheet: ImportedSpreadsheet isFirstLineHeader: spreadsheet.containsHeader, isIndependent: spreadsheet.isImportingIndependentMultiLevelTags, isGLAdjacent: spreadsheet.isGLAdjacent, + appendToExistingLists: !!spreadsheet.isAppendingToExistingLists, file, }; @@ -1542,6 +1551,7 @@ export { cleanPolicyTags, setImportedSpreadsheetIsImportingMultiLevelTags, setImportedSpreadsheetIsImportingIndependentMultiLevelTags, + setImportedSpreadsheetIsAppendingToExistingLists, setImportedSpreadsheetIsFirstLineHeader, setImportedSpreadsheetIsGLAdjacent, importMultiLevelTags, diff --git a/src/libs/actions/Policy/Vendor.ts b/src/libs/actions/Policy/Vendor.ts new file mode 100644 index 000000000000..50adfabcde1b --- /dev/null +++ b/src/libs/actions/Policy/Vendor.ts @@ -0,0 +1,123 @@ +/** + * Actions for enabling/disabling policy vendors and clearing their offline errors. + */ +import {write, read} from '@libs/API'; +import type {SetPolicyVendorsEnabledParams} from '@libs/API/parameters'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import Log from '@libs/Log'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PolicyVendor, PolicyVendors} from '@src/types/onyx'; +import type {OnyxData} from '@src/types/onyx/Request'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +type SetPolicyVendorsEnabledArgs = { + policyID: string; + vendorIDs: string[]; + enabled: boolean; + policyVendors?: OnyxEntry; +}; + +function setPolicyVendorsEnabled({policyID, vendorIDs, enabled, policyVendors}: SetPolicyVendorsEnabledArgs) { + if (!vendorIDs.length) { + return; + } + + const optimisticVendors: Record> = {}; + const successVendors: Record> = {}; + const failureVendors: Record> = {}; + + for (const vendorID of vendorIDs) { + const existing = policyVendors?.[vendorID]; + optimisticVendors[vendorID] = { + ...existing, + enabled, + pendingFields: { + ...existing?.pendingFields, + enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + pendingAction: existing?.pendingAction ?? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + errors: null, + }; + + successVendors[vendorID] = { + ...existing, + enabled, + pendingFields: { + ...existing?.pendingFields, + enabled: null, + }, + pendingAction: null, + errors: null, + }; + + failureVendors[vendorID] = { + ...existing, + pendingFields: { + ...existing?.pendingFields, + enabled: null, + }, + pendingAction: null, + errors: getMicroSecondOnyxErrorWithTranslationKey('workspace.vendors.updateFailureMessage'), + }; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, + value: optimisticVendors, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, + value: successVendors, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, + value: failureVendors, + }, + ], + }; + + const parameters: SetPolicyVendorsEnabledParams = { + policyID, + vendorIDs: JSON.stringify(vendorIDs), + enabled, + }; + + write(WRITE_COMMANDS.SET_POLICY_VENDORS_ENABLED, parameters, onyxData); +} + +function clearVendorErrors(policyID: string, vendorID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, { + [vendorID]: { + errors: null, + pendingAction: null, + pendingFields: null, + errorFields: null, + }, + }); +} + +function openPolicyVendorsPage(policyID: string) { + if (!policyID) { + Log.warn('openPolicyVendorsPage invalid params', {policyID}); + return; + } + + read(READ_COMMANDS.OPEN_POLICY_VENDORS_PAGE, {policyID}); +} + +export {setPolicyVendorsEnabled, clearVendorErrors, openPolicyVendorsPage}; diff --git a/src/libs/actions/ReimbursementAccount/index.ts b/src/libs/actions/ReimbursementAccount/index.ts index fccba12680b2..f56217ca6b51 100644 --- a/src/libs/actions/ReimbursementAccount/index.ts +++ b/src/libs/actions/ReimbursementAccount/index.ts @@ -63,6 +63,13 @@ function clearReimbursementAccountBackup() { Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT_BACKUP, null); } +/** Clear previous setup data and seed a bank account draft without linking it to a workspace. */ +function prepareStandaloneBankAccountSetup(currency: string, country = mapCurrencyToCountry(currency)) { + clearReimbursementAccount(); + clearReimbursementAccountDraft(); + updateReimbursementAccountDraft({country, currency}); +} + /** * Prepares the app to set up a new bank account by marking the account change, clearing existing data, * and initializing draft with country and currency. @@ -75,12 +82,7 @@ function prepareNewBankAccountSetup(currency: string, reimbursementAccountToBack Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT_BACKUP, reimbursementAccountToBackup); } Onyx.set(ONYXKEYS.IS_CHANGING_TO_NEW_BANK_ACCOUNT, true); - clearReimbursementAccount(); - clearReimbursementAccountDraft(); - updateReimbursementAccountDraft({ - country: mapCurrencyToCountry(currency), - currency, - }); + prepareStandaloneBankAccountSetup(currency); } /** @@ -139,4 +141,5 @@ export { resetReimbursementAccount, cancelChangingToNewBankAccount, prepareNewBankAccountSetup, + prepareStandaloneBankAccountSetup, }; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 0da562f435d4..50a47c79f374 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -41,6 +41,7 @@ import type { SearchForReportsParams, SearchForRoomsToMentionParams, SearchForUsersParams, + ShareReportParams, TogglePinnedChatParams, TransactionThreadInfo, UpdateChatNameParams, @@ -93,6 +94,7 @@ import Parser from '@libs/Parser'; import {getParsedMessageWithShortMentions} from '@libs/ParsingUtils'; import {getAllPersonalDetails} from '@libs/PersonalDetailsStore'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {isMapOrGPSRequired} from '@libs/PolicyDistanceRatesUtils'; import { getDefaultApprover, @@ -263,6 +265,7 @@ import type {CurrentUserPersonalDetails, Timezone} from '@src/types/onyx/Persona import type {ConnectionName} from '@src/types/onyx/Policy'; import type {NotificationPreference, Participants, Participant as ReportParticipant, RoomVisibility, WriteCapability} from '@src/types/onyx/Report'; import type {Message, ReportActions} from '@src/types/onyx/ReportAction'; +import type {OnyxData} from '@src/types/onyx/Request'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -391,6 +394,10 @@ type OpenReportActionParams = { /** The Concierge chat report used to build the guided setup onboarding data */ conciergeChat: OnyxEntry; + + /** The accountID of the copilot acting on behalf of the current user, if any */ + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number; }; type PregeneratedResponseParams = { @@ -618,6 +625,11 @@ function getReportChannelName(reportID: string): string { return `${CONST.PUSHER.PRIVATE_REPORT_CHANNEL_PREFIX}${reportID}${CONFIG.PUSHER.SUFFIX}`; } +/** Loads the policy expense chats the workspace picker needs, since OpenApp no longer returns all of them. */ +function openRecipientPicker() { + API.read(READ_COMMANDS.OPEN_RECIPIENT_PICKER, null); +} + function openUnreportedExpense(reportID: string | undefined, backToReport?: string) { if (!reportID) { return; @@ -1002,7 +1014,7 @@ function addActions({ // Check if the last visible action is from Concierge with unresolved followups // If so, optimistically resolve them by adding the updated action to optimisticReportActions - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID); + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, undefined, {}, undefined, undefined, currentUserAccountID); const lastActorAccountID = lastVisibleAction?.actorAccountID; const lastActionReportActionID = lastVisibleAction?.reportActionID; const resolvedAction = buildOptimisticResolvedFollowups(lastVisibleAction); @@ -1619,6 +1631,8 @@ function getGuidedSetupDataForOpenReport( isSelfTourViewed?: boolean, // TODO: This will be required eventually. Refactor issue: https://github.com/Expensify/App/issues/66424 hasCompletedGuidedSetupFlow?: boolean, + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number, ): GuidedSetupDataForOpenReport | undefined { const isInviteOnboardingComplete = introSelected?.isInviteOnboardingComplete ?? false; const isOnboardingCompleted = hasCompletedGuidedSetupFlow ?? onboarding?.hasCompletedGuidedSetupFlow ?? false; @@ -1656,9 +1670,7 @@ function getGuidedSetupDataForOpenReport( isSelfTourViewed, wasInvited: isPendingInviteOnboarding && isOnboardingCompleted, currentUserAccountID, - // delegateAccountID will be threaded in PR 16 together with the other openReport params. - // buildOptimisticAddCommentReportAction falls back to the module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) - delegateAccountID: undefined, + delegateAccountID, }); if (!onboardingData) { @@ -1712,6 +1724,7 @@ function openReport(params: OpenReportActionParams) { shouldMarkAsRead = true, shouldKeepManualUnreadMarker = false, conciergeChat, + delegateAccountID, } = params; if (!reportID) { return; @@ -1893,8 +1906,7 @@ function openReport(params: OpenReportActionParams) { isOwnPolicyExpenseChat: false, reportActionID: iouReportActionID, iouReportID: transactionParentReportID, - // delegateAccountIDParam: will be threaded in PR 15; buildOptimisticIOUReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) - delegateAccountIDParam: undefined, + delegateAccountIDParam: delegateAccountID, // Non-React call path: pass the standalone util until this file's own threading PR. getCurrencyDecimals: getCurrencyDecimalsUtil, }); @@ -1963,7 +1975,7 @@ function openReport(params: OpenReportActionParams) { }); } - const guidedSetup = getGuidedSetupDataForOpenReport(introSelected, currentUserAccountID, conciergeChat, isSelfTourViewed, hasCompletedGuidedSetupFlow); + const guidedSetup = getGuidedSetupDataForOpenReport(introSelected, currentUserAccountID, conciergeChat, isSelfTourViewed, hasCompletedGuidedSetupFlow, delegateAccountID); if (guidedSetup) { optimisticData.push(...guidedSetup.optimisticData); successData.push(...guidedSetup.successData); @@ -2174,6 +2186,10 @@ type CreateGroupChatParams = { hasCompletedGuidedSetupFlow: boolean; conciergeChat: OnyxEntry; currentUserAccountID: number; + + /** The accountID of the copilot acting on behalf of the current user, if any */ + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number; avatar?: File | CustomRNImageManipulatorResult; }; @@ -2187,6 +2203,7 @@ function createGroupChat({ hasCompletedGuidedSetupFlow, conciergeChat, currentUserAccountID, + delegateAccountID, avatar, }: CreateGroupChatParams) { const participantLoginList = Object.values(participantsPersonalDetails ?? {}) @@ -2366,7 +2383,7 @@ function createGroupChat({ } // Preserve guided setup data when creating group chats - const guidedSetup = getGuidedSetupDataForOpenReport(introSelected, currentUserAccountID, conciergeChat, isSelfTourViewed, hasCompletedGuidedSetupFlow); + const guidedSetup = getGuidedSetupDataForOpenReport(introSelected, currentUserAccountID, conciergeChat, isSelfTourViewed, hasCompletedGuidedSetupFlow, delegateAccountID); if (guidedSetup) { optimisticData.push(...guidedSetup.optimisticData); successData.push(...guidedSetup.successData); @@ -2452,6 +2469,10 @@ type CreateTransactionThreadReportParams = { hasCompletedGuidedSetupFlow?: boolean; conciergeChat: OnyxEntry; + + /** The accountID of the copilot acting on behalf of the current user, if any */ + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number; }; function createTransactionThreadReport(params: CreateTransactionThreadReportParams): OptimisticChatReport | undefined { @@ -2467,6 +2488,7 @@ function createTransactionThreadReport(params: CreateTransactionThreadReportPara isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergeChat, + delegateAccountID, } = params; const isTrackExpense = !iouReport && ReportActionsUtils.isTrackExpenseAction(iouReportAction); @@ -2535,6 +2557,7 @@ function createTransactionThreadReport(params: CreateTransactionThreadReportPara isSelfTourViewed, hasCompletedGuidedSetupFlow, hasReportActions: false, + delegateAccountID, }); return optimisticTransactionThread; } @@ -2583,6 +2606,10 @@ type NavigateToAndOpenReportParams = { hasCompletedGuidedSetupFlow: boolean | undefined; conciergeChat: OnyxEntry; + /** The accountID of the copilot acting on behalf of the current user, if any */ + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number; + /** Whether the current session is a supportal session.*/ isSupportalSession: boolean; shouldDismissModal?: boolean; @@ -2604,6 +2631,7 @@ function navigateToAndOpenReport({ isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergeChat, + delegateAccountID, isSupportalSession, shouldDismissModal = true, shouldRevalidateExistingChat = false, @@ -2645,6 +2673,7 @@ function navigateToAndOpenReport({ hasReportActions: false, currentUserAccountID, conciergeChat, + delegateAccountID, }); navigateToReport(fallbackChat.reportID, {shouldDismissModal, sourceReportID, ...linkToOptions}); @@ -2676,6 +2705,7 @@ function navigateToAndOpenReport({ hasReportActions, currentUserAccountID, conciergeChat, + delegateAccountID, }); } navigateToReport(chat.reportID, {shouldDismissModal, sourceReportID, ...linkToOptions}); @@ -2704,7 +2734,17 @@ function navigateToAndOpenReport({ // Re-open existing chats to re-validate server-side access and refresh stale local state. Pass hasCompletedGuidedSetupFlow // so a pending onboarding OpenReport is enqueued here too (see the create-path assumption note above). - openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, personalDetails, hasReportActions, currentUserAccountID, conciergeChat}); + openReport({ + reportID: chat.reportID, + introSelected, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + personalDetails, + hasReportActions, + currentUserAccountID, + conciergeChat, + delegateAccountID, + }); navigateToReport(chat.reportID, {shouldDismissModal, sourceReportID, ...linkToOptions}); } @@ -2719,6 +2759,10 @@ type NavigateToAndCreateGroupChatParams = { conciergeChat: OnyxEntry; currentUserAccountID: number; + /** The accountID of the copilot acting on behalf of the current user, if any */ + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number; + /** Whether the current session is a supportal session.*/ isSupportalSession: boolean; avatarUri?: string; @@ -2736,6 +2780,7 @@ function navigateToAndCreateGroupChat(params: NavigateToAndCreateGroupChatParams hasCompletedGuidedSetupFlow, conciergeChat, currentUserAccountID, + delegateAccountID, isSupportalSession, avatarUri, avatarFile, @@ -2764,6 +2809,7 @@ function navigateToAndCreateGroupChat(params: NavigateToAndCreateGroupChatParams hasCompletedGuidedSetupFlow, conciergeChat, currentUserAccountID, + delegateAccountID, avatar: avatarFile, }); @@ -2785,6 +2831,8 @@ function navigateToAndOpenReportWithAccountIDs( conciergeChat: OnyxEntry, shouldRevalidateExistingChat = false, hasReportActions?: boolean, + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number, ) { const participants = participantAccountIDs.map((accountID): ParticipantInfo => { return { @@ -2815,6 +2863,7 @@ function navigateToAndOpenReportWithAccountIDs( personalDetails, currentUserAccountID, conciergeChat, + delegateAccountID, }); navigateToReport(fallbackChat.reportID, {shouldDismissModal: false}); @@ -2851,7 +2900,17 @@ function navigateToAndOpenReportWithAccountIDs( }); // Re-open existing chats to re-validate server-side access and refresh stale local state. - openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, personalDetails, hasReportActions, currentUserAccountID, conciergeChat}); + openReport({ + reportID: chat.reportID, + introSelected, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + personalDetails, + hasReportActions, + currentUserAccountID, + conciergeChat, + delegateAccountID, + }); navigateToReport(chat.reportID, {shouldDismissModal: false}); } @@ -2874,9 +2933,22 @@ function navigateToAndOpenChildReport( participantsPersonalDetails: OnyxEntry, isSelfTourViewed: boolean | undefined, conciergeChat: OnyxEntry, + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number, ) { const report = - childReport ?? createChildReport(childReport, parentReportAction, parentReport, currentUserAccountID, introSelected, isSelfTourViewed, participantsPersonalDetails, conciergeChat); + childReport ?? + createChildReport( + childReport, + parentReportAction, + parentReport, + currentUserAccountID, + introSelected, + isSelfTourViewed, + participantsPersonalDetails, + conciergeChat, + delegateAccountID, + ); const backTo = Navigation.getActiveRoute(); // A money-request/expense/invoice child report must open in the wide/super-wide RHP (SEARCH_MONEY_REQUEST_REPORT in @@ -2926,6 +2998,8 @@ function createChildReport( // The personal details of the child report participants (the current user and the parent action's actor). participantsPersonalDetails: OnyxEntry, conciergeChat: OnyxEntry, + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number, ): Report { const participantAccountIDs = [...new Set([currentUserAccountID, Number(parentReportAction.actorAccountID)])]; // Threads from DMs and selfDMs don't have a chatType. All other threads inherit the chatType from their parent @@ -2970,6 +3044,7 @@ function createChildReport( hasReportActions: false, currentUserAccountID, conciergeChat, + delegateAccountID, }); } else { Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${childReportID}`, newChat); @@ -3016,20 +3091,25 @@ function explain({ // Check if explanation thread report already exists const report = - childReport ?? createChildReport(childReport, reportAction, originalReport, currentUserAccountID, introSelected, isSelfTourViewed, participantsPersonalDetails, conciergeChat); + childReport ?? + createChildReport(childReport, reportAction, originalReport, currentUserAccountID, introSelected, isSelfTourViewed, participantsPersonalDetails, conciergeChat, delegateAccountID); if (isSearchTopmostFullScreenRoute()) { Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: report.reportID, backTo: Navigation.getActiveRoute()})); } else { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.reportID, undefined, undefined, Navigation.getActiveRoute())); } + // The explanation thread is a direct child of the explained action, so that action is its only ancestor. + // addComment needs it to bump the parent's reply count, which drives the Replies link. + const ancestors: Ancestor[] = [{report: originalReport, reportAction, shouldDisplayNewMarker: false}]; + // Schedule adding the explanation comment on the next animation frame // so it runs immediately after navigation completes. requestAnimationFrame(() => { addComment({ report, notifyReportID: report.reportID, - ancestors: [], + ancestors, text: translate('reportActionContextMenu.explainMessage'), timezoneParam: timezone, currentUserAccountID, @@ -3362,7 +3442,6 @@ function saveReportDraft(reportID: string, report: Report) { * When empty string or null is passed, it will delete the draft comment from Onyx store. */ function saveReportDraftComment(reportID: string, comment: string | null, callback: () => void = () => {}) { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, comment || null).then(callback); } @@ -3384,19 +3463,34 @@ function broadcastUserIsLeavingRoom(reportID: string, currentUserAccountID: numb Pusher.sendEvent(privateReportChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM, leavingStatus); } +type DeleteReportCommentParams = { + report: OnyxEntry; + reportAction: ReportAction; + originalReportActions: OnyxEntry; + reportActions: OnyxEntry; + ancestors: Ancestor[]; + isReportArchived: boolean | undefined; + isOriginalReportArchived: boolean | undefined; + currentEmail: string; + isOffline: boolean; + currentUserAccountID: number; + visibleReportActionsData?: VisibleReportActionsDerivedValue; +}; + /** Deletes a comment from the report, basically sets it as empty string */ -function deleteReportComment( - report: OnyxEntry, - reportAction: ReportAction, - originalReportActions: OnyxEntry, - reportActions: OnyxEntry, - ancestors: Ancestor[], - isReportArchived: boolean | undefined, - isOriginalReportArchived: boolean | undefined, - currentEmail: string, - isOffline: boolean, - visibleReportActionsDataParam?: VisibleReportActionsDerivedValue, -) { +function deleteReportComment({ + report, + reportAction, + originalReportActions, + reportActions, + ancestors, + isReportArchived, + isOriginalReportArchived, + currentEmail, + isOffline, + currentUserAccountID, + visibleReportActionsData, +}: DeleteReportCommentParams) { const reportID = report?.reportID; const originalReportID = getOriginalReportID(reportID, reportAction, reportActions, isOffline); const reportActionID = reportAction.reportActionID; @@ -3498,7 +3592,13 @@ function deleteReportComment( // If we are deleting the last visible message, let's find the previous visible one (or set an empty one if there are none) and update the lastMessageText in the LHN. // Similarly, if we are deleting the last read comment we will want to update the lastVisibleActionCreated to use the previous visible message. const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report, isReportArchived); - const optimisticLastReportData = optimisticReportLastData(originalReportID, optimisticReportActions as ReportActions, canUserPerformWriteAction, isOriginalReportArchived); + const optimisticLastReportData = optimisticReportLastData( + originalReportID, + currentUserAccountID, + optimisticReportActions as ReportActions, + canUserPerformWriteAction, + isOriginalReportArchived, + ); const optimisticReport: Partial = { ...optimisticLastReportData, @@ -3510,7 +3610,7 @@ function deleteReportComment( (action) => action.reportActionID !== reportAction.reportActionID && ReportActionsUtils.didMessageMentionCurrentUser(action, currentEmail) && - ReportActionsUtils.isReportActionVisible(action, reportID, undefined, visibleReportActionsDataParam), + ReportActionsUtils.isReportActionVisible(action, reportID, undefined, visibleReportActionsData), ); optimisticReport.lastMentionedTime = latestMentionedReportAction?.created ?? null; } @@ -3840,6 +3940,15 @@ function clearAllReportActionDrafts() { Onyx.setCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {}); } +/** Clears drafts for the comment report actions of a single report, keeping drafts of other reports */ +function clearReportActionDrafts(reportID: string | undefined) { + if (!reportID) { + return; + } + + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`, null); +} + /** Saves the draft for a comment report action. This will put the comment into "edit mode" */ function saveReportActionDraft(reportID: string | undefined, reportAction: ReportAction | null, reportActions: OnyxEntry, draftMessage: string, isOffline: boolean) { if (!reportAction) { @@ -3952,6 +4061,10 @@ type ToggleSubscribeToChildReportParams = { isSelfTourViewed: boolean | undefined; hasCompletedGuidedSetupFlow: boolean | undefined; conciergeChat: OnyxEntry; + + /** The accountID of the copilot acting on behalf of the current user, if any */ + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number; prevNotificationPreference: NotificationPreference | undefined; personalDetails: OnyxEntry; hasReportActions: boolean; @@ -3974,12 +4087,23 @@ function toggleSubscribeToChildReport({ isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergeChat, + delegateAccountID, prevNotificationPreference, personalDetails, hasReportActions, }: ToggleSubscribeToChildReportParams) { if (childReportID) { - openReport({reportID: childReportID, introSelected, personalDetails, isSelfTourViewed, hasCompletedGuidedSetupFlow, hasReportActions, currentUserAccountID, conciergeChat}); + openReport({ + reportID: childReportID, + introSelected, + personalDetails, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + hasReportActions, + currentUserAccountID, + conciergeChat, + delegateAccountID, + }); const parentReportActionID = parentReportAction.reportActionID; if (!prevNotificationPreference || isHiddenForCurrentUser(prevNotificationPreference)) { updateNotificationPreference( @@ -4028,6 +4152,7 @@ function toggleSubscribeToChildReport({ conciergeChat, hasReportActions: false, currentUserAccountID, + delegateAccountID, }); const notificationPreference = isHiddenForCurrentUser(prevNotificationPreference) ? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS : CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; updateNotificationPreference(newChat.reportID, prevNotificationPreference, notificationPreference, currentUserAccountID, parentReport?.reportID, parentReportAction.reportActionID); @@ -4114,7 +4239,7 @@ function updateReportField({ report: Report; reportField: PolicyReportField; previousReportField: PolicyReportField; - policy: Policy; + policy: OnyxEntry; isASAPSubmitBetaEnabled: boolean; accountID: number; email: string; @@ -4360,16 +4485,16 @@ function deleteReportField(reportID: string, reportField: PolicyReportField) { API.write(WRITE_COMMANDS.DELETE_REPORT_FIELD, parameters, {optimisticData, failureData, successData}); } -function updateDescription(report: Report, newMarkdownValue: string, currentUserAccountID: number) { +function updateDescription(report: Report, newMarkdownValue: string, currentUserAccountID: number, reportIDToName: Record, accountIDToName: Record) { // No change needed const currentDescription = report.description ?? ''; - if (Parser.htmlToMarkdown(currentDescription) === newMarkdownValue) { + if (Parser.htmlToMarkdown(currentDescription, {reportIDToName, accountIDToName}) === newMarkdownValue) { return; } const reportID = report.reportID; const parsedDescription = getParsedComment(newMarkdownValue, {reportID}); - const optimisticDescriptionUpdatedReportAction = buildOptimisticRoomDescriptionUpdatedReportAction(parsedDescription); + const optimisticDescriptionUpdatedReportAction = buildOptimisticRoomDescriptionUpdatedReportAction(parsedDescription, accountIDToName); const optimisticData: Array> = [ { @@ -4470,6 +4595,18 @@ type NavigateToConciergeChatParams = { /** Whether the user has already viewed the self tour. */ isSelfTourViewed: boolean | undefined; + /** The accountID of the copilot acting on behalf of the current user, if any */ + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number; + + /** + * Whether the user has completed the guided setup (onboarding) flow. Only read on the branch where + * `conciergeReportID` is undefined — the one that has to find or create the Concierge chat — where it decides + * whether a still-pending onboarding OpenReport is enqueued. When the report ID is already known this function + * only navigates, so the value is unused there. + */ + hasCompletedGuidedSetupFlow: boolean | undefined; + /** Whether to dismiss the current modal instead of navigating on top of it. */ shouldDismissModal?: boolean; @@ -4504,6 +4641,8 @@ function navigateToConciergeChat({ introSelected, currentUserAccountID, isSelfTourViewed, + delegateAccountID, + hasCompletedGuidedSetupFlow, shouldDismissModal = false, checkIfCurrentPageActive = () => true, linkToOptions, @@ -4535,11 +4674,11 @@ function navigateToConciergeChat({ // The Concierge chat does not exist yet on this path (it is being created here), so there is no report to thread. conciergeChat: undefined, isSelfTourViewed, - // TODO: Pass the correct hasCompletedGuidedSetupFlow from Onyx data in the next PR. Refactor issue: https://github.com/Expensify/App/issues/66424 - hasCompletedGuidedSetupFlow: undefined, + hasCompletedGuidedSetupFlow, // Not gated: this is the Concierge fallback, not the Start chat flow. Concierge is a core report reached while // simply navigating around, so blocking it for support agents would pop the denied modal during plain navigation. isSupportalSession: false, + delegateAccountID, shouldDismissModal, linkToOptions, // Thread the source report here too: on this path the Concierge chat is being found/created, and without it @@ -4889,18 +5028,64 @@ function isNoSupportRepAvailableResponse(response: {jsonCode?: number | string; function openSupportTicket(resolvedSupportTicketReportID?: string) { const newSupportTicketReportID = resolvedSupportTicketReportID ? undefined : generateReportID(); const parameters: CreateSupportTicketParams = resolvedSupportTicketReportID ? {resolvedSupportTicketReportID, idempotencyKey: Str.guid()} : {newSupportTicketReportID}; - - if (!resolvedSupportTicketReportID) { + const onyxData: OnyxData = newSupportTicketReportID + ? { + optimisticData: [ + // The request does not survive reloads, so its fetch guard must stay in RAM. + // Set it before publishing the report ID; merges can wait on storage reads. + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, + value: {isCreatingSupportTicket: true}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, + value: {reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, + value: {isCreatingSupportTicket: false}, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, + value: null, + }, + ], + } + : {}; + + // Start the request before navigating so the optimistic support-ticket metadata is available for the pending LHN title. + // eslint-disable-next-line rulesdir/no-api-side-effects-method -- Reopening must wait for the server-selected report ID before navigating. + const request = API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_SUPPORT_TICKET, parameters, onyxData); + if (newSupportTicketReportID) { Navigation.navigate(getReportRouteForCurrentContext({reportID: newSupportTicketReportID, isPendingCreation: true})); } - // eslint-disable-next-line rulesdir/no-api-side-effects-method -- reopening must wait for the server-selected report ID before navigating. - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_SUPPORT_TICKET, parameters).then((response) => { - if (resolvedSupportTicketReportID && response?.reportID) { - Navigation.navigate(getReportRouteForCurrentContext({reportID: response.reportID})); - } - return response; - }); + return request + .then((response) => { + if (resolvedSupportTicketReportID && response?.reportID) { + Navigation.navigate(getReportRouteForCurrentContext({reportID: response.reportID})); + } + return response; + }) + .catch(async (error: unknown) => { + if (onyxData.failureData) { + await Onyx.update(onyxData.failureData); + } + throw error; + }); } function dismissFailedSupportTicket(supportTicketReportID: string, parentReportID: string, parentReportActionID: string) { @@ -5030,6 +5215,8 @@ function addPolicyReport(policyReport: OptimisticChatReport) { /** * @param reportID The reportID of the policy report (workspace room) */ +// TODO: Refactor to use options object parameter to reduce parameter count. Refactor issue: https://github.com/Expensify/App/issues/66425 +// eslint-disable-next-line @typescript-eslint/max-params function navigateToConciergeChatAndDeleteReport( reportID: string | undefined, conciergeReportID: string | undefined, @@ -5041,6 +5228,8 @@ function navigateToConciergeChatAndDeleteReport( conciergePersonalDetail: OnyxEntry, shouldPopToTop = false, shouldDeleteChildReports = false, + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number, ) { // Dismiss the current report screen and replace it with Concierge Chat if (shouldPopToTop) { @@ -5054,6 +5243,9 @@ function navigateToConciergeChatAndDeleteReport( introSelected, currentUserAccountID, isSelfTourViewed, + delegateAccountID, + // TODO: Thread hasCompletedGuidedSetupFlow through this wrapper in the next PR. Refactor issue: https://github.com/Expensify/App/issues/66424 + hasCompletedGuidedSetupFlow: undefined, shouldDismissModal: false, linkToOptions: {afterTransition: () => deleteReport(reportID, shouldDeleteChildReports)}, conciergePersonalDetails: personalDetails, @@ -5087,6 +5279,8 @@ function clearCreateChatError( currentUserPersonalDetail: OnyxEntry, conciergePersonalDetail: OnyxEntry, optimisticPersonalDetails: OnyxEntry, + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number, ) { const metaData = getReportMetadata(report?.reportID); const isOptimisticReport = metaData?.isOptimisticReport; @@ -5110,6 +5304,7 @@ function clearCreateChatError( conciergePersonalDetail, undefined, true, + delegateAccountID, ); } @@ -5326,7 +5521,7 @@ function showReportActionNotification( derivedMovedFromReportName, }); } else { - LocalNotification.showCommentNotification(report, reportAction, onClick, derivedReportName); + LocalNotification.showCommentNotification(report, reportAction, onClick, derivedReportName, formatPhoneNumber); } notifyNewAction(reportID, undefined, reportAction.actorAccountID === currentUserAccountID); @@ -5349,6 +5544,8 @@ function navigateToMostRecentReport( isSelfTourViewed: boolean | undefined, personalDetails: OnyxEntry, lastAccessedReportID?: string, + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number, ) { if (lastAccessedReportID) { // Check if route exists for super wide RHP vs regular full screen report @@ -5368,11 +5565,14 @@ function navigateToMostRecentReport( Navigation.goBack(); } + // TODO: Thread hasCompletedGuidedSetupFlow through this wrapper in the next PR. Refactor issue: https://github.com/Expensify/App/issues/66424 navigateToConciergeChat({ conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, + delegateAccountID, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: personalDetails, shouldDismissModal: false, linkToOptions: {forceReplace: true}, @@ -5422,6 +5622,8 @@ function leaveGroupChat( isSelfTourViewed: boolean | undefined, personalDetails: OnyxEntry, lastAccessedReportID?: string, + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number, ) { const reportID = report.reportID; // Use merge instead of set to avoid deleting the report too quickly, which could cause a brief "not found" page to appear. @@ -5472,7 +5674,7 @@ function leaveGroupChat( if (isSearchTopmostFullScreenRoute()) { Navigation.revealRouteBeforeDismissingModal(getReportRouteForCurrentContext({reportID})); } else { - navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, personalDetails, lastAccessedReportID); + navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, personalDetails, lastAccessedReportID, delegateAccountID); } API.write(WRITE_COMMANDS.LEAVE_GROUP_CHAT, {reportID}, {optimisticData, successData, failureData}); } @@ -5487,6 +5689,8 @@ function leaveRoom( personalDetails: OnyxEntry, isWorkspaceMemberLeavingWorkspaceRoom = false, lastAccessedReportID?: string, + // TODO: This will be made required once all callers thread the value. Refactor issue: https://github.com/Expensify/App/issues/66425 + delegateAccountID?: number, ) { const reportID = report.reportID; const isChatThread = isChatThreadReportUtils(report); @@ -5598,7 +5802,7 @@ function leaveRoom( return; } // In other cases, the report is deleted and we should move the user to another report. - navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, personalDetails, lastAccessedReportID); + navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, personalDetails, lastAccessedReportID, delegateAccountID); } function buildInviteToRoomOnyxData( @@ -5902,12 +6106,13 @@ function removeFromGroupChat(report: Report, accountIDList: number[]) { function optimisticReportLastData( reportID: string, + currentUserAccountID: number, optimisticReportActions: Record | null> = {}, canUserPerformWriteAction?: boolean, isReportArchived?: boolean, ) { const lastMessageText = getLastVisibleMessage(reportID, isReportArchived, optimisticReportActions).lastMessageText ?? ''; - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, canUserPerformWriteAction, optimisticReportActions); + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, canUserPerformWriteAction, optimisticReportActions, undefined, undefined, currentUserAccountID); return { lastMessageText, lastVisibleActionCreated: lastVisibleAction?.created ?? '', @@ -5916,7 +6121,13 @@ function optimisticReportLastData( } /** Flag a comment as offensive */ -function flagComment(reportAction: OnyxEntry, severity: string, originalReport: OnyxEntry | undefined, isOriginalReportArchived: boolean | undefined) { +function flagComment( + reportAction: OnyxEntry, + severity: string, + originalReport: OnyxEntry | undefined, + isOriginalReportArchived: boolean | undefined, + currentUserAccountID: number, +) { const originalReportID = originalReport?.reportID; const message = ReportActionsUtils.getReportActionMessage(reportAction); @@ -5972,6 +6183,7 @@ function flagComment(reportAction: OnyxEntry, severity: string, or const optimisticLastReportData = optimisticReportLastData( originalReportID ?? String(CONST.DEFAULT_NUMBER_ID), + currentUserAccountID, { [reportActionID]: {...reportAction, message: [updatedMessage], pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, } as ReportActions, @@ -6966,6 +7178,17 @@ async function exportReportToPDF({reportID}: ExportReportPDFParams) { return API.write(WRITE_COMMANDS.EXPORT_REPORT_TO_PDF, params, {optimisticData, failureData}); } +/** Emails the report to the given login and gives them access to it. */ +function shareReport(reportID: string, shareToLogin: string) { + const params = { + reportID, + shareToEmail: addSMSDomainIfPhoneNumber(shareToLogin), + permissions: [CONST.REPORT.PERMISSIONS.READ, CONST.REPORT.PERMISSIONS.WRITE].join(', '), + } satisfies ShareReportParams; + + API.write(WRITE_COMMANDS.SHARE_REPORT, params); +} + function downloadReportPDF(fileName: string, reportName: string, translate: LocalizedTranslate, currentUserLogin: string, encryptedAuthToken: string) { const baseURL = getOldDotURLFromEnvironment(environment); const downloadFileName = `${reportName}.pdf`; @@ -9086,6 +9309,7 @@ export { isNoSupportRepAvailableResponse, dismissFailedSupportTicket, clearAllReportActionDrafts, + clearReportActionDrafts, deleteReportComment, deleteReportField, dismissTrackExpenseActionableWhisper, @@ -9096,6 +9320,7 @@ export { expandURLPreview, exportReportToCSV, exportReportToPDF, + shareReport, exportToIntegration, joinReportViaSecureLink, flagComment, @@ -9181,6 +9406,7 @@ export { changeReportPolicyAndInviteSubmitter, removeFailedReport, createTransactionThreadReport, + openRecipientPicker, openUnreportedExpense, optimisticReportLastData, setOptimisticTransactionThread, diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index 33c9e0975710..6daf2f002487 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -115,9 +115,7 @@ function resolveOpenReportDuplicationConflictAction(persis // ReportFetchHandler when the screen mounts has no participants. Replacing would drop the // accountIDList, leaving the server with no way to resolve the optimistic reportID — Auth // returns NIL reportSummary and PHP throws "Report not found" (da7984df). - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const queuedHasParticipants = !!(request.data?.emailList || request.data?.accountIDList); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const newHasParticipants = !!(parameters.emailList || parameters.accountIDList); if (queuedHasParticipants && !newHasParticipants) { return { @@ -351,6 +349,36 @@ function resolveEnableFeatureConflicts( }; } +/** + * 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, @@ -365,6 +393,7 @@ export { createUpdateCommentMatcher, resolveEnableFeatureConflicts, enablePolicyFeatureCommand, + resolveVacationDelegateDeletionConflicts, }; export type {EnablePolicyFeatureCommand, AnyRequestMatcher}; diff --git a/src/libs/actions/ReusableDistanceRoutes.ts b/src/libs/actions/ReusableDistanceRoutes.ts index b79f149fa206..6aaad8bb75fb 100644 --- a/src/libs/actions/ReusableDistanceRoutes.ts +++ b/src/libs/actions/ReusableDistanceRoutes.ts @@ -1,10 +1,13 @@ import {read} from '@libs/API'; import {READ_COMMANDS} from '@libs/API/types'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {normalizeRouteWaypoints} from '@libs/ReusableDistanceRoutesUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReusableDistanceRoute} from '@src/types/onyx'; +import type {Unit} from '@src/types/onyx/Policy'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; import type {OnyxUpdate} from 'react-native-onyx'; @@ -13,6 +16,24 @@ import Onyx from 'react-native-onyx'; import {updateWaypoints} from './Transaction'; +/** + * Onyx data saving the route of a newly created distance expense locally, so the just used + * route shows in "Reuse route" in offline mode before the server list refreshes. + */ +function getLocallyCreatedRouteOnyxData( + route: ReusableDistanceRoute, + currentRoutes: ReusableDistanceRoute[], +): { + optimisticData: Array>; + failureData: Array>; +} { + const updatedRoutes = [route, ...currentRoutes.filter((item) => item.transactionID !== route.transactionID)]; + return { + optimisticData: [{onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.REUSABLE_DISTANCE_ROUTES, value: updatedRoutes}], + failureData: [{onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.REUSABLE_DISTANCE_ROUTES, value: currentRoutes}], + }; +} + function fetchReusableDistanceRoutes() { const optimisticData: Array> = [ { @@ -44,15 +65,23 @@ function fetchReusableDistanceRoutes() { /** * Seeds the draft transaction from a reused route. */ -function selectReusableRoute(transactionID: string, route: ReusableDistanceRoute, existingWaypoints?: WaypointCollection) { +function selectReusableRoute(transactionID: string, route: ReusableDistanceRoute, existingWaypoints?: WaypointCollection, distanceUnit?: Unit) { const waypoints = normalizeRouteWaypoints(route); + const quantity = route.routeDistanceMeters && distanceUnit ? roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(route.routeDistanceMeters, distanceUnit)) : route.distance; + return updateWaypoints(transactionID, waypoints, CONST.TRANSACTION.STATE.DRAFT, existingWaypoints).then(() => Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, isReusedRoute: true, - comment: {customUnit: {quantity: route.distance, routeDistanceMeters: route.routeDistanceMeters}}, + comment: { + customUnit: { + quantity, + routeDistanceMeters: route.routeDistanceMeters, + ...(distanceUnit ? {distanceUnit} : {}), + }, + }, }), ); } -export {fetchReusableDistanceRoutes, selectReusableRoute}; +export {fetchReusableDistanceRoutes, getLocallyCreatedRouteOnyxData, selectReusableRoute}; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 4e0bbab9bcb4..d71ca533a14a 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -32,7 +32,9 @@ import deferModalPresentationAfterPopoverDismiss from '@libs/deferModalPresentat import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import {getExportFileName} from '@libs/fileDownload/FileUtils'; +import getTransactionFromTransactionListItem from '@libs/getTransactionFromTransactionListItem'; import HttpUtils from '@libs/HttpUtils'; +import isTransactionGroupListItemType from '@libs/isTransactionGroupListItemType'; import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; @@ -41,6 +43,7 @@ import enhanceParameters from '@libs/Network/enhanceParameters'; import {getIsOffline} from '@libs/NetworkState'; import {rand64} from '@libs/NumberUtils'; import {getActivePaymentType} from '@libs/PaymentUtils'; +import {canAccessPolicyBankAccount} from '@libs/PolicyPaymentUtils'; import { getAccountIDForSubmitManagerEmail, getSubmitReportManagerAccountID, @@ -68,7 +71,7 @@ import { import type {SearchKey} from '@libs/SearchKeyUtils'; import {savedSearchIDToSearchKey} from '@libs/SearchKeyUtils'; import {buildSearchQueryJSON, buildSearchQueryString, getFooterSelectionFromQuery, serializeQueryJSONForBackend} from '@libs/SearchQueryUtils'; -import {isTransactionGroupListItemType} from '@libs/SearchUIUtils'; +import {getExpenseReportIDs, TODO_SEARCH_KEYS} from '@libs/SearchUIUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {cancelSpan, endSpan, startSpan} from '@libs/telemetry/activeSpans'; import {hasOnlyPendingCardTransactions} from '@libs/TransactionUtils'; @@ -92,6 +95,7 @@ import type { ReportActions, Rule, SaveSearch, + SearchResults, SearchTagFilterItem, Transaction, TransactionViolations, @@ -123,7 +127,7 @@ import {setOptimisticTransactionThread} from './Report'; import {saveLastSearchParams} from './ReportNavigation'; type OnyxSearchResponse = { - data: []; + data: SearchResults['data']; search: { offset: number; hasMoreResults: boolean; @@ -289,6 +293,7 @@ type HandleActionButtonPressParams = { personalDetailsByLogins: PersonalDetailsByLogin; conciergeChat: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; + bankAccountList: OnyxEntry; }; function handleActionButtonPress({ @@ -333,6 +338,7 @@ function handleActionButtonPress({ personalDetailsByLogins, conciergeChat, getCurrencyDecimals, + bankAccountList, }: HandleActionButtonPressParams) { // The transactionIDList is needed to handle actions taken on `status:""` where transactions on single expense reports can be approved/paid. // We need the transactionID to display the loading indicator for that list item's action. @@ -387,6 +393,8 @@ function handleActionButtonPress({ conciergeChat, getCurrencyDecimals, rules, + bankAccountList, + iouReportTransactions: allReportTransactions, }); return; case CONST.SEARCH.ACTION_TYPES.APPROVE: @@ -636,6 +644,8 @@ type GetPayActionCallbackParams = { conciergeChat: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; rules: OnyxCollection; + bankAccountList: OnyxEntry; + iouReportTransactions: Transaction[]; }; function getPayActionCallback({ @@ -666,6 +676,8 @@ function getPayActionCallback({ conciergeChat, getCurrencyDecimals, rules, + bankAccountList, + iouReportTransactions, }: GetPayActionCallbackParams) { if (!item.reportID) { Log.info('[SearchPay] Dropping row pay: item has no reportID'); @@ -678,9 +690,13 @@ function getPayActionCallback({ return; } + const paymentPolicy = snapshotPolicy ?? policy; + if (lastPolicyPaymentMethod !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { - const hasVBBA = !!snapshotPolicy?.achAccount?.bankAccountID; - if (!hasVBBA) { + // One-tap pay here always funds the payment from the workspace bank account, so it's only valid for someone the + // account is actually shared with. Anyone else has to pay from an account of their own, so open the report and let + // them pick it instead of silently paying with (and reporting) the workspace one. + if (!canAccessPolicyBankAccount(paymentPolicy, bankAccountList)) { goToItem(); return; } @@ -695,21 +711,23 @@ function getPayActionCallback({ const chatReportPolicyForPayment = chatReportPolicy ?? getPolicyFromSearchSnapshot(chatReportForPayment.policyID, searchData, undefined); payMoneyRequest({ + bankAccountList, paymentType: lastPolicyPaymentMethod, chatReport: chatReportForPayment, iouReport: snapshotReport, + iouReportTransactions, introSelected, currentUserAccountID: currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, currentUserLogin: currentUserLogin ?? '', activePolicy, - policy: snapshotPolicy ?? policy, + policy: paymentPolicy, chatReportPolicy: chatReportPolicyForPayment, isASAPSubmitBetaEnabled, isSelfTourViewed, userBillingGracePeriodEnds, amountOwed, ownerBillingGracePeriodEnd, - methodID: lastPolicyPaymentMethod === CONST.IOU.PAYMENT_TYPE.VBBA ? snapshotPolicy?.achAccount?.bankAccountID : undefined, + methodID: lastPolicyPaymentMethod === CONST.IOU.PAYMENT_TYPE.VBBA ? paymentPolicy?.achAccount?.bankAccountID : undefined, additionalOnyxData: getSearchPayOnyxData(hash, item.reportID, currentSearchKey), chatReportActions, delegateAccountID, @@ -1081,6 +1099,19 @@ function openSearchCardFiltersPage() { read(READ_COMMANDS.OPEN_SEARCH_CARD_FILTERS_PAGE, null, {optimisticData, successData, finallyData}); } +type ParseExpenseFiltersResult = {success: true; searchURL: string; humanReadableSummary: string} | {success: false; message: string}; + +function parseExpenseFilters(nlQuery: string, policyID?: string): Promise { + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + return makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.PARSE_EXPENSE_FILTERS, {nlQuery, policyID, today}).then((response) => { + if (response?.success === true && response.searchURL) { + return {success: true, searchURL: response.searchURL, humanReadableSummary: response.humanReadableSummary ?? ''} as const; + } + return {success: false, message: response?.message ?? ''} as const; + }); +} + function openSearchCategoryFiltersPage() { const optimisticData: Array> = [ { @@ -1265,7 +1296,7 @@ function handlePreventSearchAPI(hash: number | undefined) { * columns to a backend-supported date sort. */ function getBackendQueryJSON(queryJSON: Readonly) { - const {exactMatchFilterKeys, flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON; + const {flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON; const backendQueryJSON = shouldUseBackendDateSortFallback(queryJSON.sortBy) ? { ...queryJSONWithoutFlatFilters, @@ -1279,7 +1310,7 @@ function getBackendQueryJSON(queryJSON: Readonly) { ), } : queryJSONWithoutFlatFilters; - return {backendQueryJSON, limit, exactMatchFilterKeys}; + return {backendQueryJSON, limit}; } function search({ @@ -1364,7 +1395,7 @@ function search({ inFlightSearchRequests.set(dedupeKey, inFlightRequestState); const onyxLoadingData = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, true, shouldCalculateTotals, shouldShowLoading); - const {backendQueryJSON, limit, exactMatchFilterKeys} = getBackendQueryJSON(queryJSON); + const {backendQueryJSON, limit} = getBackendQueryJSON(queryJSON); const query = { ...backendQueryJSON, searchKey, @@ -1375,7 +1406,7 @@ function search({ // Backend expects 'maximumResults' instead of 'limit' ...(limit !== undefined && {maximumResults: limit}), }; - const jsonQuery = serializeQueryJSONForBackend(query, exactMatchFilterKeys ? new Set(exactMatchFilterKeys) : undefined); + const jsonQuery = serializeQueryJSONForBackend(query); if (shouldUpdateLastSearchParams) { saveLastSearchParams({ @@ -1411,7 +1442,9 @@ function search({ const startRequest = () => makeRequestWithSideEffects(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, successData, finallyData, failureData}) .then((result) => { - const response = result?.onyxData?.[0]?.value as OnyxSearchResponse; + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const; + // To-do responses list the other collections first and the snapshot last. + const response = result?.onyxData?.find((update) => update.key === snapshotKey)?.value as OnyxSearchResponse | undefined; inFlightRequestState.didSucceed = result?.jsonCode === CONST.JSON_CODE.SUCCESS; // The UI treats a successful response with no snapshot data as an empty result, so record it for diagnosis. @@ -1428,6 +1461,15 @@ function search({ ); } + // Record the reports this to-do response held. Totals bring a fresh count, so they restart the record. + if (searchKey && TODO_SEARCH_KEYS.has(searchKey) && result?.jsonCode === CONST.JSON_CODE.SUCCESS) { + const countedReportIDs = getExpenseReportIDs(response?.data ?? {}); + const clearRecord = shouldCalculateTotals ? Onyx.merge(snapshotKey, {search: {countedReportIDs: null}}) : Promise.resolve(); + clearRecord + .then(() => Onyx.merge(snapshotKey, {search: {countedReportIDs}})) + .catch((error: unknown) => Log.hmmm('[Search] failed to record the counted reports', {error: String(error)})); + } + if (shouldUpdateLastSearchParams) { const reports = Object.keys(response?.data ?? {}) .filter((key) => key.startsWith(ONYXKEYS.COLLECTION.REPORT)) @@ -1554,15 +1596,12 @@ function getFooterConvertedAmounts({ // searchKey changes what the backend query matches (e.g. unapprovedCash excludes card expenses), so it must be // sent exactly as search() sends it or the converted totals cover a different expense set than the snapshot. - const {backendQueryJSON, exactMatchFilterKeys} = getBackendQueryJSON(queryJSON); - const jsonQuery = serializeQueryJSONForBackend( - { - ...backendQueryJSON, - searchKey, - filters: backendQueryJSON.filters ?? null, - }, - exactMatchFilterKeys ? new Set(exactMatchFilterKeys) : undefined, - ); + const {backendQueryJSON} = getBackendQueryJSON(queryJSON); + const jsonQuery = serializeQueryJSONForBackend({ + ...backendQueryJSON, + searchKey, + filters: backendQueryJSON.filters ?? null, + }); read( READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT, @@ -2669,11 +2708,10 @@ function setOptimisticDataForTransactionThreadPreview( // Set optimistic transaction if (!hasTransaction) { - const {formattedFrom, formattedMerchant, formattedTo, formattedTotal, date, ...restItem} = item; onyxUpdates.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: restItem, + value: getTransactionFromTransactionListItem(item), }); } @@ -2686,6 +2724,7 @@ function setOptimisticDataForTransactionThreadPreview( } export { + parseExpenseFilters, saveSearch, seedMyExpensesSearch, search, diff --git a/src/libs/actions/SignInRedirect.ts b/src/libs/actions/SignInRedirect.ts index 06372a2349b7..0dce2d727236 100644 --- a/src/libs/actions/SignInRedirect.ts +++ b/src/libs/actions/SignInRedirect.ts @@ -20,6 +20,8 @@ let currentIsUsingImportedState: boolean | undefined; let currentSessionAuthToken: string | undefined; let currentSessionEmail: string | undefined; let currentCredentialsValidateCode: string | undefined; +let lastAuthTokenWrittenAt = 0; +let lastAuthTokenWrittenEmail: string | undefined; Onyx.connectWithoutView({ key: ONYXKEYS.NETWORK, @@ -38,6 +40,10 @@ Onyx.connectWithoutView({ Onyx.connectWithoutView({ key: ONYXKEYS.SESSION, callback: (session) => { + if (session?.authToken && session.authToken !== currentSessionAuthToken) { + lastAuthTokenWrittenAt = Date.now(); + lastAuthTokenWrittenEmail = session.email; + } currentSessionAuthToken = session?.authToken; currentSessionEmail = session?.email; }, @@ -65,6 +71,12 @@ const KEYS_TO_PRESERVE_ON_SIGN_OUT: OnyxKey[] = [ ONYXKEYS.BETA_OVERRIDES, ONYXKEYS.COLLECTION.PASSKEY_CREDENTIALS, ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS, + // This device's record of having already answered the app review prompt must outlive sign-out. + // nvp_appReview is server-owned and its lastPrompt is only written on the first answer, so after + // that this record is the only proof the user responded. Clearing it means the next sign-in + // re-shows the prompt they just dismissed - every time, forever. + // The record carries the accountID it belongs to, so a different user signing in here ignores it. + ONYXKEYS.APP_REVIEW_LAST_PROMPT, ]; function clearStorageAndRedirect(signOutReason: SignOutReason, errorMessage?: string, isSAMLReauthentication?: boolean): Promise { @@ -107,7 +119,20 @@ function clearStorageAndRedirect(signOutReason: SignOutReason, errorMessage?: st keysToPreserve.push(ONYXKEYS.LAST_VISITED_PATH); } + const clearStartedAt = Date.now(); + const emailAtClearStart = currentSessionEmail; return Onyx.clear(keysToPreserve).then(async () => { + if (lastAuthTokenWrittenAt > clearStartedAt) { + Log.hmmm('[SignInRedirect] Sign-out clear finished after a new sign-in started', { + signOutReason, + clearDurationMs: Date.now() - clearStartedAt, + newSessionMs: Date.now() - lastAuthTokenWrittenAt, + isNewSessionWiped: !currentSessionAuthToken, + emailAtClearStart, + newSessionEmail: lastAuthTokenWrittenEmail, + }); + } + // Requests may be processed while sign-out is in progress. Clear again after credentials have been removed so none of those requests remain queued for the next startup. await clearPrefetchOnAppStart(); diff --git a/src/libs/actions/Subscription.ts b/src/libs/actions/Subscription.ts index 2cf9ae58075e..bcfc85d5504b 100644 --- a/src/libs/actions/Subscription.ts +++ b/src/libs/actions/Subscription.ts @@ -84,6 +84,22 @@ function openPaymentHistoryPage() { API.read(READ_COMMANDS.OPEN_PAYMENT_HISTORY_PAGE, null); } +function openPaymentDetailsPage(purchaseID: number) { + const finallyData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PURCHASES, + value: { + [purchaseID]: { + isLoaded: true, + }, + }, + }, + ]; + + API.read(READ_COMMANDS.OPEN_PAYMENT_DETAILS_PAGE, {purchaseID}, {finallyData}); +} + /** * Fetches data when the user opens the Save The World page */ @@ -500,6 +516,7 @@ function applyExpensifyCode(promoCode: string) { export { openSubscriptionPage, + openPaymentDetailsPage, openPaymentHistoryPage, openSaveTheWorldPage, updateSubscriptionAutoRenew, diff --git a/src/libs/actions/TableColumnWidths.ts b/src/libs/actions/TableColumnWidths.ts index 9e9b8a450578..21eaeff008b6 100644 --- a/src/libs/actions/TableColumnWidths.ts +++ b/src/libs/actions/TableColumnWidths.ts @@ -1,11 +1,14 @@ import ONYXKEYS from '@src/ONYXKEYS'; +import type {StoredTableColumnWidths} from '@src/types/onyx/TableColumnWidths'; import Onyx from 'react-native-onyx'; -/** Persists a dragged column's width once the drag ends. The drag itself only touches CSS. */ -function setTableColumnWidth(columnResizingID: string, columnKey: string, width: number) { - Onyx.merge(ONYXKEYS.TABLE_COLUMN_WIDTHS, {[columnResizingID]: {[columnKey]: width}}); +/** Saves the layout once a drag ends, since mid-drag widths only live in CSS. */ +function setTableColumnWidths(columnResizingID: string, storedTableColumnWidths: StoredTableColumnWidths) { + // Clearing first makes Onyx replace the layout rather than deep merge it, so columns missing from it don't linger. + Onyx.merge(ONYXKEYS.TABLE_COLUMN_WIDTHS, {[columnResizingID]: null}); + Onyx.merge(ONYXKEYS.TABLE_COLUMN_WIDTHS, {[columnResizingID]: storedTableColumnWidths}); } // eslint-disable-next-line import/prefer-default-export -- additional actions may be added here -export {setTableColumnWidth}; +export {setTableColumnWidths}; diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 10888e92fbf6..a0668eb5e414 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -1337,7 +1337,7 @@ function getShareDestination( ): ShareDestination { const isOneOnOneChat = ReportUtils.isOneOnOneChat(report); - const participants = ReportUtils.getParticipantsAccountIDsForDisplay(report); + const participants = ReportUtils.getParticipantsAccountIDsForDisplay(report, undefined); const isMultipleParticipant = participants.length > 1; const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips( diff --git a/src/libs/actions/TaskDeletion.ts b/src/libs/actions/TaskDeletion.ts index ee843b7bc532..10aa39ff9b66 100644 --- a/src/libs/actions/TaskDeletion.ts +++ b/src/libs/actions/TaskDeletion.ts @@ -107,7 +107,12 @@ function deleteTask( }; const optimisticReportActions = parentReportAction?.reportActionID ? {[parentReportAction?.reportActionID]: optimisticReportAction} : {}; - const optimisticLastReportData = optimisticReportLastData(parentReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), optimisticReportActions, canUserPerformWriteAction); + const optimisticLastReportData = optimisticReportLastData( + parentReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), + currentUserAccountID, + optimisticReportActions, + canUserPerformWriteAction, + ); const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 524e75902e0e..83098bd75204 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -13,6 +13,7 @@ import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import getTransactionFromTransactionListItem from '@libs/getTransactionFromTransactionListItem'; import {toLocaleDigit} from '@libs/LocaleDigitUtils'; import {translateLocal} from '@libs/Localize'; import Log from '@libs/Log'; @@ -90,6 +91,7 @@ import lodashClone from 'lodash/clone'; import Onyx from 'react-native-onyx'; import {getSearchOnyxUpdate} from './IOU/SearchUpdate'; +import {removeActiveTransactionIDsForReport} from './TransactionThreadNavigation'; type SaveWaypointProps = { transactionID: string; @@ -1048,6 +1050,8 @@ function getChangeTransactionsReportOnyxData({ // The transactions that are actually moved to the destination report. Transactions already in the destination are // skipped below, so this excludes them from the transactionList sent to the backend (which does not filter them out). const movedTransactionIDs: string[] = []; + // The moved transactions grouped by the report they leave, so a carousel seeded from that report can drop them. + const movedTransactionIDsByOldReportID: Record = {}; let shouldFixViolations = false; const policyHasDependentTags = hasDependentTags(policy, policyTagList); @@ -1137,6 +1141,7 @@ function getChangeTransactionsReportOnyxData({ movedTransactionIDs.push(transaction.transactionID); const oldReportID = isUnreportedExpense ? CONST.REPORT.UNREPORTED_REPORT_ID : transaction.reportID; + (movedTransactionIDsByOldReportID[oldReportID] ??= []).push(transaction.transactionID); const oldReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${oldReportID}`]; const sourceCurrency = oldReport?.currency; const shouldClearAmount = shouldClearConvertedAmount(transaction, sourceCurrency, destinationCurrency); @@ -1176,7 +1181,8 @@ function getChangeTransactionsReportOnyxData({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: { - ...transaction, + // Callers can pass Search row items, whose display-only fields must not be saved on the real transaction + ...getTransactionFromTransactionListItem(transaction), reportID, comment, originalAmount: shouldCopyOriginalAmount ? transaction.originalAmount : null, @@ -2133,9 +2139,20 @@ function getChangeTransactionsReportOnyxData({ updatedReportReimbursableTotals, updatedReportUnheldReimbursableTotals, movedTransactionIDs, + movedTransactionIDsByOldReportID, }; } +/** + * Drops moved expenses from a carousel seeded from the report they left. Every move goes through + * `changeTransactionsReport`, so doing it here covers all entry points at once. + */ +function removeMovedTransactionsFromCarousel(movedTransactionIDsByOldReportID: Record | undefined) { + for (const [oldReportID, transactionIDs] of Object.entries(movedTransactionIDsByOldReportID ?? {})) { + removeActiveTransactionIDsForReport(transactionIDs, oldReportID); + } +} + function changeTransactionsReport(props: ChangeTransactionsReportProps) { const reportID = props.newReport?.reportID ?? CONST.REPORT.UNREPORTED_REPORT_ID; @@ -2188,6 +2205,7 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { }; API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, queryParameters, {optimisticData, successData, failureData}); + removeMovedTransactionsFromCarousel(loadedTransactionsOnyxData?.movedTransactionIDsByOldReportID); return; } @@ -2195,8 +2213,15 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { if (!changeTransactionsReportOnyxData) { return; } - const {optimisticData, successData, failureData, transactionIDToReportActionAndThreadData, transactionIDToUpdatedCustomUnitRateID, movedTransactionIDs} = - changeTransactionsReportOnyxData; + const { + optimisticData, + successData, + failureData, + transactionIDToReportActionAndThreadData, + transactionIDToUpdatedCustomUnitRateID, + movedTransactionIDs, + movedTransactionIDsByOldReportID, + } = changeTransactionsReportOnyxData; // If every selected transaction is already in the destination report, there is nothing to move, so skip the API call. if (movedTransactionIDs.length === 0) { @@ -2219,6 +2244,7 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { successData, failureData, }); + removeMovedTransactionsFromCarousel(movedTransactionIDsByOldReportID); } /** diff --git a/src/libs/actions/TransactionInlineEdit.ts b/src/libs/actions/TransactionInlineEdit.ts index a420fbc5c23b..a0c6874695a9 100644 --- a/src/libs/actions/TransactionInlineEdit.ts +++ b/src/libs/actions/TransactionInlineEdit.ts @@ -1,13 +1,17 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; + import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import {isCategoryMissing} from '@libs/CategoryUtils'; +import containsHtmlTag from '@libs/containsHtmlTag'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; -import {isValidMerchant, isValidMoneyRequestAmount} from '@libs/MoneyRequestUtils'; +import {isMerchantRequired, isValidMoneyRequestAmount} from '@libs/MoneyRequestUtils'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; import {getTagLists, isGroupPolicy, isMultiLevelTags, resolveCurrentTaxCode} from '@libs/PolicyUtils'; import {isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {canEditFieldOfMoneyRequest, canEditMoneyRequest, canUserPerformWriteAction, isArchivedReport, isInvoiceReport, isIOUReport, shouldEnableNegative} from '@libs/ReportUtils'; +import StringUtils from '@libs/StringUtils'; import {hasEnabledTags} from '@libs/TagsOptionsListUtils'; import { calculateTaxAmount, @@ -19,6 +23,7 @@ import { isPerDiemRequest, isScanning, } from '@libs/TransactionUtils'; +import {getMerchantError, getMerchantErrorMessage} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -269,14 +274,19 @@ function editTransactionDateInline(params: TransactionInlineEditParams, newDate: } /** Updates the merchant of an expense from the Search results table or the Expense Report page. */ -function editTransactionMerchantInline(params: TransactionInlineEditParams, newMerchant: string) { - // Validate before building iouParams: getIouParamsForTransaction can call createTransactionThreadReport, - // which fires Onyx.merge + openReport (an API call). Building params first would optimistically create a - // thread report for an edit we're about to discard, so validate first. - if (!isValidMerchant(newMerchant, params.transaction, params.parentReport)) { - return; +function editTransactionMerchantInline(translate: LocalizedTranslate, params: TransactionInlineEditParams, newMerchant: string): string | undefined { + // The merchant form uses strict HTML validation, which also rejects values like <✓>. FormProvider runs that + // check after validate and overwrites any length error, so a value that fails both must show invalid character. + if (containsHtmlTag(newMerchant, true)) { + return translate('common.error.invalidCharacter'); + } + + const merchantError = getMerchantError(newMerchant, isMerchantRequired(params.parentReport, params.transaction)); + if (merchantError) { + return getMerchantErrorMessage(translate, merchantError); } + // Building params can create a transaction thread, so do it only once the merchant is valid. const iouParams = getIouParamsForTransaction(params); updateMoneyRequestMerchant({ @@ -289,12 +299,23 @@ function editTransactionMerchantInline(params: TransactionInlineEditParams, newM } /** Updates the description of an expense from the Search results table or the Expense Report page. */ -function editTransactionDescriptionInline(params: TransactionInlineEditParams, newDescription: string) { +function editTransactionDescriptionInline(translate: LocalizedTranslate, params: TransactionInlineEditParams, newDescription: string): string | undefined { + // The description step validates and saves the value after FormProvider strips invisible characters and trims it. + // The cell passes the raw text, so prepare it the same way. Otherwise trailing spaces past the limit toast a value the step would save. + // HTML is checked first so a value that fails both shows the invalid character error. + const description = StringUtils.removeInvisibleCharacters(newDescription); + if (containsHtmlTag(description)) { + return translate('common.error.invalidCharacter'); + } + if (description.length > CONST.DESCRIPTION_LIMIT) { + return translate('common.error.characterLimitExceedCounter', description.length, CONST.DESCRIPTION_LIMIT); + } + const iouParams = getIouParamsForTransaction(params); updateMoneyRequestDescription({ isVendorMatchingBetaEnabled: params.isVendorMatchingBetaEnabled, ...iouParams, - comment: newDescription, + comment: description, hash: params.hash, isEditedFromExpenseList: true, }); @@ -313,7 +334,7 @@ function editTransactionCategoryInline(params: TransactionInlineEditParams, newC } /** Updates the amount and currency of an expense from the Search results table or the Expense Report page. */ -function editTransactionAmountInline(params: TransactionInlineEditParams, newAmount: number) { +function editTransactionAmountInline(translate: LocalizedTranslate, params: TransactionInlineEditParams, newAmount: number): string | undefined { // Validate before building iouParams: getIouParamsForTransaction can call createTransactionThreadReport, // which fires Onyx.merge + openReport (an API call). Building params first would optimistically create a // thread report for an edit we're about to discard, so validate against params directly first. @@ -322,7 +343,7 @@ function editTransactionAmountInline(params: TransactionInlineEditParams, newAmo const isP2P = isIOUReport(params.parentReport); if (!isValidMoneyRequestAmount(newAmount, iouType, allowNegative, isP2P)) { - return; + return translate('iou.error.invalidAmount'); } const iouParams = getIouParamsForTransaction(params); diff --git a/src/libs/actions/TransactionThreadNavigation.ts b/src/libs/actions/TransactionThreadNavigation.ts index 026ec10e4d70..226885d42762 100644 --- a/src/libs/actions/TransactionThreadNavigation.ts +++ b/src/libs/actions/TransactionThreadNavigation.ts @@ -201,6 +201,35 @@ function clearActiveTransactionIDsForSource(source: string) { return clearActiveTransactionIDs(); } +/** + * Drops expenses that left `reportID` from a carousel seeded from that report, so its arrows stop paging to them. + * Carousels from other screens, such as search results, span several reports and are left alone. + */ +function removeActiveTransactionIDsForReport(transactionIDs: string[], reportID: string | undefined) { + if (!lastSetIDs?.length || !transactionIDs.length || !reportID) { + return Promise.resolve(); + } + + const reportSources = new Set([CAROUSEL_SOURCE.report(reportID), CAROUSEL_SOURCE.reportRow(reportID), CAROUSEL_SOURCE.reportPreview(reportID)]); + if (!lastSetSource || !reportSources.has(lastSetSource)) { + return Promise.resolve(); + } + + const removedIDs = new Set(transactionIDs); + const remainingIDs = lastSetIDs.filter((transactionID) => !removedIDs.has(transactionID)); + if (remainingIDs.length === lastSetIDs.length) { + return Promise.resolve(); + } + + // One expense left is nothing to page through. + if (remainingIDs.length < 2) { + return clearActiveTransactionIDs(); + } + + lastSetIDs = remainingIDs; + return Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, remainingIDs); +} + /** * Gives up ownership of the carousel without clearing it, for a writer that unmounts while the list it seeded is * still driving the arrows of an expense open on top of it. @@ -221,6 +250,7 @@ export { setActiveTransactionIDs, clearActiveTransactionIDs, clearActiveTransactionIDsForSource, + removeActiveTransactionIDsForReport, disownActiveTransactionIDs, getActiveTransactionIDs, shouldRefreshActiveTransactionIDs, diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 418f1dcd1584..7673258ab18e 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -86,7 +86,7 @@ Onyx.connectWithoutView({ }, }); -// This value is used in the handler for a RECONNECT_APP Pusher event (anonymous users), so it's not possible to use useOnyx(). +// This value is used in the handler for a RECONNECT_APP Pusher event, so it's not possible to use useOnyx(). let lastUpdateIDAppliedToClient: OnyxEntry; Onyx.connectWithoutView({ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, @@ -878,6 +878,26 @@ function subscribeToUserEvents( PusherUtils.onPrivateUserChannelResubscribe(currentUserAccountID.toString()); + // Temporary labels bypass the reliable update stream, but wait for the current API response so an older snapshot cannot overwrite them. + // Keep events in arrival order even when they wait on different queued requests. + let processingStatusEventsPromise = Promise.resolve(); + PusherUtils.subscribeToPrivateUserChannelEvent(Pusher.TYPE.AGENT_ZERO_PROCESSING_STATUS, currentUserAccountID.toString(), (pushJSON) => { + processingStatusEventsPromise = processingStatusEventsPromise + .then(() => SequentialQueue.getCurrentRequest()) + .then(() => { + if (sessionAccountID !== currentUserAccountID || !ActiveClientManager.isClientTheLeader()) { + return; + } + + return Onyx.update(pushJSON.updates); + }) + .catch((error: unknown) => { + Log.alert('[subscribeToUserEvents] Applying Agent Zero processing status failed', { + error: error instanceof Error ? error.message : String(error), + }); + }); + }); + // Handles the mega multipleEvents from Pusher which contains an array of single events. // Each single event is passed to PusherUtils in order to trigger the callbacks for that event PusherUtils.subscribeToPrivateUserChannelEvent(Pusher.TYPE.MULTIPLE_EVENTS, currentUserAccountID.toString(), (pushJSON) => { @@ -943,14 +963,12 @@ function subscribeToUserEvents( // We have an event to reconnect the App. It is triggered when we detect that the user passed updateID // is not in the DB PusherUtils.subscribeToMultiEvent(Pusher.TYPE.MULTIPLE_EVENT_TYPE.RECONNECT_APP, () => { - // Anonymous public-room sessions already have report actions from OpenReport. A full ReconnectApp - // uses setCollection and clears them, which flashes the loading skeleton until GetNewerActions - // refills. Pass lastUpdateID so reconnect stays incremental for anonymous users (#97847). - if (isAnonymousUser()) { - reconnectApp(lastUpdateIDAppliedToClient); - } else { - reconnectApp(); - } + // This event is broadcast on the account-wide channel, so every signed-in device receives it, not only + // the one whose updateID is missing. A full ReconnectApp uses setCollection and clears report actions the + // device already holds (#97847, #100791). Pass lastUpdateID so the server can reply incrementally. It + // still returns full data when it can't serve that range, and we fall back to a full reconnect when the + // client has no lastUpdateID yet. + reconnectApp(lastUpdateIDAppliedToClient); return Promise.resolve(); }); } @@ -1313,6 +1331,55 @@ function setNameValuePair(name: TKey, value: SetNameValueP }); } +function joinExactOnlineWaitlist() { + const signupTimestamp = DateUtils.getDBTime(); + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + errorFields: {exactOnlineWaitlist: null}, + }, + }, + ]; + // The confirmation modal must not treat a queued signup as confirmed membership. + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, + value: signupTimestamp, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: {pendingFields: {exactOnlineWaitlist: null}}, + }, + ]; + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, + value: '', + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + pendingFields: {exactOnlineWaitlist: null}, + errorFields: {exactOnlineWaitlist: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ]; + + // The queued request keeps this timestamp when it retries, preserving the original signup time. + API.write(WRITE_COMMANDS.SET_NAME_VALUE_PAIR, {name: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: signupTimestamp}, {optimisticData, successData, failureData}); +} + +function clearExactOnlineWaitlistError() { + Onyx.merge(ONYXKEYS.ACCOUNT, {errorFields: {exactOnlineWaitlist: null}}); +} + function dismissMarketingWindow(updateKey: string) { const optimisticData: AnyOnyxUpdate[] = [ { @@ -1644,6 +1711,16 @@ function respondToProactiveAppReview( }, }); + // Record locally that this client prompted and got an answer. This is not optimistic data: the prompt + // happened regardless of what the request returns, and nvp_appReview is server-owned, so the timestamp + // we merge into it can be dropped by any authoritative push of that key. + // The record is scoped by the session's accountID, not userAccountID: the modal can be on screen before + // the personal details list hydrates, and until it does the caller's accountID is CONST.DEFAULT_NUMBER_ID, + // which would stamp a record that never matches the signed-in account again. + if (sessionAccountID) { + Onyx.set(ONYXKEYS.APP_REVIEW_LAST_PROMPT, {accountID: sessionAccountID, lastPrompt: DateUtils.getDBTime()}); + } + API.write(WRITE_COMMANDS.RESPOND_TO_PROACTIVE_APP_REVIEW, params, {optimisticData, successData, failureData}); } @@ -2021,6 +2098,8 @@ export { clearDraftCustomStatus, requestRefund, setNameValuePair, + joinExactOnlineWaitlist, + clearExactOnlineWaitlistError, setSubmitMigrationModalShown, clearUnvalidatedNewContactMethodAction, clearPendingContactActionErrors, diff --git a/src/libs/actions/VacationDelegate.ts b/src/libs/actions/VacationDelegate.ts index 3f814e73a5e9..c77e22e95b9f 100644 --- a/src/libs/actions/VacationDelegate.ts +++ b/src/libs/actions/VacationDelegate.ts @@ -24,27 +24,38 @@ 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; 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) { + // Without the override, the response can still be a 305 that leads to the missing workspaces step, so the pick is only applied once the + // backend saved it. Applying it optimistically would flash the new delegate on Profile, take it away on the 305, and show it again after that step. + const pickedDelegate = {creator, delegate, clearAfter: clearAfter ?? null}; + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, value: { - creator, - delegate, + ...(shouldOverridePolicyDiffWarning ? pickedDelegate : {}), errors: null, + // Set on both paths: the 305 ships an errors payload in its onyxData, and getVacationDelegateErrors only hides it while this is set pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, previousDelegate: currentDelegate ?? null, + previousClearAfter: currentClearAfter ?? null, policyDiff: null, pendingDelegate: null, + pendingClearAfter: null, }, }, ]; @@ -54,11 +65,14 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, value: { + ...(shouldOverridePolicyDiffWarning ? {} : pickedDelegate), errors: null, pendingAction: null, previousDelegate: null, + previousClearAfter: null, policyDiff: null, pendingDelegate: null, + pendingClearAfter: null, }, }, ]; @@ -77,6 +91,7 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv const parameters: SetVacationDelegateParams = { creator, vacationDelegateEmail: delegate, + clearAfter, overridePolicyDiffWarning: shouldOverridePolicyDiffWarning, }; @@ -107,7 +122,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, }); @@ -123,7 +140,7 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { return; } - const {creator, delegate} = vacationDelegate; + const {creator, delegate, clearAfter} = vacationDelegate; const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -131,11 +148,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, }, }, ]; @@ -148,6 +168,7 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { errors: null, pendingAction: null, previousDelegate: null, + previousClearAfter: null, }, }, ]; @@ -159,23 +180,34 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { value: { creator, delegate, + clearAfter, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('statusPage.vacationDelegateError'), pendingAction: null, }, }, ]; - 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) { +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/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts index 2102aa1cece2..ae2b198b9578 100644 --- a/src/libs/actions/Wallet.ts +++ b/src/libs/actions/Wallet.ts @@ -1,7 +1,10 @@ import * as API from '@libs/API'; -import type {AcceptWalletTermsParams, AnswerQuestionsForWalletParams, UpdatePersonalDetailsForWalletParams, VerifyIdentityParams} from '@libs/API/parameters'; +import type {AcceptWalletTermsParams, AnswerQuestionsForWalletParams, CreateDigitalWalletParams, UpdatePersonalDetailsForWalletParams, VerifyIdentityParams} from '@libs/API/parameters'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import Log from '@libs/Log'; +import {isHttpSuccess, parseHttpResponse} from '@libs/MultifactorAuthentication/shared/helpers'; +import type {WalletAuthentication} from '@libs/Wallet/types'; +import WalletProvisioningError from '@libs/Wallet/WalletProvisioningError'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -303,18 +306,31 @@ function clearWalletAdditionalDetailsErrors(currentErrorCode?: string | null) { }); } -function issuerEncryptPayloadCallback(nonce: string, nonceSignature: string, certificates: string[], cardID: number): Promise { - // eslint-disable-next-line rulesdir/no-api-side-effects-method - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET, { +function issuerEncryptPayloadCallback(nonce: string, nonceSignature: string, certificates: string[], cardID: number, authentication?: WalletAuthentication): Promise { + const params: CreateDigitalWalletParams = { platform: 'ios', appVersion: pkg.version, certificates: JSON.stringify({certificates}), nonce, nonceSignature, cardID, - }) + }; + // eslint-disable-next-line rulesdir/no-api-side-effects-method + return API.makeRequestWithSideEffects( + SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET, + authentication ? {...params, signedChallenge: JSON.stringify(authentication.signedChallenge), authenticationMethod: authentication.authenticationMethod} : params, + ) .then((response) => { const data = response as unknown as IOSEncryptPayload; + if (authentication) { + const parsed = parseHttpResponse(response?.jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.CREATE_DIGITAL_WALLET, response?.message); + if (!isHttpSuccess(parsed.httpStatusCode)) { + throw new WalletProvisioningError(parsed); + } + if (![data?.encryptedPassData, data?.activationData, data?.ephemeralPublicKey].every((value) => typeof value === 'string' && value.length > 0)) { + throw new WalletProvisioningError({httpStatusCode: undefined, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNRECOGNIZED, message: undefined}); + } + } return { encryptedPassData: data.encryptedPassData, activationData: data.activationData, @@ -322,6 +338,11 @@ function issuerEncryptPayloadCallback(nonce: string, nonceSignature: string, cer } as IOSEncryptPayload; }) .catch((error) => { + if (authentication) { + throw error instanceof WalletProvisioningError + ? error + : new WalletProvisioningError(parseHttpResponse(undefined, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.CREATE_DIGITAL_WALLET, undefined)); + } Log.warn(`issuerEncryptPayloadCallback error: ${error}`); return {} as IOSEncryptPayload; }); @@ -333,27 +354,60 @@ function issuerEncryptPayloadCallback(nonce: string, nonceSignature: string, cer * @param walletAccountID ID of the wallet on user's phone * @param deviceID ID of user's phone */ -function createDigitalGoogleWallet({ - walletAccountID, - deviceID, - cardID, - cardHolderName, -}: { - deviceID: string; - walletAccountID: string; - cardID: number; - cardHolderName: string; -}): Promise { - // eslint-disable-next-line rulesdir/no-api-side-effects-method - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET, { +function createDigitalGoogleWallet( + { + walletAccountID, + deviceID, + cardID, + cardHolderName, + }: { + deviceID: string; + walletAccountID: string; + cardID: number; + cardHolderName: string; + }, + authentication?: WalletAuthentication, +): Promise { + const params: CreateDigitalWalletParams = { platform: 'android', appVersion: pkg.version, walletAccountID, deviceID, cardID, - }) + }; + // eslint-disable-next-line rulesdir/no-api-side-effects-method + return API.makeRequestWithSideEffects( + SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET, + authentication ? {...params, signedChallenge: JSON.stringify(authentication.signedChallenge), authenticationMethod: authentication.authenticationMethod} : params, + ) .then((response) => { const data = response as unknown as ProvisioningCardData; + if (authentication) { + const parsed = parseHttpResponse(response?.jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.CREATE_DIGITAL_WALLET, response?.message); + if (!isHttpSuccess(parsed.httpStatusCode)) { + throw new WalletProvisioningError(parsed); + } + const address = data?.userAddress; + const requiredFields = [ + data?.network, + data?.opaquePaymentCard, + data?.lastDigits, + address?.name, + address?.address1, + address?.city, + address?.country, + address?.postal_code, + address?.phone, + ]; + // UK addresses can have an empty province, but the native bridge still requires a string. + if ( + !requiredFields.every((value) => typeof value === 'string' && value.length > 0) || + typeof address?.state !== 'string' || + (address?.address2 !== undefined && typeof address.address2 !== 'string') + ) { + throw new WalletProvisioningError({httpStatusCode: undefined, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNRECOGNIZED, message: undefined}); + } + } return { network: data.network, opaquePaymentCard: data.opaquePaymentCard, @@ -372,6 +426,11 @@ function createDigitalGoogleWallet({ } as AndroidCardData; }) .catch((error) => { + if (authentication) { + throw error instanceof WalletProvisioningError + ? error + : new WalletProvisioningError(parseHttpResponse(undefined, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.CREATE_DIGITAL_WALLET, undefined)); + } Log.warn(`createDigitalGoogleWallet error: ${error}`); return {} as AndroidCardData; }); diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index 0a3b0fc0c97d..252fc4638af8 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -1,30 +1,24 @@ -import {translate} from '@libs/Localize'; +import type {OnboardingCompanySize, OnboardingMessage, OnboardingPurpose, OnboardingTask, OnboardingTaskLinks} from '@libs/buildOnboardingMessages'; +import {buildOnboardingMessages} from '@libs/buildOnboardingMessages'; import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; -import {linkingConfig} from '@libs/Navigation/linkingConfig'; +import {config as linkingConfig} from '@libs/Navigation/linkingConfig/config'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import {openApp} from '@userActions/App'; -import type {Video} from '@userActions/Report'; import CONST from '@src/CONST'; -import IntlStore from '@src/languages/IntlStore'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; -import {hasCompletedGuidedSetupFlowSelector} from '@src/selectors/Onboarding'; import type {Account, Locale, Onboarding} from '@src/types/onyx'; import type {NavigationState, PartialState} from '@react-navigation/native'; import type {OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; import {getStateFromPath} from '@react-navigation/native'; import Onyx from 'react-native-onyx'; -type OnboardingCompanySize = ValueOf; -type OnboardingPurpose = ValueOf; - type GetOnboardingInitialPathParamsType = { isUserFromPublicDomain: boolean; hasAccessiblePolicies: boolean; @@ -38,46 +32,6 @@ type GetOnboardingInitialPathParamsType = { resumePath?: string; }; -type OnboardingTaskLinks = Partial<{ - onboardingCompanySize: OnboardingCompanySize; - integrationName: string; - workspaceSettingsLink: string; - workspaceCategoriesLink: string; - workspaceTagsLink: string; - workspaceMoreFeaturesLink: string; - workspaceMembersLink: string; - workspaceAccountingLink: string; - workspaceConfirmationLink: string; - testDriveURL: string; - corporateCardLink: string; - companyDomain: string; - workEmail: string; - validateEmailLink: string; - workEmailLink: string; - joinWorkspaceLink: string; -}>; - -type OnboardingTask = { - type: ValueOf; - autoCompleted: boolean; - title: string | ((params: OnboardingTaskLinks) => string); - description: string | ((params: OnboardingTaskLinks) => string); -}; - -type OnboardingMessage = { - /** Text message that will be displayed first */ - message: string | ((params: OnboardingTaskLinks) => string); - - /** Video object to be displayed after initial description message */ - video?: Video; - - /** List of tasks connected with the message, they will have a checkbox and a separate report for more information */ - tasks: OnboardingTask[]; - - /** Type of task described in a string format */ - type?: string; -}; - let onboardingData: OnyxEntry; Onyx.connectWithoutView({ @@ -124,7 +78,7 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI isAccountValidated, } = getOnboardingInitialPathParams; const initialPath = onboardingInitialPath ?? ''; - const state = getStateFromPath(initialPath, linkingConfig.config); + const state = getStateFromPath(initialPath, linkingConfig); const currentOnboardingValues = onboardingValuesParam ?? onboardingValues; const isVsb = currentOnboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.VSB; const isSmb = currentOnboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.SMB; @@ -144,8 +98,11 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI CONST.ONBOARDING_CHOICES.TRACK_PERSONAL, ]); } + // Preserve onboarding progress after a direct add while the public domain flag is stale. + const shouldResumeOnboardingAfterDirectAdd = currentOnboardingValues?.shouldValidate === false && state?.routes?.at(-1)?.name === NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR; + // A validated account has no reason to be on the onboarding "add work email" screen. - if (isUserFromPublicDomain && !onboardingValuesParam?.isMergeAccountStepCompleted && !isAccountValidated) { + if (isUserFromPublicDomain && !onboardingValuesParam?.isMergeAccountStepCompleted && !isAccountValidated && !shouldResumeOnboardingAfterDirectAdd) { return `/${ROUTES.ONBOARDING_WORK_EMAIL.route}`; } @@ -249,265 +206,7 @@ async function resumeOnboardingAfterRequired2FASetup(onboardingFlowParams: GetOn }); } -const getOnboardingMessages = (locale?: Locale) => { - const resolvedLocale = locale ?? IntlStore.getCurrentLocale(); - const testDrive = { - ONBOARDING_TASK_NAME: translate(resolvedLocale, 'onboarding.testDrive.name', {}), - EMBEDDED_DEMO_WHITELIST: ['http://', 'https://', 'about:'] as string[], - EMBEDDED_DEMO_IFRAME_TITLE: translate(resolvedLocale, 'onboarding.testDrive.embeddedDemoIframeTitle'), - }; - const addExpenseApprovalsTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.ADD_EXPENSE_APPROVALS, - autoCompleted: false, - title: () => translate(resolvedLocale, 'onboarding.tasks.addExpenseApprovalsTask.title'), - description: ({workspaceMoreFeaturesLink}) => translate(resolvedLocale, 'onboarding.tasks.addExpenseApprovalsTask.description', {workspaceMoreFeaturesLink}), - }; - const createReportTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.CREATE_REPORT, - autoCompleted: false, - title: translate(resolvedLocale, 'onboarding.tasks.createReportTask.title'), - description: translate(resolvedLocale, 'onboarding.tasks.createReportTask.description'), - }; - const addWorkEmailTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.ADD_WORK_EMAIL, - autoCompleted: false, - title: translate(resolvedLocale, 'onboarding.tasks.addWorkEmailTask.title'), - description: ({workEmailLink}) => translate(resolvedLocale, 'onboarding.tasks.addWorkEmailTask.description', {workEmailLink}), - }; - const validateEmailTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.VALIDATE_EMAIL, - autoCompleted: false, - title: translate(resolvedLocale, 'onboarding.tasks.validateEmailTask.title'), - description: ({validateEmailLink, workEmail}) => translate(resolvedLocale, 'onboarding.tasks.validateEmailTask.description', {validateEmailLink, workEmail}), - }; - const joinWorkspaceTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.JOIN_WORKSPACE, - autoCompleted: false, - title: translate(resolvedLocale, 'onboarding.tasks.joinWorkspaceTask.title'), - description: ({joinWorkspaceLink}) => translate(resolvedLocale, 'onboarding.tasks.joinWorkspaceTask.description', {joinWorkspaceLink}), - }; - const testDriveAdminTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.VIEW_TOUR, - autoCompleted: false, - title: ({testDriveURL}) => translate(resolvedLocale, 'onboarding.tasks.testDriveAdminTask.title', {testDriveURL}), - description: ({testDriveURL}) => translate(resolvedLocale, 'onboarding.tasks.testDriveAdminTask.description', {testDriveURL}), - }; - const testDriveEmployeeTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.VIEW_TOUR, - autoCompleted: false, - title: ({testDriveURL}) => translate(resolvedLocale, 'onboarding.tasks.testDriveEmployeeTask.title', {testDriveURL}), - description: ({testDriveURL}) => translate(resolvedLocale, 'onboarding.tasks.testDriveEmployeeTask.description', {testDriveURL}), - }; - const createTestDriveAdminWorkspaceTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.CREATE_WORKSPACE, - autoCompleted: false, - title: ({workspaceConfirmationLink}) => translate(resolvedLocale, 'onboarding.tasks.createTestDriveAdminWorkspaceTask.title', {workspaceConfirmationLink}), - description: translate(resolvedLocale, 'onboarding.tasks.createTestDriveAdminWorkspaceTask.description'), - }; - - const createWorkspaceTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.CREATE_WORKSPACE, - autoCompleted: true, - title: ({workspaceSettingsLink}) => translate(resolvedLocale, 'onboarding.tasks.createWorkspaceTask.title', {workspaceSettingsLink}), - description: ({workspaceSettingsLink}) => translate(resolvedLocale, 'onboarding.tasks.createWorkspaceTask.description', {workspaceSettingsLink}), - }; - - const setupCategoriesTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.SETUP_CATEGORIES, - autoCompleted: false, - title: ({workspaceCategoriesLink}) => translate(resolvedLocale, 'onboarding.tasks.setupCategoriesTask.title', {workspaceCategoriesLink}), - description: ({workspaceCategoriesLink}) => translate(resolvedLocale, 'onboarding.tasks.setupCategoriesTask.description', {workspaceCategoriesLink}), - }; - - const combinedTrackSubmitExpenseTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.SUBMIT_EXPENSE, - autoCompleted: false, - title: translate(resolvedLocale, 'onboarding.tasks.combinedTrackSubmitExpenseTask.title'), - description: translate(resolvedLocale, 'onboarding.tasks.combinedTrackSubmitExpenseTask.description'), - }; - - const adminSubmitExpenseTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.SUBMIT_EXPENSE, - autoCompleted: false, - title: translate(resolvedLocale, 'onboarding.tasks.adminSubmitExpenseTask.title'), - description: translate(resolvedLocale, 'onboarding.tasks.adminSubmitExpenseTask.description'), - }; - - const trackExpenseTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.TRACK_EXPENSE, - autoCompleted: false, - title: translate(resolvedLocale, 'onboarding.tasks.trackExpenseTask.title'), - description: translate(resolvedLocale, 'onboarding.tasks.trackExpenseTask.description'), - }; - - const addAccountingIntegrationTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.ADD_ACCOUNTING_INTEGRATION, - autoCompleted: false, - title: ({integrationName, workspaceAccountingLink}) => translate(resolvedLocale, 'onboarding.tasks.addAccountingIntegrationTask.title', {integrationName, workspaceAccountingLink}), - description: ({integrationName, workspaceAccountingLink}) => - translate(resolvedLocale, 'onboarding.tasks.addAccountingIntegrationTask.description', {integrationName, workspaceAccountingLink}), - }; - - const connectCorporateCardTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.CONNECT_CORPORATE_CARD, - title: ({corporateCardLink}) => translate(resolvedLocale, 'onboarding.tasks.connectCorporateCardTask.title', {corporateCardLink}), - description: ({corporateCardLink}) => translate(resolvedLocale, 'onboarding.tasks.connectCorporateCardTask.description', {corporateCardLink}), - autoCompleted: false, - }; - - const inviteTeamTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.INVITE_TEAM, - autoCompleted: false, - title: ({workspaceMembersLink}) => translate(resolvedLocale, 'onboarding.tasks.inviteTeamTask.title', {workspaceMembersLink}), - description: ({workspaceMembersLink}) => translate(resolvedLocale, 'onboarding.tasks.inviteTeamTask.description', {workspaceMembersLink}), - }; - - const setupCategoriesAndTags: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.SETUP_CATEGORIES_AND_TAGS, - autoCompleted: false, - title: ({workspaceCategoriesLink, workspaceTagsLink}) => translate(resolvedLocale, 'onboarding.tasks.setupCategoriesAndTags.title', {workspaceCategoriesLink, workspaceTagsLink}), - description: ({workspaceCategoriesLink, workspaceAccountingLink}) => - translate(resolvedLocale, 'onboarding.tasks.setupCategoriesAndTags.description', {workspaceCategoriesLink, workspaceAccountingLink}), - }; - const setupTagsTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.SETUP_TAGS, - autoCompleted: false, - title: ({workspaceTagsLink}) => translate(resolvedLocale, 'onboarding.tasks.setupTagsTask.title', {workspaceTagsLink}), - description: ({workspaceMoreFeaturesLink}) => translate(resolvedLocale, 'onboarding.tasks.setupTagsTask.description', {workspaceMoreFeaturesLink}), - }; - - const startChatTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.START_CHAT, - autoCompleted: false, - title: translate(resolvedLocale, 'onboarding.tasks.startChatTask.title'), - description: translate(resolvedLocale, 'onboarding.tasks.startChatTask.description'), - }; - - const splitExpenseTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.SPLIT_EXPENSE, - autoCompleted: false, - title: translate(resolvedLocale, 'onboarding.tasks.splitExpenseTask.title'), - description: translate(resolvedLocale, 'onboarding.tasks.splitExpenseTask.description'), - }; - - const reviewWorkspaceSettingsTask: OnboardingTask = { - type: CONST.ONBOARDING_TASK_TYPE.REVIEW_WORKSPACE_SETTINGS, - autoCompleted: false, - title: ({workspaceSettingsLink}) => translate(resolvedLocale, 'onboarding.tasks.reviewWorkspaceSettingsTask.title', {workspaceSettingsLink}), - description: ({workspaceSettingsLink}) => translate(resolvedLocale, 'onboarding.tasks.reviewWorkspaceSettingsTask.description', {workspaceSettingsLink}), - }; - - const onboardingEmployerOrSubmitMessage: OnboardingMessage = { - message: translate(resolvedLocale, 'onboarding.messages.onboardingEmployerOrSubmitMessage'), - tasks: [testDriveEmployeeTask, adminSubmitExpenseTask], - }; - - const combinedTrackSubmitOnboardingEmployerOrSubmitMessage: OnboardingMessage = { - ...onboardingEmployerOrSubmitMessage, - tasks: [testDriveEmployeeTask, combinedTrackSubmitExpenseTask], - }; - - const onboardingPersonalSpendMessage: OnboardingMessage = { - message: translate(resolvedLocale, 'onboarding.messages.onboardingPersonalSpendMessage'), - tasks: [testDriveEmployeeTask, trackExpenseTask], - }; - const combinedTrackSubmitOnboardingPersonalSpendMessage: OnboardingMessage = { - ...onboardingPersonalSpendMessage, - tasks: [testDriveEmployeeTask, trackExpenseTask], - }; - - const isOnboardingFlow = hasCompletedGuidedSetupFlowSelector(onboardingData); - const onboardingManageTeamMessage: OnboardingMessage = { - message: translate(resolvedLocale, 'onboarding.messages.onboardingManageTeamMessage', {isOnboardingFlow}), - tasks: [ - createWorkspaceTask, - testDriveAdminTask, - addAccountingIntegrationTask, - connectCorporateCardTask, - inviteTeamTask, - setupCategoriesAndTags, - setupCategoriesTask, - setupTagsTask, - addExpenseApprovalsTask, - ], - }; - - const onboardingTrackWorkspaceMessage: OnboardingMessage = { - message: translate(resolvedLocale, 'onboarding.messages.onboardingTrackWorkspaceMessage'), - video: { - url: `${CONST.CLOUDFRONT_URL}/videos/guided-setup-manage-team-v2.mp4`, - thumbnailUrl: `${CONST.CLOUDFRONT_URL}/images/guided-setup-manage-team.jpg`, - duration: 55, - width: 1280, - height: 960, - }, - tasks: [testDriveAdminTask, createReportTask, setupCategoriesTask], - }; - - const onboardingChatSplitMessage: OnboardingMessage = { - message: translate(resolvedLocale, 'onboarding.messages.onboardingChatSplitMessage'), - tasks: [testDriveEmployeeTask, startChatTask, splitExpenseTask], - }; - - const onboardingAdminMessage: OnboardingMessage = { - message: translate(resolvedLocale, 'onboarding.messages.onboardingAdminMessage'), - tasks: [reviewWorkspaceSettingsTask, adminSubmitExpenseTask], - }; - const onboardingLookingAroundMessage: OnboardingMessage = { - message: '', - tasks: [], - }; - - const onboardingTestDriveReceiverMessage: OnboardingMessage = { - message: translate(resolvedLocale, 'onboarding.messages.onboardingTestDriveReceiverMessage'), - tasks: [testDriveAdminTask, createTestDriveAdminWorkspaceTask], - }; - - const onboardingJoinWorkspaceAddWorkEmailMessage: OnboardingMessage = { - message: translate(resolvedLocale, 'onboarding.messages.onboardingJoinWorkspaceAddWorkEmailMessage'), - tasks: [addWorkEmailTask], - }; - const onboardingJoinWorkspaceValidateEmailMessage: OnboardingMessage = { - message: ({companyDomain}) => translate(resolvedLocale, 'onboarding.messages.onboardingJoinWorkspaceValidateEmailMessage', {companyDomain}), - tasks: [validateEmailTask], - }; - const onboardingJoinWorkspaceMessage: OnboardingMessage = { - message: ({companyDomain, joinWorkspaceLink}) => translate(resolvedLocale, 'onboarding.messages.onboardingJoinWorkspaceMessage', {companyDomain, joinWorkspaceLink}), - tasks: [joinWorkspaceTask], - }; - const onboardingJoinWorkspaceEmptyMessage: OnboardingMessage = { - message: translate(resolvedLocale, 'onboarding.messages.onboardingJoinWorkspaceEmptyMessage'), - tasks: [], - }; - - return { - onboardingMessages: { - [CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE]: onboardingJoinWorkspaceAddWorkEmailMessage, - [CONST.ONBOARDING_CHOICES.EMPLOYER]: onboardingEmployerOrSubmitMessage, - [CONST.ONBOARDING_CHOICES.SUBMIT]: onboardingEmployerOrSubmitMessage, - [CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: onboardingManageTeamMessage, - [CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE]: onboardingTrackWorkspaceMessage, - [CONST.ONBOARDING_CHOICES.TRACK_PERSONAL]: onboardingTrackWorkspaceMessage, - [CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]: onboardingPersonalSpendMessage, - [CONST.ONBOARDING_CHOICES.CHAT_SPLIT]: onboardingChatSplitMessage, - [CONST.ONBOARDING_CHOICES.ADMIN]: onboardingAdminMessage, - [CONST.ONBOARDING_CHOICES.LOOKING_AROUND]: onboardingLookingAroundMessage, - [CONST.ONBOARDING_CHOICES.TEST_DRIVE_RECEIVER]: onboardingTestDriveReceiverMessage, - } satisfies Record, - createExpenseOnboardingMessages: { - [CONST.CREATE_EXPENSE_ONBOARDING_CHOICES.PERSONAL_SPEND]: combinedTrackSubmitOnboardingPersonalSpendMessage, - [CONST.CREATE_EXPENSE_ONBOARDING_CHOICES.EMPLOYER]: combinedTrackSubmitOnboardingEmployerOrSubmitMessage, - [CONST.CREATE_EXPENSE_ONBOARDING_CHOICES.SUBMIT]: combinedTrackSubmitOnboardingEmployerOrSubmitMessage, - } satisfies Record, OnboardingMessage>, - testDrive, - joinWorkspaceMessages: { - addWorkEmail: onboardingJoinWorkspaceAddWorkEmailMessage, - validateEmail: onboardingJoinWorkspaceValidateEmailMessage, - joinWorkspace: onboardingJoinWorkspaceMessage, - empty: onboardingJoinWorkspaceEmptyMessage, - }, - }; -}; +const getOnboardingMessages = (locale?: Locale) => buildOnboardingMessages(onboardingData, locale); export type {OnboardingMessage, OnboardingTask, OnboardingTaskLinks, OnboardingPurpose, OnboardingCompanySize, GetOnboardingInitialPathParamsType}; export {buildOnboardingFlowParams, getOnboardingInitialPath, getRequired2FAOnboardingResumePath, resumeOnboardingAfterRequired2FASetup, startOnboardingFlow, getOnboardingMessages}; diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts index ef7a69b3499f..de53ddb61c37 100644 --- a/src/libs/actions/Welcome/index.ts +++ b/src/libs/actions/Welcome/index.ts @@ -108,8 +108,13 @@ function updateOnboardingValuesAndNavigation(onboardingValues: Onboarding | unde }); } -async function clearOnboardingShouldValidate(onboardingValues: Onboarding | undefined) { - await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {...onboardingValues, shouldValidate: undefined}); +async function resetOnboardingWorkEmailValidation(onboardingValues: Onboarding | undefined) { + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, { + ...onboardingValues, + shouldValidate: undefined, + isMergeAccountStepCompleted: undefined, + isMergeAccountStepSkipped: undefined, + }); } function setOnboardingShouldValidate(shouldValidate: boolean) { @@ -312,7 +317,7 @@ export { createJoinWorkspaceOnboardingContent, setOnboardingShouldValidate, updateOnboardingValuesAndNavigation, - clearOnboardingShouldValidate, + resetOnboardingWorkEmailValidation, setOnboardingUserReportedIntegration, setOnboardingAccountingEnabled, setOnboardingInterestedFeaturesMap, diff --git a/src/libs/actions/connections/BusinessCentral.ts b/src/libs/actions/connections/BusinessCentral.ts index 250b7ff0063a..7eea6262f3b8 100644 --- a/src/libs/actions/connections/BusinessCentral.ts +++ b/src/libs/actions/connections/BusinessCentral.ts @@ -1,19 +1,28 @@ import {write} from '@libs/API'; import type { ConnectPolicyToBusinessCentralParams, + UpdateBusinessCentralAccountingMethodParams, + UpdateBusinessCentralAutoCreateEntitiesParams, + UpdateBusinessCentralAutoSyncParams, UpdateBusinessCentralCompanyParams, UpdateBusinessCentralCustomerMappingParams, UpdateBusinessCentralDefaultVendorParams, UpdateBusinessCentralEnableNewCategoriesParams, UpdateBusinessCentralExportDateParams, UpdateBusinessCentralExporterParams, + UpdateBusinessCentralExportToNextOpenPeriodParams, UpdateBusinessCentralFieldMappingParams, UpdateBusinessCentralNonreimbursableAccountParams, UpdateBusinessCentralNonreimbursableExpensesExportDestinationParams, UpdateBusinessCentralPaymentMethodParams, + UpdateBusinessCentralPostingModeParams, UpdateBusinessCentralReimbursableAccountParams, UpdateBusinessCentralReimbursableExpensesExportDestinationParams, + UpdateBusinessCentralReimbursementBankAccountParams, + UpdateBusinessCentralSettlementsBankAccountParams, + UpdateBusinessCentralSyncExpensifyCardSettlementsParams, UpdateBusinessCentralSyncItemsParams, + UpdateBusinessCentralSyncReimbursedReportsParams, UpdateBusinessCentralSyncTaxRatesParams, } from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; @@ -21,7 +30,7 @@ import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {BusinessCentralCoding, BusinessCentralCodingOfflineFeedbackKeys, BusinessCentralCustomerMappings, BusinessCentralExport} from '@src/types/onyx/Policy'; +import type {BusinessCentralCoding, BusinessCentralCodingOfflineFeedbackKeys, BusinessCentralCustomerMappings, BusinessCentralExport, BusinessCentralSync} from '@src/types/onyx/Policy'; import type {OnyxUpdate} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -39,6 +48,12 @@ type BusinessCentralCodingUpdate = { customerMappings?: Partial>; }; +/** Config sections whose settings each update on their own */ +type BusinessCentralConfigSections = { + export: BusinessCentralExport; + sync: BusinessCentralSync; +}; + function connectToBusinessCentral(policyID: string, credentials: Omit) { const optimisticData: Array> = [ { @@ -325,13 +340,14 @@ function updateBusinessCentralCustomerMapping( } /** - * Builds the Onyx updates for a change to one export setting. The pending and error state lives under the setting's own key. + * Builds the Onyx updates for a change to one export or payment sync setting. The pending and error state lives under the setting's own key. */ -function prepareBusinessCentralExportOnyxData( +function prepareBusinessCentralConfigSectionOnyxData( policyID: string, + section: TSection, settingName: TSettingName, - settingValue: BusinessCentralExport[TSettingName], - oldSettingValue: BusinessCentralExport[TSettingName] | null, + settingValue: BusinessCentralConfigSections[TSection][TSettingName], + oldSettingValue: BusinessCentralConfigSections[TSection][TSettingName] | null, ) { const optimisticData: Array> = [ { @@ -341,7 +357,7 @@ function prepareBusinessCentralExportOnyxData> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + [CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL]: { + config: { + autoSync: {enabled}, + pendingFields: { + [CONST.BUSINESS_CENTRAL_CONFIG.AUTO_SYNC]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errorFields: { + [CONST.BUSINESS_CENTRAL_CONFIG.AUTO_SYNC]: null, + }, + }, + }, + }, + }, + }, + ]; + + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + [CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL]: { + config: { + pendingFields: { + [CONST.BUSINESS_CENTRAL_CONFIG.AUTO_SYNC]: null, + }, + }, + }, + }, + }, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + connections: { + [CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL]: { + config: { + autoSync: {enabled: oldEnabled ?? null}, + pendingFields: { + [CONST.BUSINESS_CENTRAL_CONFIG.AUTO_SYNC]: null, + }, + errorFields: { + [CONST.BUSINESS_CENTRAL_CONFIG.AUTO_SYNC]: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + }, + }, + ]; + + const parameters: UpdateBusinessCentralAutoSyncParams = {policyID, enabled}; + write(WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_AUTO_SYNC, parameters, {optimisticData, successData, failureData}); +} + +function updateBusinessCentralSyncReimbursedReports(policyID: string, enabled: BusinessCentralSync['syncReimbursedReports'], oldEnabled?: BusinessCentralSync['syncReimbursedReports']) { + const onyxData = prepareBusinessCentralConfigSectionOnyxData(policyID, 'sync', CONST.BUSINESS_CENTRAL_CONFIG.SYNC_REIMBURSED_REPORTS, enabled, oldEnabled ?? null); + const parameters: UpdateBusinessCentralSyncReimbursedReportsParams = {policyID, enabled}; + write(WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_SYNC_REIMBURSED_REPORTS, parameters, onyxData); +} + +function updateBusinessCentralReimbursementBankAccount( + policyID: string, + bankAccountID: BusinessCentralSync['reimbursementBankAccountID'], + oldBankAccountID?: BusinessCentralSync['reimbursementBankAccountID'], +) { + const onyxData = prepareBusinessCentralConfigSectionOnyxData(policyID, 'sync', CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSEMENT_BANK_ACCOUNT_ID, bankAccountID, oldBankAccountID ?? null); + const parameters: UpdateBusinessCentralReimbursementBankAccountParams = {policyID, reimbursementBankAccountID: bankAccountID}; + write(WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_REIMBURSEMENT_BANK_ACCOUNT, parameters, onyxData); +} + +function updateBusinessCentralSyncExpensifyCardSettlements( + policyID: string, + enabled: BusinessCentralSync['syncExpensifyCardSettlements'], + oldEnabled?: BusinessCentralSync['syncExpensifyCardSettlements'], +) { + const onyxData = prepareBusinessCentralConfigSectionOnyxData(policyID, 'sync', CONST.BUSINESS_CENTRAL_CONFIG.SYNC_EXPENSIFY_CARD_SETTLEMENTS, enabled, oldEnabled ?? null); + const parameters: UpdateBusinessCentralSyncExpensifyCardSettlementsParams = {policyID, enabled}; + write(WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_SYNC_EXPENSIFY_CARD_SETTLEMENTS, parameters, onyxData); +} + +function updateBusinessCentralSettlementsBankAccount( + policyID: string, + bankAccountID: BusinessCentralSync['settlementsBankAccountID'], + oldBankAccountID?: BusinessCentralSync['settlementsBankAccountID'], +) { + const onyxData = prepareBusinessCentralConfigSectionOnyxData(policyID, 'sync', CONST.BUSINESS_CENTRAL_CONFIG.SETTLEMENTS_BANK_ACCOUNT_ID, bankAccountID, oldBankAccountID ?? null); + const parameters: UpdateBusinessCentralSettlementsBankAccountParams = {policyID, settlementsBankAccountID: bankAccountID}; + write(WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_SETTLEMENTS_BANK_ACCOUNT, parameters, onyxData); +} + export { connectToBusinessCentral, clearBusinessCentralErrorField, @@ -484,4 +636,13 @@ export { updateBusinessCentralNonReimbursableAccount, updateBusinessCentralDefaultVendor, updateBusinessCentralPaymentMethod, + updateBusinessCentralExportToNextOpenPeriod, + updateBusinessCentralAccountingMethod, + updateBusinessCentralPostingMode, + updateBusinessCentralAutoCreateEntities, + updateBusinessCentralAutoSync, + updateBusinessCentralSyncReimbursedReports, + updateBusinessCentralReimbursementBankAccount, + updateBusinessCentralSyncExpensifyCardSettlements, + updateBusinessCentralSettlementsBankAccount, }; diff --git a/src/libs/actions/connections/QuickbooksOnline.ts b/src/libs/actions/connections/QuickbooksOnline.ts index 69f60837a37d..917a2fb0ce33 100644 --- a/src/libs/actions/connections/QuickbooksOnline.ts +++ b/src/libs/actions/connections/QuickbooksOnline.ts @@ -6,15 +6,13 @@ import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {getCommandURL} from '@libs/ApiUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; -import {isPolicyAdmin} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Connections, IntuitEnterpriseSuiteEntity, QBOConnectionConfig} from '@src/types/onyx/Policy'; -import type Policy from '@src/types/onyx/Policy'; import type {CONST as COMMON_CONST} from 'expensify-common'; -import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {OnyxUpdate} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; @@ -95,11 +93,6 @@ function selectIntuitEnterpriseSuiteEntity(policyID: string, entity: IntuitEnter API.write(WRITE_COMMANDS.SELECT_INTUIT_ENTERPRISE_SUITE_ENTITY, params, {optimisticData, successData, failureData}); } -function shouldShowQBOReimbursableExportDestinationAccountError(policy: OnyxEntry): boolean { - const qboConfig = policy?.connections?.quickbooksOnline?.config; - return isPolicyAdmin(policy) && !!qboConfig?.reimbursableExpensesExportDestination && !qboConfig.reimbursableExpensesAccount; -} - function buildOnyxDataForMultipleQuickbooksConfigurations>( policyID: string, configUpdate: TConfigUpdate, @@ -625,7 +618,6 @@ function updateQuickbooksOnlineTravelBillingPayableAccount(policyID: string, acc } export { - shouldShowQBOReimbursableExportDestinationAccountError, getQuickbooksOnlineSetupLink, selectIntuitEnterpriseSuiteEntity, updateQuickbooksOnlineEnableNewCategories, diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts index 244550066811..850f9ed50cf7 100644 --- a/src/libs/actions/connections/index.ts +++ b/src/libs/actions/connections/index.ts @@ -3,7 +3,7 @@ import type {RemovePolicyConnectionParams, SyncPolicyToQuickbooksDesktopParams, import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import {isMergeConnectionName} from '@libs/merge/MergeUtils'; -import * as PolicyUtils from '@libs/PolicyUtils'; +import {isCollectPolicy} from '@libs/PolicyUtils/policyType'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -84,7 +84,7 @@ function removePolicyConnection(policy: Policy, connectionName: PolicyConnection const failureData: Array> = []; const supportedConnections: PolicyConnectionName[] = [CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.POLICY.CONNECTIONS.NAME.XERO]; - if (PolicyUtils.isCollectPolicy(policy) && supportedConnections.includes(connectionName)) { + if (isCollectPolicy(policy) && supportedConnections.includes(connectionName)) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, diff --git a/src/libs/actions/getMakeDefaultPaymentOnyxData.ts b/src/libs/actions/getMakeDefaultPaymentOnyxData.ts new file mode 100644 index 000000000000..795906e486db --- /dev/null +++ b/src/libs/actions/getMakeDefaultPaymentOnyxData.ts @@ -0,0 +1,65 @@ +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type PaymentMethod from '@src/types/onyx/PaymentMethod'; + +import type {OnyxUpdate} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +function getMakeDefaultPaymentOnyxData( + bankAccountID: number, + fundID?: number, + previousPaymentMethod?: PaymentMethod, + currentPaymentMethod?: PaymentMethod, + isOptimisticData = true, +): Array> { + const onyxData: Array> = [ + isOptimisticData + ? { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.USER_WALLET, + value: { + walletLinkedAccountID: bankAccountID || fundID, + walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD, + // Only clear the error if this is optimistic data. If this is failure data, we do not want to clear the error that came from the server. + errors: null, + }, + } + : { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.USER_WALLET, + value: { + walletLinkedAccountID: bankAccountID || fundID, + walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD, + }, + }, + ]; + + if (previousPaymentMethod?.methodID) { + onyxData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST, + value: { + [previousPaymentMethod.methodID]: { + isDefault: !isOptimisticData, + }, + }, + }); + } + + if (currentPaymentMethod?.methodID) { + onyxData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST, + value: { + [currentPaymentMethod.methodID]: { + isDefault: isOptimisticData, + }, + }, + }); + } + + return onyxData; +} + +export default getMakeDefaultPaymentOnyxData; diff --git a/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts b/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts index bcb75c6b7944..35a9992dd4a2 100644 --- a/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts +++ b/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts @@ -27,7 +27,12 @@ import Onyx from 'react-native-onyx'; * rewrites the queued requests themselves. */ -const AGENT_SETTINGS_SCREENS = new Set([SCREENS.SETTINGS.AGENTS.EDIT, SCREENS.SETTINGS.AGENTS.EDIT_NAME, SCREENS.SETTINGS.AGENTS.EDIT_PROMPT, SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]); +const AGENT_SETTINGS_SCREENS = new Set([ + SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT, + SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_NAME, + SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_PROMPT, + SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_AVATAR, +]); // Reports are only read inside the mapping callback, so connectWithoutView() is used. On app start the mapping can // arrive before this collection is hydrated, so consumers wait for this promise. Onyx always fires the callback at @@ -119,7 +124,7 @@ function replaceOptimisticAgentWithActualAgent(optimisticAccountID: number, real remapReportParticipants(optimisticAccountID, realAccountID); Onyx.update([buildPersonalDetailsUpdate({[optimisticAccountID]: null})]); - Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, null); + Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, null); Onyx.merge(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, {[optimisticAccountID]: null}); }, }); diff --git a/src/libs/addEncryptedAuthTokenToURL.ts b/src/libs/addEncryptedAuthTokenToURL.ts index 3b00662e9c6f..ce8a16fe5061 100644 --- a/src/libs/addEncryptedAuthTokenToURL.ts +++ b/src/libs/addEncryptedAuthTokenToURL.ts @@ -1,7 +1,13 @@ +import canSendAuthTokenToURL from './canSendAuthTokenToURL'; + /** - * Add encryptedAuthToken to this attachment URL + * Add encryptedAuthToken to this attachment URL. A URL that is not on an Expensify server comes back unchanged, so the token never leaves Expensify. */ export default function addEncryptedAuthTokenToURL(url: string, encryptedAuthToken: string, hasOtherParameters = false) { + if (!canSendAuthTokenToURL(url)) { + return url; + } + const symbol = hasOtherParameters ? '&' : '?'; return `${url}${symbol}encryptedAuthToken=${encodeURIComponent(encryptedAuthToken)}`; } diff --git a/src/libs/buildOnboardingMessages.ts b/src/libs/buildOnboardingMessages.ts new file mode 100644 index 000000000000..6e0bcf2e46d2 --- /dev/null +++ b/src/libs/buildOnboardingMessages.ts @@ -0,0 +1,333 @@ +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import {hasCompletedGuidedSetupFlowSelector} from '@src/selectors/Onboarding'; +import type {Locale, Onboarding} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import type {Video} from './actions/Report'; + +import {translate} from './Localize'; + +type OnboardingCompanySize = ValueOf; +type OnboardingPurpose = ValueOf; + +type OnboardingTaskLinks = Partial<{ + onboardingCompanySize: OnboardingCompanySize; + integrationName: string; + workspaceSettingsLink: string; + workspaceCategoriesLink: string; + workspaceTagsLink: string; + workspaceMoreFeaturesLink: string; + workspaceMembersLink: string; + workspaceAccountingLink: string; + workspaceConfirmationLink: string; + testDriveURL: string; + corporateCardLink: string; + companyDomain: string; + workEmail: string; + validateEmailLink: string; + workEmailLink: string; + joinWorkspaceLink: string; +}>; + +type OnboardingTask = { + type: ValueOf; + autoCompleted: boolean; + title: string | ((params: OnboardingTaskLinks) => string); + description: string | ((params: OnboardingTaskLinks) => string); +}; + +type OnboardingMessage = { + /** Text message that will be displayed first */ + message: string | ((params: OnboardingTaskLinks) => string); + + /** Video object to be displayed after initial description message */ + video?: Video; + + /** List of tasks connected with the message, they will have a checkbox and a separate report for more information */ + tasks: OnboardingTask[]; + + /** Type of task described in a string format */ + type?: string; +}; + +function buildOnboardingMessages(onboardingData: OnyxEntry, locale?: Locale) { + const resolvedLocale = locale ?? IntlStore.getCurrentLocale(); + const testDrive = { + ONBOARDING_TASK_NAME: translate(resolvedLocale, 'onboarding.testDrive.name', {}), + EMBEDDED_DEMO_WHITELIST: ['http://', 'https://', 'about:'] as string[], + EMBEDDED_DEMO_IFRAME_TITLE: translate(resolvedLocale, 'onboarding.testDrive.embeddedDemoIframeTitle'), + }; + const addExpenseApprovalsTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.ADD_EXPENSE_APPROVALS, + autoCompleted: false, + title: () => translate(resolvedLocale, 'onboarding.tasks.addExpenseApprovalsTask.title'), + description: ({workspaceMoreFeaturesLink}) => translate(resolvedLocale, 'onboarding.tasks.addExpenseApprovalsTask.description', {workspaceMoreFeaturesLink}), + }; + const createReportTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.CREATE_REPORT, + autoCompleted: false, + title: translate(resolvedLocale, 'onboarding.tasks.createReportTask.title'), + description: translate(resolvedLocale, 'onboarding.tasks.createReportTask.description'), + }; + const addWorkEmailTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.ADD_WORK_EMAIL, + autoCompleted: false, + title: translate(resolvedLocale, 'onboarding.tasks.addWorkEmailTask.title'), + description: ({workEmailLink}) => translate(resolvedLocale, 'onboarding.tasks.addWorkEmailTask.description', {workEmailLink}), + }; + const validateEmailTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.VALIDATE_EMAIL, + autoCompleted: false, + title: translate(resolvedLocale, 'onboarding.tasks.validateEmailTask.title'), + description: ({validateEmailLink, workEmail}) => translate(resolvedLocale, 'onboarding.tasks.validateEmailTask.description', {validateEmailLink, workEmail}), + }; + const joinWorkspaceTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.JOIN_WORKSPACE, + autoCompleted: false, + title: translate(resolvedLocale, 'onboarding.tasks.joinWorkspaceTask.title'), + description: ({joinWorkspaceLink}) => translate(resolvedLocale, 'onboarding.tasks.joinWorkspaceTask.description', {joinWorkspaceLink}), + }; + const testDriveAdminTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.VIEW_TOUR, + autoCompleted: false, + title: ({testDriveURL}) => translate(resolvedLocale, 'onboarding.tasks.testDriveAdminTask.title', {testDriveURL}), + description: ({testDriveURL}) => translate(resolvedLocale, 'onboarding.tasks.testDriveAdminTask.description', {testDriveURL}), + }; + const testDriveEmployeeTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.VIEW_TOUR, + autoCompleted: false, + title: ({testDriveURL}) => translate(resolvedLocale, 'onboarding.tasks.testDriveEmployeeTask.title', {testDriveURL}), + description: ({testDriveURL}) => translate(resolvedLocale, 'onboarding.tasks.testDriveEmployeeTask.description', {testDriveURL}), + }; + const createTestDriveAdminWorkspaceTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.CREATE_WORKSPACE, + autoCompleted: false, + title: ({workspaceConfirmationLink}) => translate(resolvedLocale, 'onboarding.tasks.createTestDriveAdminWorkspaceTask.title', {workspaceConfirmationLink}), + description: translate(resolvedLocale, 'onboarding.tasks.createTestDriveAdminWorkspaceTask.description'), + }; + + const createWorkspaceTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.CREATE_WORKSPACE, + autoCompleted: true, + title: ({workspaceSettingsLink}) => translate(resolvedLocale, 'onboarding.tasks.createWorkspaceTask.title', {workspaceSettingsLink}), + description: ({workspaceSettingsLink}) => translate(resolvedLocale, 'onboarding.tasks.createWorkspaceTask.description', {workspaceSettingsLink}), + }; + + const setupCategoriesTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.SETUP_CATEGORIES, + autoCompleted: false, + title: ({workspaceCategoriesLink}) => translate(resolvedLocale, 'onboarding.tasks.setupCategoriesTask.title', {workspaceCategoriesLink}), + description: ({workspaceCategoriesLink}) => translate(resolvedLocale, 'onboarding.tasks.setupCategoriesTask.description', {workspaceCategoriesLink}), + }; + + const combinedTrackSubmitExpenseTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.SUBMIT_EXPENSE, + autoCompleted: false, + title: translate(resolvedLocale, 'onboarding.tasks.combinedTrackSubmitExpenseTask.title'), + description: translate(resolvedLocale, 'onboarding.tasks.combinedTrackSubmitExpenseTask.description'), + }; + + const adminSubmitExpenseTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.SUBMIT_EXPENSE, + autoCompleted: false, + title: translate(resolvedLocale, 'onboarding.tasks.adminSubmitExpenseTask.title'), + description: translate(resolvedLocale, 'onboarding.tasks.adminSubmitExpenseTask.description'), + }; + + const trackExpenseTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.TRACK_EXPENSE, + autoCompleted: false, + title: translate(resolvedLocale, 'onboarding.tasks.trackExpenseTask.title'), + description: translate(resolvedLocale, 'onboarding.tasks.trackExpenseTask.description'), + }; + + const addAccountingIntegrationTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.ADD_ACCOUNTING_INTEGRATION, + autoCompleted: false, + title: ({integrationName, workspaceAccountingLink}) => translate(resolvedLocale, 'onboarding.tasks.addAccountingIntegrationTask.title', {integrationName, workspaceAccountingLink}), + description: ({integrationName, workspaceAccountingLink}) => + translate(resolvedLocale, 'onboarding.tasks.addAccountingIntegrationTask.description', {integrationName, workspaceAccountingLink}), + }; + + const connectCorporateCardTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.CONNECT_CORPORATE_CARD, + title: ({corporateCardLink}) => translate(resolvedLocale, 'onboarding.tasks.connectCorporateCardTask.title', {corporateCardLink}), + description: ({corporateCardLink}) => translate(resolvedLocale, 'onboarding.tasks.connectCorporateCardTask.description', {corporateCardLink}), + autoCompleted: false, + }; + + const inviteTeamTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.INVITE_TEAM, + autoCompleted: false, + title: ({workspaceMembersLink}) => translate(resolvedLocale, 'onboarding.tasks.inviteTeamTask.title', {workspaceMembersLink}), + description: ({workspaceMembersLink}) => translate(resolvedLocale, 'onboarding.tasks.inviteTeamTask.description', {workspaceMembersLink}), + }; + + const setupCategoriesAndTags: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.SETUP_CATEGORIES_AND_TAGS, + autoCompleted: false, + title: ({workspaceCategoriesLink, workspaceTagsLink}) => translate(resolvedLocale, 'onboarding.tasks.setupCategoriesAndTags.title', {workspaceCategoriesLink, workspaceTagsLink}), + description: ({workspaceCategoriesLink, workspaceAccountingLink}) => + translate(resolvedLocale, 'onboarding.tasks.setupCategoriesAndTags.description', {workspaceCategoriesLink, workspaceAccountingLink}), + }; + const setupTagsTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.SETUP_TAGS, + autoCompleted: false, + title: ({workspaceTagsLink}) => translate(resolvedLocale, 'onboarding.tasks.setupTagsTask.title', {workspaceTagsLink}), + description: ({workspaceMoreFeaturesLink}) => translate(resolvedLocale, 'onboarding.tasks.setupTagsTask.description', {workspaceMoreFeaturesLink}), + }; + + const startChatTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.START_CHAT, + autoCompleted: false, + title: translate(resolvedLocale, 'onboarding.tasks.startChatTask.title'), + description: translate(resolvedLocale, 'onboarding.tasks.startChatTask.description'), + }; + + const splitExpenseTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.SPLIT_EXPENSE, + autoCompleted: false, + title: translate(resolvedLocale, 'onboarding.tasks.splitExpenseTask.title'), + description: translate(resolvedLocale, 'onboarding.tasks.splitExpenseTask.description'), + }; + + const reviewWorkspaceSettingsTask: OnboardingTask = { + type: CONST.ONBOARDING_TASK_TYPE.REVIEW_WORKSPACE_SETTINGS, + autoCompleted: false, + title: ({workspaceSettingsLink}) => translate(resolvedLocale, 'onboarding.tasks.reviewWorkspaceSettingsTask.title', {workspaceSettingsLink}), + description: ({workspaceSettingsLink}) => translate(resolvedLocale, 'onboarding.tasks.reviewWorkspaceSettingsTask.description', {workspaceSettingsLink}), + }; + + const onboardingEmployerOrSubmitMessage: OnboardingMessage = { + message: translate(resolvedLocale, 'onboarding.messages.onboardingEmployerOrSubmitMessage'), + tasks: [testDriveEmployeeTask, adminSubmitExpenseTask], + }; + + const combinedTrackSubmitOnboardingEmployerOrSubmitMessage: OnboardingMessage = { + ...onboardingEmployerOrSubmitMessage, + tasks: [testDriveEmployeeTask, combinedTrackSubmitExpenseTask], + }; + + const onboardingPersonalSpendMessage: OnboardingMessage = { + message: translate(resolvedLocale, 'onboarding.messages.onboardingPersonalSpendMessage'), + tasks: [testDriveEmployeeTask, trackExpenseTask], + }; + const combinedTrackSubmitOnboardingPersonalSpendMessage: OnboardingMessage = { + ...onboardingPersonalSpendMessage, + tasks: [testDriveEmployeeTask, trackExpenseTask], + }; + + const isOnboardingFlow = hasCompletedGuidedSetupFlowSelector(onboardingData); + const onboardingManageTeamMessage: OnboardingMessage = { + message: translate(resolvedLocale, 'onboarding.messages.onboardingManageTeamMessage', {isOnboardingFlow}), + tasks: [ + createWorkspaceTask, + testDriveAdminTask, + addAccountingIntegrationTask, + connectCorporateCardTask, + inviteTeamTask, + setupCategoriesAndTags, + setupCategoriesTask, + setupTagsTask, + addExpenseApprovalsTask, + ], + }; + + const onboardingTrackWorkspaceMessage: OnboardingMessage = { + message: translate(resolvedLocale, 'onboarding.messages.onboardingTrackWorkspaceMessage'), + video: { + url: `${CONST.CLOUDFRONT_URL}/videos/guided-setup-manage-team-v2.mp4`, + thumbnailUrl: `${CONST.CLOUDFRONT_URL}/images/guided-setup-manage-team.jpg`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [testDriveAdminTask, createReportTask, setupCategoriesTask], + }; + + const onboardingChatSplitMessage: OnboardingMessage = { + message: translate(resolvedLocale, 'onboarding.messages.onboardingChatSplitMessage'), + tasks: [testDriveEmployeeTask, startChatTask, splitExpenseTask], + }; + + const onboardingAdminMessage: OnboardingMessage = { + message: translate(resolvedLocale, 'onboarding.messages.onboardingAdminMessage'), + tasks: [reviewWorkspaceSettingsTask, adminSubmitExpenseTask], + }; + const onboardingLookingAroundMessage: OnboardingMessage = { + message: '', + tasks: [], + }; + + const onboardingTestDriveReceiverMessage: OnboardingMessage = { + message: translate(resolvedLocale, 'onboarding.messages.onboardingTestDriveReceiverMessage'), + tasks: [testDriveAdminTask, createTestDriveAdminWorkspaceTask], + }; + + const onboardingJoinWorkspaceAddWorkEmailMessage: OnboardingMessage = { + message: translate(resolvedLocale, 'onboarding.messages.onboardingJoinWorkspaceAddWorkEmailMessage'), + tasks: [addWorkEmailTask], + }; + const onboardingJoinWorkspaceValidateEmailMessage: OnboardingMessage = { + message: ({companyDomain}) => translate(resolvedLocale, 'onboarding.messages.onboardingJoinWorkspaceValidateEmailMessage', {companyDomain}), + tasks: [validateEmailTask], + }; + const onboardingJoinWorkspaceMessage: OnboardingMessage = { + message: ({companyDomain, joinWorkspaceLink}) => translate(resolvedLocale, 'onboarding.messages.onboardingJoinWorkspaceMessage', {companyDomain, joinWorkspaceLink}), + tasks: [joinWorkspaceTask], + }; + const onboardingJoinWorkspaceEmptyMessage: OnboardingMessage = { + message: translate(resolvedLocale, 'onboarding.messages.onboardingJoinWorkspaceEmptyMessage'), + tasks: [], + }; + + return { + onboardingMessages: { + [CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE]: onboardingJoinWorkspaceAddWorkEmailMessage, + [CONST.ONBOARDING_CHOICES.EMPLOYER]: onboardingEmployerOrSubmitMessage, + [CONST.ONBOARDING_CHOICES.SUBMIT]: onboardingEmployerOrSubmitMessage, + [CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: onboardingManageTeamMessage, + [CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE]: onboardingTrackWorkspaceMessage, + [CONST.ONBOARDING_CHOICES.TRACK_PERSONAL]: onboardingTrackWorkspaceMessage, + [CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]: onboardingPersonalSpendMessage, + [CONST.ONBOARDING_CHOICES.CHAT_SPLIT]: onboardingChatSplitMessage, + [CONST.ONBOARDING_CHOICES.ADMIN]: onboardingAdminMessage, + [CONST.ONBOARDING_CHOICES.LOOKING_AROUND]: onboardingLookingAroundMessage, + [CONST.ONBOARDING_CHOICES.TEST_DRIVE_RECEIVER]: onboardingTestDriveReceiverMessage, + } satisfies Record, + createExpenseOnboardingMessages: { + [CONST.CREATE_EXPENSE_ONBOARDING_CHOICES.PERSONAL_SPEND]: combinedTrackSubmitOnboardingPersonalSpendMessage, + [CONST.CREATE_EXPENSE_ONBOARDING_CHOICES.EMPLOYER]: combinedTrackSubmitOnboardingEmployerOrSubmitMessage, + [CONST.CREATE_EXPENSE_ONBOARDING_CHOICES.SUBMIT]: combinedTrackSubmitOnboardingEmployerOrSubmitMessage, + } satisfies Record, OnboardingMessage>, + testDrive, + joinWorkspaceMessages: { + addWorkEmail: onboardingJoinWorkspaceAddWorkEmailMessage, + validateEmail: onboardingJoinWorkspaceValidateEmailMessage, + joinWorkspace: onboardingJoinWorkspaceMessage, + empty: onboardingJoinWorkspaceEmptyMessage, + }, + }; +} + +type OnboardingMessages = ReturnType; + +type OnboardingMessagesWithoutManageTeam = Omit & { + onboardingMessages: Omit; +}; + +/** + * Used by `ReportUtils` instead of `OnboardingFlow.getOnboardingMessages()`, because importing `OnboardingFlow` would create an import cycle. + * It builds the messages without onboarding data, so it also skips the module-level Onyx read that `getOnboardingMessages()` relies on. + * Only the Manage team message uses onboarding data, so the return type leaves that message out. + * If another message starts using onboarding data, leave it out here too. + */ +function buildOnboardingMessagesWithoutManageTeam(locale?: Locale): OnboardingMessagesWithoutManageTeam { + return buildOnboardingMessages(undefined, locale); +} + +export {buildOnboardingMessages, buildOnboardingMessagesWithoutManageTeam}; +export type {OnboardingCompanySize, OnboardingMessage, OnboardingPurpose, OnboardingTask, OnboardingTaskLinks}; diff --git a/src/libs/canFocusInputOnScreenFocus/index.ts b/src/libs/canFocusInputOnScreenFocus/index.ts index 84a6fb86135c..65db578f5c95 100644 --- a/src/libs/canFocusInputOnScreenFocus/index.ts +++ b/src/libs/canFocusInputOnScreenFocus/index.ts @@ -1,7 +1,7 @@ -import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import type CanFocusInputOnScreenFocus from './types'; -const canFocusInputOnScreenFocus: CanFocusInputOnScreenFocus = () => !DeviceCapabilities.canUseTouchScreen(); +const canFocusInputOnScreenFocus: CanFocusInputOnScreenFocus = () => !canUseTouchScreen(); export default canFocusInputOnScreenFocus; diff --git a/src/libs/canSendAuthTokenToURL.ts b/src/libs/canSendAuthTokenToURL.ts new file mode 100644 index 000000000000..8afd3e7f98bf --- /dev/null +++ b/src/libs/canSendAuthTokenToURL.ts @@ -0,0 +1,41 @@ +/** + * Decides whether a URL may receive the user's encrypted auth token. + * + * The Expensify servers accept the encrypted auth token in place of a session to serve the user's private files. + * Attachment URLs can come from places the App does not control, such as route params, so every place that adds the + * token to a request checks the URL here first. + */ +import CONFIG from '@src/CONFIG'; + +// Each root ends with a slash, so a prefix match covers the whole host. A lookalike host such as +// `https://www.expensify.com.example.com/` does not match. Roots that are not configured are empty strings, and the web proxy +// root is the relative path `/`. Both get dropped here, and the leading slash check below covers the proxy. +const TRUSTED_URL_ROOTS = [ + CONFIG.EXPENSIFY.DEFAULT_API_ROOT, + CONFIG.EXPENSIFY.DEFAULT_SECURE_API_ROOT, + CONFIG.EXPENSIFY.EXPENSIFY_URL, + CONFIG.EXPENSIFY.SECURE_EXPENSIFY_URL, + CONFIG.EXPENSIFY.STAGING_API_ROOT, + CONFIG.EXPENSIFY.STAGING_SECURE_API_ROOT, + CONFIG.EXPENSIFY.QA_API_ROOT, + CONFIG.EXPENSIFY.QA_SECURE_API_ROOT, +].filter((root) => /^https?:\/\/[^/]+\//.test(root)); + +// URL parsers drop tabs and line breaks and read a backslash as a slash. That turns text like `/\example.com/` into a link to another host. +// eslint-disable-next-line no-control-regex -- Control characters are exactly what this pattern needs to find. +const UNSAFE_URL_CHARACTERS = /[\u0000-\u001F\u007F\\]/; + +function canSendAuthTokenToURL(url: string): boolean { + if (UNSAFE_URL_CHARACTERS.test(url)) { + return false; + } + + // One leading slash keeps the request on the current origin, but two slashes start a URL on another host. + if (url.startsWith('/')) { + return !url.startsWith('//'); + } + + return TRUSTED_URL_ROOTS.some((root) => url.startsWith(root)); +} + +export default canSendAuthTokenToURL; diff --git a/src/libs/escapeCsvField.ts b/src/libs/escapeCsvField.ts new file mode 100644 index 000000000000..b5df641b6244 --- /dev/null +++ b/src/libs/escapeCsvField.ts @@ -0,0 +1,9 @@ +/** Escapes CSV text fields and prevents spreadsheet formula injection. */ +const CSV_FORMULA_PREFIX_REGEXP = /^(?:[\t\r\n]|\s*[=+\-@])/; + +function escapeCsvField(value: string): string { + const safeValue = CSV_FORMULA_PREFIX_REGEXP.test(value) ? `'${value}` : value; + return /[",\r\n]/.test(safeValue) ? `"${safeValue.replaceAll('"', '""')}"` : safeValue; +} + +export default escapeCsvField; diff --git a/src/libs/fetchImage/index.native.ts b/src/libs/fetchImage/index.native.ts index d0816aba50f8..052922799eef 100644 --- a/src/libs/fetchImage/index.native.ts +++ b/src/libs/fetchImage/index.native.ts @@ -1,3 +1,4 @@ +import canSendAuthTokenToURL from '@libs/canSendAuthTokenToURL'; import {splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; import CONST from '@src/CONST'; @@ -15,9 +16,7 @@ export default function fetchImage(source: string, authToken: string) { fileCache: true, path, }) - .fetch('GET', source, { - [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken, - }) + .fetch('GET', source, canSendAuthTokenToURL(source) ? {[CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken} : {}) .then((res) => { // Return the file URI with file:// prefix for expo-image-manipulator return `file://${res.path()}`; diff --git a/src/libs/fetchImage/index.ts b/src/libs/fetchImage/index.ts index d9119c57aa14..f059ab5f9da0 100644 --- a/src/libs/fetchImage/index.ts +++ b/src/libs/fetchImage/index.ts @@ -1,10 +1,10 @@ +import canSendAuthTokenToURL from '@libs/canSendAuthTokenToURL'; + import CONST from '@src/CONST'; export default function fetchImage(source: string, authToken: string) { return fetch(source, { - headers: { - [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken, - }, + headers: canSendAuthTokenToURL(source) ? {[CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken} : undefined, }) .then((res) => res.blob()) .then((blob) => { diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index a882867b03aa..467adfd6b5da 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -32,7 +32,6 @@ function showSuccessAlert(translate: LocalizedTranslate, successMessage?: string Alert.alert( translate('fileDownload.success.title'), // successMessage can be an empty string and we want to default to `Localize.translate('fileDownload.success.message')` - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing successMessage || translate('fileDownload.success.message'), [ { @@ -780,7 +779,6 @@ const isValidReceiptExtension = (file: FileObject) => { }; const hasHeicOrHeifExtension = (file: FileObject) => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return file.name?.toLowerCase().endsWith('.heic') || file.name?.toLowerCase().endsWith('.heif'); }; diff --git a/src/libs/fileDownload/heicConverter/index.native.ts b/src/libs/fileDownload/heicConverter/index.native.ts index fe97617463ac..16100b2e02ec 100644 --- a/src/libs/fileDownload/heicConverter/index.native.ts +++ b/src/libs/fileDownload/heicConverter/index.native.ts @@ -57,7 +57,6 @@ const convertImageWithManipulator = ( * @param callbacks - Object containing callback functions for different stages of conversion */ const convertHeicImage: HeicConverterFunction = (file, {onSuccess = () => {}, onError = () => {}, onStart = () => {}, onFinish = () => {}} = {}) => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const needsConversion = file.name?.toLowerCase().endsWith('.heic') || file.name?.toLowerCase().endsWith('.heif'); if (!needsConversion || !file.uri || !file.type?.startsWith('image')) { diff --git a/src/libs/fileDownload/index.android.ts b/src/libs/fileDownload/index.android.ts index 10f1f0e42afc..88e9f4625104 100644 --- a/src/libs/fileDownload/index.android.ts +++ b/src/libs/fileDownload/index.android.ts @@ -24,7 +24,7 @@ function handleDownload(translate: LocalizedTranslate, url: string, fileName?: s // Android files will download to Download directory const path = dirs.DownloadDir; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and since fileName can be an empty string we want to default to `FileUtils.getFileName(url)` + // `fileName` can be an empty string, so fall back to `FileUtils.getFileName(url)` const resolvedFileName = fileName || getFileName(url); const attachmentName = appendTimestamp ? appendTimeToFileName(resolvedFileName) : resolvedFileName; diff --git a/src/libs/fileDownload/index.ios.ts b/src/libs/fileDownload/index.ios.ts index 418f239424bf..5269056aef00 100644 --- a/src/libs/fileDownload/index.ios.ts +++ b/src/libs/fileDownload/index.ios.ts @@ -134,7 +134,7 @@ const fileDownload: FileDownload = (translate, fileUrl, fileName, successMessage new Promise((resolve) => { let fileDownloadPromise; const fileType = getFileType(fileUrl); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and since fileName can be an empty string we want to default to `FileUtils.getFileName(url)` + // `fileName` can be an empty string, so fall back to `FileUtils.getFileName(url)` const resolvedFileName = fileName || getFileName(fileUrl); const attachmentName = appendTimestamp ? appendTimeToFileName(resolvedFileName) : resolvedFileName; diff --git a/src/libs/fileDownload/validateReceiptFile/index.native.ts b/src/libs/fileDownload/validateReceiptFile/index.native.ts index 55e31d8627db..519a4385a4ab 100644 --- a/src/libs/fileDownload/validateReceiptFile/index.native.ts +++ b/src/libs/fileDownload/validateReceiptFile/index.native.ts @@ -2,6 +2,7 @@ import checkFileExists from '@libs/fileDownload/checkFileExists'; import ReceiptStorage from '@libs/ReceiptStorage'; import type {ReceiptSource} from '@src/types/onyx/Transaction'; +import type {FileObject} from '@src/types/utils/Attachment'; /** * Validates a receipt file and processes it for upload @@ -11,20 +12,20 @@ function validateReceiptFile( receiptFilename: string | undefined, receiptPath: ReceiptSource | undefined, receiptType: string | undefined, - onSuccess: (file: File) => void, + onSuccess: (file: FileObject) => void, onFailure: () => void, ): Promise { const receiptPathString = receiptPath?.toString(); // The number receiptPath type is the static image on native so we don't need to check file exists. if (typeof receiptPath === 'number') { - onSuccess({uri: receiptPathString, name: receiptFilename, type: receiptType, source: receiptPathString} as File); + onSuccess({uri: receiptPathString, name: receiptFilename, type: receiptType, source: receiptPathString}); return Promise.resolve(); } const localPath = ReceiptStorage.resolve(receiptPath) ?? receiptPathString; - const succeed = () => onSuccess({uri: localPath, name: receiptFilename, type: receiptType, source: localPath} as File); + const succeed = () => onSuccess({uri: localPath, name: receiptFilename, type: receiptType, source: localPath}); return checkFileExists(localPath) .then((exists) => exists || ReceiptStorage.recheckAfterSwap(receiptPath).then((isPresent) => isPresent && checkFileExists(localPath))) diff --git a/src/libs/fileDownload/validateReceiptFile/index.ts b/src/libs/fileDownload/validateReceiptFile/index.ts index f1d12c457ab7..433fd6c6ef9c 100644 --- a/src/libs/fileDownload/validateReceiptFile/index.ts +++ b/src/libs/fileDownload/validateReceiptFile/index.ts @@ -1,6 +1,7 @@ import {checkIfLocalFileIsAccessible} from '@libs/actions/IOU/Receipt'; import type {ReceiptSource} from '@src/types/onyx/Transaction'; +import type {FileObject} from '@src/types/utils/Attachment'; /** * Validates a receipt file and processes it for upload @@ -10,7 +11,7 @@ function validateReceiptFile( receiptFilename: string | undefined, receiptPath: ReceiptSource | undefined, receiptType: string | undefined, - onSuccess: (file: File) => void, + onSuccess: (file: FileObject) => void, onFailure: () => void, ): Promise | undefined { return checkIfLocalFileIsAccessible(receiptFilename, receiptPath, receiptType, onSuccess, onFailure); diff --git a/src/libs/focusComposerWithDelay/types.ts b/src/libs/focusComposerWithDelay/types.ts index df263b3df7a6..b55a6effeded 100644 --- a/src/libs/focusComposerWithDelay/types.ts +++ b/src/libs/focusComposerWithDelay/types.ts @@ -1,3 +1,5 @@ +import type {WebComposerRef} from '@components/Composer/types'; + import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; @@ -10,6 +12,6 @@ type Selection = { type FocusComposerWithDelay = (shouldDelay?: boolean, forcedSelectionRange?: Selection, forceKeyboardIfAlreadyFocused?: boolean) => Promise; -type InputType = ComponentRef | HTMLTextAreaElement; +type InputType = ComponentRef | HTMLTextAreaElement | WebComposerRef; export type {Selection, FocusComposerWithDelay, InputType}; diff --git a/src/libs/getBankAccountLastFourDigits.ts b/src/libs/getBankAccountLastFourDigits.ts index ebf8eef28f61..48ac870d8b8a 100644 --- a/src/libs/getBankAccountLastFourDigits.ts +++ b/src/libs/getBankAccountLastFourDigits.ts @@ -2,13 +2,45 @@ import type {BankAccount} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import type {PolicyPaymentAttribution} from './PolicyPaymentUtils'; + +import {getAccessiblePolicyBankAccount, wasPaidWithPolicyBankAccount} from './PolicyPaymentUtils'; + +type GetBankAccountLastFourDigitsParams = { + /** The account the payment action names, when the paying admin picked one. */ + bankAccountID: number | undefined; + + /** The current user's own bank accounts, used to resolve `bankAccountID` and the workspace account. */ + bankAccountList: OnyxEntry>; + + /** The policy the report belongs to. Only its bank account and designated payer are read. */ + policy: OnyxEntry; + + /** + * Masked account number stored on the payment action itself. It is the only viewer-independent source, so it wins + * over any local lookup. The payer's account is not in every viewer's `bankAccountList`, and falling back to the + * policy account would show a different account to different people for the same payment. + */ + accountNumber?: string; + + /** + * Who made the payment. The workspace account is only a valid guess when the payment came from the designated + * payer. For anyone else it belongs to a different bank account than the one actually used. + */ + payerAccountID?: number; + + /** The designated payer’s account ID selected from the caller’s subscribed personal details. */ + reimburserAccountID?: number; +}; + /** * Get the last 4 digits of a bank account used for payment. - * - * `policyACHAccountNumber` is the account number of the policy's default reimbursement account - * (`policy.achAccount.accountNumber`), used as a fallback when the payment doesn't name an account. */ -function getBankAccountLastFourDigits(bankAccountID: number | undefined, bankAccountList: OnyxEntry>, policyACHAccountNumber: string | undefined): string { +function getBankAccountLastFourDigits({bankAccountID, bankAccountList, policy, accountNumber, payerAccountID, reimburserAccountID}: GetBankAccountLastFourDigitsParams): string { + if (accountNumber) { + return accountNumber.slice(-4); + } + const bankAccount = bankAccountID ? bankAccountList?.[bankAccountID] : null; if (bankAccount?.accountData?.accountNumber) { @@ -19,7 +51,18 @@ function getBankAccountLastFourDigits(bankAccountID: number | undefined, bankAcc if (bankAccountID != null) { return ''; } - return policyACHAccountNumber?.slice(-4) ?? ''; + + // The action doesn't say which account was used, so only fall back to the workspace account when the designated + // payer made the payment. + if (!wasPaidWithPolicyBankAccount(policy, payerAccountID, reimburserAccountID)) { + return ''; + } + + // Resolve the workspace account through `bankAccountList` when we can. `achAccount.accountNumber` goes stale while + // `achAccount.bankAccountID` moves on, so the two can name different accounts. The ID is the one that was debited. + const policyBankAccount = getAccessiblePolicyBankAccount(policy, bankAccountList); + + return (policyBankAccount?.accountData?.accountNumber ?? policy?.achAccount?.accountNumber)?.slice(-4) ?? ''; } export default getBankAccountLastFourDigits; diff --git a/src/libs/getBottomSheetHeight.ts b/src/libs/getBottomSheetHeight.ts index 75fe3ccccd6a..6e0904216e31 100644 --- a/src/libs/getBottomSheetHeight.ts +++ b/src/libs/getBottomSheetHeight.ts @@ -23,14 +23,28 @@ type BottomSheetHeightParams = { /** Tallest the sheet may be regardless of the window. Defaults to the dropdown cap, `CONST.POPOVER_DROPDOWN_MAX_HEIGHT` */ maxHeight?: number; + + /** Whether the sheet fills the window up to the status bar, e.g. on a phone in landscape, instead of taking its preferred height */ + shouldFillWindow?: boolean; }; /** - * Height to give the content of a bottom sheet so it fits on screen: capped by `maxHeight` and by the window, and, while - * the keyboard is open, by the room between the keyboard and the status bar. `minHeight` never pushes the sheet past that cap. + * Height to give the content of a bottom sheet so it fits on screen: capped by `maxHeight` and by the window, or the whole + * window up to the status bar with `shouldFillWindow`, and, while the keyboard is open, by the room between the keyboard and + * the status bar. `minHeight` never pushes the sheet past that cap. */ -function getBottomSheetHeight({preferredHeight, windowHeight, keyboardHeight, topSafeAreaInset, minHeight, maxHeight = CONST.POPOVER_DROPDOWN_MAX_HEIGHT}: BottomSheetHeightParams): number { - const cappedHeight = Math.min(preferredHeight, Math.min(maxHeight, windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO) - BOTTOM_SHEET_TOP_PADDING); +function getBottomSheetHeight({ + preferredHeight, + windowHeight, + keyboardHeight, + topSafeAreaInset, + minHeight, + maxHeight = CONST.POPOVER_DROPDOWN_MAX_HEIGHT, + shouldFillWindow = false, +}: BottomSheetHeightParams): number { + const cappedHeight = shouldFillWindow + ? windowHeight - topSafeAreaInset - BOTTOM_SHEET_TOP_PADDING + : Math.min(preferredHeight, Math.min(maxHeight, windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO) - BOTTOM_SHEET_TOP_PADDING); if (!keyboardHeight) { return cappedHeight; diff --git a/src/libs/getSearchColumnContentToMeasure.ts b/src/libs/getSearchColumnContentToMeasure.ts index 4a45cdd86a4e..a2a47a20c78d 100644 --- a/src/libs/getSearchColumnContentToMeasure.ts +++ b/src/libs/getSearchColumnContentToMeasure.ts @@ -73,7 +73,9 @@ const EDITABLE_SEARCH_COLUMNS = new Set([ CONST.SEARCH.TABLE_COLUMNS.MERCHANT, CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION, CONST.SEARCH.TABLE_COLUMNS.CATEGORY, + CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE, CONST.SEARCH.TABLE_COLUMNS.TAG, + CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE, CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.SUBMITTED, CONST.SEARCH.TABLE_COLUMNS.APPROVED, diff --git a/src/libs/getTransactionFromTransactionListItem.ts b/src/libs/getTransactionFromTransactionListItem.ts new file mode 100644 index 000000000000..7be736d4dfa2 --- /dev/null +++ b/src/libs/getTransactionFromTransactionListItem.ts @@ -0,0 +1,91 @@ +import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; + +import type {Transaction} from '@src/types/onyx'; + +/** + * Every field that only exists on a Search transaction row and not on a real transaction. Typing it as a record keyed by + * the difference between both types makes the compiler flag any row field that is added later without being listed here. + */ +const SEARCH_TRANSACTION_ROW_ONLY_FIELDS: Record, true> = { + text: true, + alternateText: true, + alternateTextComponent: true, + accessibilityLabel: true, + keyForList: true, + isSelected: true, + canShowSeveralIndicators: true, + isDisabledCheckbox: true, + isDisabled: true, + shouldHideSelectionButton: true, + isInteractive: true, + isBold: true, + accountID: true, + login: true, + leftElement: true, + rightElement: true, + actionElement: true, + icons: true, + invitedSecondaryLogin: true, + policyID: true, + searchText: true, + brickRoadIndicator: true, + footerContent: true, + tabIndex: true, + shouldAnimateInHighlight: true, + itemStyle: true, + titleStyles: true, + shouldShowRightCaret: true, + hash: true, + lang: true, + report: true, + submitted: true, + approved: true, + exported: true, + exportedTo: true, + policy: true, + reportAction: true, + holdReportAction: true, + from: true, + to: true, + formattedFrom: true, + formattedTo: true, + formattedTotal: true, + formattedMerchant: true, + date: true, + shouldShowMerchant: true, + shouldShowYear: true, + shouldShowYearSubmitted: true, + shouldShowYearApproved: true, + shouldShowYearPosted: true, + shouldShowYearExported: true, + isAmountColumnWide: true, + isTaxAmountColumnWide: true, + isActionColumnWide: true, + selectionGroupKey: true, + filename: true, + violations: true, + allActions: true, + action: true, + canPay: true, + canApprove: true, + canSubmit: true, + canChangeApprover: true, +}; + +const SEARCH_TRANSACTION_ROW_ONLY_FIELD_NAMES = Object.keys(SEARCH_TRANSACTION_ROW_ONLY_FIELDS); + +/** + * Returns the transaction without the display-only fields a Search row adds on top of it, so they never end up in the + * transactions collection, where they would shadow the real values (e.g. a stale `formattedMerchant` hiding an edited merchant). + */ +function getTransactionFromTransactionListItem(item: Transaction | TransactionListItemType): Transaction { + const transaction: Transaction = {...item}; + for (const fieldName of SEARCH_TRANSACTION_ROW_ONLY_FIELD_NAMES) { + Reflect.deleteProperty(transaction, fieldName); + } + return transaction; +} + +export {SEARCH_TRANSACTION_ROW_ONLY_FIELD_NAMES}; + +export default getTransactionFromTransactionListItem; diff --git a/src/libs/isInLandscapeMode/index.native.ts b/src/libs/isInLandscapeMode/index.native.ts index e6690c0f8e21..21e35658990d 100644 --- a/src/libs/isInLandscapeMode/index.native.ts +++ b/src/libs/isInLandscapeMode/index.native.ts @@ -1,11 +1,11 @@ -import DeviceInfo from 'react-native-device-info'; +import isTablet from '@libs/isTablet'; -const isTablet = DeviceInfo.isTablet(); +const isTabletDevice = isTablet(); /** * Returns whether the device is currently in landscape orientation. * Returns false on tablets. */ export default function isInLandscapeMode(windowWidth: number, windowHeight: number): boolean { - return !isTablet && windowWidth > windowHeight; + return !isTabletDevice && windowWidth > windowHeight; } diff --git a/src/libs/isInLandscapeMode/index.ts b/src/libs/isInLandscapeMode/index.ts index 8af37386def8..41b11c84fbf6 100644 --- a/src/libs/isInLandscapeMode/index.ts +++ b/src/libs/isInLandscapeMode/index.ts @@ -1,14 +1,9 @@ import {isMobile} from '@libs/Browser'; +import isTablet from '@libs/isTablet'; import {Dimensions} from 'react-native'; -const TABLET_MIN_SCREEN_DIMENSION = 600; - -function isTabletScreen(): boolean { - return Math.min(window.screen.width, window.screen.height) >= TABLET_MIN_SCREEN_DIMENSION; -} - -const isMobilePhoneWeb = isMobile() && !isTabletScreen(); +const isMobilePhoneWeb = isMobile() && !isTablet(); const screenShortSide = Math.min(window.screen.width, window.screen.height); const screenLongSide = Math.max(window.screen.width, window.screen.height); diff --git a/src/libs/isTablet/index.native.ts b/src/libs/isTablet/index.native.ts new file mode 100644 index 000000000000..302066ec188d --- /dev/null +++ b/src/libs/isTablet/index.native.ts @@ -0,0 +1,8 @@ +import DeviceInfo from 'react-native-device-info'; + +/** Whether the device is a tablet. */ +function isTablet(): boolean { + return DeviceInfo.isTablet(); +} + +export default isTablet; diff --git a/src/libs/isTablet/index.ts b/src/libs/isTablet/index.ts new file mode 100644 index 000000000000..375a0e01e3b4 --- /dev/null +++ b/src/libs/isTablet/index.ts @@ -0,0 +1,9 @@ +/** Shortest side of the screen, in pixels, from which a mobile browser is treated as a tablet */ +const TABLET_MIN_SCREEN_DIMENSION = 600; + +/** Whether the device is a tablet, judged by the screen rather than the window so resizing or rotating doesn't change it. */ +function isTablet(): boolean { + return Math.min(window.screen.width, window.screen.height) >= TABLET_MIN_SCREEN_DIMENSION; +} + +export default isTablet; diff --git a/src/libs/isTransactionGroupListItemType.ts b/src/libs/isTransactionGroupListItemType.ts new file mode 100644 index 000000000000..bb6d163e22b9 --- /dev/null +++ b/src/libs/isTransactionGroupListItemType.ts @@ -0,0 +1,11 @@ +import type {TransactionGroupListItemType} from '@components/Search/SearchList/ListItem/types'; +import type {ListItem} from '@components/SelectionList/types'; + +/** + * Type guard that checks if something is a TransactionGroupListItemType + */ +function isTransactionGroupListItemType(item: ListItem): item is TransactionGroupListItemType { + return 'transactions' in item; +} + +export default isTransactionGroupListItemType; diff --git a/src/libs/merge/MergeUtils.ts b/src/libs/merge/MergeUtils.ts index 77be4c4942c1..8c64765aa02e 100644 --- a/src/libs/merge/MergeUtils.ts +++ b/src/libs/merge/MergeUtils.ts @@ -62,9 +62,9 @@ function getMergeFinalApproverDisplayName( policyEmployeePersonalDetails: PersonalDetailsByLogin, translate: LocaleContextProps['translate'], formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], -): string { +): string | undefined { if (!finalApprover) { - return translate('workspace.merge.notSet'); + return undefined; } return temporaryGetDisplayNameOrDefault({ passedPersonalDetails: policyEmployeePersonalDetails[finalApprover], diff --git a/src/libs/merge/RecruitingUtils.ts b/src/libs/merge/RecruitingUtils.ts index 5733af175942..e2805e2350b3 100644 --- a/src/libs/merge/RecruitingUtils.ts +++ b/src/libs/merge/RecruitingUtils.ts @@ -153,6 +153,15 @@ function getMergeATSApproverField(policy: OnyxEntry): MergeATSApproverFi return policy?.connections?.merge_ats?.config?.approverField ?? undefined; } +/** + * Returns the ATS approver fields the connected provider supports as the source of a candidate's default approver. + */ +function getMergeATSApproverFields(policy: OnyxEntry): readonly MergeATSApproverField[] { + const slug = getConnectedATSProvider(policy)?.mergeSlug; + const approverFields = slug ? MERGE_ATS_PROVIDERS[slug]?.approverFields : undefined; + return approverFields ?? []; +} + /** Checks if the recruiting connection on the policy is in an error state the admin needs to resolve. */ function shouldShowRecruitingConnectionError(policy: OnyxEntry, isAdmin: boolean): boolean { if (!isAdmin || !isMergeConnected(policy, CONST.POLICY.CONNECTIONS.NAME.MERGE_ATS)) { @@ -165,6 +174,7 @@ export { getConnectedATSProvider, getMergeATSApprovalMode, getMergeATSApproverField, + getMergeATSApproverFields, getMergeATSFilterLabel, getMergeATSFilterOptions, getMergeATSFilterValues, diff --git a/src/libs/migrateOnyx.ts b/src/libs/migrateOnyx.ts index 3881956a5d83..38a651846af8 100644 --- a/src/libs/migrateOnyx.ts +++ b/src/libs/migrateOnyx.ts @@ -3,6 +3,7 @@ import CONST from '@src/CONST'; import Log from './Log'; import MoveFilesOutOfDocuments from './migrations/MoveFilesOutOfDocuments'; import MoveSearchQueryByHashToSnapshots from './migrations/MoveSearchQueryByHashToSnapshots'; +import RemoveSearchRowFieldsFromTransactions from './migrations/RemoveSearchRowFieldsFromTransactions'; import RenameLegacySageIntacctExportDate from './migrations/RenameLegacySageIntacctExportDate'; import ReplaceShouldUseStagingServerWithActiveServer from './migrations/ReplaceShouldUseStagingServerWithActiveServer'; import {endSpan, getSpan, startSpan} from './telemetry/activeSpans'; @@ -24,6 +25,7 @@ export default function () { ReplaceShouldUseStagingServerWithActiveServer, MoveSearchQueryByHashToSnapshots, RenameLegacySageIntacctExportDate, + RemoveSearchRowFieldsFromTransactions, ]; // Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the diff --git a/src/libs/migrations/MoveFilesOutOfDocuments/index.native.ts b/src/libs/migrations/MoveFilesOutOfDocuments/index.native.ts index 1ba19e46e250..c3eb07f1501c 100644 --- a/src/libs/migrations/MoveFilesOutOfDocuments/index.native.ts +++ b/src/libs/migrations/MoveFilesOutOfDocuments/index.native.ts @@ -1,4 +1,3 @@ -import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; import CONST from '@src/CONST'; @@ -11,6 +10,8 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import RNFS from 'react-native-fs'; import Onyx from 'react-native-onyx'; +import removeStaleExportStagingDir from './removeStaleExportStagingDir'; + const OLD_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/attachments`; const NEW_ATTACHMENT_DIR = `${RNFS.CachesDirectoryPath}/attachments`; @@ -128,29 +129,6 @@ function removeStaleOnyxDump(): Promise { }); } -/** - * Older app versions staged CSV/report exports in Documents/Expensify before handing them to the - * iOS share sheet, and cancelling the sheet leaked the staged file. Exports are internal files - * (current versions stage them in Caches), so the whole staging directory is removed. - * - * This only applies to iOS. On Android the same path (filesDir/Expensify) is where the native - * share intent handler stages files shared into the app, so removing it would lose the share. - */ -function removeStaleExportStagingDir(): Promise { - if (getPlatform() !== CONST.PLATFORM.IOS) { - return Promise.resolve(); - } - const stagingDir = `${RNFS.DocumentDirectoryPath}/Expensify`; - return RNFS.exists(stagingDir).then((exists) => { - if (!exists) { - return; - } - return RNFS.unlink(stagingDir).then(() => { - Log.info('[Migrate Onyx] MoveFilesOutOfDocuments removed the old export staging directory'); - }); - }); -} - /** * Internal app files used to live in the app's document directory, which iOS shows to the user * (and other apps) through the Files app because file sharing is enabled. This moves or removes diff --git a/src/libs/migrations/MoveFilesOutOfDocuments/removeStaleExportStagingDir/index.ios.ts b/src/libs/migrations/MoveFilesOutOfDocuments/removeStaleExportStagingDir/index.ios.ts new file mode 100644 index 000000000000..fe37af299e0c --- /dev/null +++ b/src/libs/migrations/MoveFilesOutOfDocuments/removeStaleExportStagingDir/index.ios.ts @@ -0,0 +1,20 @@ +import Log from '@libs/Log'; + +import RNFS from 'react-native-fs'; + +/** + * Older app versions staged CSV/report exports in Documents/Expensify before handing them to the + * iOS share sheet, and cancelling the sheet leaked the staged file. Exports are internal files + * (current versions stage them in Caches), so the whole staging directory is removed. + */ +export default function removeStaleExportStagingDir(): Promise { + const stagingDir = `${RNFS.DocumentDirectoryPath}/Expensify`; + return RNFS.exists(stagingDir).then((exists) => { + if (!exists) { + return; + } + return RNFS.unlink(stagingDir).then(() => { + Log.info('[Migrate Onyx] MoveFilesOutOfDocuments removed the old export staging directory'); + }); + }); +} diff --git a/src/libs/migrations/MoveFilesOutOfDocuments/removeStaleExportStagingDir/index.ts b/src/libs/migrations/MoveFilesOutOfDocuments/removeStaleExportStagingDir/index.ts new file mode 100644 index 000000000000..cdfd290e9b1e --- /dev/null +++ b/src/libs/migrations/MoveFilesOutOfDocuments/removeStaleExportStagingDir/index.ts @@ -0,0 +1,6 @@ +// The export staging leak only happened on iOS, so this is a no-op on other platforms. On Android +// the same path (filesDir/Expensify) is where the native share intent handler stages files shared +// into the app, so removing it would lose the share. +export default function removeStaleExportStagingDir(): Promise { + return Promise.resolve(); +} diff --git a/src/libs/migrations/RemoveSearchRowFieldsFromTransactions.ts b/src/libs/migrations/RemoveSearchRowFieldsFromTransactions.ts new file mode 100644 index 000000000000..1e64887f290e --- /dev/null +++ b/src/libs/migrations/RemoveSearchRowFieldsFromTransactions.ts @@ -0,0 +1,46 @@ +import {SEARCH_TRANSACTION_ROW_ONLY_FIELD_NAMES} from '@libs/getTransactionFromTransactionListItem'; +import Log from '@libs/Log'; +import readOnceOnyxValue from '@libs/readOnceOnyxValue'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +/** + * Removes the Search row display-only fields (e.g. `formattedMerchant`) from cached transactions. + */ +export default async function RemoveSearchRowFieldsFromTransactions(): Promise { + const transactions = await readOnceOnyxValue(ONYXKEYS.COLLECTION.TRANSACTION); + + const updates: Record> = {}; + for (const [key, transaction] of Object.entries(transactions ?? {})) { + if (!transaction) { + continue; + } + const leakedFields: Record = {}; + for (const fieldName of SEARCH_TRANSACTION_ROW_ONLY_FIELD_NAMES) { + if (fieldName in transaction) { + leakedFields[fieldName] = null; + } + } + if (Object.keys(leakedFields).length === 0) { + continue; + } + updates[key] = leakedFields; + } + + if (Object.keys(updates).length === 0) { + Log.info('[Migrate Onyx] Skipped RemoveSearchRowFieldsFromTransactions — no leaked Search row fields found'); + return; + } + + try { + // No need to add a new action just for this migration + // eslint-disable-next-line rulesdir/prefer-actions-set-data + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.TRANSACTION, updates); + Log.info(`[Migrate Onyx] Ran RemoveSearchRowFieldsFromTransactions migration on ${Object.keys(updates).length} transactions`); + } catch (error) { + // The cleanup is non-critical, so never block startup on it; it runs again on the next launch + Log.warn('[Migrate Onyx] RemoveSearchRowFieldsFromTransactions failed', {error}); + } +} diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 7e64fb96331e..c3a0e26ae49d 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -12,12 +12,14 @@ import Onyx from 'react-native-onyx'; import {setDisableDismissOnEscape} from './actions/Modal'; import SidePanelActions from './actions/SidePanel'; import {setOnboardingRHPVariant} from './actions/Welcome'; +import Log from './Log'; import isReportTopmostSplitNavigator from './Navigation/helpers/isReportTopmostSplitNavigator'; import dismissOnboardingModalBeforeExit from './Navigation/helpers/OnboardingNavigationUtils'; import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation from './Navigation/Navigation'; import {findLastAccessedReport} from './ReportUtils'; import {buildCannedSearchQuery} from './SearchQueryUtils'; +import {registerSessionCleanupCallback} from './SessionCleanup'; let onboardingRHPVariant: OnyxEntry; Onyx.connectWithoutView({ @@ -27,6 +29,34 @@ Onyx.connectWithoutView({ }, }); +/** Parked by openReportFromDeepLink() while onboarding owns the screen. In memory rather than Onyx so a stale destination cannot replay after a reload or for the next account on the device. */ +let openDeepLinkAfterOnboarding: (() => boolean) | undefined; + +/** The callback returns whether it opened the route. Returning false lets the caller pick the default destination. */ +function setDeepLinkToOpenAfterOnboarding(callback: () => boolean) { + openDeepLinkAfterOnboarding = callback; +} + +function consumeDeepLinkToOpenAfterOnboarding(): boolean { + if (!openDeepLinkAfterOnboarding) { + return false; + } + + const openDeepLink = openDeepLinkAfterOnboarding; + openDeepLinkAfterOnboarding = undefined; + try { + return openDeepLink(); + } catch (error) { + // The onboarding modal is already dismissed, so a failing replay must not take the default destination with it. + Log.alert('[navigateAfterOnboarding] Parked deep link failed to open', {error}); + return false; + } +} + +registerSessionCleanupCallback(() => { + openDeepLinkAfterOnboarding = undefined; +}); + type NavigateAfterOnboardingOptions = { afterTransition?: () => void; variantOverride?: OnboardingRHPVariant | null; @@ -42,6 +72,7 @@ function getReportIDAfterOnboarding( canUseDefaultRooms: boolean | undefined, conciergeReportID: string | undefined, reportNameValuePairs: OnyxCollection, + isAnonymousUser: boolean, onboardingPolicyID?: string, onboardingAdminsChatReportID?: string, shouldPreventOpenAdminRoom = false, @@ -56,8 +87,15 @@ function getReportIDAfterOnboarding( return undefined; } - // TODO: Pass guideAccountIDs once callers are fully migrated — PR 33 (https://github.com/Expensify/App/issues/66413); findLastAccessedReport falls back to hasExpensifyGuidesEmails → allPersonalDetails - const lastAccessedReport = findLastAccessedReport(!canUseDefaultRooms, undefined, shouldOpenOnAdminRoom() && !shouldPreventOpenAdminRoom, undefined, reportNameValuePairs); + // TODO: Pass guideAccountIDs in Phase B. findLastAccessedReport reaches hasExpensifyGuidesEmails, which falls back to the personal details store until then. See https://github.com/Expensify/App/issues/66413. + const lastAccessedReport = findLastAccessedReport( + !canUseDefaultRooms, + undefined, + isAnonymousUser, + shouldOpenOnAdminRoom() && !shouldPreventOpenAdminRoom, + undefined, + reportNameValuePairs, + ); const lastAccessedReportID = lastAccessedReport?.reportID; // When the user goes through the onboarding flow, a workspace can be created if the user selects specific options. The user should be taken to the #admins room for that workspace because it is the most natural place for them to start their experience in the app. @@ -79,6 +117,7 @@ function navigateAfterOnboarding( canUseDefaultRooms: boolean | undefined, conciergeReportID: string | undefined, reportNameValuePairs: OnyxCollection, + isAnonymousUser: boolean, onboardingPolicyID?: string, onboardingAdminsChatReportID?: string, shouldPreventOpenAdminRoom = false, @@ -86,6 +125,11 @@ function navigateAfterOnboarding( ) { setDisableDismissOnEscape(false); + // A route the user asked for before signing up outranks every default below, including the Side Panel variant. + if (consumeDeepLinkToOpenAfterOnboarding()) { + return; + } + // On mobile (small screen), Track workspace admins with the trackExpensesWithConcierge variant // should navigate directly to the Concierge DM (which contains onboarding tasks). // This check is outside shouldOpenRHPVariant because that function returns false on native @@ -118,6 +162,7 @@ function navigateAfterOnboarding( canUseDefaultRooms, conciergeReportID, reportNameValuePairs, + isAnonymousUser, onboardingPolicyID, onboardingAdminsChatReportID, shouldPreventOpenAdminRoom, @@ -135,6 +180,7 @@ function navigateAfterOnboardingWithMicrotaskQueue( canUseDefaultRooms: boolean | undefined, conciergeReportID: string | undefined, reportNameValuePairs: OnyxCollection, + isAnonymousUser: boolean, onboardingPolicyID?: string, onboardingAdminsChatReportID?: string, shouldPreventOpenAdminRoom = false, @@ -147,6 +193,7 @@ function navigateAfterOnboardingWithMicrotaskQueue( canUseDefaultRooms, conciergeReportID, reportNameValuePairs, + isAnonymousUser, onboardingPolicyID, onboardingAdminsChatReportID, shouldPreventOpenAdminRoom, @@ -163,6 +210,10 @@ function navigateAfterOnboardingWithMicrotaskQueue( function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false) { setDisableDismissOnEscape(false); + if (consumeDeepLinkToOpenAfterOnboarding()) { + return; + } + if (!policyID) { Navigation.navigate(ROUTES.HOME); return; @@ -181,4 +232,4 @@ function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: s }); } -export {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue}; +export {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, setDeepLinkToOpenAfterOnboarding, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue}; diff --git a/src/libs/preventTextInputFocusOnFirstResponderOnce/index.ios.ts b/src/libs/preventTextInputFocusOnFirstResponderOnce/index.ios.ts index 702bcb283ed7..8fd1e53331dc 100644 --- a/src/libs/preventTextInputFocusOnFirstResponderOnce/index.ios.ts +++ b/src/libs/preventTextInputFocusOnFirstResponderOnce/index.ios.ts @@ -5,7 +5,7 @@ import type {PreventTextInputFocusOnFirstResponderOnce} from './types'; * first responder in the UIResponder chain. (iOS only, no-op on Android and web) */ const preventTextInputFocusOnFirstResponderOnce: PreventTextInputFocusOnFirstResponderOnce = (composerRef) => { - composerRef.current?.preventFocusOnFirstResponderOnce(); + composerRef.current?.preventFocusOnFirstResponderOnce?.(); }; export default preventTextInputFocusOnFirstResponderOnce; diff --git a/src/libs/preventTextInputFocusOnFirstResponderOnce/types.ts b/src/libs/preventTextInputFocusOnFirstResponderOnce/types.ts index 6a7c3a15b2e4..ac83e2225d47 100644 --- a/src/libs/preventTextInputFocusOnFirstResponderOnce/types.ts +++ b/src/libs/preventTextInputFocusOnFirstResponderOnce/types.ts @@ -1,7 +1,9 @@ +import type {ComposerRef} from '@components/Composer/types'; + import type {ComponentRef, RefObject} from 'react'; import type {TextInput} from 'react-native'; -type PreventTextInputFocusOnFirstResponderOnce = (composerRef: RefObject | null>) => void; +type PreventTextInputFocusOnFirstResponderOnce = (composerRef: RefObject | ComposerRef | null>) => void; // eslint-disable-next-line import/prefer-default-export export type {PreventTextInputFocusOnFirstResponderOnce}; diff --git a/src/libs/resolveInsightsChartData.ts b/src/libs/resolveInsightsChartData.ts index 85b98bca944e..46226389a20e 100644 --- a/src/libs/resolveInsightsChartData.ts +++ b/src/libs/resolveInsightsChartData.ts @@ -1,6 +1,7 @@ import type {GroupedItem, SearchQueryJSON} from '@components/Search/types'; import type SearchResults from '@src/types/onyx/SearchResults'; +import type {SearchGroupBase} from '@src/types/onyx/SearchResults'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -38,6 +39,11 @@ type InsightsChartData = { state: InsightsChartState; }; +/** Checks whether any group holds expenses, since snapshots may include empty buckets. */ +function hasGroupWithExpenses(groups: Array>): boolean { + return groups.some((group) => group.count > 0); +} + /** Resolves one chart's rows and state from its snapshot, whether GetInsights or Search loaded it. */ function resolveInsightsChartData({snapshot, queryJSON, sortedData, isOffline = false}: ResolveInsightsChartDataParams): InsightsChartData { const isLoaded = isSearchDataLoaded(snapshot, queryJSON); @@ -55,11 +61,11 @@ function resolveInsightsChartData({snapshot, queryJSON, sortedData, isOffline = return {data: [], state: INSIGHTS_CHART_STATE.LOADING}; } - if (!sortedData?.length) { + if (!sortedData || !hasGroupWithExpenses(sortedData)) { return {data: [], state: INSIGHTS_CHART_STATE.EMPTY}; } return {data: sortedData, state: INSIGHTS_CHART_STATE.READY}; } -export {INSIGHTS_CHART_STATE, resolveInsightsChartData}; +export {INSIGHTS_CHART_STATE, hasGroupWithExpenses, resolveInsightsChartData}; diff --git a/src/libs/sanitizeLogParams.ts b/src/libs/sanitizeLogParams.ts index 6949f6aa8330..4b1ec38edb10 100644 --- a/src/libs/sanitizeLogParams.ts +++ b/src/libs/sanitizeLogParams.ts @@ -17,6 +17,14 @@ const SENSITIVE_KEYS = new Set([ 'clientSecret', 'cardNumber', 'cardCVV', + 'signedChallenge', + 'nonce', + 'nonceSignature', + 'certificates', + 'encryptedPassData', + 'activationData', + 'ephemeralPublicKey', + 'opaquePaymentCard', ]); const REDACTED = ''; diff --git a/src/libs/scrollFocusedInputToTop/index.ios.ts b/src/libs/scrollFocusedInputToTop/index.ios.ts new file mode 100644 index 000000000000..2d57e3f16475 --- /dev/null +++ b/src/libs/scrollFocusedInputToTop/index.ios.ts @@ -0,0 +1,21 @@ +import type {ScrollFocusedInputToTop} from './types'; + +/** Extra space left between the focused input and the top of the visible scroll area after scrolling. */ +const EXTRA_SCROLL_PADDING = 16; + +/** + * Scrolls the focused input to the top of the scroll view. Meant to be called from the scroll view's `onLayout`. + */ +const scrollFocusedInputToTop: ScrollFocusedInputToTop = (scrollViewRef, scrollContentRef, inputRef) => { + const input = inputRef.current; + const scrollContent = scrollContentRef.current; + if (!input || !scrollContent || !input.isFocused()) { + return; + } + + input.measureLayout(scrollContent, (_x, y) => { + scrollViewRef.current?.scrollTo({y: Math.max(y - EXTRA_SCROLL_PADDING, 0), animated: false}); + }); +}; + +export default scrollFocusedInputToTop; diff --git a/src/libs/scrollFocusedInputToTop/index.ts b/src/libs/scrollFocusedInputToTop/index.ts new file mode 100644 index 000000000000..fa46e54d3b84 --- /dev/null +++ b/src/libs/scrollFocusedInputToTop/index.ts @@ -0,0 +1,6 @@ +import type {ScrollFocusedInputToTop} from './types'; + +// iOS only functionality +const scrollFocusedInputToTop: ScrollFocusedInputToTop = () => {}; + +export default scrollFocusedInputToTop; diff --git a/src/libs/scrollFocusedInputToTop/types.ts b/src/libs/scrollFocusedInputToTop/types.ts new file mode 100644 index 000000000000..62da8a4db6d4 --- /dev/null +++ b/src/libs/scrollFocusedInputToTop/types.ts @@ -0,0 +1,13 @@ +import type {ComponentRef, RefObject} from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {ScrollView, TextInput, View} from 'react-native'; + +type ScrollFocusedInputToTop = ( + scrollViewRef: RefObject | null>, + /** A view wrapping the whole scroll view content, used as the origin to measure the input against. */ + scrollContentRef: RefObject | null>, + inputRef: RefObject | null>, +) => void; + +// eslint-disable-next-line import/prefer-default-export +export type {ScrollFocusedInputToTop}; diff --git a/src/libs/showInlineEditError.ts b/src/libs/showInlineEditError.ts new file mode 100644 index 000000000000..cf154abb9d28 --- /dev/null +++ b/src/libs/showInlineEditError.ts @@ -0,0 +1,17 @@ +/** + * Bottom-right toast for an inline table edit that was rejected and reverted. + * Spend and workspace tables share this so a rejected value always surfaces in the same place. + */ +import CONST from '@src/CONST'; + +import Growl from './Growl'; + +function showInlineEditError(message: string | undefined) { + if (!message) { + return; + } + + Growl.error(message, {position: CONST.GROWL.POSITION.BOTTOM_RIGHT}); +} + +export default showInlineEditError; diff --git a/src/libs/telemetry/TelemetrySynchronizer.ts b/src/libs/telemetry/TelemetrySynchronizer.ts index 9738f0781d7b..f4278f4ab14f 100644 --- a/src/libs/telemetry/TelemetrySynchronizer.ts +++ b/src/libs/telemetry/TelemetrySynchronizer.ts @@ -1,5 +1,4 @@ -import {getActivePolicies} from '@libs/PolicyUtils'; -import getHighestPolicyRole from '@libs/PolicyUtils/getHighestPolicyRole'; +import {getActivePolicies, getHighestPolicyRole} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/libs/telemetry/cancelTabNavigationSpans.ts b/src/libs/telemetry/cancelTabNavigationSpans.ts index 364a0cf2ab0e..604131421a1c 100644 --- a/src/libs/telemetry/cancelTabNavigationSpans.ts +++ b/src/libs/telemetry/cancelTabNavigationSpans.ts @@ -1,5 +1,9 @@ +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; + import CONST from '@src/CONST'; +import type {ValueOf} from 'type-fest'; + import {cancelSpan} from './activeSpans'; // Spans grouped by the tab they belong to. The Reports tab owns three (legacy plus the FirstPaint/ContentLoad @@ -11,6 +15,10 @@ const REPORTS_TAB_SPAN_IDS: readonly string[] = [ ]; const INBOX_TAB_SPAN_IDS: readonly string[] = [CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB]; const TAB_NAVIGATION_SPAN_IDS: readonly string[] = [...REPORTS_TAB_SPAN_IDS, ...INBOX_TAB_SPAN_IDS]; +const NAVIGATION_TAB_TO_SPANS: Partial, readonly string[]>> = { + [NAVIGATION_TABS.INBOX]: INBOX_TAB_SPAN_IDS, + [NAVIGATION_TABS.SEARCH]: REPORTS_TAB_SPAN_IDS, +}; /** * Cancels running tab-navigation spans so an abandoned tap does not leave one ticking until the user returns. @@ -26,4 +34,4 @@ function cancelTabNavigationSpans(preserve: readonly string[] = []) { } export default cancelTabNavigationSpans; -export {REPORTS_TAB_SPAN_IDS, INBOX_TAB_SPAN_IDS, TAB_NAVIGATION_SPAN_IDS}; +export {TAB_NAVIGATION_SPAN_IDS, NAVIGATION_TAB_TO_SPANS}; diff --git a/src/libs/telemetry/reportFullyDrawn/index.android.ts b/src/libs/telemetry/reportFullyDrawn/index.android.ts new file mode 100644 index 000000000000..85090dfeb4c8 --- /dev/null +++ b/src/libs/telemetry/reportFullyDrawn/index.android.ts @@ -0,0 +1,9 @@ +import {NativeModules} from 'react-native'; + +import type ReportFullyDrawn from './types'; + +const reportFullyDrawn: ReportFullyDrawn = () => { + NativeModules.AppStateTracker.reportFullyDrawn?.(); +}; + +export default reportFullyDrawn; diff --git a/src/libs/telemetry/reportFullyDrawn/index.ts b/src/libs/telemetry/reportFullyDrawn/index.ts new file mode 100644 index 000000000000..55a3d77e0598 --- /dev/null +++ b/src/libs/telemetry/reportFullyDrawn/index.ts @@ -0,0 +1,6 @@ +import type ReportFullyDrawn from './types'; + +// Time to full display is an Android startup metric, so the other platforms have nothing to report. +const reportFullyDrawn: ReportFullyDrawn = () => {}; + +export default reportFullyDrawn; diff --git a/src/libs/telemetry/reportFullyDrawn/types.ts b/src/libs/telemetry/reportFullyDrawn/types.ts new file mode 100644 index 000000000000..9b0390ba3a55 --- /dev/null +++ b/src/libs/telemetry/reportFullyDrawn/types.ts @@ -0,0 +1,3 @@ +type ReportFullyDrawn = () => void; + +export default ReportFullyDrawn; diff --git a/src/libs/telemetry/reportsTabPreloadTime.ts b/src/libs/telemetry/reportsTabPreloadTime.ts new file mode 100644 index 000000000000..bc8f2fc29954 --- /dev/null +++ b/src/libs/telemetry/reportsTabPreloadTime.ts @@ -0,0 +1,21 @@ +/** Navigation state records that the Reports tab is preloaded but not when, so the preload time is kept here. */ +type PreloadDispatch = { + tabNavigatorKey: string; + dispatchedAt: number; +}; + +let lastPreloadDispatch: PreloadDispatch | undefined; + +function markReportsTabPreloadDispatched(tabNavigatorKey: string) { + lastPreloadDispatch = {tabNavigatorKey, dispatchedAt: performance.now()}; +} + +/** Undefined when the last preload went to another tab navigator, e.g. one pushed by a cross-tab deep link. */ +function getMsSinceReportsTabPreload(tabNavigatorKey: string | undefined): number | undefined { + if (!lastPreloadDispatch || lastPreloadDispatch.tabNavigatorKey !== tabNavigatorKey) { + return undefined; + } + return Math.round(performance.now() - lastPreloadDispatch.dispatchedAt); +} + +export {getMsSinceReportsTabPreload, markReportsTabPreloadDispatched}; diff --git a/src/libs/telemetry/startTabNavigationSpans.ts b/src/libs/telemetry/startTabNavigationSpans.ts new file mode 100644 index 000000000000..55d4be3f67c0 --- /dev/null +++ b/src/libs/telemetry/startTabNavigationSpans.ts @@ -0,0 +1,52 @@ +import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; + +import {getFocusedTabRouteName, getTabNavigatorStateKey, isReportsTabPreloaded} from '@libs/Navigation/helpers/tabNavigatorUtils'; + +import navigationRef from '@navigation/navigationRef'; + +import CONST from '@src/CONST'; + +import {startSpan} from './activeSpans'; +import {startNavigateToReportsSpans} from './navigateToReportsSpans'; +import {getMsSinceReportsTabPreload} from './reportsTabPreloadTime'; + +/** Must run before the tab switch is dispatched, while the tab being left is still focused. */ +function getSourceTab(rootState: ReturnType): string { + const focusedRouteName = getFocusedTabRouteName(rootState); + return (focusedRouteName && ROUTE_TO_NAVIGATION_TAB[focusedRouteName]) ?? 'unknown'; +} + +/** + * Starts ManualNavigateToInboxTab at the tab tap. Call it before the tab navigation is dispatched, because the + * preloaded state and the source tab it records change once the tab is jumped to. + */ +function startNavigateToInboxTabSpan({isWideLayout}: {isWideLayout: boolean}) { + const rootState = navigationRef.getRootState(); + const isPreloaded = isReportsTabPreloaded(rootState); + const msSincePreload = isPreloaded ? getMsSinceReportsTabPreload(getTabNavigatorStateKey(rootState)) : undefined; + + startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, { + name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, + op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, + forceTransaction: true, + attributes: { + [CONST.TELEMETRY.ATTRIBUTE_WIDE_LAYOUT]: isWideLayout, + [CONST.TELEMETRY.ATTRIBUTE_IS_PRELOADED]: isPreloaded, + [CONST.TELEMETRY.ATTRIBUTE_WAITED_ON_OPEN_REPORT]: false, + [CONST.TELEMETRY.ATTRIBUTE_SOURCE_TAB]: getSourceTab(rootState), + ...(msSincePreload !== undefined && {[CONST.TELEMETRY.ATTRIBUTE_MS_SINCE_PRELOAD]: msSincePreload}), + }, + }); +} + +/** Starts the legacy ManualNavigateToReports span and its FirstPaint/ContentLoad split at the tab tap. */ +function startNavigateToReportsTabSpans() { + startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { + name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, + op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, + forceTransaction: true, + }); + startNavigateToReportsSpans(); +} + +export {startNavigateToInboxTabSpan, startNavigateToReportsTabSpans}; diff --git a/src/libs/testCrash/index.native.ts b/src/libs/testCrash/index.native.ts index 6d7db0fcde40..887f04f9c2d1 100644 --- a/src/libs/testCrash/index.native.ts +++ b/src/libs/testCrash/index.native.ts @@ -1,6 +1,10 @@ +import CONST from '@src/CONST'; + import * as Sentry from '@sentry/react-native'; const testCrash = () => { + // Tags sync to the native scope, fingerprints don't. Lets Sentry filter or fingerprint these QA crashes apart. + Sentry.setTag(CONST.TELEMETRY.TAGS.TEST_CRASH, 'true'); Sentry.nativeCrash(); }; export default testCrash; diff --git a/src/libs/updateMultilineInputRange/types.ts b/src/libs/updateMultilineInputRange/types.ts index 348332586466..0f4471cee551 100644 --- a/src/libs/updateMultilineInputRange/types.ts +++ b/src/libs/updateMultilineInputRange/types.ts @@ -1,8 +1,9 @@ +import type {WebComposerRef} from '@components/Composer/types'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; -type UpdateMultilineInputRange = (input: HTMLInputElement | BaseTextInputRef | ComponentRef | null, shouldAutoFocus?: boolean) => void; +type UpdateMultilineInputRange = (input: HTMLInputElement | BaseTextInputRef | ComponentRef | WebComposerRef | null, shouldAutoFocus?: boolean) => void; export default UpdateMultilineInputRange; diff --git a/src/pages/AddExistingExpense.tsx b/src/pages/AddExistingExpense.tsx index b9ce3a383a96..f484c12c8b6d 100644 --- a/src/pages/AddExistingExpense.tsx +++ b/src/pages/AddExistingExpense.tsx @@ -1,4 +1,5 @@ import AddExistingExpenseFooter from '@components/AddExistingExpenseFooter'; +import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import EmptyStateComponent from '@components/EmptyStateComponent'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -15,6 +16,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useThemeStyles from '@hooks/useThemeStyles'; import {fetchUnreportedExpenses} from '@libs/actions/UnreportedExpenses'; @@ -69,6 +71,7 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { const [allOpenReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: openExpenseReportIDsSelector}); const [openReportDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT, {selector: openExpenseReportIDsSelector}); const isInLandscapeMode = useIsInLandscapeMode(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const styles = useThemeStyles(); const transactionsSelector = useCallback( @@ -199,10 +202,12 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { testID="NewChatSelectorPage" focusTrapSettings={{active: false}} > - + + + + {paginationFooterContent} + {footerContent} + + ) : ( + paginationFooterContent + ) + } + // The skeleton appended while the next page loads continues the rows, so it draws the bottom corners. + shouldFooterRenderAsLastRow={!!paginationFooterContent} /> - {footerContent} + {!shouldFooterBeInsideList && footerContent} ); } diff --git a/src/pages/AddPersonalBankAccountPage/substeps/SuccessStep.tsx b/src/pages/AddPersonalBankAccountPage/substeps/SuccessStep.tsx index 41214e456fa4..b1bdf97506bf 100644 --- a/src/pages/AddPersonalBankAccountPage/substeps/SuccessStep.tsx +++ b/src/pages/AddPersonalBankAccountPage/substeps/SuccessStep.tsx @@ -1,4 +1,4 @@ -import ConfirmationPage from '@components/ConfirmationPage'; +import ConfirmationPageDefault from '@components/ConfirmationPage/composed/ConfirmationPageDefault'; import ScrollView from '@components/ScrollView'; import useLocalize from '@hooks/useLocalize'; @@ -13,13 +13,11 @@ function SuccessStep({onNext}: SubPageProps) { return ( - onNext(true)} - containerStyle={styles.h100} /> ); diff --git a/src/pages/AddressPage.tsx b/src/pages/AddressPage.tsx index 7c95c84d7044..926157b82536 100644 --- a/src/pages/AddressPage.tsx +++ b/src/pages/AddressPage.tsx @@ -46,7 +46,7 @@ function AddressPage({title, address, updateAddress, isLoadingApp = true, backTo // Check if country is valid const {street} = address ?? {}; const [street1, legacyStreet2] = street ? street.split('\n') : [undefined, undefined]; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing cannot be used if explicit line 2 can be an empty string + // nullish coalescing cannot be used if explicit line 2 can be an empty string const street2 = address?.street2 || legacyStreet2; const [currentCountry, setCurrentCountry] = useState(address?.country ?? defaultCountry); const [state, setState] = useState(address?.state); diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index 6e71630490af..3c0a26130717 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -9,16 +9,30 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {navigateToConciergeChat} from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@libs/Navigation/navigationRef'; +import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {OnyxValue} from 'react-native-onyx'; + import {useFocusEffect} from '@react-navigation/native'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {isActingAsDelegateSelector} from '@selectors/Account'; +import {isSingleNewDotEntrySelector} from '@selectors/HybridApp'; +import {guidedSetupAndTourStatusSelector, tryNewDotOnyxSelector, wasInvitedToNewDotSelector} from '@selectors/Onboarding'; +import {isSupportalSessionSelector} from '@selectors/Session'; import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; +const isMigratedUserSelector = (tryNewDot: OnyxValue) => tryNewDotOnyxSelector(tryNewDot).hasBeenAddedToNudgeMigration; + +function isOnboardingOpen() { + return navigationRef.isReady() && !!navigationRef.getRootState()?.routes.some((route) => route.name === NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR); +} + /* * This is a "utility page", that does this: * - If the user is authenticated, find their concierge chat and re-route to it @@ -33,7 +47,26 @@ function ConciergePage() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; + const [hasNonPersonalPolicy] = useOnyx(ONYXKEYS.HAS_NON_PERSONAL_POLICY); + const [isMigratedUser] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {selector: isMigratedUserSelector}); + const [isActingAsDelegate] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isActingAsDelegateSelector}); + const [isSingleNewDotEntry] = useOnyx(ONYXKEYS.HYBRID_APP, {selector: isSingleNewDotEntrySelector}); + + // The accounts useOnboardingFlow never sends to onboarding, so their flag can stay false for good. + const isOnboardingSkipped = + !!hasNonPersonalPolicy || + !!wasInvitedToNewDotSelector(introSelected) || + !!isMigratedUser || + isSupportalSessionSelector(session) || + !!isActingAsDelegate || + (CONFIG.IS_HYBRID_APP && !!isSingleNewDotEntry); + + // Onboarding opens on top of this page for a new account, and the redirect below dismisses the top modal, so it + // would close onboarding before it is finished. The link is opened once onboarding completes instead. + const isWaitingForOnboarding = !CONFIG.SKIP_ONBOARDING && hasCompletedGuidedSetupFlow === false; useFocusEffect( useCallback(() => { @@ -43,11 +76,17 @@ function ConciergePage() { return; } + // Onboarding can join or create a workspace before it finishes, so an open onboarding still wins. + if (isWaitingForOnboarding && (!isOnboardingSkipped || isOnboardingOpen())) { + return; + } + navigateToConciergeChat({ conciergeReportID, introSelected, currentUserAccountID: session.accountID ?? CONST.DEFAULT_NUMBER_ID, isSelfTourViewed, + hasCompletedGuidedSetupFlow, conciergePersonalDetails, shouldDismissModal: true, checkIfCurrentPageActive: () => !isUnmounted.current, @@ -56,7 +95,17 @@ function ConciergePage() { } else { Navigation.navigate(ROUTES.INBOX); } - }, [session, isLoadingReportData, conciergeReportID, introSelected, isSelfTourViewed, conciergePersonalDetails]), + }, [ + session, + isLoadingReportData, + conciergeReportID, + introSelected, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + isWaitingForOnboarding, + isOnboardingSkipped, + ]), ); useEffect(() => { diff --git a/src/pages/Debug/Report/DebugReportActions.tsx b/src/pages/Debug/Report/DebugReportActions.tsx index 9c76fbf68f79..31258c8dd3b3 100644 --- a/src/pages/Debug/Report/DebugReportActions.tsx +++ b/src/pages/Debug/Report/DebugReportActions.tsx @@ -49,7 +49,7 @@ function DebugReportActions({reportID}: DebugReportActionsProps) { const [sortedAllReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { selector: (allReportActions: OnyxEntry): ReportAction[] => getSortedReportActionsForDisplay(allReportActions, ifUserCanPerformWriteAction, true, undefined, reportID), }); - const participantAccountIDs = getParticipantsAccountIDsForDisplay(report, undefined, undefined, true); + const participantAccountIDs = getParticipantsAccountIDsForDisplay(report, undefined, undefined, undefined, true); const participantPersonalDetailList = Object.values(getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails)); const getReportActionDebugText = useCallback( diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx index 45370a0a6415..c8056409e612 100644 --- a/src/pages/Debug/Report/DebugReportPage.tsx +++ b/src/pages/Debug/Report/DebugReportPage.tsx @@ -41,6 +41,7 @@ import type {ReportAttributesDerivedValue} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {pendingExpenseActionSelector} from '@selectors/ReportMetaData'; import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; @@ -72,6 +73,7 @@ function DebugReportPage({ const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [pendingExpenseActionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {selector: pendingExpenseActionSelector}); const {isOffline} = useNetwork(); const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { selector: (attributes: OnyxEntry) => attributes?.reports?.[reportID], @@ -103,7 +105,8 @@ function DebugReportPage({ const shouldDisplayViolations = !!getViolatingReportIDForRBRInLHN(report, transactionViolations); const hasViolations = !!shouldDisplayViolations; const {reason: reasonGBR, reportAction: reportActionGBR} = - DebugUtils.getReasonAndReportActionForGBRInLHNRow(report, currentUserLogin ?? '', currentUserAccountID, isReportArchived, transactionViolations) ?? {}; + DebugUtils.getReasonAndReportActionForGBRInLHNRow(report, currentUserLogin ?? '', currentUserAccountID, pendingExpenseActionMetadata, isReportArchived, transactionViolations) ?? + {}; const {reason: reasonRBR, reportAction: reportActionRBR} = DebugUtils.getReasonAndReportActionForRBRInLHNRow( report, @@ -185,6 +188,7 @@ function DebugReportPage({ currentUserLogin, currentUserAccountID, isReportArchived, + pendingExpenseActionMetadata, chatReport, reportActions, transactions, diff --git a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx index 9dadbf096997..fcb48b715e4d 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx @@ -8,6 +8,7 @@ import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; +import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; @@ -73,6 +74,7 @@ function DebugReportActionCreatePage({ const [draftReportAction, setDraftReportAction] = useState(() => getInitialReportAction(reportID, session, currentUserPersonalDetail)); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.chatReportID)}`); + const reportsParentHierarchy = useReportsParentHierarchy(reportID); const reportAction = parseReportActionJSON(draftReportAction); @@ -142,6 +144,7 @@ function DebugReportActionCreatePage({ shouldDisplayNewMarker={false} isFirstVisibleReportAction={false} shouldDisplayContextMenu={false} + reportsParentHierarchy={reportsParentHierarchy} /> ) : ( {translate('debug.nothingToPreview')} diff --git a/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx b/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx index ea59af7c6344..235702973b60 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx @@ -1,6 +1,7 @@ import ScrollView from '@components/ScrollView'; import useOnyx from '@hooks/useOnyx'; +import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -25,6 +26,7 @@ function DebugReportActionPreview({reportAction, reportID}: DebugReportActionPre const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportAction?.childReportID}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.chatReportID)}`); + const reportsParentHierarchy = useReportsParentHierarchy(reportID); return ( @@ -38,6 +40,7 @@ function DebugReportActionPreview({reportAction, reportID}: DebugReportActionPre shouldDisplayNewMarker={false} isFirstVisibleReportAction={false} shouldDisplayContextMenu={false} + reportsParentHierarchy={reportsParentHierarchy} /> ); diff --git a/src/pages/DynamicEditReportFieldPage.tsx b/src/pages/DynamicEditReportFieldPage.tsx index 6a63a50e7a10..f24e212352c5 100644 --- a/src/pages/DynamicEditReportFieldPage.tsx +++ b/src/pages/DynamicEditReportFieldPage.tsx @@ -2,7 +2,6 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {ModalActions} from '@components/Modal/Global/ModalContext'; -import {useSession} from '@components/OnyxListItemProvider'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -12,10 +11,10 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import useSaveReportField from '@hooks/useSaveReportField'; -import {deleteReportField, updateReportField, updateReportName} from '@libs/actions/Report'; +import {deleteReportField} from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {EditRequestNavigatorParamList} from '@libs/Navigation/types'; @@ -25,10 +24,8 @@ import { getReportFieldFromReportNameValuePairs, getReportFieldKey, getTitleFieldWithFallback, - hasViolations as hasViolationsReportUtils, isGroupPolicyExpenseReport, isInvoiceReport, - isReportFieldDisabled, isReportFieldDisabledForUser, isReportFieldOfTypeTitle, isReportFieldTargetMatchingReport, @@ -38,9 +35,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {Policy} from '@src/types/onyx'; -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {Str} from 'expensify-common'; import React from 'react'; @@ -58,7 +53,7 @@ function DynamicEditReportFieldPage({route}: DynamicEditReportFieldPageProps) { const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const derivedReportName = useDerivedReportNameByReportID(reportID); - const [recentlyUsedReportFields] = useOnyx(ONYXKEYS.RECENTLY_USED_REPORT_FIELDS); + const saveReportField = useSaveReportField(report, policy); const isTitleField = route.params.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID; const reportFieldFromNVP = getReportFieldFromReportNameValuePairs(reportNameValuePairs, fieldKey) ?? getReportFieldFromReportNameValuePairs(reportNameValuePairs, route.params.fieldID); @@ -76,20 +71,12 @@ function DynamicEditReportFieldPage({route}: DynamicEditReportFieldPageProps) { const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const isDisabled = isReportFieldDisabledForUser(report, reportField, policy, currentUserAccountID, rules) && reportField?.type !== CONST.REPORT_FIELD_TYPES.FORMULA; - const {isBetaEnabled} = usePermissions(); - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - const session = useSession(); - const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); - const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? ''); const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); const isReportFieldTitle = isReportFieldOfTypeTitle(reportField); const isReportFieldsFeatureEnabled = report?.type === CONST.REPORT.TYPE.INVOICE ? policy?.areInvoiceFieldsEnabled : policy?.areReportFieldsEnabled; const reportFieldsEnabled = ((isGroupPolicyExpenseReport(report, policy?.type) || isInvoiceReport(report)) && !!isReportFieldsFeatureEnabled) || isReportFieldTitle; - const hasOtherViolations = - report?.fieldList && Object.entries(report.fieldList).some(([key, field]) => key !== fieldKey && field.value === '' && !isReportFieldDisabled(report, reportField, policy, rules)); if (!reportFieldsEnabled || !reportField || !policyField || !report || !isReportFieldTargetMatchingReport(report, reportField) || isDisabled) { return ( @@ -140,28 +127,8 @@ function DynamicEditReportFieldPage({route}: DynamicEditReportFieldPageProps) { return; } - if (isReportFieldTitle) { - updateReportName(report.reportID, value, report.reportName ?? ''); - goBack(); - } else { - if (value !== '') { - updateReportField({ - report: {...report, reportID: report.reportID}, - reportField: {...reportField, value}, - previousReportField: reportField, - policy: policy as unknown as Policy, - isASAPSubmitBetaEnabled, - accountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, - email: session?.email ?? '', - hasViolationsParam: hasViolations, - recentlyUsedReportFields, - shouldFixViolations: hasOtherViolations ?? false, - isTrackIntentUser, - rules, - }); - } - goBack(); - } + saveReportField(reportField, value); + goBack(); }; const menuItems: PopoverMenuItem[] = []; diff --git a/src/pages/DynamicFlagCommentPage.tsx b/src/pages/DynamicFlagCommentPage.tsx index 234be63bfba6..c39ca57cf150 100644 --- a/src/pages/DynamicFlagCommentPage.tsx +++ b/src/pages/DynamicFlagCommentPage.tsx @@ -124,7 +124,7 @@ function DynamicFlagCommentPage({parentReportAction, report, parentReport, repor const flagComment = (severity: Severity) => { if (reportAction && canFlagReportAction(reportAction, reportID, currentUserAccountID)) { - flagCommentUtil(reportAction, severity, originalReport, isOriginalReportArchived); + flagCommentUtil(reportAction, severity, originalReport, isOriginalReportArchived, currentUserAccountID); } if (superWideRHPRouteKeys.length > 0 || wideRHPRouteKeys.length > 0) { diff --git a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx index 01a2561e242b..1695c596d829 100644 --- a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx +++ b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx @@ -19,6 +19,7 @@ import {useIsAppLoadPending} from '@hooks/useInFlightRequests'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useOpenRecipientPicker from '@hooks/useOpenRecipientPicker'; import usePermissions from '@hooks/usePermissions'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -200,6 +201,8 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec }, }); + useOpenRecipientPicker(); + // Open the confirmation modal after pendingPolicySelection is committed so the hook has the correct policyName useEffect(() => { if (!pendingPolicySelection) { diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsMenuLeaveItem.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsMenuLeaveItem.tsx index 754fd05565e4..beee59581027 100644 --- a/src/pages/DynamicReportDetailsPage/ReportDetailsMenuLeaveItem.tsx +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsMenuLeaveItem.tsx @@ -3,13 +3,14 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import useReportIsArchived from '@hooks/useReportIsArchived'; -import {isPolicyAdmin as isPolicyAdminUtil, isPolicyEmployee as isPolicyEmployeeUtil} from '@libs/PolicyUtils'; +import {isPolicyAdmin as isPolicyAdminUtil, isPolicyEmployee as isPolicyEmployeeUtil, isPolicyGuest as isPolicyGuestUtil} from '@libs/PolicyUtils'; import { canLeaveChat, findLastAccessedReport, @@ -24,6 +25,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {pendingChatMembersSelector} from '@selectors/ReportMetaData'; import React from 'react'; type ReportDetailsMenuLeaveItemProps = { @@ -37,6 +39,7 @@ function ReportDetailsMenuLeaveItem({reportID}: ReportDetailsMenuLeaveItemProps) const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); + const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {selector: pendingChatMembersSelector}); const [guideAccountIDs] = useOnyx(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); @@ -44,10 +47,11 @@ function ReportDetailsMenuLeaveItem({reportID}: ReportDetailsMenuLeaveItemProps) const [personalDetails] = useAllPersonalDetails(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails?.accountID; + const isAnonymousUser = useIsAnonymousUser(); const isReportArchived = useReportIsArchived(reportID); const {showConfirmModal} = useConfirmModal(); - const shouldShowLeaveButton = canLeaveChat(report, policy, currentUserAccountID, !!reportNameValuePairs?.private_isArchived); + const shouldShowLeaveButton = canLeaveChat(report, policy, currentUserAccountID, isAnonymousUser, !!reportNameValuePairs?.private_isArchived); if (!report || !shouldShowLeaveButton) { return null; @@ -55,11 +59,12 @@ function ReportDetailsMenuLeaveItem({reportID}: ReportDetailsMenuLeaveItemProps) const isPolicyAdmin = isPolicyAdminUtil(policy); const isPolicyEmployee = isPolicyEmployeeUtil(report.policyID, policy); + const isPolicyGuest = isPolicyGuestUtil(policy); const isRootGroupChat = isRootGroupChatUtil(report, isReportArchived); const leaveChat = () => { // Resolve on tap from the module-scoped copies so this large page does not subscribe to whole collections. - const lastAccessedReportID = findLastAccessedReport(false, guideAccountIDs, false, report.reportID)?.reportID; + const lastAccessedReportID = findLastAccessedReport(false, guideAccountIDs, isAnonymousUser, false, report.reportID)?.reportID; if (isRootGroupChat) { leaveGroupChat( report, @@ -74,7 +79,7 @@ function ReportDetailsMenuLeaveItem({reportID}: ReportDetailsMenuLeaveItemProps) return; } - const isWorkspaceMemberLeavingWorkspaceRoom = isWorkspaceMemberLeavingWorkspaceRoomUtil(report, isPolicyEmployee, isPolicyAdmin); + const isWorkspaceMemberLeavingWorkspaceRoom = isWorkspaceMemberLeavingWorkspaceRoomUtil(report, isPolicyEmployee, isPolicyAdmin, isPolicyGuest); leaveRoom(report, currentUserAccountID, conciergeReportID, introSelected, isSelfTourViewed, personalDetails, isWorkspaceMemberLeavingWorkspaceRoom, lastAccessedReportID); }; @@ -98,7 +103,7 @@ function ReportDetailsMenuLeaveItem({reportID}: ReportDetailsMenuLeaveItemProps) title={translate('common.leave')} icon={expensifyIcons.Exit} onPress={() => { - if (getParticipantsAccountIDsForDisplay(report, false, true).length === 1 && isRootGroupChat) { + if (getParticipantsAccountIDsForDisplay(report, reportMetadata, false, true).length === 1 && isRootGroupChat) { showLastMemberLeavingModal(); return; } diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsNameSection.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsNameSection.tsx index 3187363d7621..a34e85604d44 100644 --- a/src/pages/DynamicReportDetailsPage/ReportDetailsNameSection.tsx +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsNameSection.tsx @@ -1,5 +1,8 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import {useMenuItemConfig} from '@components/MenuItem/MenuItemContext'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -7,6 +10,8 @@ import useParentReportAction from '@hooks/useParentReportAction'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useThemeStyles from '@hooks/useThemeStyles'; +import convertToLTR from '@libs/convertToLTR'; +import {containsCustomEmoji, containsOnlyCustomEmoji} from '@libs/EmojiUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import { @@ -16,29 +21,27 @@ import { isExpenseReport as isExpenseReportUtil, isGroupChat as isGroupChatUtil, isInvoiceRoom as isInvoiceRoomUtil, - isInvoiceReport as isInvoiceReportUtil, - isMoneyRequest as isMoneyRequestUtil, - isMoneyRequestReport as isMoneyRequestReportUtil, isPolicyExpenseChat as isPolicyExpenseChatUtil, isThread as isThreadUtil, - isTrackExpenseReportNew as isTrackExpenseReportUtil, isUserCreatedPolicyRoom as isUserCreatedPolicyRoomUtil, isWorkspaceChat as isWorkspaceChatUtil, shouldDisableRename as shouldDisableRenameUtil, } from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; +import TextWithEmojiFragment from '@pages/inbox/report/comment/TextWithEmojiFragment'; + import {clearPolicyRoomNameErrors} from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Report} from '@src/types/onyx'; +import type {StyleProp, TextStyle} from 'react-native'; + import React from 'react'; import {View} from 'react-native'; -import getReportDetailsCaseID from './getReportDetailsCaseID'; -import {CASES} from './types'; import useReportDetailsReportName from './useReportDetailsReportName'; type ReportDetailsNameSectionProps = { @@ -49,42 +52,99 @@ type ReportDetailsNameSectionContentProps = { report: Report; }; -function ReportDetailsNameSectionContent({report}: ReportDetailsNameSectionContentProps) { - const reportID = report.reportID; +type ReportNameTextProps = { + name: string; + numberOfLines?: number; + style?: StyleProp; +}; + +function ReportNameText({name, numberOfLines, style}: ReportNameTextProps) { + const styles = useThemeStyles(); + const textStyle = [styles.flexShrink1, styles.popoverMenuText, styles.preWrap, styles.ltr, styles.breakWord, styles.mw100, styles.newKansasLarge, style]; + const message = convertToLTR(name); + + return ( + + {containsCustomEmoji(name) && !containsOnlyCustomEmoji(name) ? ( + + ) : ( + message + )} + + ); +} + +type CenteredReportNameContentProps = { + report: Report; + + /** Display name of the report, with line breaks already flattened to spaces */ + name: string; +}; + +/** The name as a centered header, with the chat it lives in underneath, for threads, workspace chats, trip rooms and other non-room chats */ +function CenteredReportNameContent({report, name}: CenteredReportNameContentProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const {isInteractive} = useMenuItemConfig(); + const isReportArchived = useReportIsArchived(report.reportID); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + + const isWorkspaceChat = isWorkspaceChatUtil(report.chatType ?? ''); + let subtitle = ''; + if (!isGroupChatUtil(report)) { + const chatRoomSubtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, rules, false, isReportArchived) ?? ''; + const isSubtitleBare = isExpenseReportUtil(report) || isPolicyExpenseChatUtil(report) || isInvoiceRoomUtil(report); + subtitle = chatRoomSubtitle && (isSubtitleBare ? chatRoomSubtitle : `${translate('threads.in')} ${chatRoomSubtitle}`); + } + + return ( + + {/* An interactive row shows a chevron, so this extends under the Row gap to center the name against the chevron's edge, not the gap's */} + + + {!!subtitle && ( + + {subtitle} + + )} + + + ); +} + +function ReportDetailsNameSectionContent({report}: ReportDetailsNameSectionContentProps) { + const reportID = report.reportID; + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`); const parentReportAction = useParentReportAction(report); const isReportArchived = useReportIsArchived(reportID); const {reportName} = useReportDetailsReportName(report, parentReport, parentReportAction); const isGroupChat = isGroupChatUtil(report); - const isThread = isThreadUtil(report); - const isWorkspaceChat = isWorkspaceChatUtil(report.chatType ?? ''); - const shouldDisableRename = shouldDisableRenameUtil(report, isReportArchived, policy); - const chatRoomSubtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, rules, false, isReportArchived) ?? ''; - const additionalRoomDetails = - isExpenseReportUtil(report) || isPolicyExpenseChatUtil(report) || isInvoiceRoomUtil(report) ? chatRoomSubtitle : `${translate('threads.in')} ${chatRoomSubtitle}`; - const caseID = getReportDetailsCaseID({ - isMoneyRequestReport: isMoneyRequestReportUtil(report), - isInvoiceReport: isInvoiceReportUtil(report), - isMoneyRequest: isMoneyRequestUtil(report), - isTrackExpenseReport: isTrackExpenseReportUtil(report, parentReport, parentReportAction), - }); - - let roomDescription: string | undefined; - if (caseID === CASES.MONEY_REQUEST) { - roomDescription = translate('common.name'); - } else if (isGroupChat) { - roomDescription = translate('newRoomPage.groupName'); - } else { - roomDescription = translate('newRoomPage.roomName'); - } + const canRename = !shouldDisableRenameUtil(report, isReportArchived, policy); + const name = StringUtils.lineBreaksToSpaces(reportName); - const shouldDisplayGroupWorkspaceAsPushRow = !isThread && (isGroupChat || isUserCreatedPolicyRoomUtil(report) || isDefaultRoomUtil(report)); + // Group chats and rooms show their name as a labeled field, every other report as a centered header + const isNameField = !isThreadUtil(report) && (isGroupChat || isUserCreatedPolicyRoomUtil(report) || isDefaultRoomUtil(report)); + const fieldName = translate(isGroupChat ? 'newRoomPage.groupName' : 'newRoomPage.roomName'); return ( clearPolicyRoomNameErrors(report.reportID)} > - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_SETTINGS_NAME.path)); - }} - numberOfLinesTitle={isThread ? 2 : 0} - shouldBreakWord - /> - {shouldDisplayGroupWorkspaceAsPushRow && !isGroupChat && ( - + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_SETTINGS_NAME.path)) : undefined} + accessibilityLabel={isNameField ? `${fieldName}, ${name}` : name} + > + + {isNameField ? ( + + + + ) : ( + + )} + {canRename && ( + + + + )} + + + {isNameField && !isGroupChat && ( + )} diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsTaskDeleteAction.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsTaskDeleteAction.tsx index 69f3bde3a436..db6a85c27062 100644 --- a/src/pages/DynamicReportDetailsPage/ReportDetailsTaskDeleteAction.tsx +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsTaskDeleteAction.tsx @@ -3,6 +3,7 @@ import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import useAncestors from '@hooks/useAncestors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useHasOutstandingChildTask from '@hooks/useHasOutstandingChildTask'; +import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -47,6 +48,7 @@ function ReportDetailsTaskDeleteActionContent({report, parentReport, parentRepor const taskDeleteBackTo = Navigation.getTopmostSearchReportRouteParams()?.backTo; const hasOutstandingChildTask = useHasOutstandingChildTask(report); const [guideAccountIDs] = useOnyx(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS); + const isAnonymousUser = useIsAnonymousUser(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [reportActionsForOriginalReportID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); @@ -68,7 +70,7 @@ function ReportDetailsTaskDeleteActionContent({report, parentReport, parentRepor { ancestors, shouldNavigateBack: !taskDeleteBackTo, - lastAccessedReportID: findLastAccessedReport(false, guideAccountIDs, false, report.reportID)?.reportID, + lastAccessedReportID: findLastAccessedReport(false, guideAccountIDs, isAnonymousUser, false, report.reportID)?.reportID, }, ); }; diff --git a/src/pages/DynamicReportParticipantsInvitePage.tsx b/src/pages/DynamicReportParticipantsInvitePage.tsx index 3f42a52441f1..e1a1ff3f9bdc 100644 --- a/src/pages/DynamicReportParticipantsInvitePage.tsx +++ b/src/pages/DynamicReportParticipantsInvitePage.tsx @@ -27,7 +27,7 @@ import {getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils'; import type {OptionData} from '@libs/PersonalDetailOptionsListUtils'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber'; import {getGroupChatName} from '@libs/ReportNameUtils'; -import {canInviteMembersToReport, getParticipantsAccountIDsForDisplay} from '@libs/ReportUtils'; +import {canInviteMembersToReport, getParticipantsAccountIDsForDisplay, getPendingDeleteMemberAccountIDs} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -37,7 +37,7 @@ import {accountIDSelector} from '@src/selectors/Session'; import type {InvitedEmailsToAccountIDs} from '@src/types/onyx'; import getEmptyArray from '@src/types/utils/getEmptyArray'; -import {pendingDeleteMemberAccountIDsSelector} from '@selectors/ReportMetaData'; +import {pendingChatMembersSelector} from '@selectors/ReportMetaData'; import React, {useEffect, useState} from 'react'; import type {WithReportOrNotFoundProps} from './inbox/report/withReportOrNotFound'; @@ -52,9 +52,10 @@ function DynamicReportParticipantsInvitePage({report}: DynamicReportParticipants const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); - const participantAccountIDs = getParticipantsAccountIDsForDisplay(report, false, true); + const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`, {selector: pendingChatMembersSelector}); + const participantAccountIDs = getParticipantsAccountIDsForDisplay(report, reportMetadata, false, true); const [participantLogins = getEmptyArray()] = usePersonalDetailsByIDs(participantAccountIDs, personalDetailsLoginsSelector(participantAccountIDs)); - const [pendingDeleteMemberAccountIDs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`, {selector: pendingDeleteMemberAccountIDsSelector}); + const pendingDeleteMemberAccountIDs = getPendingDeleteMemberAccountIDs(reportMetadata?.pendingChatMembers); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_PARTICIPANTS_INVITE.path); const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); diff --git a/src/pages/DynamicRoomInvitePage.tsx b/src/pages/DynamicRoomInvitePage.tsx index 8c3ca0cf27d6..ad7969830bb6 100644 --- a/src/pages/DynamicRoomInvitePage.tsx +++ b/src/pages/DynamicRoomInvitePage.tsx @@ -71,7 +71,7 @@ function DynamicRoomInvitePage({report, policy, didScreenTransitionEnd}: Dynamic const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`, {selector: pendingChatMembersSelector}); - const participantAccountIDs = getParticipantsAccountIDsForDisplay(report, false, true, undefined, reportMetadata); + const participantAccountIDs = getParticipantsAccountIDsForDisplay(report, reportMetadata, false, true); const [participantLogins = getEmptyArray()] = usePersonalDetailsByIDs(participantAccountIDs, personalDetailsLoginsSelector(participantAccountIDs)); const delegateAccountID = useDelegateAccountID(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); diff --git a/src/pages/DynamicRoomMemberDetailsPage.tsx b/src/pages/DynamicRoomMemberDetailsPage.tsx index bb0261e09fd0..caf5fe7634a8 100644 --- a/src/pages/DynamicRoomMemberDetailsPage.tsx +++ b/src/pages/DynamicRoomMemberDetailsPage.tsx @@ -11,6 +11,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -22,11 +23,12 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {RoomMembersNavigatorParamList} from '@libs/Navigation/types'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {isRoomMemberProtectedByPolicyRole} from '@libs/PolicyUtils'; -import {isPolicyExpenseChat} from '@libs/ReportUtils'; +import {isPolicyExpenseChat, isThreadMemberProtectedByParentReport} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {PersonalDetails} from '@src/types/onyx'; @@ -62,7 +64,12 @@ function DynamicRoomMemberDetailsPage({report, route}: DynamicRoomMemberDetailsP // Check the selected member's own role on the policy, not the viewer's, and fail closed when their login is missing. // Kept in sync with the members list through the shared helper. const isSelectedMemberProtectedByPolicyRole = isRoomMemberProtectedByPolicyRole(policy, details.login, accountID); - const shouldDisableRemoveUser = (isPolicyExpenseChat(report) && isSelectedMemberProtectedByPolicyRole) || isSelectedMemberCurrentUser || isSelectedMemberOwner; + // Select only the derived boolean so this page doesn't re-render on every unrelated change to the parent report. + const [isSelectedMemberProtectedByParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`, { + selector: (parentReport) => isThreadMemberProtectedByParentReport(parentReport, accountID), + }); + const shouldDisableRemoveUser = + (isPolicyExpenseChat(report) && isSelectedMemberProtectedByPolicyRole) || isSelectedMemberCurrentUser || isSelectedMemberOwner || !!isSelectedMemberProtectedByParentReport; const askForConfirmationToRemove = () => { showConfirmModal({ buttonVariant: CONST.BUTTON_VARIANT.DANGER, diff --git a/src/pages/DynamicRoomMembersPage.tsx b/src/pages/DynamicRoomMembersPage.tsx index c2d72666f3cb..0dff3b062f9f 100644 --- a/src/pages/DynamicRoomMembersPage.tsx +++ b/src/pages/DynamicRoomMembersPage.tsx @@ -11,6 +11,7 @@ import type {TableHandle} from '@components/Table'; import type {RoomMemberRowData, RoomMembersTableColumnKey} from '@components/Tables/RoomMembersTable'; import RoomMembersTable from '@components/Tables/RoomMembersTable'; +import {useMentionedAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; import useConfirmModal from '@hooks/useConfirmModal'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; @@ -45,6 +46,7 @@ import { isChatThread, isDefaultRoom, isPolicyExpenseChat as isPolicyExpenseChatUtils, + isThreadMemberProtectedByParentReport, isUserCreatedPolicyRoom, } from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; @@ -92,6 +94,7 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { const reportForSubtitle = useMemo(() => getReportForHeader(report, parentReport), [report, parentReport]); const derivedSubtitleReportName = useDerivedReportNameByReportID(reportForSubtitle?.reportID); const subtitleReportName = getReportName(reportForSubtitle, derivedSubtitleReportName); + const accountIDToName = useMentionedAccountIDToNameMap(subtitleReportName); const {chatParticipants: participants, personalDetailsParticipants} = useMemo( () => getReportPersonalDetailsParticipants(report, personalDetails, reportMetadata, true), @@ -223,7 +226,8 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { (isPolicyExpenseChat && isProtectedByPolicyRole) || accountID === session?.accountID || pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || - details.accountID === report.ownerAccountID; + details.accountID === report.ownerAccountID || + isThreadMemberProtectedByParentReport(parentReport, accountID); result.push({ keyForList: String(accountID), @@ -250,6 +254,7 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { isPolicyExpenseChat, localeCompare, openRoomMemberDetails, + parentReport, participants, personalDetails, policy, @@ -357,7 +362,7 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { > { if (isMobileSelectionModeEnabled) { clearTableSelection(); diff --git a/src/pages/EditReportFieldDropdown.tsx b/src/pages/EditReportFieldDropdown.tsx index 038d01ca64de..166332ce0d83 100644 --- a/src/pages/EditReportFieldDropdown.tsx +++ b/src/pages/EditReportFieldDropdown.tsx @@ -5,6 +5,7 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; import {getHeaderMessageForNonUserList} from '@libs/OptionsListUtils'; import {getReportFieldOptionsSection} from '@libs/ReportFieldOptionsListUtils'; @@ -25,13 +26,39 @@ type EditReportFieldDropdownPageProps = { /** Callback to fire when the Save button is pressed */ onSubmit: (form: Record) => void; + + /** Whether the search input is shown. Callers with a short list hide it, since there is little to search through. */ + shouldShowTextInput?: boolean; + + /** Whether the "Recent" section is shown above the full option list */ + shouldShowRecentlyUsedOptions?: boolean; + + /** Whether the selected value is lifted to the top of the list. Callers with a short list leave it in place, so the order doesn't change as the value does. */ + shouldPinSelectedOption?: boolean; + + /** Whether the "Recent" and "All" section titles are rendered */ + shouldShowSectionTitles?: boolean; + + /** Whether options use the shorter 52px row the Spend dropdowns use, instead of the default 64px page row */ + shouldUseCompactRows?: boolean; }; -function EditReportFieldDropdown({onSubmit, fieldKey, fieldValue, fieldOptions}: EditReportFieldDropdownPageProps) { +function EditReportFieldDropdown({ + onSubmit, + fieldKey, + fieldValue, + fieldOptions, + shouldShowTextInput = true, + shouldShowRecentlyUsedOptions = true, + shouldPinSelectedOption = true, + shouldShowSectionTitles = true, + shouldUseCompactRows = false, +}: EditReportFieldDropdownPageProps) { + const styles = useThemeStyles(); const [recentlyUsedReportFields] = useOnyx(ONYXKEYS.RECENTLY_USED_REPORT_FIELDS); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const {translate, localeCompare} = useLocalize(); - const recentlyUsedOptions = recentlyUsedReportFields?.[fieldKey]?.sort(localeCompare) ?? []; + const recentlyUsedOptions = shouldShowRecentlyUsedOptions ? (recentlyUsedReportFields?.[fieldKey]?.sort(localeCompare) ?? []) : []; const validFieldOptions = fieldOptions?.filter((option) => !!option)?.sort(localeCompare); @@ -39,7 +66,7 @@ function EditReportFieldDropdown({onSubmit, fieldKey, fieldValue, fieldOptions}: // The live value still drives the checkmark, so tapping a row marks it without reordering the list. The reorder happens only on reopen. const initialFieldValue = useInitialSelection(fieldValue, {resetOnFocus: true}); - const sections = getReportFieldOptionsSection({ + const optionSections = getReportFieldOptionsSection({ searchValue: debouncedSearchValue, // Live value drives the checkmark, so tapping a row marks it immediately. selectedOptions: [ @@ -54,8 +81,16 @@ function EditReportFieldDropdown({onSubmit, fieldKey, fieldValue, fieldOptions}: options: validFieldOptions, recentlyUsedOptions, translate, + shouldPinSelectedOption, + shouldShowSectionTitles, }); + // Same override the Spend single-select dropdowns use to shorten the default 64px option row. It also makes the rows + // match the 52px `getSelectionListPopoverHeight` already assumes, so the popover stops being sized for less content + // than it holds. Without vertical padding a two line title still fits the row height. + const compactRowStyle = [styles.optionRowCompactMinHeight, styles.pv0]; + const sections = shouldUseCompactRows ? optionSections.map((section) => ({...section, data: section.data.map((option) => ({...option, itemStyle: compactRowStyle}))})) : optionSections; + const policyReportFieldData = sections.at(0)?.data ?? []; const textInputOptions = { @@ -63,15 +98,21 @@ function EditReportFieldDropdown({onSubmit, fieldKey, fieldValue, fieldOptions}: label: translate('common.search'), onChangeText: setSearchValue, headerMessage: getHeaderMessageForNonUserList(translate, policyReportFieldData.length > 0, debouncedSearchValue), + // Nothing is rendered above the search input here, so it needs the top padding the surrounding page or popover doesn't provide. + style: {containerStyle: styles.pt3}, }; return ( onSubmit({[fieldKey]: !option?.text || fieldValue === option.text ? '' : option.text})} + // Re-selecting the value the field already holds submits that same value rather than an empty string. + // Both call sites compare the submitted value against the stored one and skip the save themselves, so + // signalling "no change" this way is no longer needed and leaves '' free to mean "cleared". + onSelectRow={(option) => onSubmit({[fieldKey]: option?.text ?? ''})} initiallyFocusedItemKey={initialFieldValue} shouldUpdateFocusedIndex /> diff --git a/src/pages/EnablePayments/Pay/Activate/ActivateStep.tsx b/src/pages/EnablePayments/Pay/Activate/ActivateStep.tsx index aca6ca5a4d90..eb113bdd8ef4 100644 --- a/src/pages/EnablePayments/Pay/Activate/ActivateStep.tsx +++ b/src/pages/EnablePayments/Pay/Activate/ActivateStep.tsx @@ -1,4 +1,5 @@ import ConfirmationPage from '@components/ConfirmationPage'; +import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {KYCWallContext} from '@components/KYCWall/KYCWallContext'; import LottieAnimations from '@components/LottieAnimations'; @@ -40,14 +41,21 @@ function ActivateStep({userWallet}: ActivateStepProps) { return ( <> - continueSetup(kycWallRef)} - /> + + + + {translate(`activateStep.${isActivatedWallet ? 'activated' : 'checkBackLater'}Title`)} + {translate(`activateStep.${isActivatedWallet ? 'activated' : 'checkBackLater'}Message`)} + + {!!isActivatedWallet && ( + + continueSetup(kycWallRef)} + /> + + )} + ); } diff --git a/src/pages/EnablePayments/Pay/Onfido/OnfidoStep.tsx b/src/pages/EnablePayments/Pay/Onfido/OnfidoStep.tsx index 5ea6e9a71b2d..3feaac42180c 100644 --- a/src/pages/EnablePayments/Pay/Onfido/OnfidoStep.tsx +++ b/src/pages/EnablePayments/Pay/Onfido/OnfidoStep.tsx @@ -51,7 +51,7 @@ function OnfidoStep() { }, []); const reportError = useCallback(() => { - Growl.error(translate('onfidoStep.genericError'), 10000); + Growl.error(translate('onfidoStep.genericError'), {duration: 10000}); }, [translate]); const verifyIdentity = useCallback( diff --git a/src/pages/EnablePayments/Wallet/VerifyIdentity/VerifyIdentity.tsx b/src/pages/EnablePayments/Wallet/VerifyIdentity/VerifyIdentity.tsx index 175b1ee31f9e..9c5c254eb16a 100644 --- a/src/pages/EnablePayments/Wallet/VerifyIdentity/VerifyIdentity.tsx +++ b/src/pages/EnablePayments/Wallet/VerifyIdentity/VerifyIdentity.tsx @@ -54,7 +54,7 @@ function VerifyIdentity() { const shouldShowOnfido = walletOnfidoData?.hasAcceptedPrivacyPolicy && !walletOnfidoData?.isLoading && !walletOnfidoData?.errors && !!walletOnfidoData?.sdkToken; const handleOnfidoError = () => { - Growl.error(translate('onfidoStep.genericError'), ONFIDO_ERROR_DISPLAY_DURATION); + Growl.error(translate('onfidoStep.genericError'), {duration: ONFIDO_ERROR_DISPLAY_DURATION}); }; const goBack = () => { diff --git a/src/pages/EnablePayments/shared/useWalletPhoneValidateCode.ts b/src/pages/EnablePayments/shared/useWalletPhoneValidateCode.ts index 5c266c0cb7c3..a77744673f13 100644 --- a/src/pages/EnablePayments/shared/useWalletPhoneValidateCode.ts +++ b/src/pages/EnablePayments/shared/useWalletPhoneValidateCode.ts @@ -4,6 +4,7 @@ import type {UpdatePersonalDetailsForWalletParams} from '@libs/API/parameters'; import Navigation from '@libs/Navigation/Navigation'; import {parsePhoneNumber} from '@libs/PhoneNumber'; +import {setDraftValues} from '@userActions/FormActions'; import {updatePersonalDetails} from '@userActions/Wallet'; import CONST from '@src/CONST'; @@ -32,6 +33,19 @@ function useWalletPhoneValidateCode(getValidateCodeRoute: () => Route = ROUTES.S const normalizedStoredPhoneNumber = (storedPhoneNumber && parsePhoneNumber(storedPhoneNumber, {regionCode: CONST.COUNTRY.US}).number?.significant) ?? ''; const isSettingPhoneNumber = !!personalDetails.phoneNumber && personalDetails.phoneNumber !== normalizedStoredPhoneNumber; if (isSettingPhoneNumber) { + // The validateCode screen rebuilds the details from the form draft, which only holds the fields the user + // edited. Save everything being submitted so pre-filled values (e.g. the legal name) are not sent empty. + setDraftValues(ONYXKEYS.FORMS.WALLET_ADDITIONAL_DETAILS, { + legalFirstName: personalDetails.legalFirstName, + legalLastName: personalDetails.legalLastName, + addressStreet: personalDetails.addressStreet, + addressCity: personalDetails.addressCity, + addressState: personalDetails.addressState, + addressZipCode: personalDetails.addressZip, + dob: personalDetails.dob, + ssn: personalDetails.ssn, + phoneNumber: personalDetails.phoneNumber, + }); Navigation.navigate(getValidateCodeRoute()); return; } diff --git a/src/pages/GroupChatNameEditPage.tsx b/src/pages/GroupChatNameEditPage.tsx index 8f1666aab12b..9198f70683fb 100644 --- a/src/pages/GroupChatNameEditPage.tsx +++ b/src/pages/GroupChatNameEditPage.tsx @@ -50,7 +50,6 @@ function GroupChatNameEditPage({report}: GroupChatNameEditPageProps) { const existingReportName = report ? getGroupChatName(formatPhoneNumber, translate, undefined, false, report, pendingDeleteMemberAccountIDs) : getGroupChatName(formatPhoneNumber, translate, groupChatDraft?.participants); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const currentChatName = reportID ? existingReportName : groupChatDraft?.reportName || existingReportName; const validate = (values: FormOnyxValues): Errors => { diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx index f4673a15d8b0..e7ba7d3806e7 100644 --- a/src/pages/Insights/InsightsDashboard.tsx +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -1,7 +1,9 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import TabRootScrollViewMarker from '@components/Navigation/TabBarBottomContent/TabRootScrollViewMarker'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import TopBar from '@components/Navigation/TopBar'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -15,6 +17,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -31,6 +34,7 @@ import React, {useEffect, useEffectEvent} from 'react'; import {View} from 'react-native'; import type {InsightsFilters} from './insightsFilters'; +import type {InsightsChartQuery} from './insightsQueries'; import type {InsightsDashboardChart, InsightsDashboardState} from './resolveDashboardState'; import InsightsChartWidget from './charts/InsightsChartWidget'; @@ -66,6 +70,13 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['OfflineCloud']); + const triggerScrollEvent = useScrollEventEmitter(); + const isEmptyState = state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES || state === INSIGHTS_DASHBOARD_STATE.EMPTY; + const scrollProps = useTabRootScrollProps(isEmptyState ? [styles.flexGrow1, styles.flexShrink0] : [styles.flexGrow1, pageGutter, styles.pb5], { + hasBottomSafeAreaPadding: true, + hasOfflineIndicatorPadding: true, + fillsVisibleArea: isEmptyState, + }); const didRequestFail = state === INSIGHTS_DASHBOARD_STATE.ERROR || state === INSIGHTS_DASHBOARD_STATE.STALE; @@ -107,14 +118,16 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte ); } - if (state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES || state === INSIGHTS_DASHBOARD_STATE.EMPTY) { + if (isEmptyState) { return ( - - {state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES ? : } - + + + {state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES ? : } + + ); } @@ -122,47 +135,59 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte const columns = shouldUseNarrowLayout ? [supportingCharts] : [supportingCharts.filter((chart, index) => index % 2 === 0), supportingCharts.filter((chart, index) => index % 2 === 1)]; return ( - - - - - {columns.map((columnCharts, columnIndex) => ( - - {columnCharts.map(({chart, queryJSON, snapshot}) => ( - - ))} - - ))} + + + + + + {columns.map((columnCharts, columnIndex) => ( + + {columnCharts.map(({chart, queryJSON, snapshot, previousPeriodQueryJSON, previousPeriodSnapshot, averageQueryJSON, averageSnapshot}) => ( + + ))} + + ))} + - - + + ); } function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { const {translate} = useLocalize(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.INSIGHTS); const {isOffline} = useNetwork(); const isFocused = useIsFocused(); const {login} = useCurrentUserPersonalDetails(); @@ -170,15 +195,15 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { const {filters, defaultFilters, isResolved, setFilters} = useInsightsFilters(dashboardID); const {isBetaEnabled} = usePermissions(); - const query = isResolved ? buildInsightsJsonQuery(dashboardID, filters, isBetaEnabled(CONST.BETAS.INSIGHTS_COMPARE)) : undefined; - const jsonQuery = query?.jsonQuery; + const isCompareEnabled = isBetaEnabled(CONST.BETAS.INSIGHTS_COMPARE); + const query = isResolved ? buildInsightsJsonQuery(dashboardID, filters, isCompareEnabled) : undefined; const hash = query?.hash; const requestDashboard = () => { - if (!query || !jsonQuery || hash === undefined || isOffline) { + if (!query || hash === undefined || isOffline) { return; } - getInsights(dashboardID, hash, jsonQuery, query.snapshotHashes); + getInsights(dashboardID, hash, query.jsonQuery, query.snapshotHashes); }; const onRequestConditionsChanged = useEffectEvent(() => { @@ -190,7 +215,8 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { return; } onRequestConditionsChanged(); - }, [dashboardID, jsonQuery, hash, isFocused, isOffline]); + // Leaves out jsonQuery so toggling Compare mode doesn't refetch, since every response covers it. Group by is listed because the hash leaves it out + }, [dashboardID, hash, filters.groupBy, isCompareEnabled, isFocused, isOffline]); const [dashboard] = useOnyx(`${ONYXKEYS.COLLECTION.INSIGHTS}${dashboardID}_${hash}`); const {headlineChart: headlineSpec, supportingCharts: supportingSpecs} = INSIGHTS_DASHBOARD_SPECS[dashboardID]; @@ -198,11 +224,17 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { // Until the filters resolve, the charts have no query to read data for, so they show their loading state const visibleChartQueries = query ? [query.headlineChart, ...query.supportingCharts.filter(({chart}) => eligibleCharts.includes(chart))] - : [headlineSpec, ...eligibleCharts].map((chart) => ({chart, queryJSON: undefined})); - const snapshots = useMultipleSnapshots(visibleChartQueries.flatMap(({queryJSON}) => (queryJSON ? [String(queryJSON.hash)] : []))); + : [headlineSpec, ...eligibleCharts].map((chart): InsightsChartQuery => ({chart, queryJSON: undefined})); + const snapshots = useMultipleSnapshots( + visibleChartQueries.flatMap(({queryJSON, previousPeriodQueryJSON, averageQueryJSON}) => + [queryJSON, previousPeriodQueryJSON, averageQueryJSON].flatMap((chartQueryJSON) => (chartQueryJSON ? [String(chartQueryJSON.hash)] : [])), + ), + ); const charts: InsightsDashboardChart[] = visibleChartQueries.map((chartQuery) => ({ ...chartQuery, snapshot: chartQuery.queryJSON ? snapshots[chartQuery.queryJSON.hash] : undefined, + previousPeriodSnapshot: chartQuery.previousPeriodQueryJSON ? snapshots[chartQuery.previousPeriodQueryJSON.hash] : undefined, + averageSnapshot: chartQuery.averageQueryJSON ? snapshots[chartQuery.averageQueryJSON.hash] : undefined, })); const [headlineChart, ...supportingCharts] = charts; const state = getDashboardState(dashboard, isOffline, charts); @@ -211,7 +243,7 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { } + {...tabRootScreenWrapperProps} testID="InsightsPage" > number; + translate: LocalizedTranslate; +}; + +const GROUP_HEADER_KEYS: Partial> = { + [CONST.SEARCH.GROUP_BY.FROM]: 'common.from', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'common.merchant', + [CONST.SEARCH.GROUP_BY.CATEGORY]: 'common.category', +}; + +function getPeriodStarts(groupBy: SearchGroupBy, interval: Interval): Date[] { + switch (groupBy) { + case CONST.SEARCH.GROUP_BY.DAY: + return eachDayOfInterval(interval); + case CONST.SEARCH.GROUP_BY.WEEK: + // Search weeks start on Sunday, regardless of the display locale. + return eachWeekOfInterval(interval, {weekStartsOn: 0}); + case CONST.SEARCH.GROUP_BY.MONTH: + return eachMonthOfInterval(interval); + case CONST.SEARCH.GROUP_BY.QUARTER: + return eachQuarterOfInterval(interval); + case CONST.SEARCH.GROUP_BY.YEAR: + return eachYearOfInterval(interval); + default: + return []; + } +} + +function buildInsightsChartCSV({data, groupBy, dateRange, currency, getCurrencyDecimals, translate}: BuildInsightsChartCSVParams): string { + const {getLabel, getDateRange} = CHART_GROUP_BY_CONFIG[groupBy]; + const groupHeader = translate(GROUP_HEADER_KEYS[groupBy] ?? 'common.date'); + const header = [groupHeader, translate('common.expenses'), translate('common.total'), translate('common.currency')].map(escapeCsvField).join(','); + + let rows = data.map((item) => ({ + label: getDateRange?.(item)?.start ?? getLabel(item), + count: item.count ?? 0, + total: item.total ?? 0, + currency: item.currency ?? currency, + })); + + if (getDateRange && dateRange.start && dateRange.end) { + const rowsByDate = new Map(rows.map((row) => [row.label, row])); + rows = getPeriodStarts(groupBy, {start: parseISO(dateRange.start), end: parseISO(dateRange.end)}).map((date) => { + const label = format(date, CONST.DATE.FNS_FORMAT_STRING); + return rowsByDate.get(label) ?? {label, count: 0, total: 0, currency}; + }); + } + + const csvRows = rows.map((row) => + [escapeCsvField(row.label), row.count, convertToFrontendAmountAsString(row.total, getCurrencyDecimals(row.currency)), escapeCsvField(row.currency)].join(','), + ); + + return `\uFEFF${[header, ...csvRows].join('\r\n')}`; +} + +export default buildInsightsChartCSV; diff --git a/src/pages/Insights/charts/InsightsChartWidget.tsx b/src/pages/Insights/charts/InsightsChartWidget.tsx index 783f855f3ab6..a55d7049c0c1 100644 --- a/src/pages/Insights/charts/InsightsChartWidget.tsx +++ b/src/pages/Insights/charts/InsightsChartWidget.tsx @@ -2,24 +2,28 @@ import {buildViewOnSpendQuery} from '@components/Search/chartDrillDown'; import ChartEmptyState from '@components/Search/ChartEmptyState'; import ChartErrorState from '@components/Search/ChartErrorState'; import ChartOfflineState from '@components/Search/ChartOfflineState'; -import useGroupedItems from '@components/Search/hooks/useGroupedItems'; import SearchChartView from '@components/Search/SearchChartView'; import type {SearchQueryJSON} from '@components/Search/types'; import WidgetContainer from '@components/WidgetContainer'; import WidgetHeaderMenu from '@components/WidgetHeaderMenu'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; +import localFileDownload from '@libs/localFileDownload'; import Navigation from '@libs/Navigation/Navigation'; -import {INSIGHTS_CHART_STATE, resolveInsightsChartData} from '@libs/resolveInsightsChartData'; +import {INSIGHTS_CHART_STATE} from '@libs/resolveInsightsChartData'; +import {getDateFilterRange} from '@libs/SearchQueryUtils'; +import buildInsightsChartCSV from '@pages/Insights/buildInsightsChartCSV'; import InsightsGroupByDropdown from '@pages/Insights/controls/InsightsGroupByDropdown'; import type {InsightsChartSpec} from '@pages/Insights/dashboardSpecs'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; +import {getInsightsGroupByOptions, getQueryGroupBy} from '@pages/Insights/insightsGroupByUtils'; +import useInsightsChartData from '@pages/Insights/useInsightsChartData'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -41,6 +45,18 @@ type InsightsChartWidgetProps = { /** The snapshot stored under the chart's own query */ snapshot: OnyxEntry; + /** The chart's query for the previous period */ + previousPeriodQueryJSON: Readonly | undefined; + + /** The snapshot stored under the chart's query for the previous period */ + previousPeriodSnapshot: OnyxEntry; + + /** The chart's query for the average amounts */ + averageQueryJSON: Readonly | undefined; + + /** The snapshot stored under the chart's query for the average amounts */ + averageSnapshot: OnyxEntry; + /** Page-level filters every chart on the dashboard is narrowed by */ filters: InsightsFilters; @@ -51,25 +67,49 @@ type InsightsChartWidgetProps = { onGroupByChange?: (groupBy: InsightsFilters['groupBy']) => void; }; -function InsightsChartWidget({chart, queryJSON, snapshot, filters, onRetry, onGroupByChange}: InsightsChartWidgetProps) { +function InsightsChartWidget({ + chart, + queryJSON, + snapshot, + previousPeriodQueryJSON, + previousPeriodSnapshot, + averageQueryJSON, + averageSnapshot, + filters, + onRetry, + onGroupByChange, +}: InsightsChartWidgetProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {cardPaddingHorizontal, cardPaddingBottom} = useLayoutSpacing(); - const icons = useMemoizedLazyExpensifyIcons(['Expand']); + const icons = useMemoizedLazyExpensifyIcons(['Expand', 'Table']); + const {getCurrencyDecimals} = useCurrencyListActions(); - const {isOffline} = useNetwork(); - const sortedData = useGroupedItems(snapshot, queryJSON); - const {data, state} = resolveInsightsChartData({snapshot, queryJSON, sortedData, isOffline}); - const groupBy = chart.groupBy ?? filters.groupBy; + const {data, state, comparison} = useInsightsChartData({chart, queryJSON, snapshot, previousPeriodQueryJSON, previousPeriodSnapshot, averageQueryJSON, averageSnapshot}, filters); + const groupBy = chart.groupBy ?? getQueryGroupBy(filters); const isLoading = state === INSIGHTS_CHART_STATE.LOADING; - const shouldShowTable = chart.view === CONST.SEARCH.VIEW.BAR || chart.view === CONST.SEARCH.VIEW.PIE; - - const groupByControl = onGroupByChange ? ( - - ) : null; + const view = comparison && chart.view === CONST.SEARCH.VIEW.PIE ? CONST.SEARCH.VIEW.BAR : chart.view; + const shouldShowTable = view === CONST.SEARCH.VIEW.BAR || view === CONST.SEARCH.VIEW.PIE; + + const groupByOptions = getInsightsGroupByOptions(filters.date); + + const downloadCSV = () => { + if (!queryJSON || state !== INSIGHTS_CHART_STATE.READY) { + return; + } + + const content = buildInsightsChartCSV({data, groupBy, dateRange: getDateFilterRange(queryJSON), currency: filters.groupCurrency, getCurrencyDecimals, translate}); + localFileDownload(`Expensify-${chart.graphKey}.csv`, content, translate); + }; + + const groupByControl = + onGroupByChange && groupByOptions.length > 1 ? ( + + ) : null; const headerMenu = !!queryJSON && (state === INSIGHTS_CHART_STATE.READY || isLoading) ? ( @@ -84,6 +124,16 @@ function InsightsChartWidget({chart, queryJSON, snapshot, filters, onRetry, onGr onSelected: () => Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: buildViewOnSpendQuery(queryJSON)})), shouldCallAfterModalHide: true, }, + ...(state === INSIGHTS_CHART_STATE.READY + ? [ + { + text: translate('spreadsheet.downloadCSV'), + icon: icons.Table, + onSelected: downloadCSV, + shouldCallAfterModalHide: true, + }, + ] + : []), ]} /> ) : null; @@ -107,10 +157,11 @@ function InsightsChartWidget({chart, queryJSON, snapshot, filters, onRetry, onGr - {rows.map(({item, point}) => ( - - - {isMemberGroup(item) ? ( - - - - ) : ( - - )} - {point.label} + {rows.map(({item, point}) => { + // Only time buckets have points without a group, and the table lists ranked groups + if (!item) { + return null; + } + const primaryValue = point.values[CHART_SERIES_KEY.PRIMARY]; + const comparedValue = point.values[CHART_SERIES_KEY.COMPARISON]; + const hasChange = primaryValue !== undefined && comparedValue !== undefined && primaryValue !== comparedValue; + const changePercentage = hasChange ? formatChangePercentage(primaryValue, comparedValue, preferredLocale) : undefined; + const isIncrease = hasChange && primaryValue > comparedValue; + const isNewSpend = comparedValue === 0; + const changeColor = StyleUtils.getSpendChangeColor(isIncrease); + + return ( + + + {isMemberGroup(item) ? ( + + + + ) : ( + + )} + {point.label} + + + {convertToDisplayString(item.total ?? 0, item.currency)} + {!!changePercentage && isNewSpend && ( + + {changePercentage} + + )} + {!!changePercentage && !isNewSpend && ( + + + {changePercentage} + + )} + - {convertToDisplayString(item.total ?? 0, item.currency)} - - ))} + ); + })} ); } diff --git a/src/pages/Insights/controls/InsightsGroupByDropdown.tsx b/src/pages/Insights/controls/InsightsGroupByDropdown.tsx index cdab886e72b7..3f60916ea578 100644 --- a/src/pages/Insights/controls/InsightsGroupByDropdown.tsx +++ b/src/pages/Insights/controls/InsightsGroupByDropdown.tsx @@ -7,7 +7,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; -import DEFAULT_INSIGHTS_FILTERS, {INSIGHTS_GROUP_BY_OPTIONS} from '@pages/Insights/insightsFilters'; +import DEFAULT_INSIGHTS_FILTERS from '@pages/Insights/insightsFilters'; import variables from '@styles/variables'; @@ -21,14 +21,17 @@ type InsightsGroupByDropdownProps = { /** Time bucket the headline chart aggregates into */ groupBy: InsightsFilters['groupBy']; + /** Options that fit the date range */ + options: Array; + onChange: (groupBy: InsightsFilters['groupBy']) => void; }; -function InsightsGroupByDropdown({groupBy, onChange}: InsightsGroupByDropdownProps) { +function InsightsGroupByDropdown({groupBy, options, onChange}: InsightsGroupByDropdownProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const items: Array> = INSIGHTS_GROUP_BY_OPTIONS.map((option) => ({ + const items: Array> = options.map((option) => ({ text: translate(`search.filters.groupBy.${option}`), value: option, })); diff --git a/src/pages/Insights/insightsCompare.ts b/src/pages/Insights/insightsCompare.ts new file mode 100644 index 000000000000..bd0ccbf79cbd --- /dev/null +++ b/src/pages/Insights/insightsCompare.ts @@ -0,0 +1,49 @@ +import type {ChartComparisonPeriods, ChartPeriod} from '@components/Search/comparisonBuckets'; + +import {getDateRangeForPreset} from '@libs/SearchQueryUtils'; + +import CONST from '@src/CONST'; + +import {differenceInCalendarDays, differenceInCalendarMonths, format, parseISO, startOfYear, subDays, subMonths, subYears} from 'date-fns'; + +import type {InsightsFilters} from './insightsFilters'; + +/** Formats a period's first and last day */ +function formatPeriod(start: Date, end: Date): ChartPeriod { + return { + start: format(start, CONST.DATE.FNS_FORMAT_STRING), + end: format(end, CONST.DATE.FNS_FORMAT_STRING), + }; +} + +/** Resolves the period the Date filter covers and the one before it, which decide how buckets pair up and what each series is named. The backend picks the compared rows itself. */ +function resolveComparisonPeriods(dateFilter: InsightsFilters['date']): ChartComparisonPeriods | undefined { + let primary; + if ('preset' in dateFilter) { + primary = getDateRangeForPreset(dateFilter.preset); + } else { + primary = 'on' in dateFilter ? {start: dateFilter.on, end: dateFilter.on} : {start: dateFilter.from, end: dateFilter.to}; + } + if (!primary.start || !primary.end) { + return undefined; + } + const start = parseISO(primary.start); + const end = parseISO(primary.end); + + // The year so far compares against the same days of the year before. + if ('preset' in dateFilter && dateFilter.preset === CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE) { + const sameDayLastYear = subYears(end, 1); + return {primary, comparison: formatPeriod(startOfYear(sameDayLastYear), sameDayLastYear), monthsApart: 12}; + } + + // The other presets cover whole months and compare month for month. + if ('preset' in dateFilter) { + const monthsApart = differenceInCalendarMonths(end, start) + 1; + return {primary, comparison: formatPeriod(subMonths(start, monthsApart), subDays(start, 1)), monthsApart}; + } + + // A custom range compares against the same number of days just before. + return {primary, comparison: formatPeriod(subDays(start, differenceInCalendarDays(end, start) + 1), subDays(start, 1))}; +} + +export default resolveComparisonPeriods; diff --git a/src/pages/Insights/insightsGroupByUtils.ts b/src/pages/Insights/insightsGroupByUtils.ts new file mode 100644 index 000000000000..f34a944b3a2d --- /dev/null +++ b/src/pages/Insights/insightsGroupByUtils.ts @@ -0,0 +1,66 @@ +import {getDateRangeForPreset} from '@libs/SearchQueryUtils'; + +import CONST from '@src/CONST'; + +import {addDays, differenceInCalendarDays, differenceInMonths, parseISO} from 'date-fns'; + +import type {InsightsFilters} from './insightsFilters'; + +import {INSIGHTS_GROUP_BY_OPTIONS} from './insightsFilters'; + +type InsightsGroupBy = InsightsFilters['groupBy']; + +const MAX_DAYS_GROUPED_BY_DAY = 62; +const MIN_DAYS_GROUPED_BY_WEEK = 14; +const MIN_MONTHS_BY_GROUP_BY = { + [CONST.SEARCH.GROUP_BY.MONTH]: 2, + [CONST.SEARCH.GROUP_BY.QUARTER]: 6, + [CONST.SEARCH.GROUP_BY.YEAR]: 24, +} as const; + +/** Returns the Group by options that fit the date range, in the dropdown's order */ +function getInsightsGroupByOptions(date: InsightsFilters['date']): InsightsGroupBy[] { + if ('on' in date) { + return [CONST.SEARCH.GROUP_BY.DAY]; + } + + const boundaries = 'from' in date ? {start: date.from, end: date.to} : getDateRangeForPreset(date.preset); + + // Presets like "never" have no fixed range, so there's no length to fit a grouping to + if (!boundaries.start || !boundaries.end) { + return []; + } + + const start = parseISO(boundaries.start); + const end = parseISO(boundaries.end); + const days = differenceInCalendarDays(end, start) + 1; + const months = differenceInMonths(addDays(end, 1), start); + + return INSIGHTS_GROUP_BY_OPTIONS.filter((option) => { + switch (option) { + case CONST.SEARCH.GROUP_BY.DAY: + return days <= MAX_DAYS_GROUPED_BY_DAY; + case CONST.SEARCH.GROUP_BY.WEEK: + return days >= MIN_DAYS_GROUPED_BY_WEEK; + default: + return months >= MIN_MONTHS_BY_GROUP_BY[option]; + } + }); +} + +/** Keeps the Group by when the options include it, otherwise picks the nearest option they do include. */ +function getFittingGroupBy(groupBy: InsightsGroupBy, options: InsightsGroupBy[]): InsightsGroupBy { + if (options.length === 0 || options.includes(groupBy)) { + return groupBy; + } + + const getDistanceFromSelection = (option: InsightsGroupBy) => Math.abs(INSIGHTS_GROUP_BY_OPTIONS.indexOf(option) - INSIGHTS_GROUP_BY_OPTIONS.indexOf(groupBy)); + return options.reduce((nearest, option) => (getDistanceFromSelection(option) < getDistanceFromSelection(nearest) ? option : nearest)); +} + +/** Returns the Group by the charts are queried with, fitted to the date range. */ +function getQueryGroupBy(filters: InsightsFilters): InsightsGroupBy { + return getFittingGroupBy(filters.groupBy, getInsightsGroupByOptions(filters.date)); +} + +export {getFittingGroupBy, getInsightsGroupByOptions, getQueryGroupBy}; diff --git a/src/pages/Insights/insightsQueries.ts b/src/pages/Insights/insightsQueries.ts index fe61577ac23b..a27f0e3ba234 100644 --- a/src/pages/Insights/insightsQueries.ts +++ b/src/pages/Insights/insightsQueries.ts @@ -10,9 +10,10 @@ import type {InsightsChartSpec} from './dashboardSpecs'; import type {InsightsFilters} from './insightsFilters'; import INSIGHTS_DASHBOARD_SPECS from './dashboardSpecs'; +import {getQueryGroupBy} from './insightsGroupByUtils'; -/** How many periods before the selected date range the Average mode spans (Typical on the ranking charts) */ -const COMPARE_TYPICAL_PERIOD_COUNT = 1; +/** How many periods before the selected date range the Average mode spans */ +const COMPARE_AVERAGE_PERIOD_COUNT = 1; /** Builds the date filter in the shape a search query is built from. */ function buildDateFormValues(date: InsightsFilters['date']): Partial { @@ -42,7 +43,7 @@ function buildInsightsQueryString(filters: InsightsFilters): SearchQueryString { /** Builds a chart's query with the page's filters applied. */ function applyInsightsFilters(chart: InsightsChartSpec, filters: InsightsFilters, compare?: SearchCompareMode): SearchQueryString { return buildQueryStringFromFilterFormValues( - {...buildFilterFormValues(filters), groupBy: chart.groupBy ?? filters.groupBy, view: chart.view, compare}, + {...buildFilterFormValues(filters), groupBy: chart.groupBy ?? getQueryGroupBy(filters), view: chart.view, compare}, {sortBy: chart.sortBy, sortOrder: chart.sortOrder, limit: chart.limit}, ); } @@ -50,6 +51,8 @@ function applyInsightsFilters(chart: InsightsChartSpec, filters: InsightsFilters type InsightsChartQuery = { chart: InsightsChartSpec; queryJSON: Readonly | undefined; + previousPeriodQueryJSON?: Readonly; + averageQueryJSON?: Readonly; }; type InsightsGraphHashes = { @@ -60,7 +63,7 @@ type InsightsGraphHashes = { type InsightsGraphHashEntry = [InsightsGraphKey, InsightsGraphHashes]; /** Returns the chart's graph slot paired with the hashes its current period, previous period and average are stored under. */ -function buildSnapshotHashEntry({chart, queryJSON}: InsightsChartQuery, filters: InsightsFilters, shouldIncludeComparisons: boolean): InsightsGraphHashEntry | undefined { +function buildSnapshotHashEntry({chart, queryJSON, previousPeriodQueryJSON, averageQueryJSON}: InsightsChartQuery, shouldIncludeComparisons: boolean): InsightsGraphHashEntry | undefined { const snapshotHash = queryJSON?.hash; if (!snapshotHash) { return undefined; @@ -69,8 +72,8 @@ function buildSnapshotHashEntry({chart, queryJSON}: InsightsChartQuery, filters: return [chart.graphKey, {snapshotHash}]; } - const previousPeriodSnapshotHash = buildSearchQueryJSON(applyInsightsFilters(chart, filters, CONST.SEARCH.COMPARE.PREVIOUS_PERIOD))?.hash; - const averageSnapshotHash = buildSearchQueryJSON(applyInsightsFilters(chart, filters, CONST.SEARCH.COMPARE.AVERAGE))?.hash; + const previousPeriodSnapshotHash = previousPeriodQueryJSON?.hash; + const averageSnapshotHash = averageQueryJSON?.hash; if (!previousPeriodSnapshotHash || !averageSnapshotHash) { return undefined; } @@ -94,18 +97,26 @@ type InsightsQuery = { /** Builds one request for the whole dashboard, naming each graph's snapshots by its chart's query hashes, and returns those chart queries to read the snapshots back with. */ function buildInsightsJsonQuery(dashboard: InsightsDashboardID, filters: InsightsFilters, shouldIncludeComparisons: boolean): InsightsQuery | undefined { - const inputQuery = buildInsightsQueryString({...filters, compare: undefined}); - const queryJSON = buildSearchQueryJSON(inputQuery); + // The backend saves inputQuery as the dashboard's filters, so it keeps the Group by the user picked, not the one fitted to the date range + const inputQuery = buildInsightsQueryString(filters); + const queryJSON = buildSearchQueryJSON(buildInsightsQueryString({...filters, groupBy: getQueryGroupBy(filters), compare: undefined})); if (!queryJSON) { return undefined; } const spec = INSIGHTS_DASHBOARD_SPECS[dashboard]; - const buildChartQuery = (chart: InsightsChartSpec): InsightsChartQuery => ({chart, queryJSON: buildSearchQueryJSON(applyInsightsFilters(chart, filters))}); + const buildChartQuery = (chart: InsightsChartSpec): InsightsChartQuery => ({ + chart, + queryJSON: buildSearchQueryJSON(applyInsightsFilters(chart, filters)), + ...(shouldIncludeComparisons && { + previousPeriodQueryJSON: buildSearchQueryJSON(applyInsightsFilters(chart, filters, CONST.SEARCH.COMPARE.PREVIOUS_PERIOD)), + averageQueryJSON: buildSearchQueryJSON(applyInsightsFilters(chart, filters, CONST.SEARCH.COMPARE.AVERAGE)), + }), + }); const headlineChart = buildChartQuery(spec.headlineChart); const supportingCharts = spec.supportingCharts.map(buildChartQuery); const graphEntries = [headlineChart, ...supportingCharts] - .map((chartQuery) => buildSnapshotHashEntry(chartQuery, filters, shouldIncludeComparisons)) + .map((chartQuery) => buildSnapshotHashEntry(chartQuery, shouldIncludeComparisons)) .filter((entry): entry is InsightsGraphHashEntry => !!entry); const insightsHashes: InsightsDashboard['graphs'] = Object.fromEntries(graphEntries); @@ -117,7 +128,7 @@ function buildInsightsJsonQuery(dashboard: InsightsDashboardID, filters: Insight inputQuery, searchKey: spec.searchKey, insightsHashes, - numberOfPeriods: shouldIncludeComparisons ? COMPARE_TYPICAL_PERIOD_COUNT : undefined, + numberOfPeriods: shouldIncludeComparisons ? COMPARE_AVERAGE_PERIOD_COUNT : undefined, }), hash: queryJSON.hash, snapshotHashes: graphEntries.flatMap(([, hashes]) => Object.values(hashes).filter((hash): hash is number => hash !== undefined)), diff --git a/src/pages/Insights/resolveDashboardState.ts b/src/pages/Insights/resolveDashboardState.ts index 65be897e50c9..23b112b5a9ed 100644 --- a/src/pages/Insights/resolveDashboardState.ts +++ b/src/pages/Insights/resolveDashboardState.ts @@ -1,3 +1,4 @@ +import {hasGroupWithExpenses} from '@libs/resolveInsightsChartData'; import {isGroupEntry, isSearchDataLoaded} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -24,6 +25,8 @@ type InsightsDashboardState = ValueOf; type InsightsDashboardChart = InsightsChartQuery & { snapshot: OnyxEntry; + previousPeriodSnapshot?: OnyxEntry; + averageSnapshot?: OnyxEntry; }; /** Resolves the page's state from the dashboard record and the snapshot of every chart on screen. */ @@ -32,7 +35,13 @@ function getDashboardState(dashboard: OnyxEntry, isOffline: b const hasDashboardResponse = !!dashboard?.inputQuery; const chartStates = charts.map(({snapshot, queryJSON}) => { const isLoaded = isSearchDataLoaded(snapshot, queryJSON) && isEmptyObject(snapshot?.errors); - return {isLoaded, hasRows: isLoaded && Object.keys(snapshot?.data ?? {}).some(isGroupEntry)}; + const data = snapshot?.data; + const groups = data + ? Object.keys(data) + .filter(isGroupEntry) + .map((key) => data[key]) + : []; + return {isLoaded, hasRows: isLoaded && hasGroupWithExpenses(groups)}; }); const hasLoadedChart = chartStates.some(({isLoaded}) => isLoaded); diff --git a/src/pages/Insights/useInsightsChartData.ts b/src/pages/Insights/useInsightsChartData.ts new file mode 100644 index 000000000000..b81bd72e4fe2 --- /dev/null +++ b/src/pages/Insights/useInsightsChartData.ts @@ -0,0 +1,92 @@ +import VictoryTheme from '@components/Charts/VictoryTheme'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import type {ChartComparison} from '@components/Search/buildChartSeries'; +import useGroupedItems from '@components/Search/hooks/useGroupedItems'; +import type {GroupedItem, SearchQueryJSON} from '@components/Search/types'; + +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePermissions from '@hooks/usePermissions'; +import useTheme from '@hooks/useTheme'; + +import {INSIGHTS_CHART_STATE, resolveInsightsChartData} from '@libs/resolveInsightsChartData'; + +import CONST from '@src/CONST'; +import type {SearchResults} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import type {InsightsFilters} from './insightsFilters'; +import type {InsightsDashboardChart} from './resolveDashboardState'; + +import resolveComparisonPeriods from './insightsCompare'; + +type InsightsChartState = ValueOf; + +type InsightsChartData = { + /** The chart's own grouped rows */ + data: GroupedItem[]; + + /** What the chart shows: its data, or a loading, empty, error or offline state */ + state: InsightsChartState; + + /** Rows the chart is compared against, set once both they and the chart's own rows are ready */ + comparison: ChartComparison | undefined; +}; + +/** What a chart is compared against: the query and snapshot of the compared rows, and what names the compared series */ +type ComparedChart = Pick & { + queryJSON: Readonly | undefined; + snapshot: OnyxEntry; +}; + +const BLOCKING_STATES = new Set([INSIGHTS_CHART_STATE.LOADING, INSIGHTS_CHART_STATE.ERROR, INSIGHTS_CHART_STATE.OFFLINE]); + +/** Returns what the chart is compared against for the page's Compare mode, or nothing when it doesn't compare */ +function getComparedChart(chart: InsightsDashboardChart, filters: InsightsFilters, translate: LocaleContextProps['translate']): ComparedChart | undefined { + switch (filters.compare) { + case CONST.SEARCH.COMPARE.PREVIOUS_PERIOD: { + const periods = resolveComparisonPeriods(filters.date); + return periods && {queryJSON: chart.previousPeriodQueryJSON, snapshot: chart.previousPeriodSnapshot, periods}; + } + case CONST.SEARCH.COMPARE.AVERAGE: + return {queryJSON: chart.averageQueryJSON, snapshot: chart.averageSnapshot, label: translate('insightsPage.compare.average')}; + default: + return undefined; + } +} + +/** Resolves a chart's rows, and the rows it is compared against when the page compares. */ +function useInsightsChartData(dashboardChart: InsightsDashboardChart, filters: InsightsFilters): InsightsChartData { + const {queryJSON, snapshot} = dashboardChart; + const theme = useTheme(); + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const {isBetaEnabled} = usePermissions(); + + const sortedData = useGroupedItems(snapshot, queryJSON); + const current = resolveInsightsChartData({snapshot, queryJSON, sortedData, isOffline}); + + const comparedChart = isBetaEnabled(CONST.BETAS.INSIGHTS_COMPARE) ? getComparedChart(dashboardChart, filters, translate) : undefined; + // Keeps every compared group, so each plotted group finds its own and not only the top ones. + const comparedSortedData = useGroupedItems(comparedChart?.snapshot, queryJSON && {...queryJSON, limit: undefined}); + + if (!comparedChart || current.state !== INSIGHTS_CHART_STATE.READY) { + return {...current, comparison: undefined}; + } + + const compared = resolveInsightsChartData({snapshot: comparedChart.snapshot, queryJSON: comparedChart.queryJSON, sortedData: comparedSortedData, isOffline}); + // A compared chart waits for both sets of rows. + if (BLOCKING_STATES.has(compared.state)) { + return {data: current.data, state: compared.state, comparison: undefined}; + } + + const color = VictoryTheme.colors.getComparisonColor(VictoryTheme.colors.default, theme.colorScheme); + return { + ...current, + comparison: {data: compared.data, color, label: comparedChart.label, periods: comparedChart.periods}, + }; +} + +export default useInsightsChartData; diff --git a/src/pages/Insights/useInsightsFilters.ts b/src/pages/Insights/useInsightsFilters.ts index 78498aa3f8a0..0cf3d18e7d10 100644 --- a/src/pages/Insights/useInsightsFilters.ts +++ b/src/pages/Insights/useInsightsFilters.ts @@ -1,6 +1,6 @@ import useOnyx from '@hooks/useOnyx'; -import {setInsightsFilters} from '@libs/actions/Insights'; +import {saveInsightsFilters, setInsightsFilters} from '@libs/actions/Insights'; import {getPolicyIDOrDefault} from '@libs/PolicyUtils'; import {getLastSearchQuery} from '@libs/SearchUIUtils'; @@ -43,7 +43,13 @@ function useInsightsFilters(dashboard: InsightsDashboardID): UseInsightsFiltersR const filters: InsightsFilters = {...defaultFilters, ...parseInsightsFilters(storedQuery)}; const setFilters = (update: Partial) => { - setInsightsFilters(searchKey, buildInsightsQueryString({...filters, ...update})); + const query = buildInsightsQueryString({...filters, ...update}); + // GetInsights saves every other filter, but a Compare mode change doesn't fetch the dashboard again + if ('compare' in update) { + saveInsightsFilters(searchKey, query); + return; + } + setInsightsFilters(searchKey, query); }; return { diff --git a/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts b/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts index 3f02a7e5263b..fd9286920b2d 100644 --- a/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts +++ b/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts @@ -39,7 +39,7 @@ function useGroupChatDraftParticipantSync( const shouldRestoreSelectedOptionsRef = useRef(true); const isScreenFocusedRef = useIsFocusedRef(); - const {dateFnsLocale} = useLocalize(); + const {dateFnsLocale, formatPhoneNumber} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const draftParticipantsSelector = (draft: NewGroupChatDraft | undefined) => { const isSubscriptionActive = shouldRestoreSelectedOptionsRef.current || !isScreenFocusedRef.current; @@ -75,6 +75,7 @@ function useGroupChatDraftParticipantSync( currentUserEmail, currentUserAccountID, rules, + formatPhoneNumber, }); if (option) { result.push({...option, isSelected: true}); diff --git a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx index f64d8ba35e82..26bae713e0ca 100644 --- a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx +++ b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx @@ -8,17 +8,18 @@ import type {ExpensifyIconName} from '@components/Icon/ExpensifyIconLoader'; import OnboardingHeader from '@components/OnboardingHeader'; import {PressableWithoutFeedback} from '@components/Pressable'; import RadioButton from '@components/RadioButton'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useCompleteOnboarding from '@hooks/useCompleteOnboarding'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -27,6 +28,7 @@ import {getDefaultOnboardingFeaturesMap} from '@libs/actions/Welcome/OnboardingF import {isMobileSafari} from '@libs/Browser'; import Navigation from '@libs/Navigation/Navigation'; import {isGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import scrollFocusedInputToTop from '@libs/scrollFocusedInputToTop'; import variables from '@styles/variables'; @@ -85,6 +87,7 @@ function BaseOnboardingAccounting({shouldUseNativeStyles}: BaseOnboardingAccount // We need to use isSmallScreenWidth, see navigateAfterOnboarding function comment // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {onboardingIsMediumOrLargerScreenWidth, isSmallScreenWidth, isInLandscapeMode} = useResponsiveLayout(); + const shouldFooterBeInsideScrollView = useShouldFooterBeInsideList(); const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [session] = useOnyx(ONYXKEYS.SESSION); @@ -103,7 +106,8 @@ function BaseOnboardingAccounting({shouldUseNativeStyles}: BaseOnboardingAccount const [shouldScrollToOtherInput, setShouldScrollToOtherInput] = useState(initialSelectedIntegration === 'other'); const [error, setError] = useState(''); const scrollViewRef = useRef>(null); - const otherAccountingSoftwareInputRef = useRef(null); + const scrollContentRef = useRef(null); + const otherAccountingSoftwareInputRef = useRef(null); const isOtherSelected = selectedIntegration === 'other'; const groupPolicy = Object.values(allPolicies ?? {}).find((policy) => isGroupPolicy(policy) && isPolicyAdmin(policy, session?.email)); @@ -241,26 +245,29 @@ function BaseOnboardingAccounting({shouldUseNativeStyles}: BaseOnboardingAccount scrollFocusedInputToTop(scrollViewRef, scrollContentRef, otherAccountingSoftwareInputRef)} style={[onboardingIsMediumOrLargerScreenWidth ? styles.mh8 : styles.mh5]} contentContainerStyle={[styles.pt3, styles.pb5]} onContentSizeChange={handleContentSizeChange} keyboardShouldPersistTaps="handled" > - {accountingOptionKeys.map(renderOption)} - {isOtherSelected && ( - - )} + + {accountingOptionKeys.map(renderOption)} + {isOtherSelected && ( + + )} - {isInLandscapeMode && {continueButton}} + {shouldFooterBeInsideScrollView && {continueButton}} + - {!isInLandscapeMode && {continueButton}} + {!shouldFooterBeInsideScrollView && {continueButton}} ); } diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index c19a21a3c89a..1ad4a1e99c57 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -11,6 +11,7 @@ import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; import useAutoCreateTrackWorkspace from '@hooks/useAutoCreateTrackWorkspace'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import useLocalize from '@hooks/useLocalize'; import useOnboardingMessages from '@hooks/useOnboardingMessages'; import useOnyx from '@hooks/useOnyx'; @@ -23,7 +24,7 @@ import Log from '@libs/Log'; import {getEmailDomain} from '@libs/LoginUtils'; import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; -import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; +import {getJoinWorkspaceMessage, isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {hasURL} from '@libs/Url'; import {expensifyLoginsSelector, isCurrentUserValidated} from '@libs/UserUtils'; import {doesContainReservedWord, isValidDisplayName} from '@libs/ValidationUtils'; @@ -39,7 +40,6 @@ import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/DisplayNameForm'; import {hasSeenTourSelector} from '@selectors/Onboarding'; -import {PUBLIC_DOMAINS_SET} from 'expensify-common'; import React, {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; @@ -56,15 +56,12 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat const delegateAccountID = useDelegateAccountID(); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const isAnonymousUser = useIsAnonymousUser(); const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const [conciergeChatReportID = ''] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeChatReportID}`); const {onboardingMessages, joinWorkspaceMessages} = useOnboardingMessages(); - const joinWorkspaceMessagesAddWorkEmail = joinWorkspaceMessages.addWorkEmail; - const joinWorkspaceMessagesValidateEmail = joinWorkspaceMessages.validateEmail; - const joinWorkspaceMessagesEmpty = joinWorkspaceMessages.empty; - const joinWorkspaceMessagesJoinWorkspace = joinWorkspaceMessages.joinWorkspace; const [session] = useOnyx(ONYXKEYS.SESSION); const currentUserAccountID = currentUserPersonalDetails.accountID; const [onboardingPersonalDetailsForm] = useOnyx(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM); @@ -86,7 +83,6 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat const isPrivateDomainAndHasAccessiblePolicies = !account?.isFromPublicDomain && !!account?.hasAccessibleDomainPolicies; const isValidated = isCurrentUserValidated(loginList, currentUserPersonalDetails.email); const workEmail = session?.email ?? ''; - const isFromPublicDomain = PUBLIC_DOMAINS_SET.has(getEmailDomain(workEmail)); const isVsb = onboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.VSB; const isSmb = onboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.SMB; @@ -103,14 +99,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat setIsLoading(true); try { - let joinWorkspaceMessage = joinWorkspaceMessagesEmpty; - if (onboardingPolicyID) { - joinWorkspaceMessage = onboardingPolicy ? {...joinWorkspaceMessagesJoinWorkspace, tasks: []} : joinWorkspaceMessagesJoinWorkspace; - } else if (isFromPublicDomain) { - joinWorkspaceMessage = joinWorkspaceMessagesAddWorkEmail; - } else if (!isValidated) { - joinWorkspaceMessage = joinWorkspaceMessagesValidateEmail; - } + const joinWorkspaceMessage = getJoinWorkspaceMessage({joinWorkspaceMessages, onboardingPolicyID, onboardingPolicy, workEmail, isValidated}); await completeOnboardingReport({ engagementChoice: onboardingPurposeSelected, onboardingMessage: onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE ? joinWorkspaceMessage : onboardingMessages[onboardingPurposeSelected], @@ -135,6 +124,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), conciergeChatReportID, reportNameValuePairs, + isAnonymousUser, onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE && !onboardingPolicy ? undefined : onboardingPolicyID, mergedAccountConciergeReportID, false, @@ -150,17 +140,14 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat onboardingPurposeSelected, onboardingAdminsChatReportID, onboardingMessages, - joinWorkspaceMessagesAddWorkEmail, - joinWorkspaceMessagesValidateEmail, - joinWorkspaceMessagesEmpty, - joinWorkspaceMessagesJoinWorkspace, + joinWorkspaceMessages, isValidated, - isFromPublicDomain, workEmail, onboardingPolicyID, onboardingPolicy, isBetaEnabled, reportNameValuePairs, + isAnonymousUser, isSmallScreenWidth, mergedAccountConciergeReportID, conciergeChatReportID, diff --git a/src/pages/OnboardingPersonalTrackGoal/BaseOnboardingPersonalTrackGoal.tsx b/src/pages/OnboardingPersonalTrackGoal/BaseOnboardingPersonalTrackGoal.tsx index d1aa80c97244..1f0db15e99b2 100644 --- a/src/pages/OnboardingPersonalTrackGoal/BaseOnboardingPersonalTrackGoal.tsx +++ b/src/pages/OnboardingPersonalTrackGoal/BaseOnboardingPersonalTrackGoal.tsx @@ -1,8 +1,11 @@ import Button from '@components/Button'; +import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import OnboardingHeader from '@components/OnboardingHeader'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; @@ -11,9 +14,11 @@ import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hook import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; +import scrollFocusedInputToTop from '@libs/scrollFocusedInputToTop'; import variables from '@styles/variables'; @@ -23,9 +28,10 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import React, {useState} from 'react'; +import type {ComponentRef} from 'react'; + +import React, {useRef, useState} from 'react'; import {View} from 'react-native'; -import {ScrollView} from 'react-native-gesture-handler'; import type {BaseOnboardingPersonalTrackGoalProps} from './types'; @@ -35,9 +41,14 @@ function BaseOnboardingPersonalTrackGoal({shouldUseNativeStyles, route}: BaseOnb const styles = useThemeStyles(); const {translate} = useLocalize(); const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); + const shouldFooterBeInsideScrollView = useShouldFooterBeInsideList(); const [selectedGoalOverride, setSelectedGoalOverride] = useState(null); const [somethingElseTextOverride, setSomethingElseTextOverride] = useState(null); const [inputError, setInputError] = useState(''); + const [shouldScrollToSomethingElseInput, setShouldScrollToSomethingElseInput] = useState(false); + const scrollViewRef = useRef>(null); + const scrollContentRef = useRef(null); + const inputRef = useRef(null); const illustrations = useMemoizedLazyIllustrations(['RealEstate', 'HouseMoney', 'TargetWithArrow', 'Binoculars']); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Checkmark']); const [account] = useOnyx(ONYXKEYS.ACCOUNT); @@ -73,6 +84,15 @@ function BaseOnboardingPersonalTrackGoal({shouldUseNativeStyles, route}: BaseOnb completeTrackGoalSelection(somethingElseText.trim()); }; + const handleContentSizeChange = () => { + if (!shouldScrollToSomethingElseInput) { + return; + } + + scrollViewRef.current?.scrollToEnd({animated: false}); + setShouldScrollToSomethingElseInput(false); + }; + const menuIcons = { [CONST.ONBOARDING_PERSONAL_TRACK_GOALS.INVESTMENT_TRACKING]: illustrations.RealEstate, [CONST.ONBOARDING_PERSONAL_TRACK_GOALS.HOUSEHOLD_TRACKING]: illustrations.HouseMoney, @@ -82,22 +102,43 @@ function BaseOnboardingPersonalTrackGoal({shouldUseNativeStyles, route}: BaseOnb const paddingHorizontal = onboardingIsMediumOrLargerScreenWidth ? styles.ph8 : styles.ph5; + const continueButton = ( + + + + ); + return ( - { - Navigation.goBack(ROUTES.ONBOARDING_PURPOSE.getRoute(route.params?.backTo)); - }} - /> + + { + Navigation.goBack(ROUTES.ONBOARDING_PURPOSE.getRoute(route.params?.backTo)); + }} + /> + scrollFocusedInputToTop(scrollViewRef, scrollContentRef, inputRef)} style={[styles.flex1, styles.flexGrow1, onboardingIsMediumOrLargerScreenWidth && styles.mt5, paddingHorizontal]} + onContentSizeChange={handleContentSizeChange} keyboardShouldPersistTaps="handled" > - + { setSelectedGoalOverride(goal); setInputError(''); + setShouldScrollToSomethingElseInput(goal === CONST.ONBOARDING_PERSONAL_TRACK_GOALS.SOMETHING_ELSE); if (goal !== CONST.ONBOARDING_PERSONAL_TRACK_GOALS.SOMETHING_ELSE) { completeTrackGoalSelection(goal); } @@ -135,8 +177,9 @@ function BaseOnboardingPersonalTrackGoal({shouldUseNativeStyles, route}: BaseOnb ); })} {isSomethingElseSelected && ( - + )} + + {isSomethingElseSelected && shouldFooterBeInsideScrollView && continueButton} - {isSomethingElseSelected && ( - - - - )} + {isSomethingElseSelected && !shouldFooterBeInsideScrollView && continueButton} ); } diff --git a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx index fda0d3c4b487..423eea2b87e9 100644 --- a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx +++ b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx @@ -4,28 +4,23 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import ValidateCodeForm from '@components/ValidateCodeActionModal/ValidateCodeForm'; +import useContinueJoinWorkspaceOnboarding from '@hooks/useContinueJoinWorkspaceOnboarding'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useLocalize from '@hooks/useLocalize'; import useOnboardingIntent from '@hooks/useOnboardingIntent'; -import useOnboardingMessages from '@hooks/useOnboardingMessages'; import useOnboardingTaskInformation from '@hooks/useOnboardingTaskInformation'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useReturnToOriginReport from '@hooks/useReturnToOriginReport'; import useThemeStyles from '@hooks/useThemeStyles'; -import {createJoinWorkspaceOnboardingContent, setOnboardingAdminsChatReportID, setOnboardingPolicyID, updateOnboardingValuesAndNavigation} from '@libs/actions/Welcome'; -import Log from '@libs/Log'; -import {getEmailDomain} from '@libs/LoginUtils'; -import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {createJoinWorkspaceOnboardingContent, updateOnboardingValuesAndNavigation} from '@libs/actions/Welcome'; import Navigation from '@libs/Navigation/Navigation'; import {expensifyLoginsSelector, isCurrentUserValidated} from '@libs/UserUtils'; import {setDisableDismissOnEscape} from '@userActions/Modal'; import {clearGetAccessiblePoliciesErrors, getAccessiblePolicies} from '@userActions/Policy/Policy'; -import {completeOnboarding} from '@userActions/Report'; import {resendValidateCode} from '@userActions/User'; import CONST from '@src/CONST'; @@ -34,7 +29,7 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import {isUserValidatedSelector} from '@selectors/Account'; -import {hasCompletedGuidedSetupFlowSelector, hasSeenTourSelector} from '@selectors/Onboarding'; +import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; import {CONST as COMMON_CONST, PUBLIC_DOMAINS_SET} from 'expensify-common'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -56,9 +51,7 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard const [joinablePolicies] = useOnyx(ONYXKEYS.JOINABLE_POLICIES); const joinablePoliciesLength = Object.keys(joinablePolicies ?? {}).length; - // We need to use isSmallScreenWidth, see navigateAfterOnboarding function comment. - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {onboardingIsMediumOrLargerScreenWidth, isSmallScreenWidth} = useResponsiveLayout(); + const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); const email = session?.email ?? ''; const domain = email.split('@').at(1) ?? ''; @@ -66,15 +59,6 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard const isValidated = isCurrentUserValidated(loginList, session?.email); const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); - const [onboardingPersonalDetails] = useOnyx(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM); - const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); - const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID); - const [onboardingAdminsChatReportID] = useOnyx(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); - const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); - const {joinWorkspaceMessages} = useOnboardingMessages(); - const {isBetaEnabled} = usePermissions(); const isVsb = onboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.VSB; const isSmb = onboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.SMB; const hasCompletedGuidedSetupFlow = hasCompletedGuidedSetupFlowSelector(onboardingValues); @@ -96,9 +80,20 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard const delegateAccountID = useDelegateAccountID(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); - const isCompletingOnboarding = useRef(false); - const firstName = onboardingPersonalDetails?.firstName?.trim(); - const lastName = onboardingPersonalDetails?.lastName?.trim() ?? ''; + const [onboardingPersonalDetails] = useOnyx(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const continueJoinWorkspaceOnboarding = useContinueJoinWorkspaceOnboarding({ + session, + loginList, + onboardingValues, + onboardingPersonalDetails, + introSelected, + conciergeReportID, + conciergeChat, + reportNameValuePairs, + delegateAccountID, + }); const sendValidateCode = useCallback(() => { if (!email) { @@ -108,13 +103,15 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard }, [email]); const handleBackButtonPress = useCallback(() => { - if (onboardingValues?.shouldValidate === false) { + const backTo = route.params?.backTo as Route | undefined; + // When this screen was opened directly from the intent list (backTo is the purpose route), go straight + // back there instead of running the work-email restore path, which would insert a work-email screen. + if (onboardingValues?.shouldValidate === false && backTo !== ROUTES.ONBOARDING_PURPOSE.getRoute()) { updateOnboardingValuesAndNavigation(onboardingValues); return; } - const routeToNavigate = (route.params?.backTo as Route) ?? ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(); - Navigation.goBack(routeToNavigate); + Navigation.goBack(backTo ?? ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()); }, [route.params?.backTo, onboardingValues]); const navigateToNextOnboardingStep = useCallback( @@ -128,76 +125,6 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard [isVsb, isSmb], ); - const completeOnboardingWithSavedPersonalDetails = useCallback(async () => { - if (!firstName || !onboardingPurposeSelected || isCompletingOnboarding.current) { - return; - } - - isCompletingOnboarding.current = true; - try { - let onboardingMessage = joinWorkspaceMessages.validateEmail; - if (isValidated) { - onboardingMessage = joinWorkspaceMessages.empty; - } - if (PUBLIC_DOMAINS_SET.has(domain.toLowerCase())) { - onboardingMessage = joinWorkspaceMessages.addWorkEmail; - } - - await completeOnboarding({ - engagementChoice: onboardingPurposeSelected, - onboardingMessage, - firstName, - lastName, - adminsChatReportID: onboardingAdminsChatReportID, - onboardingPolicyID, - introSelected, - isSelfTourViewed, - conciergeChat, - companyDomain: getEmailDomain(email), - workEmail: email, - currentUserAccountID: currentUserPersonalDetails.accountID, - delegateAccountID, - }); - - setOnboardingAdminsChatReportID(); - setOnboardingPolicyID(); - navigateAfterOnboardingWithMicrotaskQueue( - isSmallScreenWidth, - isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), - conciergeReportID, - reportNameValuePairs, - onboardingPolicyID, - onboardingAdminsChatReportID, - false, - ); - } catch (error) { - Log.warn('[BaseOnboardingPrivateDomain] Error completing onboarding with saved personal details', {error}); - isCompletingOnboarding.current = false; - } - }, [ - conciergeChat, - conciergeReportID, - currentUserPersonalDetails.accountID, - delegateAccountID, - domain, - email, - introSelected, - isBetaEnabled, - firstName, - isCompletingOnboarding, - isSelfTourViewed, - isSmallScreenWidth, - isValidated, - joinWorkspaceMessages.addWorkEmail, - joinWorkspaceMessages.empty, - joinWorkspaceMessages.validateEmail, - onboardingAdminsChatReportID, - lastName, - onboardingPolicyID, - onboardingPurposeSelected, - reportNameValuePairs, - ]); - // Reaching this screen from the join-workspace intent means there is no further onboarding step to route // back into: skipping or finding no joinable workspaces should complete onboarding (collecting a name first // if needed), or simply close when this screen was reopened from a Concierge task after onboarding finished. @@ -208,16 +135,12 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard returnToOriginReport(); return; } - if (firstName) { - completeOnboardingWithSavedPersonalDetails(); - return; - } - Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), options); + continueJoinWorkspaceOnboarding(options); return; } navigateToNextOnboardingStep(backTo, options); }, - [completeOnboardingWithSavedPersonalDetails, firstName, isJoiningCompanyWorkspace, isConciergeTaskFlow, navigateToNextOnboardingStep, returnToOriginReport], + [continueJoinWorkspaceOnboarding, isJoiningCompanyWorkspace, isConciergeTaskFlow, navigateToNextOnboardingStep, returnToOriginReport], ); const handleConciergeTaskExit = useCallback(() => { diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 40858a9131c5..9e1478b18f5b 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -9,6 +9,7 @@ import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; import useAutoCreateTrackWorkspace from '@hooks/useAutoCreateTrackWorkspace'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnboardingMessages from '@hooks/useOnboardingMessages'; @@ -32,6 +33,7 @@ import {clearOnboardingMergeAccountBlocked, setOnboardingErrorMessage, setOnboar import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/OnboardingWorkEmailForm'; import type {OnboardingPurpose} from '@src/types/onyx'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -78,6 +80,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const {onboardingMessages} = useOnboardingMessages(); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const isAnonymousUser = useIsAnonymousUser(); const isPrivateDomainAndHasAccessiblePolicies = !account?.isFromPublicDomain && !!account?.hasAccessibleDomainPolicies; @@ -86,6 +89,8 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro const [onboardingAdminsChatReportID] = useOnyx(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID); const [adminsChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${onboardingAdminsChatReportID}`); const [personalDetailsForm] = useOnyx(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM); + const [workEmailForm] = useOnyx(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM); + const workEmail = workEmailForm?.[INPUT_IDS.ONBOARDING_WORK_EMAIL]; const [onboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); @@ -191,6 +196,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), conciergeReportID, reportNameValuePairs, + isAnonymousUser, onboardingPolicyID, onboardingAdminsChatReportID, false, @@ -220,6 +226,17 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro const onboardingLocalRef = useRef(null); useImperativeHandle(isFocused ? OnboardingRefManager.ref : onboardingLocalRef, () => ({handleOuterClick}), [handleOuterClick]); + const getErrorMessage = () => { + if (!onboardingErrorMessage) { + return undefined; + } + // These merge-block messages interpolate the work email, so translate them with the email explicitly. + if (onboardingErrorMessage === 'onboarding.mergeBlockScreen.domainControlledSubtitle' || onboardingErrorMessage === 'onboarding.mergeBlockScreen.validatedPublicDomainSubtitle') { + return translate(onboardingErrorMessage, workEmail); + } + return translate(onboardingErrorMessage); + }; + if (isLoadingOnyxValue(onboardingErrorMessageResult)) { return null; } @@ -250,7 +267,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro - + ); diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index 15b314007c43..fba19ce07e7a 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -11,7 +11,10 @@ import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useContinueJoinWorkspaceOnboarding from '@hooks/useContinueJoinWorkspaceOnboarding'; +import useDelegateAccountID from '@hooks/useDelegateAccountID'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -32,8 +35,8 @@ import {AddWorkEmail} from '@userActions/Session'; import { addWorkEmailFormError, clearOnboardingMergeAccountBlocked, - clearOnboardingShouldValidate, clearWorkEmailFormErrors, + resetOnboardingWorkEmailValidation, setOnboardingErrorMessage, setOnboardingMergeAccountStepValue, setOnboardingMergingAccountBlocked, @@ -89,6 +92,23 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW const isJoiningCompanyWorkspace = onboardingIntent === CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE; const {taskReport: addWorkEmailTaskReport} = useOnboardingTaskInformation(CONST.ONBOARDING_TASK_TYPE.ADD_WORK_EMAIL); const returnToOriginReport = useReturnToOriginReport(route.params?.reportID); + const [onboardingPersonalDetails] = useOnyx(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const delegateAccountID = useDelegateAccountID(); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const continueJoinWorkspaceOnboarding = useContinueJoinWorkspaceOnboarding({ + session, + loginList, + onboardingValues, + onboardingPersonalDetails, + introSelected, + conciergeReportID, + conciergeChat, + reportNameValuePairs, + delegateAccountID, + }); const [formValue] = useOnyx(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM); const workEmail = formValue?.[INPUT_IDS.ONBOARDING_WORK_EMAIL]; const [onboardingErrorMessageTranslationKey] = useOnyx(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY); @@ -242,12 +262,13 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW // AddWorkEmail already made this the account's login, and resending it would merge the account into itself. if (sessionEmail && submittedWorkEmail.toLowerCase() === sessionEmail.toLowerCase()) { + setHasSubmittedWorkEmail(true); setOnboardingShouldValidate(false); return; } // The form's onSubmit must stay synchronous, so chain off the Onyx write instead of awaiting it. - clearOnboardingShouldValidate(onboardingValues).then(() => { + resetOnboardingWorkEmailValidation(onboardingValues).then(() => { setHasSubmittedWorkEmail(true); AddWorkEmail(submittedWorkEmail, addWorkEmailTaskReport); }); @@ -255,14 +276,19 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW [addWorkEmailTaskReport, isConciergeTaskFlow, isCurrentPrimaryValidated, onboardingValues, originReportID, sessionEmail], ); + const isBackButtonMessage = onboardingErrorMessageTranslationKey === 'onboarding.purpose.errorBackButton'; + + // The back button message is about leaving onboarding, not about the email, so it never becomes a field error and never blocks the submit + const workEmailErrorTranslationKey = onboardingErrorMessageTranslationKey && !isBackButtonMessage ? onboardingErrorMessageTranslationKey : undefined; + useEffect(() => { - if (!onboardingErrorMessageTranslationKey) { + if (!workEmailErrorTranslationKey) { clearWorkEmailFormErrors(); return; } - addWorkEmailFormError(translate(onboardingErrorMessageTranslationKey)); - }, [onboardingErrorMessageTranslationKey, translate]); + addWorkEmailFormError(translate(workEmailErrorTranslationKey)); + }, [workEmailErrorTranslationKey, translate]); const clearOnboardingErrorMessage = useCallback(() => { if (!onboardingErrorMessageTranslationKey) { @@ -271,6 +297,13 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW setOnboardingErrorMessage(null); }, [onboardingErrorMessageTranslationKey]); + const clearBackButtonMessage = () => { + if (!isBackButtonMessage) { + return; + } + setOnboardingErrorMessage(null); + }; + const handleRegularJoinWorkspaceErrorConfirm = useCallback(() => { setOnboardingErrorMessage(null); setOnboardingMergingAccountBlocked(false); @@ -291,6 +324,31 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW handleMergingAccountBlockedConfirm = handleRegularJoinWorkspaceErrorConfirm; } + // This screen normally opens onboarding, so there is nothing to go back to unless the intent list sent us here. + // The intent stays saved across a cold start, so check it instead of the route index. + const canGoBackToPurpose = isJoiningCompanyWorkspace && !isConciergeTaskFlow; + + const goBackToPurpose = () => { + // Clear any work email error so the purpose screen doesn't render it without its translation params. + clearOnboardingMergeAccountBlocked(); + // Pops to the intent list when it's in the stack, or replaces this screen with it, so the user stays in onboarding. + Navigation.goBack(ROUTES.ONBOARDING_PURPOSE.getRoute()); + }; + + const handleAndroidBackPress = () => { + if (canGoBackToPurpose) { + goBackToPurpose(); + return true; + } + + // Otherwise there is no earlier step, so keep the press from closing onboarding and tell the user to finish setup. + setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); + return true; + }; + + // Android's system back runs the same logic as the header back button. + useAndroidBackButtonHandler(handleAndroidBackPress); + const shouldRenderOfflineFeedback = useCallback((errorTranslation: string) => { if ( errorTranslation !== 'onboarding.workEmail2FAError' && @@ -359,10 +417,9 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW testID="BaseOnboardingWorkEmail" style={[styles.defaultModalContainer, shouldUseNativeStyles && styles.pt8]} > - {/* This screen normally opens onboarding, so there is nothing to go back to unless the intent list sent us here. */} Navigation.goBack(ROUTES.ONBOARDING_PURPOSE.getRoute())} + shouldShowBackButton={canGoBackToPurpose} + onBackButtonPress={goBackToPurpose} shouldShowCloseButton={isConciergeTaskFlow} onCloseButtonPress={onboardingValues?.isMergingAccountBlocked ? handleConciergeTaskErrorConfirm : returnToOriginReport} /> @@ -379,6 +436,7 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW style={[styles.flexGrow1, onboardingIsMediumOrLargerScreenWidth && styles.mt5, onboardingIsMediumOrLargerScreenWidth ? styles.mh8 : styles.mh5]} formID={ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM} validate={validate} + onBeforeSubmit={clearBackButtonMessage} onSubmit={submitWorkEmail} submitButtonText={translate('onboarding.workEmail.addWorkEmail')} enabledWhenOffline @@ -418,7 +476,7 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW // The user already picked an intent, so skipping continues to the last onboarding // step rather than returning them to the intent list they came from. if (isJoiningCompanyWorkspace) { - Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()); + continueJoinWorkspaceOnboarding(); } }} sentryLabel={CONST.SENTRY_LABEL.ONBOARDING.SKIP} diff --git a/src/pages/OnboardingWorkEmail/index.native.tsx b/src/pages/OnboardingWorkEmail/index.native.tsx index b43a629125a2..bec1f099003b 100644 --- a/src/pages/OnboardingWorkEmail/index.native.tsx +++ b/src/pages/OnboardingWorkEmail/index.native.tsx @@ -1,28 +1,10 @@ -import {setOnboardingErrorMessage} from '@userActions/Welcome'; - -import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback} from 'react'; -import {BackHandler} from 'react-native'; +import React from 'react'; import type {OnboardingWorkEmailProps} from './types'; import BaseOnboardingWorkEmail from './BaseOnboardingWorkEmail'; function OnboardingWorkEmail(props: OnboardingWorkEmailProps) { - // To block android native back button behavior - useFocusEffect( - useCallback(() => { - // Return true to indicate that the back button press is handled here - const backAction = () => { - setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); - return true; - }; - - const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction); - - return () => backHandler.remove(); - }, []), - ); return ( (); const isFinishingOnboarding = useRef(false); const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace(); + const continueJoinWorkspaceOnboarding = useContinueJoinWorkspaceOnboarding({ + session, + loginList, + onboardingValues, + onboardingPersonalDetails, + introSelected, + conciergeReportID, + conciergeChat, + reportNameValuePairs, + delegateAccountID, + }); const returnToOriginReport = useReturnToOriginReport(); // Nothing in this stack to go back to. The work email merge and the private domain screen force-replace into this // screen, so it is the only route there. `canGoBack()` can't answer this: it bubbles to the root stack. @@ -144,7 +156,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding isFinishingOnboarding.current = true; const isJoiningSubmitPolicy = policy.policyType === CONST.POLICY.TYPE.SUBMIT; - const shouldUseSubmitFlow = policy.automaticJoiningEnabled && isJoiningSubmitPolicy; + const shouldUseSubmitFlow = !isJoiningCompanyWorkspace && policy.automaticJoiningEnabled && isJoiningSubmitPolicy; // Auto-joining finishes the join-workspace task immediately, so the welcome message drops its task list. const joinWorkspaceOnboardingMessage = policy.automaticJoiningEnabled ? {...joinWorkspaceMessages.joinWorkspace, tasks: []} : joinWorkspaceMessages.joinWorkspace; @@ -157,7 +169,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setOnboardingAdminsChatReportID(); setOnboardingPolicyID(policy.policyID); - if (isJoiningCompanyWorkspace && (!onboardingPersonalDetails?.firstName.trim() || !onboardingPersonalDetails.lastName.trim())) { + if (isJoiningCompanyWorkspace && !onboardingPersonalDetails?.firstName?.trim()) { Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); return; } @@ -188,6 +200,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), conciergeReportID, reportNameValuePairs, + isAnonymousUser, policy.automaticJoiningEnabled ? policy.policyID : undefined, undefined, false, @@ -250,7 +263,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding const wrapperPadding = onboardingIsMediumOrLargerScreenWidth ? styles.mh8 : styles.mh5; - // Empty deps keep this cleanup tied to losing focus rather than to the data below changing, so the guard only + // Stable setter deps keep this cleanup tied to losing focus rather than to the data below changing, so the guard only // clears when the screen is actually left and a later focus can retry. useFocusEffect( useCallback( @@ -258,15 +271,16 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setHasRequestedAccessiblePolicies(false); setAccessiblePoliciesRequestID(undefined); }, - [], + [setHasRequestedAccessiblePolicies, setAccessiblePoliciesRequestID], ), ); useFocusEffect( useCallback(() => { // Guarded by a ref instead of by omitting the loading/count dependencies: an empty response leaves the - // count at 0, so reacting to those updates would immediately issue another request. - if (!isValidated || joinablePoliciesLength > 0 || hasRequestedAccessiblePolicies) { + // count at 0, so reacting to those updates would immediately issue another request. Joining the last + // listed workspace also empties the list, which must not start a lookup while onboarding is finishing. + if (!isValidated || joinablePoliciesLength > 0 || hasRequestedAccessiblePolicies || isFinishingOnboarding.current) { return; } @@ -282,11 +296,27 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setHasRequestedAccessiblePolicies(true); setAccessiblePoliciesRequestID(getAccessiblePolicies()); - }, [accessiblePoliciesActionRequestID, hasRequestedAccessiblePolicies, isValidated, joinablePoliciesLength, joinablePoliciesLoading]), + }, [ + accessiblePoliciesActionRequestID, + hasRequestedAccessiblePolicies, + isValidated, + joinablePoliciesLength, + joinablePoliciesLoading, + setHasRequestedAccessiblePolicies, + setAccessiblePoliciesRequestID, + ]), ); + const continueJoinWorkspaceOnboardingFromEffect = useEffectEvent(continueJoinWorkspaceOnboarding); + useEffect(() => { - if (accessiblePoliciesActionRequestID !== accessiblePoliciesRequestID || joinablePoliciesLoading !== false || joinablePoliciesErrors || joinablePoliciesLength > 0) { + if ( + isFinishingOnboarding.current || + accessiblePoliciesActionRequestID !== accessiblePoliciesRequestID || + joinablePoliciesLoading !== false || + joinablePoliciesErrors || + joinablePoliciesLength > 0 + ) { return; } @@ -306,7 +336,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding if (!isJoiningCompanyWorkspace) { return; } - Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); + continueJoinWorkspaceOnboardingFromEffect({forceReplace: true}); }, [ conciergeChat, conciergeReportID, @@ -336,7 +366,8 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding }, [conciergeChat, delegateAccountID, introSelected?.joinWorkspace, joinWorkspaceTaskReport, joinablePoliciesLength, session?.email, shouldCreateJoinWorkspaceTaskOnExit]); useEffect(() => { - if (joinablePoliciesLoading !== false || joinablePoliciesLength > 0 || !defaultPolicy?.id) { + // The Join Workspace flow handles empty results above, including completion when a name is already saved. + if (isJoiningCompanyWorkspace || joinablePoliciesLoading !== false || joinablePoliciesLength > 0 || !defaultPolicy?.id) { return; } @@ -349,7 +380,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding automaticJoiningEnabled: false, policyType: defaultPolicy.type, }); - }, [joinablePoliciesLoading, joinablePoliciesLength, defaultPolicy?.id, defaultPolicy?.name, defaultPolicy?.owner, defaultPolicy?.type]); + }, [isJoiningCompanyWorkspace, joinablePoliciesLoading, joinablePoliciesLength, defaultPolicy?.id, defaultPolicy?.name, defaultPolicy?.owner, defaultPolicy?.type]); const skipJoiningWorkspaces = () => { setOnboardingErrorMessage(null); @@ -388,7 +419,16 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding }); setOnboardingAdminsChatReportID(); - navigateAfterOnboardingWithMicrotaskQueue(isSmallScreenWidth, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), conciergeReportID, reportNameValuePairs, undefined, undefined, false); + navigateAfterOnboardingWithMicrotaskQueue( + isSmallScreenWidth, + isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), + conciergeReportID, + reportNameValuePairs, + isAnonymousUser, + undefined, + undefined, + false, + ); return; } diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx index b4af47485238..a6d3948e4e73 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx @@ -8,6 +8,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import {useMentionedAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; @@ -58,10 +59,12 @@ function PrivateNotesEditPageInternal({route, report, accountID, privateNoteDraf const styles = useThemeStyles(); const {translate} = useLocalize(); const personalDetailsList = usePersonalDetails(); + const originalNote = report?.privateNotes?.[Number(route.params.accountID)]?.note ?? ''; + const accountIDToName = useMentionedAccountIDToNameMap(originalNote); const {login} = useCurrentUserPersonalDetails(); // We need to edit the note in markdown format, but display it in HTML format - const [privateNote, setPrivateNote] = useState(() => privateNoteDraft || Parser.htmlToMarkdown(report?.privateNotes?.[Number(route.params.accountID)]?.note ?? '').trim()); + const [privateNote, setPrivateNote] = useState(() => privateNoteDraft || Parser.htmlToMarkdown(originalNote, {accountIDToName}).trim()); /** * Save the draft of the private note. This debounced so that we're not ceaselessly saving your edit. Saving the draft @@ -96,10 +99,9 @@ function PrivateNotesEditPageInternal({route, report, accountID, privateNoteDraf ); const savePrivateNote = () => { - const originalNote = report?.privateNotes?.[Number(route.params.accountID)]?.note ?? ''; let editedNote = ''; if (privateNote.trim() !== originalNote.trim()) { - editedNote = handleUserDeletedLinksInHtml(privateNote.trim(), Parser.htmlToMarkdown(originalNote).trim(), login ?? '', personalDetailsList); + editedNote = handleUserDeletedLinksInHtml(privateNote.trim(), Parser.htmlToMarkdown(originalNote, {accountIDToName}).trim(), login ?? '', personalDetailsList); updatePrivateNotes(report.reportID, Number(route.params.accountID), editedNote); } diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index d6af9a528d91..336214cc725f 100755 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -101,7 +101,7 @@ function ProfilePage({route}: ProfilePageProps) { const guideCalendarLink = account?.guideDetails?.calendarLink ?? ''; const expensifyIcons = useMemoizedLazyExpensifyIcons(['Bug', 'History', 'Pencil', 'Phone', 'UserPlus']); const accountID = Number(route.params?.accountID ?? CONST.DEFAULT_NUMBER_ID); - const [agentPrompt] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); + const [agentPrompt] = useOnyx(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`); const isCurrentUser = currentUserAccountID === accountID; const reportID = isCurrentUser ? findSelfDMReportID() : getChatByParticipants(currentUserAccountID ? [accountID, currentUserAccountID] : [], reports)?.reportID; const reportKey = isAnonymousUserSession() || !reportID ? (`${ONYXKEYS.COLLECTION.REPORT}0` as const) : (`${ONYXKEYS.COLLECTION.REPORT}${reportID}` as const); @@ -293,10 +293,9 @@ function ProfilePage({route}: ProfilePageProps) { ) : null} {canManageMemberDisplayName ? ( - + Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_PROMPT.getRoute(accountID))} + onPress={() => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT_PROMPT.getRoute()))} /> )} diff --git a/src/pages/ReimbursementAccount/NonUSD/Finish/index.tsx b/src/pages/ReimbursementAccount/NonUSD/Finish/index.tsx index 73b365b6a17f..c106ec09a96b 100644 --- a/src/pages/ReimbursementAccount/NonUSD/Finish/index.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/Finish/index.tsx @@ -22,7 +22,7 @@ import {navigateToConciergeChat} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React from 'react'; import {View} from 'react-native'; @@ -37,14 +37,16 @@ function Finish() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {getTwoFactorAuthRoute} = useTwoFactorAuthRoute(); const handleBackButtonPress = () => { Navigation.dismissModal(); }; const handleNavigateToConciergeChat = () => - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: true}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails, shouldDismissModal: true}); return ( Navigation.dismissModal()} /> - Navigation.dismissModal()} diff --git a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/ConnectBankAccount.tsx b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/ConnectBankAccount.tsx index ba6b6c3728e5..7566f84da8de 100644 --- a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/ConnectBankAccount.tsx +++ b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/ConnectBankAccount.tsx @@ -1,4 +1,4 @@ -import ConfirmationPage from '@components/ConfirmationPage'; +import ConfirmationPageDefault from '@components/ConfirmationPage/composed/ConfirmationPageDefault'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ReimbursementAccountLoadingIndicator from '@components/ReimbursementAccountLoadingIndicator'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -25,7 +25,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; @@ -58,11 +58,13 @@ function ConnectBankAccount({onBackButtonPress, setShouldShowConnectedVerifiedBa const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const handleNavigateToConciergeChat = () => { - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: true}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails, shouldDismissModal: true}); }; const bankAccountState = reimbursementAccount?.achData?.state ?? ''; const pendingAction = reimbursementAccount?.pendingAction; @@ -91,10 +93,9 @@ function ConnectBankAccount({onBackButtonPress, setShouldShowConnectedVerifiedBa title={translate('bankAccount.addBankAccount')} onBackButtonPress={() => Navigation.dismissModal()} /> - Navigation.dismissModal()} diff --git a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx index 22dcdc007160..33af33cfe3fc 100644 --- a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx +++ b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/FinishChatCard.tsx @@ -24,7 +24,7 @@ import type {Policy, ReimbursementAccount} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React from 'react'; import {View} from 'react-native'; @@ -53,7 +53,9 @@ function FinishChatCard({requiresTwoFactorAuth, reimbursementAccount, policy, se const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); useResetBankAccountModal({ @@ -68,6 +70,7 @@ function FinishChatCard({requiresTwoFactorAuth, reimbursementAccount, policy, se introSelected, currentUserAccountID, isSelfTourViewed, + hasCompletedGuidedSetupFlow, conciergePersonalDetails, shouldDismissModal: true, reportActionID: reimbursementAccount?.achData?.ACHRequestReportActionID, diff --git a/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx b/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx index ffa2ace762f7..02f93d6744c1 100644 --- a/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx +++ b/src/pages/ReimbursementAccount/USD/KYBDocuments/index.tsx @@ -24,7 +24,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import type {FileObject} from '@src/types/utils/Attachment'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; @@ -58,7 +58,9 @@ function KYBDocuments({onBackButtonPress, onSubmit}: KYBDocumentsProps) { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const reimbursementAccountVerificationData = reimbursementAccount?.achData?.verifications?.externalApiResponses; const bankAccountID = reimbursementAccount?.achData?.bankAccountID ?? CONST.DEFAULT_NUMBER_ID; @@ -70,6 +72,7 @@ function KYBDocuments({onBackButtonPress, onSubmit}: KYBDocumentsProps) { introSelected, currentUserAccountID, isSelfTourViewed, + hasCompletedGuidedSetupFlow, conciergePersonalDetails, shouldDismissModal: true, reportActionID: reimbursementAccount?.achData?.ACHRequestReportActionID, diff --git a/src/pages/ReimbursementAccount/USD/Requestor/VerifyIdentity/VerifyIdentity.tsx b/src/pages/ReimbursementAccount/USD/Requestor/VerifyIdentity/VerifyIdentity.tsx index 9cc5987bfddb..12a89bc0912a 100644 --- a/src/pages/ReimbursementAccount/USD/Requestor/VerifyIdentity/VerifyIdentity.tsx +++ b/src/pages/ReimbursementAccount/USD/Requestor/VerifyIdentity/VerifyIdentity.tsx @@ -61,7 +61,7 @@ function VerifyIdentity({onBackButtonPress, onSubmit}: VerifyIdentityProps) { const handleOnfidoError = () => { // In case of any unexpected error we log it to the server, show a growl, and return the user back to the requestor step so they can try again. - Growl.error(translate('onfidoStep.genericError'), ONFIDO_ERROR_DISPLAY_DURATION); + Growl.error(translate('onfidoStep.genericError'), {duration: ONFIDO_ERROR_DISPLAY_DURATION}); clearOnfidoToken(); onBackButtonPress(); }; diff --git a/src/pages/ReportSubmitToContent.tsx b/src/pages/ReportSubmitToContent.tsx index e207d69b7c49..16ae7c0ad504 100644 --- a/src/pages/ReportSubmitToContent.tsx +++ b/src/pages/ReportSubmitToContent.tsx @@ -19,6 +19,7 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePersonalDetailByLogin, {useGetPersonalDetailsByLogin, usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; +import useReportTransactions from '@hooks/useReportTransactions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -87,7 +88,7 @@ function ReportSubmitToContent({ shouldResolveAcknowledgedViolations, }: ReportSubmitToContentProps) { const styles = useThemeStyles(); - const {translate, localeCompare, dateFnsLocale} = useLocalize(); + const {translate, localeCompare, dateFnsLocale, formatPhoneNumber} = useLocalize(); const {getCurrencyDecimals, convertToDisplayString} = useCurrencyListActions(); const isInLandscapeMode = useIsInLandscapeMode(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -116,6 +117,7 @@ function ReportSubmitToContent({ const lazyIllustrations = useMemoizedLazyIllustrations(['PaperAirplane']); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.login ?? ''); + const reportTransactions = useReportTransactions(report?.reportID); const prepopulatedEmail = getSubmitToEmail(policy, report, submitterLogin, rules); const prepopulatedAccountID = usePersonalDetailByLogin(prepopulatedEmail?.trim(), (details) => details?.accountID); @@ -232,6 +234,7 @@ function ReportSubmitToContent({ selectedOptions: [], loginsToExclude: CONST.EXPENSIFY_EMAILS_OBJECT, rules, + formatPhoneNumber, }); if (!inviteOption?.login) { @@ -257,6 +260,7 @@ function ReportSubmitToContent({ convertToDisplayString, rules, currentUserDetails.accountID, + formatPhoneNumber, ]); const submitToSelectionData = useMemo(() => { @@ -328,6 +332,7 @@ function ReportSubmitToContent({ submitReport({ getCurrencyDecimals, expenseReport: report, + expenseReportTransactions: reportTransactions, policy, rules, currentUserAccountIDParam: currentUserDetails.accountID, @@ -368,6 +373,7 @@ function ReportSubmitToContent({ employeePersonalDetails, managerEmail, report, + reportTransactions, policy, currentUserDetails.accountID, currentUserDetails.email, diff --git a/src/pages/RoomDescriptionPage.tsx b/src/pages/RoomDescriptionPage.tsx index fb3fdf279eb4..e1e3c411bc45 100644 --- a/src/pages/RoomDescriptionPage.tsx +++ b/src/pages/RoomDescriptionPage.tsx @@ -9,9 +9,11 @@ import Text from '@components/Text'; import TextInput from '@components/TextInput'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; +import useReportIDToNameMap from '@hooks/useReportIDToNameMap'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -47,7 +49,9 @@ type RoomDescriptionPageProps = { function RoomDescriptionPage({report, policy}: RoomDescriptionPageProps) { const backPath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_DESCRIPTION.path); const styles = useThemeStyles(); - const [description, setDescription] = useState(() => Parser.htmlToMarkdown(getReportDescription(report))); + const reportIDToName = useReportIDToNameMap(); + const accountIDToName = useAccountIDToNameMap(); + const [description, setDescription] = useState(() => Parser.htmlToMarkdown(getReportDescription(report), {reportIDToName, accountIDToName})); const reportDescriptionInputRef = useRef(null); const focusTimeoutRef = useRef | null>(null); const {translate} = useLocalize(); @@ -64,9 +68,9 @@ function RoomDescriptionPage({report, policy}: RoomDescriptionPageProps) { const submitForm = useCallback(() => { const newValue = description.trim(); - updateDescription(report, newValue, currentUserAccountID); + updateDescription(report, newValue, currentUserAccountID, reportIDToName, accountIDToName); goBack(); - }, [report, description, goBack, currentUserAccountID]); + }, [report, description, goBack, currentUserAccountID, reportIDToName, accountIDToName]); const validate = useCallback( (values: FormOnyxValues): Errors => { diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index 72717f655a14..e6864bcd72c1 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -1,6 +1,7 @@ import BookTravelButton from '@components/BookTravelButton'; import GenericEmptyStateComponent from '@components/EmptyStateComponent/GenericEmptyStateComponent'; import type {EmptyStateButton, HeaderMedia} from '@components/EmptyStateComponent/types'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import {SearchScopeProvider} from '@components/Search/SearchScopeProvider'; import type {SearchQueryJSON} from '@components/Search/types'; import Text from '@components/Text'; @@ -24,7 +25,7 @@ import {startTestDrive} from '@libs/actions/Tour'; import DateUtils from '@libs/DateUtils'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; -import {canSendInvoice, getDefaultChatEnabledPolicy, getGroupPoliciesWhereReportCanBeCreated} from '@libs/PolicyUtils'; +import {canSendInvoice} from '@libs/PolicyUtils'; import {generateReportID, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; import {getAllPolicyValues, getFilterFromQuery, isDefaultExpenseReportsQuery, isDefaultExpensesQuery, isSearchBeforeViolationsSnapshotStarted} from '@libs/SearchQueryUtils'; import {TODO_SEARCH_KEYS} from '@libs/SearchUIUtils'; @@ -63,7 +64,6 @@ type EmptySearchViewContentProps = EmptySearchViewProps & { typeMenuSections: SearchTypeMenuSection[]; allPolicies: OnyxCollection; activePolicy: OnyxEntry; - groupPoliciesWithChatEnabled: readonly never[] | Array>; hasSeenTour: boolean; }; @@ -87,8 +87,6 @@ function EmptySearchView({similarSearchHash, type, hasResults, queryJSON, violat const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); - const groupPoliciesWithChatEnabled = getGroupPoliciesWhereReportCanBeCreated(allPolicies); - const [hasSeenTour = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { selector: hasSeenTourSelector, }); @@ -103,7 +101,6 @@ function EmptySearchView({similarSearchHash, type, hasResults, queryJSON, violat typeMenuSections={typeMenuSections} allPolicies={allPolicies} activePolicy={activePolicy} - groupPoliciesWithChatEnabled={groupPoliciesWithChatEnabled} hasSeenTour={hasSeenTour} queryJSON={queryJSON} violationSnapshotStartedAt={violationSnapshotStartedAt} @@ -128,7 +125,6 @@ function EmptySearchViewContent({ typeMenuSections, allPolicies, activePolicy, - groupPoliciesWithChatEnabled, hasSeenTour, queryJSON, violationSnapshotStartedAt, @@ -139,6 +135,7 @@ function EmptySearchViewContent({ const timezone = useCurrentTimezone(); const styles = useThemeStyles(); const isInLandscapeMode = useIsInLandscapeMode(); + const tabRootScrollProps = useTabRootScrollProps([styles.flexGrow1, styles.flexShrink0, contentContainerStyle], {fillsVisibleArea: true}); const illustrations = useMemoizedLazyIllustrations(['EmptyStateTravel']); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); @@ -157,8 +154,6 @@ function EmptySearchViewContent({ const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const defaultChatEnabledPolicy = getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled, activePolicy); - const filteredPolicyID = getFilterFromQuery(queryJSON, CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID); let isFilteredWorkspaceAccessible = true; if (filteredPolicyID.value) { @@ -167,8 +162,8 @@ function EmptySearchViewContent({ isFilteredWorkspaceAccessible = !!filteredPolicy; } - const handleCreateWorkspaceReport = (shouldDismissEmptyReportsConfirmation?: boolean) => { - if (!defaultChatEnabledPolicy?.id) { + const handleCreateWorkspaceReport = (policy: OnyxEntry, shouldDismissEmptyReportsConfirmation?: boolean) => { + if (!policy?.id) { return; } @@ -176,7 +171,7 @@ function EmptySearchViewContent({ currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, - defaultChatEnabledPolicy, + policy, isTrackIntentUser, getCurrencyDecimals, rules, @@ -195,7 +190,6 @@ function EmptySearchViewContent({ const {createReport, isVisible: isCreateReportVisible} = useCreateReport({ onCreateReport: handleCreateWorkspaceReport, - groupPoliciesWithChatEnabled, }); const handleCreateMoneyRequest = (iouType: typeof CONST.IOU.TYPE.CREATE | typeof CONST.IOU.TYPE.INVOICE) => { @@ -421,7 +415,7 @@ function EmptySearchViewContent({ return ( diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/MerchantFilterContentPageWrapper.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/MerchantFilterContentPageWrapper.tsx new file mode 100644 index 000000000000..d49a1e36d85a --- /dev/null +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/MerchantFilterContentPageWrapper.tsx @@ -0,0 +1,23 @@ +import MerchantFilterContent from '@components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent'; +import type {MerchantFilterContentWrapperProps} from '@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +function MerchantFilterContentPageWrapper({baseFilterKey, value, isNegated, merchantOperator, buttonText, onChange}: MerchantFilterContentWrapperProps) { + return ( + + ); +} + +export default MerchantFilterContentPageWrapper; diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx index 31e9652459fa..d93abad75c4b 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx @@ -33,6 +33,7 @@ import {View} from 'react-native'; import AmountFilterContentPageWrapper from './AmountFilterContentPageWrapper'; import DateFilterContentPageWrapper from './DateFilterContentPageWrapper'; import ListFilterContentPageWrapper from './ListFilterContentPageWrapper'; +import MerchantFilterContentPageWrapper from './MerchantFilterContentPageWrapper'; import ReportFieldFilterContentPageWrapper from './ReportFieldFilterContentPageWrapper'; import TextInputFilterContentPageWrapper from './TextInputFilterContentPageWrapper'; @@ -115,6 +116,7 @@ function SearchAdvancedFiltersContentBase() { ready={didScreenTransitionEnd} components={{ List: ListFilterContentPageWrapper, + Merchant: MerchantFilterContentPageWrapper, Text: TextInputFilterContentPageWrapper, Amount: AmountFilterContentPageWrapper, Date: DateFilterContentPageWrapper, diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx index 5a2ea21b9225..277f72eda168 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx @@ -1,16 +1,27 @@ import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; +import {PressableWithFeedback} from '@components/Pressable'; import ScreenWrapper from '@components/ScreenWrapper'; import FilterList from '@components/Search/FilterComponents/AdvancedFilters/FilterList'; +import SpacerView from '@components/SpacerView'; +import Text from '@components/Text'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import getButtonState from '@libs/getButtonState'; import Navigation from '@libs/Navigation/Navigation'; import {SearchAdvancedFiltersActionContext, SearchAdvancedFiltersContext} from '@pages/Search/SearchAdvancedFiltersProvider'; +import variables from '@styles/variables'; + import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -19,10 +30,15 @@ import {View} from 'react-native'; function SearchAdvancedFiltersBase() { const styles = useThemeStyles(); + const theme = useTheme(); const {translate} = useLocalize(); const {currentDraftFilters, shouldShowResetFilters} = useContext(SearchAdvancedFiltersContext); const {applyFilters, resetFilters} = useContext(SearchAdvancedFiltersActionContext); const isInLandscapeMode = useIsInLandscapeMode(); + const StyleUtils = useStyleUtils(); + const {isBetaEnabled} = usePermissions(); + const canUseNLFilters = isBetaEnabled(CONST.BETAS.NL_FILTERS); + const icons = useMemoizedLazyExpensifyIcons(['Sparkles', 'ArrowRight']); return ( + {canUseNLFilters && ( + <> + [styles.typeFilterMenu, pressed && styles.buttonHoveredBG]} + accessible + accessibilityLabel={translate('search.filters.describeSearch.title')} + sentryLabel="SearchAdvancedFiltersBase-DescribeSearch" + onPress={() => Navigation.navigate(ROUTES.SEARCH_NL_FILTER)} + > + {({pressed}) => ( + <> + + {translate('search.filters.describeSearch.title')} + + + )} + + + + )} { return () => { clearBulkEditDraftTransaction(); @@ -382,7 +387,7 @@ function SearchEditMultiplePage() { {areAttendeesEnabled && ( Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_ATTENDEES_RHP)} isDisabled={isAttendeesDisabled} /> diff --git a/src/pages/Search/SearchNLFilterPage.tsx b/src/pages/Search/SearchNLFilterPage.tsx new file mode 100644 index 000000000000..58e352207617 --- /dev/null +++ b/src/pages/Search/SearchNLFilterPage.tsx @@ -0,0 +1,57 @@ +/** + * Full-screen page (narrow layout / RHP) wrapping the "Describe your search" natural-language filter input. + */ +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SearchNLFilterContent from '@components/Search/FilterComponents/AdvancedFilters/SearchNLFilterContent'; + +import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; + +import React, {useEffect} from 'react'; + +function SearchNLFilterPage() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isBetaEnabledOrUnknown} = usePermissions(); + const isNLFiltersBeta = isBetaEnabledOrUnknown(CONST.BETAS.NL_FILTERS); + + useEffect(() => { + if (isNLFiltersBeta !== false) { + return; + } + Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS); + }, [isNLFiltersBeta]); + + const handleSuccess = (route: Route) => { + Navigation.dismissModal({afterTransition: () => Navigation.navigate(route)}); + }; + + return ( + + Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS)} + /> + + + ); +} + +export default SearchNLFilterPage; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index f9fcd25fa461..0b56b4ff15a8 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -6,6 +6,7 @@ import {usePlaybackActionsContext} from '@components/VideoPlayerContexts/Playbac import useDocumentTitle from '@hooks/useDocumentTitle'; import useEndSubmitNavigationSpans from '@hooks/useEndSubmitNavigationSpans'; import useHasFilterBars from '@hooks/useHasFilterBars'; +import useHasTabBeenShown from '@hooks/useHasTabBeenShown'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import {PaymentContextProvider} from '@hooks/usePaymentContext'; @@ -25,6 +26,7 @@ import {isQueryARefinement} from '@libs/SearchQueryRefinement'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; import type SCREENS from '@src/SCREENS'; import type {SearchResults} from '@src/types/onyx'; @@ -51,7 +53,10 @@ function SearchPage({route}: SearchPageProps) { const [lastNonEmptySearchResults, setLastNonEmptySearchResults] = useState(undefined); - useSearchPageSetup(currentSearchQueryJSON); + // Until Spend is first shown, it neither requests a search nor saves its default query over the latest search that the + // Spend tab reopens. + const hasSpendBeenShown = useHasTabBeenShown(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + useSearchPageSetup(hasSpendBeenShown ? currentSearchQueryJSON : undefined); useSeedMyExpensesSearch(); useReleaseOptionListCaches(); diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 3092bb405f2a..94c01f0a2590 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -3,7 +3,8 @@ import CollapsibleHeaderOnKeyboardGroup from '@components/CollapsibleHeaderOnKey import CollapsibleHeaderOnKeyboardGroupMember from '@components/CollapsibleHeaderOnKeyboard/GroupMember'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabBarCoveredHeight from '@components/Navigation/TabBarBottomContent/useTabBarCoveredHeight'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import PulsingView from '@components/PulsingView'; import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -15,6 +16,7 @@ import SearchPageHeaderNarrow from '@components/Search/SearchPageHeader/SearchPa import SearchSelectionFooter from '@components/Search/SearchSelectionFooter'; import SearchWithNavigationDeferredMount from '@components/Search/SearchWithNavigationDeferredMount'; import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; +import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useEndSubmitNavigationSpans from '@hooks/useEndSubmitNavigationSpans'; @@ -79,8 +81,6 @@ type SearchPageNarrowProps = { isOverlayActive: boolean; }; -const tabBarContent = ; - function SearchPageNarrow({ queryJSON, searchResults, @@ -103,6 +103,10 @@ function SearchPageNarrow({ const {windowHeight} = useWindowDimensions(); const styles = useThemeStyles(); const {pageGutterMargin} = useLayoutSpacing(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.SEARCH); + const tabBarCoveredHeight = useTabBarCoveredHeight(); + // The footer is shown and hidden by its own selection state, so its height is read from layout. + const [footerHeight, setFooterHeight] = useState(0); const StyleUtils = useStyleUtils(); const {clearSelectedTransactions} = useSearchSelectionActions(); const {shouldUseLiveData} = useSearchResultsContext(); @@ -191,6 +195,8 @@ function SearchPageNarrow({ }); const [isInteractive, setIsInteractive] = useState(!useStaticRendering); const [isHeaderInteractive, setIsHeaderInteractive] = useState(!useStaticRendering); + // A page mounted in the background, as native tabs mount Spend, keeps its skeleton still until it is first shown. + const [hasBeenFocused, setHasBeenFocused] = useState(!useStaticRendering); const isHeaderInteractiveRef = useRef(isHeaderInteractive); const [, startTransition] = useTransition(); useEffect(() => { @@ -213,6 +219,7 @@ function SearchPageNarrow({ // useFocusEffect avoids the extra re-renders that useIsFocused causes on every focus change. useFocusEffect( useCallback(() => { + setHasBeenFocused(true); if (isInteractive) { return; } @@ -252,6 +259,10 @@ function SearchPageNarrow({ const contentContainerStyle = !isMobileSelectionModeEnabled ? styles.searchListContentContainerStyles(hasFilterBars) : undefined; const shouldRenderLayoutProbe = (isOverlayActive || !isHeaderInteractive) && !searchOverlayContent; + // Static rendering also covers Spend mounted in the background by native tabs, which may be first shown before + // any results exist, so it needs the loading skeleton too. The submit overlay is the exception: Search has to + // mount under it even without results, to flush the deferred write and dismiss the overlay. + const shouldShowStaticLoadingSkeleton = shouldShowLoadingSkeleton && !isOverlayActive; return ( 0 ? styles.offlineIndicatorAboveFooter(footerHeight) : tabRootScreenWrapperProps.offlineIndicatorStyle]} > {!isMobileSelectionModeEnabled ? ( @@ -337,18 +347,27 @@ function SearchPageNarrow({ {useStaticRendering && ( <> - {isInteractive && ( - + ) : ( + + ))} + {!isInteractive && !searchOverlayContent && ( + )} {shouldRenderLayoutProbe && } @@ -398,7 +417,15 @@ function SearchPageNarrow({ )} - + setFooterHeight(event.nativeEvent.layout.height)} + > + + diff --git a/src/pages/Search/SearchReportsMergeReports.tsx b/src/pages/Search/SearchReportsMergeReports.tsx index 1f7fff4c4167..1494a1045e55 100644 --- a/src/pages/Search/SearchReportsMergeReports.tsx +++ b/src/pages/Search/SearchReportsMergeReports.tsx @@ -23,6 +23,7 @@ import {mergeReports} from '@libs/actions/Report'; import {getReportFromSearchSnapshot, setSearchMergeReportIDs} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; +import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; import {canMergeReports, getMoneyRequestSpendBreakdown, getPersonalDetailsForAccountID} from '@libs/ReportUtils'; import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper'; @@ -164,7 +165,14 @@ function SearchMergeReports() { !!destinationReportID && !!destinationReport && sourceReportIDs.length > 0 && - canMergeReports(reportItems, currentUserPersonalDetails.accountID, rules, reportNameValuePairs, allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${reportItems.at(0)?.policyID}`]); + canMergeReports( + reportItems, + currentUserPersonalDetails.accountID, + rules, + getLoginByAccountID(reportItems.at(0)?.ownerAccountID, personalDetails), + reportNameValuePairs, + allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${reportItems.at(0)?.policyID}`], + ); const mergeSelectedReports = () => { if (!destinationReportID || !destinationReport || !isValidForMerge) { @@ -223,7 +231,7 @@ function SearchMergeReports() { isLoadingOnyxData || selectedReportIDs?.some((reportID) => { const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportID}` as const; - return !allReports?.[reportKey] && !!currentSearchResults?.data[reportKey]; + return !allReports?.[reportKey] && !!currentSearchResults?.data?.[reportKey]; }); if (!isReportMergeBetaEnabled) { diff --git a/src/pages/Search/SearchSavePage.tsx b/src/pages/Search/SearchSavePage.tsx index d8befb33b33e..b32ecd1f5436 100644 --- a/src/pages/Search/SearchSavePage.tsx +++ b/src/pages/Search/SearchSavePage.tsx @@ -26,7 +26,7 @@ import {saveSearch} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import {rand64} from '@libs/NumberUtils'; import {savedSearchIDToSearchKey} from '@libs/SearchKeyUtils'; -import {getCustomColumnDefault, getSearchColumnTranslationKey, getValidGroupBy, mapFiltersFormToLabelValueList} from '@libs/SearchUIUtils'; +import {getCustomColumnDefault, getFooterCountItems, getFooterTotalItems, getSearchColumnTranslationKey, getValidGroupBy, mapFiltersFormToLabelValueList} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; @@ -136,6 +136,22 @@ function getAppliedDisplays(searchAdvancedFiltersForm: Partial item.value === footerCount)?.text ?? footerCount}); + } + + if (searchAdvancedFiltersForm.footerTotal) { + const footerTotal = searchAdvancedFiltersForm.footerTotal; + appliedDisplays.push({label: translate('common.total'), value: getFooterTotalItems(translate).find((item) => item.value === footerTotal)?.text ?? footerTotal}); + } + + if (searchAdvancedFiltersForm.footerCurrency) { + appliedDisplays.push({label: translate('common.currency'), value: searchAdvancedFiltersForm.footerCurrency}); + } + if (queryJSON?.sortBy) { appliedDisplays.push({label: translate('search.display.sortBy'), value: translate(getSearchColumnTranslationKey(queryJSON.sortBy, queryJSON.type))}); } diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 17926abcaccc..0217d3e03fd5 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -1,8 +1,8 @@ -import DecisionModal from '@components/DecisionModal'; import {usePersonalDetails, useSession} from '@components/OnyxListItemProvider'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import type {ListItem} from '@components/SelectionList/types'; +import useAlertModals from '@hooks/useAlertModals'; import useChangeTransactionsReportReports from '@hooks/useChangeTransactionsReportReports'; import useConditionalCreateEmptyReportConfirmation from '@hooks/useConditionalCreateEmptyReportConfirmation'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; @@ -10,13 +10,11 @@ import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useHasPerDiemTransactions from '@hooks/useHasPerDiemTransactions'; import useHydrateReportsFromSnapshot from '@hooks/useHydrateReportsFromSnapshot'; import useLoadSearchCardData from '@hooks/useLoadSearchCardData'; -import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {createNewReport} from '@libs/actions/Report'; import {autoReportTransactions, changeTransactionsReport} from '@libs/actions/Transaction'; @@ -43,7 +41,7 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {PersonalDetails, Transaction} from '@src/types/onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import React, {useMemo, useState} from 'react'; +import React, {useMemo} from 'react'; type TransactionGroupListItem = ListItem & { /** reportID of the report */ @@ -57,12 +55,8 @@ function SearchTransactionsChangeReport() { const {currentSearchResults} = useSearchResultsContext(); const {currentSearchQueryJSON} = useSearchQueryContext(); const selectedTransactionsKeys = useMemo(() => Object.keys(selectedTransactions), [selectedTransactions]); - const {translate} = useLocalize(); const {isOffline} = useNetwork(); - // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); - const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); + const {showOfflineModal} = useAlertModals(); const allMatchingQueryParams = getAllMatchingQueryParams(areAllMatchingItemsSelected, excludedTransactions, currentSearchQueryJSON); @@ -114,7 +108,7 @@ function SearchTransactionsChangeReport() { // Get the policyID from the selected transactions' report to pass to usePolicyForMovingExpenses // This ensures the "Create report" button shows the correct workspace instead of the user's default const selectedReportPolicyID = selectedReportID ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReportID}`]?.policyID : undefined; - const {policyForMovingExpensesID, shouldSelectPolicy, shouldNavigateToUpgradePath} = usePolicyForMovingExpenses( + const {policyForMovingExpensesID, shouldSelectPolicy, shouldNavigateToUpgradePath, arePoliciesLoaded} = usePolicyForMovingExpenses( hasPerDiemTransactions, undefined, selectedReportPolicyID, @@ -231,9 +225,14 @@ function SearchTransactionsChangeReport() { const createReport = () => { if (shouldBlockOfflineAllMatchingMove()) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } + + if (!arePoliciesLoaded) { + return; + } + if (shouldNavigateToUpgradePath && selectedTransactionsKeys.length > 0) { const firstTransactionID = selectedTransactionsKeys.at(0); if (firstTransactionID) { @@ -286,7 +285,7 @@ function SearchTransactionsChangeReport() { return; } if (shouldBlockOfflineAllMatchingMove()) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } @@ -337,7 +336,7 @@ function SearchTransactionsChangeReport() { return; } if (shouldBlockOfflineAllMatchingMove()) { - setIsOfflineModalVisible(true); + showOfflineModal(); return; } const policyTagList = personalPolicyID ? allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${personalPolicyID}`] : {}; @@ -367,37 +366,26 @@ function SearchTransactionsChangeReport() { }; return ( - <> - - setIsOfflineModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isOfflineModalVisible} - onClose={() => setIsOfflineModalVisible(false)} - /> - + ); } diff --git a/src/pages/Share/ShareDetailsPage.tsx b/src/pages/Share/ShareDetailsPage.tsx index 44ee9c1d0f24..d00b5c317dca 100644 --- a/src/pages/Share/ShareDetailsPage.tsx +++ b/src/pages/Share/ShareDetailsPage.tsx @@ -62,7 +62,7 @@ function ShareDetailsPage({route}: ShareDetailsPageProps) { const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const styles = useThemeStyles(); - const {translate, dateFnsLocale} = useLocalize(); + const {translate, dateFnsLocale, formatPhoneNumber} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const [unknownUserDetails] = useOnyx(ONYXKEYS.SHARE_UNKNOWN_USER_DETAILS); const [currentAttachment] = useOnyx(ONYXKEYS.SHARE_TEMP_FILE); @@ -105,6 +105,7 @@ function ShareDetailsPage({route}: ShareDetailsPageProps) { currentUserAccountID, reportAttributesDerived, rules, + formatPhoneNumber, pendingDeleteMemberAccountIDs, }), [ @@ -120,6 +121,7 @@ function ShareDetailsPage({route}: ShareDetailsPageProps) { dateFnsLocale, convertToDisplayString, rules, + formatPhoneNumber, pendingDeleteMemberAccountIDs, ], ); diff --git a/src/pages/Share/ShareRootPage.tsx b/src/pages/Share/ShareRootPage.tsx index f8c53c173f6c..6ec0c6e448d9 100644 --- a/src/pages/Share/ShareRootPage.tsx +++ b/src/pages/Share/ShareRootPage.tsx @@ -37,11 +37,11 @@ function showErrorAlert(title: string, message: string) { Alert.alert(title, message, [ { onPress: () => { - Navigation.navigate(ROUTES.INBOX); + Navigation.navigate(ROUTES.HOME); }, }, ]); - Navigation.navigate(ROUTES.INBOX); + Navigation.navigate(ROUTES.HOME); } function ShareRootPage() { @@ -190,7 +190,7 @@ function ShareRootPage() { Navigation.navigate(ROUTES.INBOX)} + onBackButtonPress={() => Navigation.navigate(ROUTES.HOME)} /> {isFileReady ? ( { - const defaultReimbursable = (isPolicyExpenseChat && isGroupPolicy(policy)) || isCreatingTrackExpense ? (policy?.defaultReimbursable ?? true) : true; + const defaultReimbursable = getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: !!isPolicyExpenseChat, isCreatingTrackExpense}); setMoneyRequestReimbursable(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, defaultReimbursable); }, [policy, isPolicyExpenseChat, isCreatingTrackExpense]); diff --git a/src/pages/ShareCodePage.tsx b/src/pages/ShareCodePage.tsx index 2f9cb6faf826..e1c1eda00954 100644 --- a/src/pages/ShareCodePage.tsx +++ b/src/pages/ShareCodePage.tsx @@ -58,6 +58,9 @@ type ShareCodePageOnyxProps = { /** The policy for the report currently being looked at */ policy?: OnyxEntry; + + /** Whether to show only the QR code, without the rows under it */ + shouldShowQRCodeOnly?: boolean; }; type ShareCodePageProps = ShareCodePageOnyxProps & BackToParams; @@ -98,7 +101,7 @@ function createAvatarQRLogo(LocalAvatarLogo: React.FC): React.FC getDisplayNameForParticipant({accountID, formatPhoneNumber, hiddenTranslation: hiddenText})) .join(' & '); } @@ -212,40 +215,42 @@ function ShareCodePage({report, policy, backTo}: ShareCodePageProps) { /> - - Clipboard.setString(url)} - shouldLimitWidth={false} - /> - {/* Remove this platform specific condition once https://github.com/Expensify/App/issues/19834 is done. - We shouldn't introduce platform specific code in our codebase. - This is a temporary solution while Web is not supported for the QR code download feature */} - {shouldAllowDownloadQRCode && ( - qrCodeRef.current?.download?.()}> - - - - - - {translate('common.download')} - - - - )} - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REFERRAL_DETAILS.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE))); - }} - /> - + {!shouldShowQRCodeOnly && ( + + Clipboard.setString(url)} + shouldLimitWidth={false} + /> + {/* Remove this platform specific condition once https://github.com/Expensify/App/issues/19834 is done. + We shouldn't introduce platform specific code in our codebase. + This is a temporary solution while Web is not supported for the QR code download feature */} + {shouldAllowDownloadQRCode && ( + qrCodeRef.current?.download?.()}> + + + + + + {translate('common.download')} + + + + )} + + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REFERRAL_DETAILS.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE))); + }} + /> + + )} ); diff --git a/src/pages/TrackExpensePage.tsx b/src/pages/TrackExpensePage.tsx index ddaa82617787..2a8e031f975d 100644 --- a/src/pages/TrackExpensePage.tsx +++ b/src/pages/TrackExpensePage.tsx @@ -43,12 +43,7 @@ function TrackExpensePage() { return; } Navigation.goBack(); - startMoneyRequest( - CONST.IOU.TYPE.TRACK, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - findSelfDMReportID() || generateReportID(), - draftTransactionIDs, - ); + startMoneyRequest(CONST.IOU.TYPE.TRACK, findSelfDMReportID() || generateReportID(), draftTransactionIDs); if (!hasSeenTrackTraining && !isOffline && !shouldSuppressPromotionalUI) { setTimeout(() => { diff --git a/src/pages/TransactionDuplicate/DynamicReviewPage.tsx b/src/pages/TransactionDuplicate/DynamicReviewPage.tsx index b053d14073b4..e46b32624900 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewPage.tsx @@ -1,6 +1,6 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import Button from '@components/Button'; -import ConfirmationPage from '@components/ConfirmationPage'; +import ConfirmationPageDefault from '@components/ConfirmationPage/composed/ConfirmationPageDefault'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; @@ -274,10 +274,9 @@ function DynamicReviewPage() { title={translate('iou.reviewDuplicates')} onBackButtonPress={() => Navigation.goBack(backPath, {compareParams: false})} /> - Navigation.goBack(backPath, {compareParams: false})} /> diff --git a/src/pages/TransactionMerge/DynamicConfirmationPage.tsx b/src/pages/TransactionMerge/DynamicConfirmationPage.tsx index 77d345cb7f43..70118ea98532 100644 --- a/src/pages/TransactionMerge/DynamicConfirmationPage.tsx +++ b/src/pages/TransactionMerge/DynamicConfirmationPage.tsx @@ -91,6 +91,7 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { const [selfDMReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(selfDMReport?.reportID)}`); const [sourceReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(sourceTransaction?.reportID)}`); + const sourceReportTransactionsCollection = useReportTransactionsCollection(sourceTransaction?.reportID); const sourceIOUAction = sourceTransaction ? getIOUActionForTransactionID(Object.values(sourceReportActions ?? {}), sourceTransaction.transactionID) : undefined; const selfDMSourceIOUAction = selfDMReport?.reportID && sourceTransaction ? getIOUActionForTransactionID(Object.values(selfDMReportActions ?? {}), sourceTransaction.transactionID) : undefined; @@ -116,6 +117,8 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { // lingers in the stack and briefly flashes the "not found" page when the user taps back. Must be read pre-merge. const willDeleteTargetTransactionReport = Object.keys(targetReportTransactionsCollection ?? {}).length === 1; + const transactionsOfDeletableReportCollection = mergeTransaction.reportID === targetTransaction.reportID ? sourceReportTransactionsCollection : targetReportTransactionsCollection; + setIsMergingExpenses(true); mergeTransactionRequest({ @@ -126,6 +129,7 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { mergeTransaction, targetTransaction, sourceTransaction, + transactionsOfDeletableReportCollection, targetTransactionThreadReport, targetTransactionThreadParentReport, iouReportOwnerLogin, diff --git a/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx b/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx index 9760acfbb2dd..2ef0795c6f6d 100644 --- a/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx @@ -49,7 +49,7 @@ import MergeFieldReview from './MergeFieldReview'; type DynamicDetailsReviewPageProps = PlatformStackScreenProps; function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { - const {translate, localeCompare} = useLocalize(); + const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const styles = useThemeStyles(); const {getCurrencyDecimals, convertToDisplayString} = useCurrencyListActions(); const {transactionID, isOnSearch} = route.params; @@ -82,8 +82,6 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { [targetTransactionReport, sourceTransactionReport], targetTransactionPolicy, sourceTransactionPolicy, - targetReportOwnerAsAttendee, - sourceReportOwnerAsAttendee, ); setMergeTransactionKey(transactionID, mergeableData); @@ -98,8 +96,6 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { targetTransactionPolicy, sourceTransactionPolicy, getCurrencyDecimals, - targetReportOwnerAsAttendee, - sourceReportOwnerAsAttendee, ]); // Handle selection @@ -188,6 +184,7 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { translate, convertToDisplayString, localeCompare, + formatPhoneNumber, reports: [targetTransactionReport, sourceTransactionReport], }), [ @@ -204,6 +201,7 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { translate, convertToDisplayString, localeCompare, + formatPhoneNumber, ], ); diff --git a/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx b/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx index e4d2a4b7df9c..5415ec4e96f7 100644 --- a/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx +++ b/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx @@ -12,7 +12,6 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useMergeTransactions from '@hooks/useMergeTransactions'; import useOnyx from '@hooks/useOnyx'; -import useReportOwnerAsAttendee from '@hooks/useReportOwnerAsAttendee'; import useThemeStyles from '@hooks/useThemeStyles'; import {setMergeTransactionKey} from '@libs/actions/MergeTransaction'; @@ -47,8 +46,6 @@ function DynamicReceiptReviewPage({route}: DynamicReceiptReviewPageProps) { const [mergeTransaction, mergeTransactionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); const {targetTransaction, sourceTransaction, targetTransactionPolicy, sourceTransactionPolicy} = useMergeTransactions({mergeTransaction}); - const targetReportOwnerAsAttendee = useReportOwnerAsAttendee(targetTransaction); - const sourceReportOwnerAsAttendee = useReportOwnerAsAttendee(sourceTransaction); const transactions = [targetTransaction, sourceTransaction].filter((transaction): transaction is Transaction => !!transaction); @@ -69,8 +66,6 @@ function DynamicReceiptReviewPage({route}: DynamicReceiptReviewPageProps) { [], targetTransactionPolicy, sourceTransactionPolicy, - targetReportOwnerAsAttendee, - sourceReportOwnerAsAttendee, ); if (!conflictFields.length) { // If there are no conflict fields, we should set mergeable data and navigate to the confirmation page diff --git a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx index 6b541330b8b8..5458652aacbd 100644 --- a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx +++ b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx @@ -23,10 +23,10 @@ import {getAmount, getCreated, getCurrency, getDescription, getMerchant, isExpen import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {MergeTransaction} from '@src/types/onyx'; +import type {MergeTransaction, Policy, Report} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import React, {useEffect} from 'react'; import {View} from 'react-native'; @@ -40,6 +40,23 @@ type MergeTransactionsListContentProps = { mergeTransaction: OnyxEntry; }; +const reportsSelector = (reports: OnyxCollection): OnyxCollection => + Object.fromEntries( + Object.entries(reports ?? {}).map(([key, report]) => [ + key, + report && { + reportID: report.reportID, + type: report.type, + policyID: report.policyID, + ownerAccountID: report.ownerAccountID, + managerID: report.managerID, + stateNum: report.stateNum, + statusNum: report.statusNum, + total: report.total, + }, + ]), + ) as OnyxCollection; + function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTransactionsListContentProps) { const illustrations = useMemoizedLazyIllustrations(['EmptyShelves']); const {translate, localeCompare} = useLocalize(); @@ -47,10 +64,32 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const [session] = useOnyx(ONYXKEYS.SESSION); - const [personalDetails] = useAllPersonalDetails(); const currentUserLogin = session?.email; const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const policiesSelector = (policies: OnyxCollection): OnyxCollection => + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- only the fields used by the admin check are kept + Object.fromEntries( + Object.entries(policies ?? {}).map(([key, policy]) => { + const login = currentUserLogin ?? ''; + const employee = policy?.employeeList?.[login] ?? policy?.employeeList?.[login.toLowerCase()]; + return [ + key, + policy && { + id: policy.id, + role: policy.role, + employeeList: employee ? {[login]: employee} : undefined, + }, + ]; + }), + ) as OnyxCollection; + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: policiesSelector, + }); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { + selector: reportsSelector, + }); + const [personalDetails] = useAllPersonalDetails(); const {isOffline} = useNetwork(); const {convertToDisplayString, getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -75,6 +114,9 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr report: targetTransactionReport, currentUserLogin, rules, + personalDetails, + allPolicies, + allReports, }); }, [ transactions, @@ -86,6 +128,9 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr targetTransaction, targetReportTransactions, rules, + personalDetails, + allPolicies, + allReports, ]); const data = !eligibleTransactions @@ -179,18 +224,10 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr } const reports = targetTransactionReport && sourceTransactionReport ? [targetTransactionReport, sourceTransactionReport] : undefined; - setupMergeTransactionDataAndNavigate( - transactionID, - [targetTransaction, sourceTransaction], - localeCompare, - getCurrencyDecimals, - reports, - true, - undefined, - [targetTransactionPolicy, sourceTransactionPolicy], - session?.accountID, - personalDetails, - ); + setupMergeTransactionDataAndNavigate(transactionID, [targetTransaction, sourceTransaction], localeCompare, getCurrencyDecimals, reports, true, undefined, [ + targetTransactionPolicy, + sourceTransactionPolicy, + ]); }; const confirmButtonOptions = { diff --git a/src/pages/domain/BaseDomainVerifiedPage.tsx b/src/pages/domain/BaseDomainVerifiedPage.tsx index a772a7d7fd24..935737e95a65 100644 --- a/src/pages/domain/BaseDomainVerifiedPage.tsx +++ b/src/pages/domain/BaseDomainVerifiedPage.tsx @@ -1,7 +1,7 @@ import ConfirmationPage from '@components/ConfirmationPage'; +import FixedFooter from '@components/FixedFooter'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import LottieAnimations from '@components/LottieAnimations'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -64,19 +64,21 @@ function BaseDomainVerifiedPage({domainAccountID, redirectTo, confirmDestination shouldShowOfflineIndicator={false} > - + + + {translate('domain.domainVerified.header')} - } - innerContainerStyle={styles.p10} - buttonText={translate('common.buttonConfirm')} - shouldShowButton - onButtonPress={() => Navigation.navigate(confirmDestination)} - /> + + + Navigation.navigate(confirmDestination)} + /> + + ); } diff --git a/src/pages/domain/DomainAccessRestrictedPage.tsx b/src/pages/domain/DomainAccessRestrictedPage.tsx index 7f57737bdbdd..068745da69aa 100644 --- a/src/pages/domain/DomainAccessRestrictedPage.tsx +++ b/src/pages/domain/DomainAccessRestrictedPage.tsx @@ -1,5 +1,5 @@ import ConfirmationPage from '@components/ConfirmationPage'; -import FormHelpMessage from '@components/FormHelpMessage'; +import FixedFooter from '@components/FixedFooter'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import RenderHTML from '@components/RenderHTML'; @@ -13,7 +13,6 @@ import useRedirectOnDomainAccessChange from '@hooks/useRedirectOnDomainAccessCha import useThemeStyles from '@hooks/useThemeStyles'; import {clearRequestAdminshipError, requestDomainAdminship} from '@libs/actions/Domain'; -import {getLatestErrorMessage} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspacesDomainModalNavigatorParamList} from '@libs/Navigation/types'; @@ -69,37 +68,35 @@ function DomainAccessRestrictedPage({route}: DomainAccessRestrictedPageProps) { title={translate('domain.accessRestricted.headerTitle')} onBackButtonPress={Navigation.goBack} /> - + + + {translate('domain.accessRestricted.title')} - } - footerComponent={ - !!requestError && ( - - ) - } - shouldShowSecondaryButton - secondaryButtonText={translate(hasPendingRequest ? 'domain.requestSent' : 'domain.accessRestricted.requestAdminAccess')} - isSecondaryButtonLoading={isRequestPending} - isSecondaryButtonDisabled={!isRequestPending && (!!hasPendingRequest || isOffline)} - onSecondaryButtonPress={() => { - if (!currentUserAccountID) { - return; - } - requestDomainAdminship(domainAccountID, currentUserAccountID, false); - }} - shouldShowButton - buttonText={translate('domain.accessRestricted.verifyYourself')} - onButtonPress={() => Navigation.navigate(ROUTES.WORKSPACES_VERIFY_DOMAIN.getRoute(domainAccountID))} - /> + + + { + if (!currentUserAccountID) { + return; + } + requestDomainAdminship(domainAccountID, currentUserAccountID, false); + }} + /> + Navigation.navigate(ROUTES.WORKSPACES_VERIFY_DOMAIN.getRoute(domainAccountID))} + /> + + )} diff --git a/src/pages/domain/DomainAddedPage.tsx b/src/pages/domain/DomainAddedPage.tsx index 7860f6a7354e..aa3aeac41b30 100644 --- a/src/pages/domain/DomainAddedPage.tsx +++ b/src/pages/domain/DomainAddedPage.tsx @@ -1,4 +1,4 @@ -import ConfirmationPage from '@components/ConfirmationPage'; +import ConfirmationPageDefault from '@components/ConfirmationPage/composed/ConfirmationPageDefault'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {loadIllustration} from '@components/Icon/IllustrationLoader'; @@ -47,14 +47,13 @@ function DomainAddedPage({route}: DomainAddedPageProps) { return ( - Navigation.navigate(ROUTES.DOMAIN_INITIAL.getRoute(domainAccountID))} /> diff --git a/src/pages/domain/DomainAlreadyExistsPage.tsx b/src/pages/domain/DomainAlreadyExistsPage.tsx index 85a94694c2cf..11a1db75624a 100644 --- a/src/pages/domain/DomainAlreadyExistsPage.tsx +++ b/src/pages/domain/DomainAlreadyExistsPage.tsx @@ -1,5 +1,5 @@ import ConfirmationPage from '@components/ConfirmationPage'; -import FormHelpMessage from '@components/FormHelpMessage'; +import FixedFooter from '@components/FixedFooter'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {loadIllustration} from '@components/Icon/IllustrationLoader'; @@ -13,7 +13,6 @@ import useRedirectOnDomainAccessChange from '@hooks/useRedirectOnDomainAccessCha import useThemeStyles from '@hooks/useThemeStyles'; import {clearRequestAdminshipError, requestDomainAdminship} from '@libs/actions/Domain'; -import {getLatestErrorMessage} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspacesDomainModalNavigatorParamList} from '@libs/Navigation/types'; @@ -80,36 +79,37 @@ function DomainAlreadyExistsPage({route}: DomainAlreadyExistsPageProps) { title={translate('domain.domainAlreadyExists.headerTitle')} onBackButtonPress={goToDomainsList} /> - - ) - } - shouldShowSecondaryButton - secondaryButtonText={translate('domain.common.neverMind')} - onSecondaryButtonPress={goToDomainsList} - shouldShowButton - buttonText={translate(hasPendingRequest ? 'domain.requestSent' : 'domain.domainAlreadyExists.requestAccess')} - isButtonLoading={isRequestPending} - isButtonDisabled={!isRequestPending && (!!hasPendingRequest || isOffline)} - onButtonPress={() => { - if (!currentUserAccountID) { - return; - } - setHasSubmittedRequest(true); - requestDomainAdminship(domainAccountID, currentUserAccountID, !!isTransientDomainEntry); - }} - /> + + + + {translate('domain.domainAlreadyExists.title')} + {translate('domain.domainAlreadyExists.description')} + + + + { + if (!currentUserAccountID) { + return; + } + setHasSubmittedRequest(true); + requestDomainAdminship(domainAccountID, currentUserAccountID, !!isTransientDomainEntry); + }} + /> + + ); } diff --git a/src/pages/domain/DomainInitialPage.tsx b/src/pages/domain/DomainInitialPage.tsx index 64187a0751d4..a5686293daeb 100644 --- a/src/pages/domain/DomainInitialPage.tsx +++ b/src/pages/domain/DomainInitialPage.tsx @@ -2,7 +2,8 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -43,6 +44,8 @@ type DomainInitialPageProps = PlatformStackScreenProps findFocusedRoute(state)?.name); @@ -102,8 +105,7 @@ function DomainInitialPage({route}: DomainInitialPageProps) { } - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > Navigation.dismissModal()} @@ -121,7 +123,7 @@ function DomainInitialPage({route}: DomainInitialPageProps) { shouldDisplayAccountButton /> - + {/* Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. diff --git a/src/pages/domain/Members/DomainMemberDetailsPage.tsx b/src/pages/domain/Members/DomainMemberDetailsPage.tsx index 345e09535742..b012ff301f7b 100644 --- a/src/pages/domain/Members/DomainMemberDetailsPage.tsx +++ b/src/pages/domain/Members/DomainMemberDetailsPage.tsx @@ -22,6 +22,7 @@ import { closeUserAccount, setTwoFactorAuthExemptEmailForDomain, } from '@libs/actions/Domain'; +import {clearDraftValues} from '@libs/actions/FormActions'; import {requestUnlockAccount} from '@libs/actions/User'; import {getLatestError} from '@libs/ErrorUtils'; @@ -165,10 +166,14 @@ function DomainMemberDetailsPage({route}: DomainMemberDetailsPageProps) { Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID))} + timezone={personalDetails?.timezone?.selected} + onPress={() => { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE.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 styles = useThemeStyles(); + + 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)); + }; + + // 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; + } + + setDomainVacationDelegate(domainAccountID, accountID, currentUserLogin ?? '', memberLogin, delegate, vacationDelegate, clearAfter); + goBackToMemberDetails(); + }; + + const onRemove = () => { + if (!memberLogin || !vacationDelegate) { + return; + } + + deleteDomainVacationDelegate(domainAccountID, accountID, memberLogin, vacationDelegate); + goBackToMemberDetails(); + }; + + return ( + + + + {/* 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} + /> + )} + + + ); +} + +export default DomainMemberVacationDelegateFormPage; diff --git a/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx b/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx index f86ddcef8c47..90e2ec7c8b05 100644 --- a/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx +++ b/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx @@ -6,34 +6,42 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; +import {setDraftValues} from '@libs/actions/FormActions'; import Navigation from '@libs/Navigation/Navigation'; +import {getActiveVacationDelegate} from '@libs/VacationDelegateUtils'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; 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 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 {useNavigationState} from '@react-navigation/native'; import {vacationDelegateSelector} from '@selectors/Domain'; 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 isFormInStack = useNavigationState((state) => state.routes.some((stackRoute) => stackRoute.name === SCREENS.DOMAIN.VACATION_DELEGATE)); const [personalDetails] = usePersonalDetail(accountID); const memberLogin = personalDetails?.login; @@ -45,14 +53,16 @@ function DomainMemberVacationDelegatePage({route}: DomainMemberVacationDelegateP return; } - if (delegateLogin === vacationDelegate?.delegate) { - deleteDomainVacationDelegate(domainAccountID, accountID, memberLogin, 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}); + + // 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; } - - setDomainVacationDelegate(domainAccountID, accountID, currentUserLogin ?? '', memberLogin, delegateLogin, vacationDelegate); - Navigation.goBack(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID)); + Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID)); }; return ( @@ -62,12 +72,16 @@ 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} + // 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/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 8e5d886182ff..4da89cf44f38 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -45,7 +45,7 @@ import type {AnchorPosition} from '@src/styles'; import type {FileObject} from '@src/types/utils/Attachment'; import type {ComponentRef} from 'react'; -import type {HostInstance, TextInputKeyPressEvent} from 'react-native'; +import type {TextInputKeyPressEvent} from 'react-native'; import {useIsFocused} from '@react-navigation/core'; import React, {useEffect, useRef, useState} from 'react'; @@ -130,7 +130,7 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible, isCopyLoading}: Co const [longPlaceholderHeight, setLongPlaceholderHeight] = useState(null); const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(null); const actionButtonRef = useRef | HTMLDivElement | null>(null); - const animatedRef = useAnimatedRef(); + const animatedRef = useAnimatedRef(); const containerRef = useRef>(null); diff --git a/src/pages/home/ForYouSection/useReviewFlaggedExpenses.ts b/src/pages/home/ForYouSection/useReviewFlaggedExpenses.ts index 83d806751b07..48169854c1bb 100644 --- a/src/pages/home/ForYouSection/useReviewFlaggedExpenses.ts +++ b/src/pages/home/ForYouSection/useReviewFlaggedExpenses.ts @@ -5,7 +5,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {CAROUSEL_SOURCE, setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; -import {isOneTransactionReport} from '@libs/ReportUtils'; +import {getSubmitterUnfixableViolationNames, isOneTransactionReport, isReportEligibleForViolationFix} from '@libs/ReportUtils'; import {getVisibleTransactionViolations} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; @@ -17,6 +17,7 @@ import type TransactionViolations from '@src/types/onyx/TransactionViolation'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useIsFocused} from '@react-navigation/native'; +import {forwardedSinceLastSubmitReportIDsSelector} from '@selectors/ReportAction'; import noop from 'lodash/noop'; import {useState} from 'react'; @@ -39,13 +40,14 @@ type FlaggedExpense = { reportID: string; }; -/** - * Returns true when this report is an OPEN expense report owned by the current user. - * - * `currentUserAccountID` is required. Callers should pass `session?.accountID ?? CONST.DEFAULT_NUMBER_ID` - * so that the ownership check fails closed when the session is not yet populated (no real ownerAccountID is 0). - */ -function isCurrentUserOpenExpenseReport(report: Report | null | undefined, currentUserAccountID: number): boolean { +// True when the current user owns this expense report and its state still lets them fix violations. +// Pass currentUserAccountID as `session?.accountID ?? DEFAULT_NUMBER_ID` so ownership fails closed before login. +function isCurrentUserReportEligibleForViolationFix( + report: Report | null | undefined, + policy: OnyxEntry, + currentUserAccountID: number, + hasBeenForwardedSinceLastSubmit?: boolean, +): boolean { if (!report) { return false; } @@ -55,15 +57,17 @@ function isCurrentUserOpenExpenseReport(report: Report | null | undefined, curre if (report.ownerAccountID !== currentUserAccountID) { return false; } - return report.stateNum === CONST.REPORT.STATE_NUM.OPEN && report.statusNum === CONST.REPORT.STATUS_NUM.OPEN; + return isReportEligibleForViolationFix(report, policy, hasBeenForwardedSinceLastSubmit); } -/** Returns true when at least one visible violation should surface in the "Review X expenses" row. */ -function hasReviewableViolation(violations: TransactionViolations | null | undefined): boolean { +// True when a violation should show in "Review expenses". +function hasReviewableViolation(violations: TransactionViolations | null | undefined, report: OnyxEntry): boolean { if (!violations || violations.length === 0) { return false; } + const {excludedViolations, excludedNotices} = getSubmitterUnfixableViolationNames(report); + return violations.some((violation) => { if (!violation) { return false; @@ -74,20 +78,27 @@ function hasReviewableViolation(violations: TransactionViolations | null | undef if (violation.name === CONST.REPORT_VIOLATIONS.FIELD_REQUIRED) { return false; } + if (excludedViolations.includes(violation.name)) { + return false; + } if (violation.type === CONST.VIOLATION_TYPES.NOTICE || violation.type === CONST.VIOLATION_TYPES.WARNING) { + if (violation.type === CONST.VIOLATION_TYPES.NOTICE && excludedNotices.includes(violation.name)) { + return false; + } return violation.showInReview === true; } return true; }); } -/** Scans the current user's OPEN expense reports for transactions that have at least one reviewable violation. */ +// Scans the current user's still-fixable expense reports for transactions with a reviewable violation. function getFlaggedExpenses( allReports: OnyxCollection, allTransactions: OnyxCollection, allTransactionViolations: OnyxCollection, allPolicies: OnyxCollection, session: OnyxEntry, + forwardedSinceLastSubmitReportIDs?: Record, ): FlaggedExpense[] { if (!allReports || !allTransactions) { return []; @@ -104,7 +115,9 @@ function getFlaggedExpenses( } const report = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; - if (!isCurrentUserOpenExpenseReport(report, currentUserAccountID)) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; + const hasBeenForwardedSinceLastSubmit = forwardedSinceLastSubmitReportIDs ? !!forwardedSinceLastSubmitReportIDs[transaction.reportID] : undefined; + if (!isCurrentUserReportEligibleForViolationFix(report, policy, currentUserAccountID, hasBeenForwardedSinceLastSubmit)) { continue; } @@ -113,11 +126,9 @@ function getFlaggedExpenses( continue; } - const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; - // The report is one of the current user's OPEN expense reports (see isCurrentUserOpenExpenseReport), - // so the report owner is the current user and the owner login is currentUserEmail. + // The report is owned by the current user, so the owner login is currentUserEmail. const visibleViolations = getVisibleTransactionViolations(transaction, violations, currentUserEmail, currentUserAccountID, report, currentUserEmail, policy); - if (!hasReviewableViolation(visibleViolations)) { + if (!hasReviewableViolation(visibleViolations, report)) { continue; } @@ -141,8 +152,10 @@ function useReviewFlaggedExpenses(): ReviewFlaggedExpenses { const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [session] = useOnyx(ONYXKEYS.SESSION); + // Catches a forward that lands via REPORT_ACTIONS alone, with no report write to trigger a re-render. + const [forwardedSinceLastSubmitReportIDs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {selector: forwardedSinceLastSubmitReportIDsSelector}); - const flaggedExpenses = isFocused ? getFlaggedExpenses(allReports, allTransactions, allTransactionViolations, allPolicies, session) : undefined; + const flaggedExpenses = isFocused ? getFlaggedExpenses(allReports, allTransactions, allTransactionViolations, allPolicies, session, forwardedSinceLastSubmitReportIDs) : undefined; // Retain the count across blur via "adjust state during render". It's a primitive, so this guard can't loop // and doesn't depend on the scan being referentially stable. diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index a09a8bb0a4f1..edfa2a4176ca 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -1,6 +1,7 @@ import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import QuickCreationActionsBar from '@components/Navigation/QuickCreationActionsBar'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import TopBar from '@components/Navigation/TopBar'; import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -13,6 +14,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useThemeStyles from '@hooks/useThemeStyles'; import openHomePage from '@libs/actions/HomePage'; @@ -45,7 +47,10 @@ function HomePage() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); const {pageGutter} = useLayoutSpacing(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.HOME); + const tabRootScrollProps = useTabRootScrollProps([styles.homePageContentContainer, pageGutter], {hasBottomSafeAreaPadding: true, hasOfflineIndicatorPadding: true}); const {translate} = useLocalize(); + const triggerScrollEvent = useScrollEventEmitter(); useDocumentTitle(translate('common.home')); const {isOffline} = useNetwork({onReconnect: openHomePage}); const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); @@ -146,8 +151,7 @@ function HomePage() { shouldShowOfflineIndicatorInWideScreen testID="HomePage" enableEdgeToEdgeBottomSafeAreaPadding={false} - bottomContent={} - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > diff --git a/src/pages/home/InsightsSection/InsightsSectionContent.tsx b/src/pages/home/InsightsSection/InsightsSectionContent.tsx index cb6a346e5c2e..a6a15e9a675d 100644 --- a/src/pages/home/InsightsSection/InsightsSectionContent.tsx +++ b/src/pages/home/InsightsSection/InsightsSectionContent.tsx @@ -15,6 +15,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {setNameValuePair} from '@libs/actions/User'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; import {INSIGHTS_CHART_STATE} from '@libs/resolveInsightsChartData'; import type {SearchKey} from '@libs/SearchKeyUtils'; @@ -100,7 +101,7 @@ function InsightsSectionContent() { data={data} isLoading={state === INSIGHTS_CHART_STATE.LOADING} shouldShowSinglePointAsBar={isInsightsPageEnabled} - shouldShowGroupLabels={!isInsightsPageEnabled} + shouldUseResponsiveLabels shouldDrillDownOnPress={!isInsightsPageEnabled} /> @@ -108,7 +109,7 @@ function InsightsSectionContent() { ); - if (!isInsightsPageEnabled) { + if (!isInsightsPageEnabled || canUseTouchScreen()) { return widget; } diff --git a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddDepositAccount.ts b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddDepositAccount.ts new file mode 100644 index 000000000000..facc518234c0 --- /dev/null +++ b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddDepositAccount.ts @@ -0,0 +1,30 @@ +import useOnyx from '@hooks/useOnyx'; + +import BankAccountModel from '@libs/models/BankAccount'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {BankAccountList} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {isCollectingDepositAccountsSelector} from '@selectors/Policy'; + +const hasDepositAccountSelector = (bankAccountList: OnyxEntry): boolean => + Object.values(bankAccountList ?? {}).some((bankAccountJSON) => { + const bankAccount = new BankAccountModel(bankAccountJSON); + const type = bankAccount.getType(); + return bankAccount.isOpen() && type === CONST.BANK_ACCOUNT.TYPE.PERSONAL; + }); + +function useTimeSensitiveAddDepositAccount() { + const [hasDepositAccount = false, bankAccountListMetadata] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {selector: hasDepositAccountSelector}); + const [isCollectingDepositAccounts = false] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: isCollectingDepositAccountsSelector}); + + const isBankAccountListRead = !isLoadingOnyxValue(bankAccountListMetadata); + + return {shouldShowAddDepositAccount: isBankAccountListRead && !hasDepositAccount && isCollectingDepositAccounts}; +} + +export default useTimeSensitiveAddDepositAccount; diff --git a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveNetSuiteMigration.ts b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveNetSuiteMigration.ts new file mode 100644 index 000000000000..a5c51342ac23 --- /dev/null +++ b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveNetSuiteMigration.ts @@ -0,0 +1,29 @@ +import useOnyx from '@hooks/useOnyx'; + +import {isArchivedOrPendingDeletePolicy} from '@libs/PolicyUtils/policyType'; + +import {shouldShowNetSuiteOAuthMigrationNudge} from '@pages/workspace/accounting/netsuite/utils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type NetSuiteMigrationPolicy = { + policyID: string; + policyName: string; +}; + +function useTimeSensitiveNetSuiteMigration() { + const [netSuiteMigrationPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: (allPolicies): NetSuiteMigrationPolicy[] => + Object.values(allPolicies ?? {}).flatMap((p) => { + if (!p || p.role !== CONST.POLICY.ROLE.ADMIN || isArchivedOrPendingDeletePolicy(p) || !shouldShowNetSuiteOAuthMigrationNudge(p, allPolicies)) { + return []; + } + return [{policyID: p.id, policyName: p.name}]; + }), + }); + + return {netSuiteMigrationPolicies: netSuiteMigrationPolicies ?? CONST.EMPTY_ARRAY}; +} + +export default useTimeSensitiveNetSuiteMigration; diff --git a/src/pages/home/TimeSensitiveSection/items/AddDepositAccount.tsx b/src/pages/home/TimeSensitiveSection/items/AddDepositAccount.tsx new file mode 100644 index 000000000000..a6bbdfb536bd --- /dev/null +++ b/src/pages/home/TimeSensitiveSection/items/AddDepositAccount.tsx @@ -0,0 +1,30 @@ +import BaseWidgetItem from '@components/BaseWidgetItem'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; + +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; + +import React from 'react'; + +function AddDepositAccount() { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Bank']); + + return ( + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.COLLECT_DEPOSIT_ACCOUNT.getRoute()))} + buttonVariant={CONST.BUTTON_VARIANT.SUCCESS} + /> + ); +} + +export default AddDepositAccount; diff --git a/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx b/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx index 0060836f5874..3d476c3d3e5b 100644 --- a/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx +++ b/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx @@ -15,7 +15,7 @@ import {showUnlockAlreadyRequestedModal} from '@libs/BankAccountUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React from 'react'; type UnlockBankAccountProps = { @@ -32,7 +32,9 @@ function UnlockBankAccount({bankAccountID, policyName}: UnlockBankAccountProps) const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); const [unlockRequestedAt] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${bankAccountID}`); @@ -51,7 +53,7 @@ function UnlockBankAccount({bankAccountID, policyName}: UnlockBankAccountProps) return; } pressLockedBankAccount(bankAccountID, translate, conciergeReportID, delegateAccountID, initiatingBankAccountUnlock); - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails}); }; return ( diff --git a/src/pages/home/TimeSensitiveSection/items/UpgradeNetSuiteConnection.tsx b/src/pages/home/TimeSensitiveSection/items/UpgradeNetSuiteConnection.tsx new file mode 100644 index 000000000000..cebd4ecb1e23 --- /dev/null +++ b/src/pages/home/TimeSensitiveSection/items/UpgradeNetSuiteConnection.tsx @@ -0,0 +1,37 @@ +import BaseWidgetItem from '@components/BaseWidgetItem'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; + +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import React from 'react'; + +type Props = { + /** The policy ID of the workspace with the un-migrated NetSuite connection */ + policyID: string; + + /** The policy name shown as the subtitle */ + policyName: string; +}; + +function UpgradeNetSuiteConnection({policyID, policyName}: Props) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Connect']); + + return ( + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID, CONST.NETSUITE_CONFIG.TOKEN_INPUT.PAGE_NAME.INSTALL, undefined, true))} + buttonVariant={CONST.BUTTON_VARIANT.DANGER} + /> + ); +} + +export default UpgradeNetSuiteConnection; diff --git a/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx b/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx index e757a8ac118e..71b16a127f16 100644 --- a/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx +++ b/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx @@ -12,16 +12,19 @@ import React from 'react'; import useBrokenDirectCompanyCardFeedsForAdmin from './hooks/useBrokenDirectCompanyCardFeedsForAdmin'; import useTimeSensitiveAddBankAccount from './hooks/useTimeSensitiveAddBankAccount'; +import useTimeSensitiveAddDepositAccount from './hooks/useTimeSensitiveAddDepositAccount'; import useTimeSensitiveAddPaymentCard from './hooks/useTimeSensitiveAddPaymentCard'; import useTimeSensitiveBilling from './hooks/useTimeSensitiveBilling'; import useTimeSensitiveCards from './hooks/useTimeSensitiveCards'; import useTimeSensitiveHomeAddress from './hooks/useTimeSensitiveHomeAddress'; import useTimeSensitiveLockedBankAccount from './hooks/useTimeSensitiveLockedBankAccount'; +import useTimeSensitiveNetSuiteMigration from './hooks/useTimeSensitiveNetSuiteMigration'; import useTimeSensitiveOverdueInvoice from './hooks/useTimeSensitiveOverdueInvoice'; import useTimeSensitiveSignerInfo from './hooks/useTimeSensitiveSignerInfo'; import useTimeSensitiveSubscriptionExpiring from './hooks/useTimeSensitiveSubscriptionExpiring'; import ActivateCard from './items/ActivateCard'; import AddBankAccount from './items/AddBankAccount'; +import AddDepositAccount from './items/AddDepositAccount'; import AddHomeAddress from './items/AddHomeAddress'; import AddPaymentCard from './items/AddPaymentCard'; import AddShippingAddress from './items/AddShippingAddress'; @@ -37,6 +40,7 @@ import PayOverdueInvoice from './items/PayOverdueInvoice'; import RenewSubscription from './items/RenewSubscription'; import ReviewCardFraud from './items/ReviewCardFraud'; import UnlockBankAccount from './items/UnlockBankAccount'; +import UpgradeNetSuiteConnection from './items/UpgradeNetSuiteConnection'; type BrokenPersonalCardConnection = { /** The card ID associated with this connection */ @@ -53,6 +57,7 @@ function useTimeSensitiveItems(): React.ReactNode[] { // Use custom hooks for offers and cards (Release 3) const {shouldShowAddPaymentCard} = useTimeSensitiveAddPaymentCard(); const {shouldShowAddBankAccount} = useTimeSensitiveAddBankAccount(); + const {shouldShowAddDepositAccount} = useTimeSensitiveAddDepositAccount(); const { shouldShowAddShippingAddress, shouldShowActivateCard, @@ -83,6 +88,7 @@ function useTimeSensitiveItems(): React.ReactNode[] { const brokenPolicyConnections = adminPoliciesData?.brokenConnections ?? CONST.EMPTY_ARRAY; const {lockedBankAccounts} = useTimeSensitiveLockedBankAccount(adminPolicies); const {pendingSignerInfo} = useTimeSensitiveSignerInfo(); + const {netSuiteMigrationPolicies} = useTimeSensitiveNetSuiteMigration(); // Get card feed errors for company card connections (Release 4) const cardFeedErrors = useCardFeedErrors(); @@ -112,14 +118,15 @@ function useTimeSensitiveItems(): React.ReactNode[] { // 7. Broken bank connections (personal cards) // 8. Locked bank accounts (workspace VBAs and personal) // 9. Broken policy connections (accounting + HR) - // 10. Add home address (commuter exclusions, homeAndOffice method) - // 11. Add payment card (trial ended, no payment card) - // 12. Add bank account for a queued reimbursement - // 13. Enter signer info for global bank accounts - // 14. Expensify card shipping - // 15. Expensify card activation - // 16. Virtual Expensify card needs personal details - // 17. Digital wallet addition needs confirming + // 10. NetSuite connections requiring OAuth 2.0 migration + // 11. Add home address (commuter exclusions, homeAndOffice method) + // 12. Add payment card (trial ended, no payment card) + // 13. Add bank account for a queued reimbursement, or a deposit account for a workspace collecting details + // 14. Enter signer info for global bank accounts + // 15. Expensify card shipping + // 16. Expensify card activation + // 17. Virtual Expensify card needs personal details + // 18. Digital wallet addition needs confirming const items: React.ReactNode[] = []; // Priority 1: Failed billing for existing customers @@ -213,19 +220,32 @@ function useTimeSensitiveItems(): React.ReactNode[] { />, ); } - // Priority 10: Add home address (commuter exclusions, homeAndOffice method) + // Priority 10: NetSuite connections requiring OAuth 2.0 migration (deadline: February 2027) + for (const item of netSuiteMigrationPolicies) { + items.push( + , + ); + } + // Priority 11: Add home address (commuter exclusions, homeAndOffice method) if (shouldShowAddHomeAddress) { items.push(); } - // Priority 11: Add payment card (trial ended, no payment card) + // Priority 12: Add payment card (trial ended, no payment card) if (shouldShowAddPaymentCard) { items.push(); } - // Priority 12: Add bank account for a queued reimbursement + // Priority 13: Add bank account for a queued reimbursement if (shouldShowAddBankAccount) { items.push(); + } else if (shouldShowAddDepositAccount) { + // Priority 13: Add a deposit account so a workspace with reimbursements enabled can pay the user. + items.push(); } - // Priority 13: Enter signer info for global bank accounts + // Priority 14: Enter signer info for global bank accounts for (const item of pendingSignerInfo) { items.push( , ); } - // Priority 14: Expensify card shipping + // Priority 15: Expensify card shipping if (shouldShowAddShippingAddress) { for (const card of cardsNeedingShippingAddress) { items.push( @@ -247,7 +267,7 @@ function useTimeSensitiveItems(): React.ReactNode[] { ); } } - // Priority 15: Expensify card activation + // Priority 16: Expensify card activation if (shouldShowActivateCard) { for (const card of cardsNeedingActivation) { items.push( @@ -258,7 +278,7 @@ function useTimeSensitiveItems(): React.ReactNode[] { ); } } - // Priority 16: Virtual Expensify card needs personal details before reveal + // Priority 17: Virtual Expensify card needs personal details before reveal if (shouldShowAddVirtualCardPersonalDetails) { for (const card of virtualCardsNeedingPersonalDetails) { items.push( @@ -269,7 +289,7 @@ function useTimeSensitiveItems(): React.ReactNode[] { ); } } - // Priority 17: Confirm a digital wallet addition + // Priority 18: Confirm a digital wallet addition if (shouldShowConfirmDigitalWalletAddition) { for (const card of cardsPendingDigitalWalletApproval) { items.push( diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 41272a88ce33..a5b3595cf035 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -23,6 +23,7 @@ import useHasTeam2025Pricing from '@hooks/useHasTeam2025Pricing'; import useInitialFocusRef from '@hooks/useInitialFocusRef'; import useIsInSidePanel from '@hooks/useIsInSidePanel'; import useIsPaidPolicyAdmin from '@hooks/useIsPaidPolicyAdmin'; +import useIsPastDate from '@hooks/useIsPastDate'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -59,7 +60,6 @@ import { getReportStatusTranslation, hasReportNameError, isAdminRoom, - isArchivedReport, isChatRoom as isChatRoomReportUtils, isChatThread as isChatThreadReportUtils, isChatUsedForOnboarding as isChatUsedForOnboardingReportUtils, @@ -97,8 +97,9 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useRoute} from '@react-navigation/native'; import {accountGuideDetailsSelector} from '@selectors/Account'; import {accountIDSelector, isPersonalDetailOptimistic} from '@selectors/PersonalDetails'; +import {getStableReportSelector} from '@selectors/Report'; import {pendingChatMembersSelector} from '@selectors/ReportMetaData'; -import {isPast} from 'date-fns'; +import {latestCalendlyCallSelector} from '@selectors/ReportNameValuePairs'; import React, {useMemo} from 'react'; import {Keyboard, View} from 'react-native'; @@ -110,8 +111,8 @@ type HeaderViewProps = { }; function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) { - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const parentReportAction = useParentReportAction(report); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getStableReportSelector}); + const parentReportAction = useParentReportAction(report, {shouldIgnoreThreadReplies: true}); const icons = useMemoizedLazyExpensifyIcons(['BackArrow', 'Close', 'DotIndicator']); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -119,9 +120,12 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const isInSidePanel = useIsInSidePanel(); const route = useRoute(); const openParentReportInCurrentTab = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT; - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID) ?? getNonEmptyStringOnyxID(report?.reportID)}`); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID) ?? getNonEmptyStringOnyxID(report?.reportID)}`, { + selector: getStableReportSelector, + }); const [grandParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(parentReport?.parentReportID)}`); - const grandParentReportAction = useParentReportAction(parentReport); + const grandParentReportAction = useParentReportAction(parentReport, {shouldIgnoreThreadReplies: true}); + const [grandParentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(parentReport?.parentReportID)}`); const policy = usePolicy(report?.policyID); const isPaidPolicyAdmin = useIsPaidPolicyAdmin(); const [personalDetails] = useAllPersonalDetails(); @@ -137,9 +141,10 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) partnerManagerCalendarLink: account?.partnerManagerCalendarLink, }), }); - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); + const [latestScheduledCall] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`, {selector: latestCalendlyCallSelector}); + const isScheduledCallPast = useIsPastDate(latestScheduledCall?.eventTime); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`, {selector: pendingChatMembersSelector}); - const isReportArchived = isArchivedReport(reportNameValuePairs); + const isReportArchived = useReportIsArchived(report?.reportID); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); @@ -154,7 +159,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const isConciergeChat = isConciergeChatReport(report, conciergeReportID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); - const allParticipants = getParticipantsAccountIDsForDisplay(report, false, true, undefined, reportMetadata); + const allParticipants = getParticipantsAccountIDsForDisplay(report, reportMetadata, false, true); const shouldAddEllipsis = allParticipants?.length > CONST.DISPLAY_PARTICIPANTS_LIMIT; const participants = allParticipants.slice(0, CONST.DISPLAY_PARTICIPANTS_LIMIT); const isMultipleParticipant = participants.length > 1; @@ -171,7 +176,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) // Transaction threads under an invoice use the invoice report header. Other threads use their parent action message. const isParentInvoiceAndIsTransactionThread = isChatThread && !!parentReport && isInvoiceReport(parentReport) && isTransactionThread(parentReportAction); const reportHeaderData = (!isTaskReport && !isSupportTicketReport && !isChatThread && report?.parentReportID) || isParentInvoiceAndIsTransactionThread ? parentReport : report; - const isParentOneTransactionThread = isOneTransactionThread(parentReport, grandParentReport, grandParentReportAction); + const isParentOneTransactionThread = isOneTransactionThread(parentReport, grandParentReport, grandParentReportAction, undefined, grandParentReportActions); const parentNavigationReport = isParentOneTransactionThread ? parentReport : reportHeaderData; const derivedNames = useDerivedReportNamesByReportIDs([parentNavigationReport?.parentReportID, reportHeaderData?.reportID]); const derivedParentReportName = getReportNameFromNames(derivedNames, parentNavigationReport?.parentReportID); @@ -349,8 +354,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) !shouldMirrorSidePanelHeader; const shouldShowOnBoardingHelpDropdownButton = (shouldShowRegisterForWebinar || shouldShowGuideBooking) && !isReportArchived && !shouldMirrorSidePanelHeader; const shouldShowEarlyDiscountBanner = shouldShowDiscount && isChatUsedForOnboarding && !shouldMirrorSidePanelHeader; - const latestScheduledCall = reportNameValuePairs?.calendlyCalls?.at(-1); - const hasActiveScheduledCall = latestScheduledCall && !isPast(latestScheduledCall.eventTime) && latestScheduledCall.status !== CONST.SCHEDULE_CALL_STATUS.CANCELLED; + const hasActiveScheduledCall = !!latestScheduledCall && !isScheduledCallPast && latestScheduledCall.status !== CONST.SCHEDULE_CALL_STATUS.CANCELLED; // Not gated on !shouldUseNarrowLayout like the side-panel case: isInNarrowPaneModal makes // shouldUseNarrowLayout true for any RHP regardless of actual screen width. const shouldShowCloseButton = (!!isInSidePanel && !shouldUseNarrowLayout) || isAgentReportInRHP; @@ -501,7 +505,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) /> )} {!shouldUseNarrowLayout && chatIncludesChronos(report) && } - {!shouldUseNarrowLayout && isOpenTaskReport(report, parentReportAction) && } + {!shouldUseNarrowLayout && isOpenTaskReport(report, parentReportAction) && } {!isParentReportLoading && canJoin && !shouldUseNarrowLayout && joinButton} {shouldShowCloseButton && ( @@ -551,7 +555,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) {!!report && shouldUseNarrowLayout && isOpenTaskReport(report, parentReportAction) && ( - + )} diff --git a/src/pages/inbox/ReportFetchHandler.tsx b/src/pages/inbox/ReportFetchHandler.tsx index 7b250efc7157..86ea0723eeff 100644 --- a/src/pages/inbox/ReportFetchHandler.tsx +++ b/src/pages/inbox/ReportFetchHandler.tsx @@ -77,6 +77,7 @@ const defaultReportMetadata = { const defaultReportLoadingState = { hasOnceLoadedReportActions: false, + isCreatingSupportTicket: false, isLoadingInitialReportActions: true, isLoadingOlderReportActions: false, hasLoadingOlderReportActionsError: false, @@ -129,6 +130,7 @@ function ReportFetchHandler() { const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportOnyx?.chatReportID}`); const [reportMetadata = defaultReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportIDFromRoute}`); const [reportLoadingState = defaultReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportIDFromRoute}`); + const isPendingReportCreation = !!reportLoadingState.isCreatingSupportTicket; const isReportActionsLoaded = useIsReportActionsLoaded(reportIDFromRoute); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); @@ -190,9 +192,8 @@ function ReportFetchHandler() { // isPendingCreationFromRoute means reportIDFromRoute is a client-generated ID that doesn't exist // on the server yet. Calling openReport for it would 403 and show the not-found page instead. - // Once the real submit creates the report under this ID, reportOnyx?.reportID becomes truthy - // and normal fetching resumes. - if (isPendingCreationFromRoute && !reportOnyx?.reportID) { + // Wait until the report exists locally and is no longer optimistic before fetching it. + if (isPendingCreationFromRoute && (!reportOnyx?.reportID || isPendingReportCreation)) { return; } @@ -356,11 +357,12 @@ function ReportFetchHandler() { }, [report?.reportID, prevReportID, reportIDFromRoute, isInPreloadedTab]); useEffect(() => { - if (!transactionThreadReportID || !route?.params?.reportActionID || !isOneTransactionThread(childReport, report, linkedAction)) { + // `reportActions` is the same list `transactionThreadReportID` above is derived from, so both checks agree on which actions the report has + if (!transactionThreadReportID || !route?.params?.reportActionID || !isOneTransactionThread(childReport, report, linkedAction, undefined, reportActions)) { return; } navigation.setParams({reportActionID: ''}); - }, [transactionThreadReportID, route?.params?.reportActionID, linkedAction, reportID, navigation, report, childReport]); + }, [transactionThreadReportID, route?.params?.reportActionID, linkedAction, reportID, navigation, report, childReport, reportActions]); // When an approver opens a Submit-via-PDF secure access link (/r/:reportID?secureKey=...), validate it once they're // signed in. On success the backend shares the report and sets them as manager; on failure the ReportNotFoundGuard @@ -387,15 +389,16 @@ function ReportFetchHandler() { navigation.setParams({secureKey: undefined}); }, [secureKeyFromRoute, reportIDFromRoute, report?.reportID, report?.errorFields?.notFound, navigation]); - // isPendingCreation is only needed before the report exists. Clear it from the route params once it + // isPendingCreation is only needed before the report exists on the server. Clear it from the route params once it // does, so a copied URL, restored navigation state, or reload doesn't carry the stale flag and skip - // fetching again later. + // fetching again later. A support-ticket request is lost on reload, so its RAM-only guard also resets + // and lets OpenReport discover whether the server created the ticket. useEffect(() => { - if (!isPendingCreationFromRoute || !reportOnyx?.reportID) { + if (!isPendingCreationFromRoute || !reportOnyx?.reportID || isPendingReportCreation) { return; } navigation.setParams({isPendingCreation: undefined}); - }, [isPendingCreationFromRoute, reportOnyx?.reportID, navigation]); + }, [isPendingCreationFromRoute, reportOnyx?.reportID, isPendingReportCreation, navigation]); // A restored or shared route can carry isPendingCreation with no submit behind it. The owning submit writes the // report row within the dismiss transition budget (about 2s) after this screen gains focus, so a focused screen diff --git a/src/pages/inbox/ReportNavigateAwayHandler.tsx b/src/pages/inbox/ReportNavigateAwayHandler.tsx index 531a5ba69f16..a6a43484c86c 100644 --- a/src/pages/inbox/ReportNavigateAwayHandler.tsx +++ b/src/pages/inbox/ReportNavigateAwayHandler.tsx @@ -27,6 +27,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; import {useIsFocused, useRoute} from '@react-navigation/native'; +import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; import {useEffect, useEffectEvent, useRef} from 'react'; import useReportWasDeleted from './hooks/useReportWasDeleted'; @@ -77,6 +78,7 @@ function ReportNavigateAwayHandler() { const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const isSelfTourViewed = onboarding?.selfTourViewed; + const hasCompletedGuidedSetupFlow = hasCompletedGuidedSetupFlowSelector(onboarding); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const parentReportAction = useParentReportAction(report); @@ -144,7 +146,15 @@ function ReportNavigateAwayHandler() { } Navigation.isNavigationReady().then(() => { - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); + navigateToConciergeChat({ + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + shouldDismissModal: false, + }); }); }); @@ -235,7 +245,7 @@ function ReportNavigateAwayHandler() { } // Fallback to Concierge - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails}); }); }, [ reportWasDeleted, @@ -246,6 +256,7 @@ function ReportNavigateAwayHandler() { introSelected, currentUserAccountID, isSelfTourViewed, + hasCompletedGuidedSetupFlow, conciergePersonalDetails, reportIDFromRoute, ]); diff --git a/src/pages/inbox/conciergeDraftState.ts b/src/pages/inbox/conciergeDraftState.ts index aca78ccba544..13f64d242928 100644 --- a/src/pages/inbox/conciergeDraftState.ts +++ b/src/pages/inbox/conciergeDraftState.ts @@ -33,6 +33,7 @@ type ConciergeDraft = { type BuildConciergeDraftReportActionParams = { actorAccountID?: number; + accountIDToName?: Record; bodyMarkdown?: string; created: string; finalRenderedHTML?: string; @@ -585,6 +586,7 @@ function stripIncompleteMarkdown(markdown: string): string { function buildConciergeDraftReportAction({ actorAccountID, + accountIDToName, bodyMarkdown, created, finalRenderedHTML, @@ -612,7 +614,7 @@ function buildConciergeDraftReportAction({ actorAccountID: resolvedActorAccountID, person: [{style: 'strong', text: CONST.CONCIERGE_DISPLAY_NAME, type: 'TEXT'}], created, - message: [{type: CONST.REPORT.MESSAGE.TYPE.COMMENT, html, text: Parser.htmlToText(html)}], + message: [{type: CONST.REPORT.MESSAGE.TYPE.COMMENT, html, text: Parser.htmlToText(html, {accountIDToName})}], originalMessage: {html, whisperedTo: []}, shouldShow: true, } as ReportAction; @@ -687,7 +689,13 @@ function setCachedDraft(reportID: string, draft: ConciergeDraft | null): void { } } -function applyConciergeDraftEvent(currentDraft: ConciergeDraft | null, event: ConciergeDraftEvent, reportID: string, isGroupPolicyReport: boolean): ConciergeDraft | null { +function applyConciergeDraftEvent( + currentDraft: ConciergeDraft | null, + event: ConciergeDraftEvent, + reportID: string, + isGroupPolicyReport: boolean, + accountIDToName?: Record, +): ConciergeDraft | null { if (event.reportID !== reportID) { return currentDraft; } @@ -709,6 +717,7 @@ function applyConciergeDraftEvent(currentDraft: ConciergeDraft | null, event: Co const nextReportAction = buildConciergeDraftReportAction({ actorAccountID: event.actorAccountID, + accountIDToName, bodyMarkdown: event.bodyMarkdown, created: event.created, finalRenderedHTML: event.finalRenderedHTML, diff --git a/src/pages/inbox/report/AncestorReportActionItem.tsx b/src/pages/inbox/report/AncestorReportActionItem.tsx index caf4dbb51e8d..341b7870e015 100644 --- a/src/pages/inbox/report/AncestorReportActionItem.tsx +++ b/src/pages/inbox/report/AncestorReportActionItem.tsx @@ -9,7 +9,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getLatestConciergeFeedbackActionIDFromReportActions, isTripPreview} from '@libs/ReportActionsUtils'; +import {isTripPreview} from '@libs/ReportActionsUtils'; import { canCurrentUserOpenReport, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, @@ -18,11 +18,13 @@ import { navigateToLinkedReportAction, } from '@libs/ReportUtils'; +import type {ReportHierarchyInfo} from '@userActions/ClearReportActionErrors'; import {navigateToConciergeChatAndDeleteReport} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {getStableReportSelector} from '@src/selectors/Report'; +import {getLatestFeedbackActionIDByAuthorSelector} from '@src/selectors/ReportAction'; import type {IntroSelected, PersonalDetails, Report, ReportAction, ReportNameValuePairs} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; @@ -59,11 +61,11 @@ type AncestorReportActionItemProps = { linkedTransactionRouteError: Errors | undefined; parentReportAction: OnyxEntry; - - /** Whether the report being viewed allows the Concierge feedback prompt on the message it hangs off */ - shouldAllowConciergeFeedback: boolean; shouldUseThreadDividerLine: boolean; transactionThreadReport: OnyxEntry; + + /** Parent links of the thread and its ancestors, used to clear related errors on dismiss */ + reportsParentHierarchy: OnyxCollection; }; function AncestorReportActionItem({ @@ -80,9 +82,9 @@ function AncestorReportActionItem({ isSelfTourViewed, linkedTransactionRouteError, parentReportAction, - shouldAllowConciergeFeedback, shouldUseThreadDividerLine, transactionThreadReport, + reportsParentHierarchy, }: AncestorReportActionItemProps) { const styles = useThemeStyles(); const currentUserPersonalDetail = useCurrentUserPersonalDetails(); @@ -93,9 +95,17 @@ function AncestorReportActionItem({ const {isBetaEnabled} = usePermissions(); - // The message shown above a thread belongs to the parent report, so its own actions decide whether it is the newest Concierge answer + const authorAccountID = reportAction.actorAccountID; + const [isAuthorCustomAgent = false] = usePersonalDetail(authorAccountID, (personalDetail) => !!personalDetail?.isCustomAgent); + + // An agent account can be opened through Copilot, and the agent must not rate its own replies + const canAuthorCollectFeedback = authorAccountID === CONST.ACCOUNT_ID.CONCIERGE || (isAuthorCustomAgent && authorAccountID !== currentUserAccountID); + + // The message shown above a thread belongs to the parent report, so its own actions decide whether it is the author's newest answer. + // The thread's feedback marker is deliberately left out of that decision: the marker outlives the reaction, so consulting it would keep the prompt + // away here after the user takes their thumbs down back, while the same message offers it again in the chat. const [latestConciergeFeedbackActionID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(report?.reportID)}`, { - selector: getLatestConciergeFeedbackActionIDFromReportActions, + selector: getLatestFeedbackActionIDByAuthorSelector(canAuthorCollectFeedback ? authorAccountID : undefined), }); const shouldDisplayThreadDivider = !isTripPreview(reportAction); @@ -165,8 +175,9 @@ function AncestorReportActionItem({ isFirstVisibleReportAction={isFirstVisibleReportAction} shouldUseThreadDividerLine={shouldUseThreadDividerLine} isThreadReportParentAction - isLatestConciergeFeedbackAction={shouldAllowConciergeFeedback && !!latestConciergeFeedbackActionID && latestConciergeFeedbackActionID === reportAction.reportActionID} + isLatestConciergeFeedbackAction={!!latestConciergeFeedbackActionID && latestConciergeFeedbackActionID === reportAction.reportActionID} linkedTransactionRouteError={linkedTransactionRouteError} + reportsParentHierarchy={reportsParentHierarchy} /> ); diff --git a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx index a35ea6e7d34c..24f6c746bcc0 100755 --- a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -310,6 +310,7 @@ function BaseReportActionContextMenu({ isHarvestReport, currentUserAccountID: currentUserPersonalDetails?.accountID, rules, + personalDetails, cardList, }), ); diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index e96364ae4c88..54281c8b4210 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -23,6 +23,7 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import Navigation from '@libs/Navigation/Navigation'; import Parser from '@libs/Parser'; import {getLoginByAccountID, getParticipantsPersonalDetails, getPersonalDetailsByID, getPersonalDetailsListByIDs} from '@libs/PersonalDetailsUtils'; +import {getPolicyReimburserAccountID} from '@libs/PolicyPaymentUtils'; import {getCleanedTagName, isPolicyAdmin} from '@libs/PolicyUtils'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import stripFollowupListFromHtml from '@libs/ReportActionFollowupUtils/stripFollowupListFromHtml'; @@ -35,6 +36,7 @@ import { getAddedBudgetMessage, getAddedCardFeedMessage, getAddedConnectionMessage, + getAddedOfficeLocationMessage, getApprovalLimitUpdateMessage, getAssignedCompanyCardMessage, getAutoCategorizeNewExpensesMessage, @@ -56,6 +58,7 @@ import { getDelegateSubmitMessage, getDeletedApprovalRuleMessage, getDeletedBudgetMessage, + getDeletedOfficeLocationMessage, getDismissedViolationMessageText, getDynamicExternalWorkflowApproveFailedActionMessage, getDynamicExternalWorkflowRoutedMessage, @@ -87,6 +90,7 @@ import { getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage, getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, getPolicyChangeLogUpdateEmployee, + getPolicyWorkArrangementMessage, getReimbursedMessage, getReimburserUpdateMessage, getRemovedCardFeedMessage, @@ -115,9 +119,11 @@ import { getUpdatedCardFeedLiabilityMessage, getUpdatedCardFeedStatementPeriodMessage, getUpdatedDefaultTitleMessage, + getUpdatedEmployeeOfficeLocationMessage, getUpdatedIndividualBudgetNotificationMessage, getUpdatedManualApprovalThresholdMessage, getUpdatedMemberWorkArrangementMessage, + getUpdatedOfficeLocationMessage, getUpdatedOwnershipMessage, getUpdatedProhibitedExpensesMessage, getUpdatedReimbursementChoiceMessage, @@ -328,6 +334,8 @@ type ShouldShow = (args: { isHarvestReport?: boolean; currentUserAccountID: number; rules: OnyxCollection; + /** The full list is needed because canDeleteReportAction resolves the report owner from the reportID it is given. */ + personalDetails: OnyxEntry; cardList: OnyxEntry; }) => boolean; @@ -1124,10 +1132,18 @@ const ContextMenuActions: ContextMenuAction[] = [ const modifyExpenseMessage = Parser.htmlToMarkdown(modifyExpenseMessageWithHTML); Clipboard.setString(modifyExpenseMessage); } else if (isReimbursementDeQueuedOrCanceledAction(reportAction)) { - const displayMessage = getReimbursementDeQueuedOrCanceledActionMessage(translate, reportAction, report?.ownerAccountID, convertToDisplayString); + const displayMessage = getReimbursementDeQueuedOrCanceledActionMessage(translate, formatPhoneNumber, reportAction, report?.ownerAccountID, convertToDisplayString); Clipboard.setString(displayMessage); } else if (isMoneyRequestAction(reportAction)) { - const displayMessage = getIOUReportActionDisplayMessage(translate, reportAction, convertToDisplayString, policy?.achAccount?.accountNumber, transaction, bankAccountList); + const displayMessage = getIOUReportActionDisplayMessage( + translate, + reportAction, + convertToDisplayString, + policy, + transaction, + bankAccountList, + getPolicyReimburserAccountID(policy, personalDetails), + ); if (displayMessage === Parser.htmlToText(displayMessage)) { Clipboard.setString(displayMessage); } else { @@ -1481,8 +1497,18 @@ const ContextMenuActions: ContextMenuAction[] = [ setClipboardMessage(getUpdatedDefaultTitleMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_HARVESTING)) { setClipboardMessage(getUpdatedAutoHarvestingMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_POLICY_WORK_ARRANGEMENT)) { + setClipboardMessage(getPolicyWorkArrangementMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT)) { setClipboardMessage(getUpdatedMemberWorkArrangementMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION)) { + setClipboardMessage(getAddedOfficeLocationMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION)) { + setClipboardMessage(getUpdatedOfficeLocationMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION)) { + setClipboardMessage(getDeletedOfficeLocationMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION)) { + setClipboardMessage(getUpdatedEmployeeOfficeLocationMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INDIVIDUAL_BUDGET_NOTIFICATION)) { setClipboardMessage(getUpdatedIndividualBudgetNotificationMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SHARED_BUDGET_NOTIFICATION)) { @@ -1747,6 +1773,7 @@ const ContextMenuActions: ContextMenuAction[] = [ childReportActions, currentUserAccountID, rules, + personalDetails, cardList, }) => { // A single-expense report preview also exposes its embedded money request action. @@ -1776,7 +1803,7 @@ const ContextMenuActions: ContextMenuAction[] = [ return ( !!reportIDParam && type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && - canDeleteReportAction(actionToDelete, reportID, iouTransaction, transactions, childReportActions, currentUserAccountID, rules, cardList) && + canDeleteReportAction(actionToDelete, reportID, iouTransaction, transactions, childReportActions, currentUserAccountID, rules, personalDetails, cardList) && !isArchivedRoom && !isChronosReport && !isMessageDeleted(reportAction) diff --git a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx index e5ca3a3d9ba4..763bd3dc7bac 100644 --- a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -253,7 +253,6 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro typeRef.current = type; reportIDRef.current = reportID; reportActionIDRef.current = reportActionID; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing originalReportIDRef.current = originalReportID || undefined; selectionRef.current = selection; setIsPopoverVisible(true); @@ -430,18 +429,19 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro }); } else if (reportAction) { callbackWhenDeleteModalHide.current = () => { - deleteReportComment( + deleteReportComment({ report, reportAction, originalReportActions, reportActions, - ancestorsRef.current, + ancestors: ancestorsRef.current, isReportArchived, isOriginalReportArchived, - email ?? '', + currentEmail: email ?? '', isOffline, - visibleReportActionsData ?? undefined, - ); + currentUserAccountID, + visibleReportActionsData: visibleReportActionsData ?? undefined, + }); }; } diff --git a/src/pages/inbox/report/DynamicReportDetailsExportPage.tsx b/src/pages/inbox/report/DynamicReportDetailsExportPage.tsx index ee4726346767..b7c602a75171 100644 --- a/src/pages/inbox/report/DynamicReportDetailsExportPage.tsx +++ b/src/pages/inbox/report/DynamicReportDetailsExportPage.tsx @@ -1,4 +1,4 @@ -import ConfirmationPage from '@components/ConfirmationPage'; +import ConfirmationPageDefault from '@components/ConfirmationPage/composed/ConfirmationPageDefault'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -59,6 +59,7 @@ function DynamicReportDetailsExportPage({route}: DynamicReportDetailsExportPageP 'RilletSquare', 'DualEntrySquare', 'CampfireSquare', + 'ZohoBooksSquare', 'BusinessCentralSquare', 'GustoSquare', ]); @@ -129,11 +130,10 @@ function DynamicReportDetailsExportPage({route}: DynamicReportDetailsExportPageP title={translate('common.export')} onBackButtonPress={() => Navigation.goBack(navigateBackFromExportPath)} /> - Navigation.goBack()} illustrationStyle={{width: 233, height: 162}} diff --git a/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx b/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx index 11c5d7b12c95..f77da2477a38 100644 --- a/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx +++ b/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx @@ -18,7 +18,7 @@ import withReportOrNotFound from './withReportOrNotFound'; type DynamicReportDetailsShareCodePageProps = WithReportOrNotFoundProps & PlatformStackScreenProps; -function DynamicReportDetailsShareCodePage({report, policy}: DynamicReportDetailsShareCodePageProps) { +function DynamicReportDetailsShareCodePage({report, policy, route}: DynamicReportDetailsShareCodePageProps) { const navigateBackRoute = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_CODE.path); if (isSelfDM(report)) { @@ -30,6 +30,7 @@ function DynamicReportDetailsShareCodePage({report, policy}: DynamicReportDetail backTo={navigateBackRoute} report={report} policy={policy} + shouldShowQRCodeOnly={route.params?.shouldShowQRCodeOnly === 'true'} /> ); } diff --git a/src/pages/inbox/report/DynamicReportDetailsShareReportPage.tsx b/src/pages/inbox/report/DynamicReportDetailsShareReportPage.tsx new file mode 100644 index 000000000000..464a9cb4bb8c --- /dev/null +++ b/src/pages/inbox/report/DynamicReportDetailsShareReportPage.tsx @@ -0,0 +1,168 @@ +/** + * Picks who to email an expense report to. Opens from Share report in the report's More menu. + */ +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SingleSelectWithAvatarListItem from '@components/SelectionList/ListItem/SingleSelectWithAvatarListItem'; +import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; +import type {Section} from '@components/SelectionList/SelectionListWithSections/types'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePersonalDetailSearchSelector from '@hooks/usePersonalDetailSearchSelector'; +import usePressLoading from '@hooks/usePressLoading'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {searchUserInServer, shareReport} from '@libs/actions/Report'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; +import Growl from '@libs/Growl'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReportDetailsNavigatorParamList} from '@libs/Navigation/types'; +import {getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils'; +import type {OptionData} from '@libs/PersonalDetailOptionsListUtils'; +import {getReportName} from '@libs/ReportNameUtils'; +import {canShareReport} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React, {useEffect, useState} from 'react'; + +import type {WithReportOrNotFoundProps} from './withReportOrNotFound'; + +import withReportOrNotFound from './withReportOrNotFound'; + +type DynamicReportDetailsShareReportPageProps = WithReportOrNotFoundProps & PlatformStackScreenProps; + +function DynamicReportDetailsShareReportPage({report, policy}: DynamicReportDetailsShareReportPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_REPORT.path); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + + const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); + const [isSearchingForUsers] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS); + + const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); + const {isLoading, startWithLoading} = usePressLoading(); + + const {searchTerm, debouncedSearchTerm, setSearchTerm, selectedOptions, availableOptions, toggleSelection, areOptionsInitialized} = usePersonalDetailSearchSelector({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, + includeUserToInvite: true, + includeRecentReports: false, + shouldUpdateSelectedOptionsOnSingleSelect: true, + excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + shouldInitialize: didScreenTransitionEnd, + }); + + useEffect(() => { + searchUserInServer(debouncedSearchTerm); + }, [debouncedSearchTerm]); + + const selectedLogin = selectedOptions.at(0)?.login; + + const shareWithSelectedUser = () => { + if (!selectedLogin) { + return; + } + startWithLoading(() => { + shareReport(report.reportID, selectedLogin); + Navigation.goBack(backPath); + Growl.success(translate('common.reportShared')); + }); + }; + + const sections: Array> = []; + if (areOptionsInitialized) { + if (availableOptions.selectedOptions.length > 0) { + sections.push({ + title: undefined, + data: availableOptions.selectedOptions, + sectionIndex: 0, + }); + } + + if (availableOptions.personalDetails.length > 0) { + sections.push({ + title: translate('common.contacts'), + data: availableOptions.personalDetails, + sectionIndex: 1, + }); + } + + if (availableOptions.userToInvite) { + sections.push({ + title: undefined, + data: [availableOptions.userToInvite], + sectionIndex: 2, + }); + } + } + + const textInputOptions = { + label: translate('selectionList.nameEmailOrPhoneNumber'), + value: searchTerm, + onChangeText: setSearchTerm, + headerMessage: sections.length > 0 ? '' : getHeaderMessage(translate, debouncedSearchTerm.trim().toLowerCase(), countryCode), + }; + + const footerContent = ( + + ); + + return ( + setDidScreenTransitionEnd(true)} + > + + Navigation.goBack(backPath)} + /> + + + + ); +} + +export default withReportOrNotFound()(DynamicReportDetailsShareReportPage); diff --git a/src/pages/inbox/report/ParticipantLocalTime.tsx b/src/pages/inbox/report/ParticipantLocalTime.tsx index 492cd0e68db1..4b122b0c1c07 100644 --- a/src/pages/inbox/report/ParticipantLocalTime.tsx +++ b/src/pages/inbox/report/ParticipantLocalTime.tsx @@ -18,8 +18,7 @@ type ParticipantLocalTimeProps = { }; function getParticipantLocalTime(participant: PersonalDetails, getLocalDateFromDatetime: LocaleContextProps['getLocalDateFromDatetime'], dateFnsLocale: LocaleContextProps['dateFnsLocale']) { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null - const reportRecipientTimezone = participant.timezone || CONST.DEFAULT_TIME_ZONE; + const reportRecipientTimezone = participant.timezone ?? CONST.DEFAULT_TIME_ZONE; const reportTimezone = getLocalDateFromDatetime(undefined, reportRecipientTimezone.selected); const currentTimezone = getLocalDateFromDatetime(); const reportRecipientDay = DateUtils.formatToDayOfWeek(reportTimezone, dateFnsLocale); @@ -46,7 +45,6 @@ function ParticipantLocalTime({participant}: ParticipantLocalTimeProps) { }; }, [participant, getLocalDateFromDatetime, dateFnsLocale]); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null const reportRecipientDisplayName = participant.firstName || participant.displayName; if (!reportRecipientDisplayName) { diff --git a/src/pages/inbox/report/ReactionList/BaseReactionList.tsx b/src/pages/inbox/report/ReactionList/BaseReactionList.tsx index 08c6e59e264a..c3a9a5dddfdf 100755 --- a/src/pages/inbox/report/ReactionList/BaseReactionList.tsx +++ b/src/pages/inbox/report/ReactionList/BaseReactionList.tsx @@ -50,7 +50,7 @@ const getItemLayout = (data: ArrayLike | null | undefined, inde function BaseReactionList({hasUserReacted = false, users, isVisible = false, emojiCodes, emojiCount, emojiName, onClose}: BaseReactionListProps) { const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); - const {translate} = useLocalize(); + const {translate, formatPhoneNumber} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {hoveredComponentBG, reactionListContainer, reactionListContainerFixedWidth, pv2} = useThemeStyles(); const {accountID} = useCurrentUserPersonalDetails(); @@ -65,34 +65,38 @@ function BaseReactionList({hasUserReacted = false, users, isVisible = false, emo * so that the sticky headers function properly * */ - const renderItem: FlatListProps['renderItem'] = ({item}) => ( - { - onClose?.(); - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.PROFILE.getRoute(item.accountID))); - }); - }} - option={{ - accountID: item.accountID, - text: Str.removeSMSDomain(getDisplayNameOrYou(item.displayName ?? '', item.accountID, accountID, translate)), - alternateText: Str.removeSMSDomain(item.login ?? ''), - participantsList: [item], - icons: [ - { - id: item.accountID, - source: item.avatar ?? icons.FallbackAvatar, - name: item.login ?? '', - type: CONST.ICON_TYPE_AVATAR, - }, - ], - keyForList: item.login ?? String(item.accountID), - }} - /> - ); + const renderItem: FlatListProps['renderItem'] = ({item}) => { + const displayName = getDisplayNameOrYou(item.displayName ?? '', item.accountID, accountID, translate); + + return ( + { + onClose?.(); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.PROFILE.getRoute(item.accountID))); + }); + }} + option={{ + accountID: item.accountID, + text: Str.isSMSLogin(displayName) ? formatPhoneNumber(displayName) : displayName, + alternateText: formatPhoneNumber(item.login ?? ''), + participantsList: [item], + icons: [ + { + id: item.accountID, + source: item.avatar ?? icons.FallbackAvatar, + name: item.login ?? '', + type: CONST.ICON_TYPE_AVATAR, + }, + ], + keyForList: item.login ?? String(item.accountID), + }} + /> + ); + }; return ( <> diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index 5c9625313f73..3ebff162ecc1 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -1,6 +1,6 @@ import type {Emoji} from '@assets/emojis/types'; -import type {TextSelection} from '@components/Composer/types'; +import type {CustomSelectionChangeEvent, TextSelection} from '@components/Composer/types'; import type Mention from '@components/MentionSuggestions/types'; import type {ReportActionEditMessageState} from '@pages/inbox/report/ReportActionEditMessageContext'; @@ -10,7 +10,7 @@ import type {ReportAction} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; import type {ComponentRef, RefObject} from 'react'; -import type {BlurEvent, TextInputSelectionChangeEvent, View} from 'react-native'; +import type {BlurEvent, View} from 'react-native'; import {createContext, useContext} from 'react'; @@ -19,7 +19,7 @@ import type useDebouncedCommentMaxLengthValidation from './useDebouncedCommentMa type SuggestionsRef = { resetSuggestions: () => void; - onSelectionChange?: (event: TextInputSelectionChangeEvent) => void; + onSelectionChange?: (event: CustomSelectionChangeEvent) => void; triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined; updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void; setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 128ef3727bc5..c274290c2ff1 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -4,6 +4,7 @@ import Composer from '@components/Composer'; import type {ComposerRef, CustomSelectionChangeEvent, TextSelection} from '@components/Composer/types'; import {useWideRHPState} from '@components/WideRHPContextProvider'; +import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsInSidePanel from '@hooks/useIsInSidePanel'; import useKeyboardState from '@hooks/useKeyboardState'; @@ -55,7 +56,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {Ref, RefObject} from 'react'; -import type {BlurEvent, HostInstance, LayoutChangeEvent, MeasureInWindowOnSuccessCallback, TextInputContentSizeChangeEvent, TextInputKeyPressEvent} from 'react-native'; +import type {BlurEvent, LayoutChangeEvent, MeasureInWindowOnSuccessCallback, TextInputContentSizeChangeEvent, TextInputKeyPressEvent} from 'react-native'; import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; import lodashDebounce from 'lodash/debounce'; @@ -82,14 +83,14 @@ type SyncSelection = { type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; -type ComposerWithSuggestionsRef = ComposerRef & { +type ComposerWithSuggestionsRef = Pick & { focus: (shouldDelay?: boolean, forcedSelectionRange?: Selection, forceKeyboardIfAlreadyFocused?: boolean) => void; replaceSelectionWithText: OnEmojiSelected; getCurrentText: () => string; /** * Calling clear will immediately clear the input on the UI thread (its a worklet). - * Once the composer ahs cleared onCleared will be called with the value that was cleared. + * Once the composer has cleared, onClear will be called with the value that was cleared. */ clearWorklet: () => void; @@ -289,6 +290,7 @@ function ComposerWithSuggestions({ const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const reportIDToName = useReportIDToNameMap(); + const accountIDToName = useAccountIDToNameMap(); const commentRef = useRef(initialText); @@ -384,7 +386,7 @@ function ComposerWithSuggestions({ isTransitioningToPreExistingReport.current = false; }, []); - const animatedRef = useAnimatedRef(); + const animatedRef = useAnimatedRef(); /** * Set the TextInput Ref */ @@ -616,7 +618,7 @@ function ComposerWithSuggestions({ webEvent.preventDefault(); if (lastReportAction) { const message = Array.isArray(lastReportAction?.message) ? (lastReportAction?.message?.at(-1) ?? null) : (lastReportAction?.message ?? null); - saveReportActionDraft(reportID, lastReportAction, reportActions, Parser.htmlToMarkdown(message?.html ?? '', {reportIDToName}), isOffline); + saveReportActionDraft(reportID, lastReportAction, reportActions, Parser.htmlToMarkdown(message?.html ?? '', {reportIDToName, accountIDToName}), isOffline); } } // Flag emojis like "Wales" have several code points. Default backspace key action does not remove such flag emojis completely. @@ -667,6 +669,7 @@ function ComposerWithSuggestions({ reportID, reportActions, reportIDToName, + accountIDToName, updateComment, setCurrentEditMessageSelection, isOffline, @@ -955,31 +958,15 @@ function ComposerWithSuggestions({ useImperativeHandle( ref, - () => - new Proxy( - {}, - { - get: (_target, prop) => { - if (prop === 'focus') { - return focus; - } - if (prop === 'replaceSelectionWithText') { - return replaceSelectionWithText; - } - if (prop === 'getCurrentText') { - return getCurrentText; - } - if (prop === 'clearWorklet') { - return clearWorklet; - } - if (prop === 'resetHeight') { - return resetHeight; - } - - return composerRef.current?.[prop as keyof ComposerRef]; - }, - }, - ) as ComposerWithSuggestionsRef, + (): ComposerWithSuggestionsRef => ({ + focus, + replaceSelectionWithText, + getCurrentText, + clearWorklet, + resetHeight, + blur: () => composerRef.current?.blur(), + isFocused: () => composerRef.current?.isFocused() ?? false, + }), ); const onClear = useCallback( diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts index 566e01914903..9ae193251a85 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts @@ -17,7 +17,7 @@ const updateNativeSelectionValue = (composerRef: RefObject, requestIdleCallback(() => { // note: this implementation is only available on non-web RN, thus the wrapping // 'if' block contains a redundant (since the ref is only used on iOS) platform check - composerRef.current?.setSelection(start, end); + composerRef.current?.setSelection?.(start, end); }); }; diff --git a/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx b/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx index 326597ab5cdc..2dbbc22ccadb 100644 --- a/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx @@ -1,11 +1,10 @@ import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; -import type {TextSelection} from '@components/Composer/types'; +import type {CustomSelectionChangeEvent, TextSelection} from '@components/Composer/types'; import {useDragAndDropState} from '@components/DragAndDrop/Provider'; import usePrevious from '@hooks/usePrevious'; import type {ForwardedRef} from 'react'; -import type {TextInputSelectionChangeEvent} from 'react-native'; import React, {useEffect, useImperativeHandle, useRef} from 'react'; import {View} from 'react-native'; @@ -102,7 +101,7 @@ function Suggestions({ return emojiHandler ?? mentionHandler; }; - const onSelectionChange = (e: TextInputSelectionChangeEvent) => { + const onSelectionChange = (e: CustomSelectionChangeEvent) => { const emojiHandler = suggestionEmojiRef.current?.onSelectionChange?.(e); suggestionMentionRef.current?.onSelectionChange?.(e); return emojiHandler; diff --git a/src/pages/inbox/report/ReportActionCompose/getScrollPosition/types.ts b/src/pages/inbox/report/ReportActionCompose/getScrollPosition/types.ts index 41fa35e2f13a..97ebba53ddb6 100644 --- a/src/pages/inbox/report/ReportActionCompose/getScrollPosition/types.ts +++ b/src/pages/inbox/report/ReportActionCompose/getScrollPosition/types.ts @@ -1,9 +1,11 @@ +import type {ComposerRef} from '@components/Composer/types'; + import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; type TextInputScrollProps = { mobileInputScrollPosition: React.RefObject; - textInputRef: React.RefObject | null>; + textInputRef: React.RefObject | ComposerRef | null>; }; type GetScrollPositionType = {scrollValue: number}; diff --git a/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios.ts b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios.ts index 99a8f2f23e44..6beb8bc8e520 100644 --- a/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios.ts +++ b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios.ts @@ -8,7 +8,7 @@ const updateNativeTextInputValue: UpdateNativeTextInputValue = ({text, shouldFor return; } - composerRef.current?.setNativeProps({text}); + composerRef.current?.setNativeProps?.({text}); }; export default updateNativeTextInputValue; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts index 21f768e95792..c3768f5d8b11 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts @@ -47,7 +47,8 @@ function useComposerSuggestions({composerRef, selection, measureParentContainer, useEffect(() => { // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data. - tag.set(findNodeHandle(composerRef.current) ?? -1); + const input = composerRef.current; + tag.set(findNodeHandle(input && 'measure' in input ? input : null) ?? -1); }, [tag, composerRef]); useFocusedInputHandler( diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 5c7b584f8200..68fb2fd3ebf3 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -27,7 +27,7 @@ type UseEditMessageProps = { shouldScrollToLastMessage?: boolean; scrollToLastMessage?: () => void; debouncedCommentMaxLengthValidation: DebouncedFuncLeading<(value: string) => boolean>; - composerRef: React.RefObject; + composerRef: React.RefObject | null>; }; /** diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 22bc949234a7..4474b1c0e308 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -20,7 +20,6 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useOriginalReportID from '@hooks/useOriginalReportID'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; -import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -72,6 +71,7 @@ import {ReactionListContext} from '@pages/inbox/ReactionListContext'; import AttachmentModalContext from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; import {clearAllRelatedReportActionErrors} from '@userActions/ClearReportActionErrors'; +import type {ReportHierarchyInfo} from '@userActions/ClearReportActionErrors'; import {hideEmojiPicker, isActive} from '@userActions/EmojiPickerAction'; import {expandURLPreview} from '@userActions/Report'; import {clearErrorWithOriginalTransactionError} from '@userActions/Transaction'; @@ -87,7 +87,7 @@ import type {Errors} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject, isEmptyValueObject} from '@src/types/utils/EmptyObject'; import type {GestureResponderEvent, TextInput} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useNavigation} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; @@ -153,7 +153,7 @@ type ReportActionItemProps = { shouldUseThreadDividerLine?: boolean; shouldDisplayContextMenu?: boolean; - /** Whether this is the newest Concierge comment eligible for the inline feedback prompt */ + /** Whether this is the newest comment of Concierge or of a custom agent that is eligible for the inline feedback prompt */ isLatestConciergeFeedbackAction?: boolean; linkedTransactionRouteError?: Errors; @@ -166,6 +166,9 @@ type ReportActionItemProps = { /** Whether the action is the "Created" action of a harvest-created expense report */ isHarvestCreatedExpenseReport?: boolean; + + /** Parent links of the report and its ancestors, used to clear related errors on dismiss */ + reportsParentHierarchy: OnyxCollection; }; function ReportActionItem({ @@ -189,12 +192,12 @@ function ReportActionItem({ shouldShowBorder, shouldHighlight = false, isHarvestCreatedExpenseReport = false, + reportsParentHierarchy, }: ReportActionItemProps) { const reportID = report?.reportID ?? action?.reportID; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const originalReportID = useOriginalReportID(report?.reportID, action); const {isOffline} = useNetwork(); - const reportsParentHierarchy = useReportsParentHierarchy(); const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getIOUReportIDFromReportActionPreview(action)}`, {selector: getStableReportSelector}); const [iouPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${iouReport?.policyID}`); @@ -533,7 +536,7 @@ function ReportActionItem({ const iouReportID = isMoneyRequestAction(action) ? action?.reportID : undefined; const isWhisper = whisperedTo.length > 0 && getTransactionsWithReceipts(iouReportID).length === 0; - const isClosedExpenseReportWithNoExpenses = isClosedExpenseReportWithNoExpensesUtils(iouReport, transactionsOnIOUReport); + const isClosedExpenseReportWithNoExpenses = isClosedExpenseReportWithNoExpensesUtils(iouReport, !isEmptyObject(transactionsOnIOUReport)); const isEmpty = !shouldRenderViewBasedOnAction && !isClosedExpenseReportWithNoExpenses; const shouldDisplayThreadReplies = shouldDisplayThreadRepliesUtils(action, isThreadReportParentAction, isAgentProcessingInThread) && !isOnSearch; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index e6cd200eeb43..d1df646be63a 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -170,7 +170,7 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy }, [action, reportID]); useEffect(() => { - composerFocusKeepFocusOn(composerRef.current as HTMLElement, isFocused, modal, onyxInputFocused); + composerFocusKeepFocusOn(composerRef.current, isFocused, modal, onyxInputFocused); }, [isFocused, modal, onyxInputFocused]); useEffect( diff --git a/src/pages/inbox/report/ReportActionItemParentAction.tsx b/src/pages/inbox/report/ReportActionItemParentAction.tsx index 4c5504a2fb68..a165581626d8 100644 --- a/src/pages/inbox/report/ReportActionItemParentAction.tsx +++ b/src/pages/inbox/report/ReportActionItemParentAction.tsx @@ -8,10 +8,10 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useThemeStyles from '@hooks/useThemeStyles'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {shouldExcludeAncestorReportAction} from '@libs/ReportUtils'; +import type {ReportHierarchyInfo} from '@userActions/ClearReportActionErrors'; import {navigateToConciergeChatAndDeleteReport} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -21,7 +21,6 @@ import type {Report, ReportAction, ReportNameValuePairs, Transaction} from '@src import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {hasSeenTourSelector} from '@selectors/Onboarding'; -import {getConciergeFeedbackForReportActionID} from '@selectors/ReportNameValuePairs'; import React from 'react'; import {View} from 'react-native'; @@ -42,6 +41,9 @@ type ReportActionItemParentActionProps = { shouldDisplayReplyDivider: boolean; isFirstVisibleReportAction: boolean; shouldUseThreadDividerLine?: boolean; + + /** Parent links of the thread and its ancestors, shared by every ancestor shown above it */ + reportsParentHierarchy: OnyxCollection; }; function ReportActionItemParentAction({ @@ -53,6 +55,7 @@ function ReportActionItemParentAction({ shouldDisplayReplyDivider, isFirstVisibleReportAction = false, shouldUseThreadDividerLine = false, + reportsParentHierarchy, }: ReportActionItemParentActionProps) { const styles = useThemeStyles(); const ancestors = useAncestors(report, shouldExcludeAncestorReportAction); @@ -84,11 +87,6 @@ function ReportActionItemParentAction({ }, }); - // The backend marks the thread it opens after a thumbs down, and the answer it collects feedback on is not up for rating again - const [conciergeFeedbackForReportActionID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getNonEmptyStringOnyxID(report?.reportID)}`, { - selector: getConciergeFeedbackForReportActionID, - }); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); @@ -135,9 +133,9 @@ function ReportActionItemParentAction({ parentReportAction={parentReportAction} transactionThreadReport={transactionThreadReport} isFirstVisibleReportAction={isFirstVisibleReportAction} - shouldAllowConciergeFeedback={!conciergeFeedbackForReportActionID} shouldUseThreadDividerLine={shouldUseThreadDividerLine} linkedTransactionRouteError={linkedTransactionRouteError} + reportsParentHierarchy={reportsParentHierarchy} /> ))} diff --git a/src/pages/inbox/report/ReportActionItemThread.tsx b/src/pages/inbox/report/ReportActionItemThread.tsx index 7bc7fe8e10b4..3c5f58a4b483 100644 --- a/src/pages/inbox/report/ReportActionItemThread.tsx +++ b/src/pages/inbox/report/ReportActionItemThread.tsx @@ -19,6 +19,7 @@ import type {GestureResponderEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {getStableReportSelector} from '@selectors/Report'; import React from 'react'; import {View} from 'react-native'; @@ -46,11 +47,11 @@ function ReportActionItemThread({report, reportAction, isHovered, onSecondaryInt const styles = useThemeStyles(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {translate, datetimeToCalendarTime} = useLocalize(); - const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportAction.childReportID}`); + const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportAction.childReportID}`, {selector: getStableReportSelector}); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`, {selector: getStableReportSelector}); const personalDetails = usePersonalDetails(); const isAgentProcessingInThread = !!processingAgentIDs?.length; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 3f683a0ecaf2..43e3541fc350 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -11,8 +11,10 @@ import useLocalize from '@hooks/useLocalize'; import useMarkAsRead from '@hooks/useMarkAsRead'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useReportActionsScroll from '@hooks/useReportActionsScroll'; import useReportScrollManager from '@hooks/useReportScrollManager'; +import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useRetireMerchantRuleSuggestionOnLeave from '@hooks/useRetireMerchantRuleSuggestionOnLeave'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -26,7 +28,7 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import TransitionTracker from '@libs/Navigation/TransitionTracker'; import { getFirstVisibleReportActionID, - getLatestConciergeFeedbackActionID, + getFeedbackPromptActionIDs, getReportActionMessage, isConsecutiveActionMadeByPreviousActor, isDeletedParentAction, @@ -60,13 +62,16 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import {getStableReportSelector} from '@src/selectors/Report'; import type * as OnyxTypes from '@src/types/onyx'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {ListRenderItemInfo} from '@shopify/flash-list'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; +import {getCustomAgentAccountIDs} from '@selectors/AgentZeroChat'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {accountIDSelector} from '@selectors/Session'; import React, {useEffect, useRef, useState} from 'react'; import FloatingMessageCounter from './FloatingMessageCounter'; @@ -179,6 +184,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getStableReportSelector}); const [chatReportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportStable?.chatReportID)}`, {selector: getStableReportSelector}); + const reportsParentHierarchy = useReportsParentHierarchy(reportID); const linkedReportActionID = reportActionIDFromRoute; @@ -392,11 +398,17 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const firstVisibleReportActionID = getFirstVisibleReportActionID(sortedReportActions, isOffline); + const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); + + // An agent account can be opened through Copilot, and the agent must not rate its own replies + const actorAccountIDs = [...new Set(renderedVisibleReportActions.map((action) => action.actorAccountID))].filter((accountID) => accountID !== currentUserAccountID); + const [customAgentAccountIDs = getEmptyArray()] = usePersonalDetailsByIDs(actorAccountIDs, getCustomAgentAccountIDs); + // Skip inside the thread the backend opens after a thumbs down, while a Concierge answer is still streaming, and while newer actions are not loaded because the newest reply may not be in the list yet - const latestConciergeFeedbackActionID = + const latestConciergeFeedbackActionIDs = reportNameValuePairs?.conciergeFeedbackForReportActionID || isDraftPendingCompletion || hasNewerActions - ? undefined - : getLatestConciergeFeedbackActionID(renderedVisibleReportActions, allReportActionIDs); + ? getEmptyArray() + : getFeedbackPromptActionIDs(renderedVisibleReportActions, allReportActionIDs, customAgentAccountIDs); useFollowActionBadgeTarget({ reportID, @@ -458,10 +470,11 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct shouldDisplayNewMarker={reportAction.reportActionID === unreadMarkerReportActionID} shouldDisplayReplyDivider={renderedVisibleReportActions.length > 1} isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} - isLatestConciergeFeedbackAction={!!latestConciergeFeedbackActionID && latestConciergeFeedbackActionID === reportAction.reportActionID} + isLatestConciergeFeedbackAction={latestConciergeFeedbackActionIDs.includes(reportAction.reportActionID)} shouldUseThreadDividerLine={shouldUseThreadDividerLine} isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReportAction} shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} + reportsParentHierarchy={reportsParentHierarchy} /> {!!reportStable?.reportID && ( { trackVerticalScrolling(undefined); + flushPendingScrollToBottom(); }} /> diff --git a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx index 4da6c57f43b5..7a0bc8685a8a 100644 --- a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx +++ b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx @@ -1,10 +1,12 @@ import {getOriginalMessage, isSentMoneyReportAction, isTransactionThread} from '@libs/ReportActionsUtils'; import {isChatThread} from '@libs/ReportUtils'; +import type {ReportHierarchyInfo} from '@userActions/ClearReportActionErrors'; + import CONST from '@src/CONST'; import type {Report, ReportAction} from '@src/types/onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import React, {memo, useMemo} from 'react'; @@ -39,7 +41,7 @@ type ReportActionsListItemRendererProps = { shouldDisplayReplyDivider: boolean; isFirstVisibleReportAction: boolean; - /** Whether this is the newest Concierge comment eligible for the inline feedback prompt */ + /** Whether this is the newest comment of Concierge or of a custom agent that is eligible for the inline feedback prompt */ isLatestConciergeFeedbackAction?: boolean; shouldUseThreadDividerLine?: boolean; @@ -52,6 +54,9 @@ type ReportActionsListItemRendererProps = { /** Whether context menu should be disabled for the active Concierge draft */ shouldDisableContextMenuForConciergeDraft?: boolean; + + /** Parent links of the report and its ancestors, used to clear related errors on dismiss */ + reportsParentHierarchy: OnyxCollection; }; function ReportActionsListItemRenderer({ @@ -72,6 +77,7 @@ function ReportActionsListItemRenderer({ isHarvestCreatedExpenseReport = false, shouldDisableContextMenuForConciergeDraft = false, isLatestConciergeFeedbackAction = false, + reportsParentHierarchy, }: ReportActionsListItemRendererProps) { const originalMessage = useMemo(() => getOriginalMessage(reportAction), [reportAction]); @@ -160,6 +166,7 @@ function ReportActionsListItemRenderer({ transactionThreadReport={transactionThreadReport} isFirstVisibleReportAction={isFirstVisibleReportAction} shouldUseThreadDividerLine={shouldUseThreadDividerLine} + reportsParentHierarchy={reportsParentHierarchy} /> ); } @@ -182,6 +189,7 @@ function ReportActionsListItemRenderer({ isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReport} shouldDisplayContextMenu={!shouldDisableContextMenuForConciergeDraft} isLatestConciergeFeedbackAction={isLatestConciergeFeedbackAction} + reportsParentHierarchy={reportsParentHierarchy} /> ); } diff --git a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx index b65a2e66dcaa..f53fa5721a39 100644 --- a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx +++ b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx @@ -1,5 +1,5 @@ import useBackfillWhenNoVisibleActions from '@hooks/useBackfillWhenNoVisibleActions'; -import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; +import useCopySelectionEvent from '@hooks/useCopySelectionEvent'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; import useReportActionsListModel from '@hooks/useReportActionsListModel'; @@ -50,7 +50,7 @@ function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonG } = readinessSignals; // Side effects that must run whenever the chat list is shown, including while the skeleton renders. - useCopySelectionHelper(); + useCopySelectionEvent(); usePendingConciergeResponse(reportID); useStartConciergeSession({ diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index a78f4a3421e1..c5a3e9d8f125 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -136,7 +136,7 @@ type ActionContentRouterProps = { /** Whether the search-page UI is active */ isOnSearch: boolean; - /** Whether this is the newest Concierge comment eligible for the inline feedback prompt */ + /** Whether this is the newest comment of Concierge or of a custom agent that is eligible for the inline feedback prompt */ isLatestConciergeFeedbackAction: boolean; setIsPaymentMethodPopoverActive: (value: boolean) => void; @@ -326,6 +326,7 @@ function ActionContentRouter({ ); } diff --git a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx index 237a2ed3adf7..e879160ebb9b 100644 --- a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx +++ b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx @@ -1,6 +1,7 @@ import type {Emoji} from '@assets/emojis/types'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import EmojiReactionBubble from '@components/Reactions/EmojiReactionBubble'; import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; @@ -39,14 +40,29 @@ type ConciergeFeedbackPromptProps = { /** Whether to show the generic feedback prompt alongside the thumbs */ shouldShowPrompt?: boolean; + /** Custom prompt text shown alongside the thumbs */ + promptText?: string; + /** Whether the thumbs should remain available after the user rates the action */ shouldPersistAfterRating?: boolean; /** Whether hovering a thumb should show its tooltip */ shouldShowTooltips?: boolean; + /** Tooltip text for the positive feedback option */ + positiveTooltipText?: string; + + /** Tooltip text for the negative feedback option */ + negativeTooltipText?: string; + /** Whether to place only the thumbs inline with surrounding content */ shouldRenderInline?: boolean; + + /** Whether a selected thumb should use the standard selected reaction bubble */ + shouldRenderSelectedReactionBubble?: boolean; + + /** Whether to hide the unselected thumb after the user rates the action */ + shouldHideUnselectedThumbAfterRating?: boolean; }; /** A reaction can be stored under the emoji name or under its hexcode, so both keys are read */ @@ -90,28 +106,39 @@ type ConciergeFeedbackThumbProps = { /** Whether the current user selected this rating */ isSelected: boolean; + + /** Whether a selected thumb should use the standard selected reaction bubble */ + shouldRenderSelectedReactionBubble: boolean; }; -function ConciergeFeedbackThumb({emoji, label, accessibilityLabel, onPress, shouldShowTooltip, isSelected}: ConciergeFeedbackThumbProps) { +function ConciergeFeedbackThumb({emoji, label, accessibilityLabel, onPress, shouldShowTooltip, isSelected, shouldRenderSelectedReactionBubble}: ConciergeFeedbackThumbProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); - const thumb = ( - - {emoji.code} - - ); + const thumb = + isSelected && shouldRenderSelectedReactionBubble ? ( + + ) : ( + + {emoji.code} + + ); return shouldShowTooltip ? {thumb} : thumb; } @@ -120,9 +147,14 @@ function ConciergeFeedbackPrompt({ action, reportID, shouldShowPrompt = true, + promptText, shouldPersistAfterRating = false, shouldShowTooltips = true, + positiveTooltipText, + negativeTooltipText, shouldRenderInline = false, + shouldRenderSelectedReactionBubble = false, + shouldHideUnselectedThumbAfterRating = false, }: ConciergeFeedbackPromptProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -136,21 +168,53 @@ function ConciergeFeedbackPrompt({ const thumbsDown = findEmojiByName('-1'); const thumbsUpReactedAt = getReactedAtTimestamp(thumbsUp, reactions, currentUserAccountID); + const thumbsDownReactedAt = getReactedAtTimestamp(thumbsDown, reactions, currentUserAccountID); - // The reaction that was already there when this row mounted is an old rating, so only a reaction that lands while the row is open is acknowledged + // Either rating starts the acknowledgement window, and the zero fallback keeps an unanswered survey undefined. + const latestReactionTimestamp = Math.max(thumbsUpReactedAt ?? 0, thumbsDownReactedAt ?? 0) || undefined; + + // Regular Concierge feedback preserves its debounced thumbs-up acknowledgement. const [mountedWithThumbsUpReactedAt] = useState(thumbsUpReactedAt); - const hasJustReacted = thumbsUpReactedAt !== undefined && thumbsUpReactedAt !== mountedWithThumbsUpReactedAt; + const hasJustReactedWithThumbsUp = thumbsUpReactedAt !== undefined && thumbsUpReactedAt !== mountedWithThumbsUpReactedAt; + const hasSettledAfterThumbsUpReaction = useDebouncedValue(hasJustReactedWithThumbsUp, THANKS_VISIBLE_DURATION_MS); + const isRegularThankMessageDisplayed = hasJustReactedWithThumbsUp && !hasSettledAfterThumbsUpReaction; + + // A survey accepts either rating, so its acknowledgement restarts when the selected reaction changes. + const [mountedWithLatestReactionTimestamp] = useState(latestReactionTimestamp); + const debouncedLatestReactionTimestamp = useDebouncedValue(latestReactionTimestamp, THANKS_VISIBLE_DURATION_MS); + const isSurveyThankMessageDisplayed = + latestReactionTimestamp !== undefined && latestReactionTimestamp !== mountedWithLatestReactionTimestamp && latestReactionTimestamp !== debouncedLatestReactionTimestamp; - // The debounced copy catches up once the window has passed, which is what takes the acknowledgement back down - const hasSettledAfterReacting = useDebouncedValue(hasJustReacted, THANKS_VISIBLE_DURATION_MS); - const isDisplayedThankMessage = hasJustReacted && !hasSettledAfterReacting; + const isDisplayedThankMessage = shouldPersistAfterRating ? isSurveyThankMessageDisplayed : isRegularThankMessageDisplayed; const rate = (emoji: Emoji) => { // Skin tone is ignored on compare so a user whose preferred tone changed toggles their existing reaction instead of adding a second one toggleEmojiReaction(reportID, action, emoji, reactions, preferredSkinTone, currentUserAccountID, reportActions, true, true, emoji.name === thumbsUp.name ? thumbsDown : thumbsUp); }; - const hasRated = hasReactedWithEmoji(thumbsUp, reactions, currentUserAccountID) || hasReactedWithEmoji(thumbsDown, reactions, currentUserAccountID); + const hasReactedWithThumbsUp = hasReactedWithEmoji(thumbsUp, reactions, currentUserAccountID); + const hasReactedWithThumbsDown = hasReactedWithEmoji(thumbsDown, reactions, currentUserAccountID); + const hasRated = hasReactedWithThumbsUp || hasReactedWithThumbsDown; + const shouldHideUnselectedThumbs = shouldHideUnselectedThumbAfterRating && hasRated; + + const getFeedbackThumb = (emoji: Emoji, isSelected: boolean) => ( + rate(emoji))} + shouldShowTooltip={shouldShowTooltips} + isSelected={isSelected} + shouldRenderSelectedReactionBubble={shouldRenderSelectedReactionBubble} + /> + ); + + const feedbackThumbs = ( + + {(!shouldHideUnselectedThumbs || hasReactedWithThumbsUp) && getFeedbackThumb(thumbsUp, hasReactedWithThumbsUp)} + {(!shouldHideUnselectedThumbs || hasReactedWithThumbsDown) && getFeedbackThumb(thumbsDown, hasReactedWithThumbsDown)} + + ); // The acknowledgement comes from the reaction, so removing it from the reaction row brings the prompt back right away if (!shouldPersistAfterRating && isDisplayedThankMessage) { @@ -161,29 +225,23 @@ function ConciergeFeedbackPrompt({ return null; } - const feedbackThumbs = ( - - rate(thumbsUp))} - shouldShowTooltip={shouldShowTooltips} - isSelected={hasReactedWithEmoji(thumbsUp, reactions, currentUserAccountID)} - /> - rate(thumbsDown))} - shouldShowTooltip={shouldShowTooltips} - isSelected={hasReactedWithEmoji(thumbsDown, reactions, currentUserAccountID)} - /> - - ); + if (shouldPersistAfterRating && hasRated) { + return ( + + {shouldRenderInline && shouldShowPrompt && {promptText ?? translate('concierge.feedback.prompt')}} + {isDisplayedThankMessage && {translate('concierge.feedback.thanks')}} + {feedbackThumbs} + + ); + } if (shouldRenderInline) { - return feedbackThumbs; + return ( + + {shouldShowPrompt && {promptText ?? translate('concierge.feedback.prompt')}} + {feedbackThumbs} + + ); } return ( @@ -191,7 +249,7 @@ function ConciergeFeedbackPrompt({ layout="horizontal" style={styles.alignItemsCenter} > - {shouldShowPrompt && {translate('concierge.feedback.prompt')}} + {shouldShowPrompt && {promptText ?? translate('concierge.feedback.prompt')}} {feedbackThumbs} ); diff --git a/src/pages/inbox/report/actionContents/IouReportActionMessage.tsx b/src/pages/inbox/report/actionContents/IouReportActionMessage.tsx index 7cb0c9f2597e..80118e2b3eb4 100644 --- a/src/pages/inbox/report/actionContents/IouReportActionMessage.tsx +++ b/src/pages/inbox/report/actionContents/IouReportActionMessage.tsx @@ -1,8 +1,10 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getPolicyReimburserAccountID} from '@libs/PolicyPaymentUtils'; import {getLinkedTransactionID, getOriginalMessage, isActionOfType} from '@libs/ReportActionsUtils'; import {getIOUReportActionDisplayMessage} from '@libs/ReportUtils'; @@ -12,7 +14,7 @@ import type {ReportAction} from '@src/types/onyx'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; -import {policyACHAccountNumberSelector} from '@selectors/Policy'; +import {policyPaymentAttributionSelector} from '@selectors/Policy'; import React from 'react'; import ReportActionMessageContent from './ReportActionMessageContent'; @@ -38,13 +40,14 @@ function IouReportActionMessage({action, displayAsGroup, reportID, style, isHidd const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(getLinkedTransactionID(action))}`); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - const [policyACHAccountNumber] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`, {selector: policyACHAccountNumberSelector}); + const [policyPaymentAttribution] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`, {selector: policyPaymentAttributionSelector}); + const [reimburserAccountID] = useAllPersonalDetails((personalDetails) => getPolicyReimburserAccountID(policyPaymentAttribution, personalDetails)); let iouMessage: string | undefined; const isIOUAction = isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.IOU); const originalMessageType = isIOUAction ? getOriginalMessage(action)?.type : undefined; if (isIOUAction && originalMessageType !== CONST.IOU.REPORT_ACTION_TYPE.TRACK) { - iouMessage = getIOUReportActionDisplayMessage(translate, action, convertToDisplayString, policyACHAccountNumber, transaction, bankAccountList); + iouMessage = getIOUReportActionDisplayMessage(translate, action, convertToDisplayString, policyPaymentAttribution, transaction, bankAccountList, reimburserAccountID); } return ( diff --git a/src/pages/inbox/report/actionContents/PaymentContent.tsx b/src/pages/inbox/report/actionContents/PaymentContent.tsx index 39c92a018d7e..b196a3bc981f 100644 --- a/src/pages/inbox/report/actionContents/PaymentContent.tsx +++ b/src/pages/inbox/report/actionContents/PaymentContent.tsx @@ -3,8 +3,10 @@ import RenderHTML from '@components/RenderHTML'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import getBankAccountLastFourDigits from '@libs/getBankAccountLastFourDigits'; +import {getPolicyReimburserAccountID} from '@libs/PolicyPaymentUtils'; import {getCrossBorderReimbursedMessage, getElsewherePaymentReportActionMessage, getOriginalMessage, getPaymentMessageWithExpectedDate} from '@libs/ReportActionsUtils'; import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; @@ -13,7 +15,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import {policyACHAccountNumberSelector} from '@selectors/Policy'; +import {policyPaymentAttributionSelector} from '@selectors/Policy'; import React from 'react'; type PaymentContentProps = { @@ -24,7 +26,8 @@ type PaymentContentProps = { function PaymentContent({action, expectedDate, policyID}: PaymentContentProps) { const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - const [policyACHAccountNumber] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: policyACHAccountNumberSelector}); + const [policyPaymentAttribution] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: policyPaymentAttributionSelector}); + const [reimburserAccountID] = useAllPersonalDetails((personalDetails) => getPolicyReimburserAccountID(policyPaymentAttribution, personalDetails)); const {translate, dateFnsLocale} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const originalMessage = getOriginalMessage(action); @@ -41,7 +44,14 @@ function PaymentContent({action, expectedDate, policyID}: PaymentContentProps) { } if (paymentType === CONST.IOU.PAYMENT_TYPE.VBBA) { - const last4Digits = originalMessage.accountNumber?.slice(-4) ?? getBankAccountLastFourDigits(originalMessage.bankAccountID, bankAccountList, policyACHAccountNumber); + const last4Digits = getBankAccountLastFourDigits({ + bankAccountID: originalMessage.bankAccountID, + bankAccountList, + policy: policyPaymentAttribution, + accountNumber: originalMessage.accountNumber, + payerAccountID: action.actorAccountID, + reimburserAccountID, + }); const crossBorderMessage = getCrossBorderReimbursedMessage(translate, originalMessage, convertToDisplayString, last4Digits); const paymentMessage = crossBorderMessage ?? translate(wasAutoPaid ? 'iou.automaticallyPaidWithBusinessBankAccount' : 'iou.businessBankAccount', '', last4Digits); const translation = getPaymentMessageWithExpectedDate(translate, dateFnsLocale, paymentMessage, expectedDate); diff --git a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx index e59a1afc816c..4ee94cf93cc7 100644 --- a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx +++ b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx @@ -13,6 +13,7 @@ import { getAddedBudgetMessage, getAddedCardFeedMessage, getAddedConnectionMessage, + getAddedOfficeLocationMessage, getApprovalLimitUpdateMessage, getAssignedCompanyCardMessage, getAutoCategorizeNewExpensesMessage, @@ -26,6 +27,8 @@ import { getDefaultApproverUpdateMessage, getDeletedApprovalRuleMessage, getDeletedBudgetMessage, + getDeletedOfficeLocationMessage, + getUpdatedEmployeeOfficeLocationMessage, getForeignCurrencyDefaultTaxUpdateMessage, getForwardsToUpdateMessage, getInvoiceCompanyNameUpdateMessage, @@ -68,6 +71,7 @@ import { getUpdatedDefaultTitleMessage, getUpdatedIndividualBudgetNotificationMessage, getUpdatedManualApprovalThresholdMessage, + getUpdatedOfficeLocationMessage, getUpdatedOwnershipMessage, getUpdatedProhibitedExpensesMessage, getUpdatedReimbursementChoiceMessage, @@ -239,6 +243,10 @@ const POLICY_CHANGE_LOG_RESOLVERS: Record = { [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_COMMUTER_EXCLUSIONS]: (translate, action) => getUpdatedCommuterExclusionsMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_POLICY_WORK_ARRANGEMENT]: (translate, action) => getPolicyWorkArrangementMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT]: (translate, action) => getUpdatedMemberWorkArrangementMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION]: (translate, action) => getAddedOfficeLocationMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION]: (translate, action) => getUpdatedOfficeLocationMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION]: (translate, action) => getDeletedOfficeLocationMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION]: (translate, action) => getUpdatedEmployeeOfficeLocationMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSEMENT_CHOICE]: (translate, action) => getUpdatedReimbursementChoiceMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_AUTO_JOIN]: (translate, action) => getSetAutoJoinMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE]: (translate, action) => getUpdatedDefaultTitleMessage(translate, action), diff --git a/src/pages/inbox/report/actionContents/ReimbursedContent.tsx b/src/pages/inbox/report/actionContents/ReimbursedContent.tsx index 2b11daee84b6..c5be0bd62ff5 100644 --- a/src/pages/inbox/report/actionContents/ReimbursedContent.tsx +++ b/src/pages/inbox/report/actionContents/ReimbursedContent.tsx @@ -1,13 +1,19 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; -import {getReimbursedMessage} from '@libs/ReportActionsUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getReimbursedMessage, hasReasoning} from '@libs/ReportActionsUtils'; import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import ReportActionItemMessageWithExplain from '@pages/inbox/report/ReportActionItemMessageWithExplain'; -import type {ReportAction} from '@src/types/onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; import {loginSelector} from '@selectors/PersonalDetails'; import React from 'react'; @@ -15,16 +21,33 @@ import React from 'react'; type ReimbursedContentProps = { action: ReportAction; reportOwnerAccountID: number | undefined; + + /** Original report from which the given reportAction is first created */ + originalReport: OnyxEntry; }; -function ReimbursedContent({action, reportOwnerAccountID}: ReimbursedContentProps) { +function ReimbursedContent({action, reportOwnerAccountID, originalReport}: ReimbursedContentProps) { const {translate, dateFnsLocale} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [submitterLogin] = usePersonalDetail(reportOwnerAccountID, loginSelector); const [actorLogin] = usePersonalDetail(action.actorAccountID, loginSelector); + const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(action.childReportID)}`); const message = getReimbursedMessage(translate, dateFnsLocale, action, reportOwnerAccountID, submitterLogin, actorLogin, convertToDisplayString, currentUserAccountID); + // getReimbursedMessage can fall back to '' when there's no paymentMethod/creditBankAccountLast4 to build a + // message from. Guard against that so an empty message never renders as a bare ". Explain". + if (hasReasoning(action) && message) { + return ( + + ); + } + return ; } diff --git a/src/pages/inbox/report/actionContents/ReimbursementDeQueuedContent.tsx b/src/pages/inbox/report/actionContents/ReimbursementDeQueuedContent.tsx index 58598d797462..b5af51a31e07 100644 --- a/src/pages/inbox/report/actionContents/ReimbursementDeQueuedContent.tsx +++ b/src/pages/inbox/report/actionContents/ReimbursementDeQueuedContent.tsx @@ -18,10 +18,11 @@ type ReimbursementDeQueuedContentProps = { }; function ReimbursementDeQueuedContent({action, reportOwnerAccountID}: ReimbursementDeQueuedContentProps) { - const {translate} = useLocalize(); + const {translate, formatPhoneNumber} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const message = getReimbursementDeQueuedOrCanceledActionMessage( translate, + formatPhoneNumber, action as OnyxEntry>, reportOwnerAccountID, convertToDisplayString, diff --git a/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx b/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx index fbd937960d5d..991e71dc9132 100644 --- a/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx +++ b/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx @@ -43,6 +43,7 @@ const SIMPLE_MESSAGE_ACTION_TYPES = new Set([ CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION, CONST.REPORT.ACTIONS.TYPE.UNDELETED_TRANSACTION, CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION, + CONST.REPORT.ACTIONS.TYPE.MERGED_EXPENSE_DELETED, CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION, CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES, CONST.REPORT.ACTIONS.TYPE.DEMOTED_FROM_WORKSPACE, @@ -101,6 +102,9 @@ function SimpleMessageContent({action}: SimpleMessageContentProps) { if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION)) { return ; } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MERGED_EXPENSE_DELETED)) { + return ; + } if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION)) { return ; } diff --git a/src/pages/inbox/report/useClearReportActionDraftsOnReportChange.ts b/src/pages/inbox/report/useClearReportActionDraftsOnReportChange.ts index 289b325ab6c1..935dd5596ca8 100644 --- a/src/pages/inbox/report/useClearReportActionDraftsOnReportChange.ts +++ b/src/pages/inbox/report/useClearReportActionDraftsOnReportChange.ts @@ -1,16 +1,32 @@ -import {clearAllReportActionDrafts} from '@libs/actions/Report'; +import useIsInSidePanel from '@hooks/useIsInSidePanel'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import {clearAllReportActionDrafts, clearReportActionDrafts} from '@libs/actions/Report'; import {useEffect} from 'react'; // When the report screen is navigated away from or the report changes, clear all report action edit drafts function useClearReportActionDraftsOnReportChange(reportID: string | undefined) { + const isInSidePanel = useIsInSidePanel(); + const {isExtraLargeScreenWidth} = useResponsiveLayout(); + // Only the docked panel sits beside a usable chat, the narrow modal covers it like a new page on native + const isDockedSidePanel = isInSidePanel && isExtraLargeScreenWidth; + useEffect(() => { + if (isDockedSidePanel) { + clearReportActionDrafts(reportID); + + return () => { + clearReportActionDrafts(reportID); + }; + } + clearAllReportActionDrafts(); return () => { clearAllReportActionDrafts(); }; - }, [reportID]); + }, [reportID, isDockedSidePanel]); } export default useClearReportActionDraftsOnReportChange; diff --git a/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts b/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts index a0f46d880404..73cc12862d8d 100644 --- a/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts +++ b/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts @@ -1,3 +1,4 @@ +import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap'; import usePrevious from '@hooks/usePrevious'; import useReportIDToNameMap from '@hooks/useReportIDToNameMap'; @@ -29,6 +30,7 @@ function useDraftMessageVideoAttributeCache({ isEditInProgressRef, }: UseDraftMessageVideoAttributeCacheProps): DraftMessageVideoAttributeCache { const prevDraftMessage = usePrevious(draftMessage); + const accountIDToName = useAccountIDToNameMap(); const reportIDToName = useReportIDToNameMap(); useEffect(() => { @@ -40,6 +42,7 @@ function useDraftMessageVideoAttributeCache({ const originalMessage = Parser.htmlToMarkdown(getReportActionHtml(editingReportAction), { reportIDToName, + accountIDToName, cacheVideoAttributes: (videoSource, attrs) => draftMessageVideoAttributeCache.set(videoSource, attrs), }); if ( @@ -50,7 +53,7 @@ function useDraftMessageVideoAttributeCache({ return; } updateDraftMessageProp(draftMessage); - }, [draftMessage, editingReportAction, isEditInProgressRef, isEditing, prevDraftMessage, reportIDToName, updateDraftMessageProp]); + }, [draftMessage, editingReportAction, isEditInProgressRef, isEditing, prevDraftMessage, accountIDToName, reportIDToName, updateDraftMessageProp]); return draftMessageVideoAttributeCache; } diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index 0c985a096315..c99fe489b71e 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -13,7 +13,6 @@ import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -101,18 +100,13 @@ function useReportActionsNewActionLiveTail({ if (!isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute())) { return; } - if (!hasNewestReportAction && !isFromCurrentUser) { - if (Navigation.getReportRHPActiveRoute()) { - return; - } - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); - }); - return; - } + // An unread marker only needs openReport when the newest actions are not loaded yet. When they are, they are + // just off-screen, so scroll to them directly instead of waiting on a network round trip. const shouldJumpToLiveTail = - !isOffline && action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && (hasNewerActions || !!linkedReportActionID || unreadMarkerReportActionID); + !isOffline && + action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && + (hasNewerActions || !!linkedReportActionID || (!!unreadMarkerReportActionID && !hasNewestReportAction)); if (shouldJumpToLiveTail) { if (liveTailJumpRef.current.stage === 'idle') { diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index c02909053c2f..0b6451b97d23 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -194,6 +194,7 @@ export default function useReportUnreadMessageScrollTracking({ isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, isActionBadgeAboveViewport, + setIsActionBadgeAboveViewport, trackVerticalScrolling, onViewableItemsChanged, updatePillVisibility, diff --git a/src/pages/inbox/report/withReportOrNotFound.tsx b/src/pages/inbox/report/withReportOrNotFound.tsx index baee6ca53277..4fff348181e9 100644 --- a/src/pages/inbox/report/withReportOrNotFound.tsx +++ b/src/pages/inbox/report/withReportOrNotFound.tsx @@ -56,6 +56,7 @@ type ScreenProps = | PlatformStackScreenProps | PlatformStackScreenProps | PlatformStackScreenProps + | PlatformStackScreenProps | PlatformStackScreenProps | PlatformStackScreenProps | PlatformStackScreenProps diff --git a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx index dd92d4ecd347..1cacee978912 100644 --- a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx +++ b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx @@ -1,5 +1,5 @@ import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import TopBarWithLoadingBar from '@components/Navigation/TopBarWithLoadingBar'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -22,6 +22,7 @@ import SidebarLinksData from './SidebarLinksData'; function BaseSidebarScreen() { const styles = useThemeStyles(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.INBOX); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const shouldShowSkeleton = useAppLoadSkeletonState(); @@ -40,8 +41,7 @@ function BaseSidebarScreen() { shouldEnableKeyboardAvoidingView={false} style={[styles.sidebar, isMobile() ? styles.userSelectNone : {}]} testID="BaseSidebarScreen" - bottomContent={} - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > {({insets}) => ( <> diff --git a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx index 4ac975dab4b1..51b437ad31bc 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx @@ -10,7 +10,6 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {createNewReport} from '@libs/actions/Report'; import getCreateReportRoute, {getReportsRootRoute, navigateToCreateReportWorkspaceSelection} from '@libs/Navigation/helpers/getCreateReportRoute'; import Navigation from '@libs/Navigation/Navigation'; -import {getDefaultChatEnabledPolicy, getGroupPoliciesWhereReportCanBeCreated} from '@libs/PolicyUtils'; import {hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; import isOnSearchMoneyRequestReportPage from '@navigation/helpers/isOnSearchMoneyRequestReportPage'; @@ -24,23 +23,17 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {sessionEmailAndAccountIDSelector} from '@src/selectors/Session'; import type * as OnyxTypes from '@src/types/onyx'; -import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import React from 'react'; const ITEM_ID = CONST.FAB_MENU_ITEM_IDS.CREATE_REPORT; -// Returns up to 2 matching policies -const chatEnabledPaidGroupPoliciesSelector = (policies: OnyxCollection, currentUserLogin: string | undefined) => - getGroupPoliciesWhereReportCanBeCreated(policies, currentUserLogin).slice(0, 2); - function CreateReportMenuItem() { - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['Document']); - const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); const [session] = useOnyx(ONYXKEYS.SESSION, {selector: sessionEmailAndAccountIDSelector}); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -48,18 +41,13 @@ function CreateReportMenuItem() { const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const hasViolations = hasViolationsReportUtils(undefined, transactionViolations, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? ''); - const [groupPoliciesWithChatEnabled = CONST.EMPTY_ARRAY] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { - selector: (policies: Parameters[0]) => chatEnabledPaidGroupPoliciesSelector(policies, session?.email), - }); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const defaultChatEnabledPolicy = getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled, activePolicy); - const isReportInSearch = isOnSearchMoneyRequestReportPage(); - const handleCreateWorkspaceReport = (shouldDismissEmptyReportsConfirmation?: boolean) => { - if (!defaultChatEnabledPolicy?.id) { + const handleCreateWorkspaceReport = (policy: OnyxEntry, shouldDismissEmptyReportsConfirmation?: boolean) => { + if (!policy?.id) { return; } @@ -71,7 +59,7 @@ function CreateReportMenuItem() { currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, - defaultChatEnabledPolicy, + policy, isTrackIntentUser, getCurrencyDecimals, rules, @@ -88,7 +76,6 @@ function CreateReportMenuItem() { const {createReport, isVisible} = useCreateReport({ onCreateReport: handleCreateWorkspaceReport, - groupPoliciesWithChatEnabled, onNavigateToWorkspaceSelection: () => navigateToCreateReportWorkspaceSelection({forceReplace: isReportInSearch}), shouldHandleNavigationBack: false, }); diff --git a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx index 2ecdae3d7e9f..f308c8706689 100644 --- a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx +++ b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx @@ -37,27 +37,42 @@ function NavigationTabBarAvatar({onPress, isSelected = false, style}: Navigation onPress={onPress} accessibilityLabel={`${translate('initialSettingsPage.account')}, ${translate('sidebarScreen.buttonMySettings')}. ${status ? `${translate('common.yourReviewIsRequired')}.` : ''}`} role={CONST.ROLE.TAB} - wrapperStyle={styles.flex1} + wrapperStyle={shouldUseNarrowLayout ? styles.navigationTabBarSlot : styles.flex1} accessibilityState={accountAccessibilityState} aria-selected={accountAccessibilityState.selected} style={({hovered}) => [style, !shouldUseNarrowLayout && hovered && styles.navigationTabBarItemHovered]} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.ACCOUNT} > {({hovered}) => ( - <> - + + {translate('initialSettingsPage.account')} - + )} ); diff --git a/src/pages/inbox/sidebar/SidebarLinks.tsx b/src/pages/inbox/sidebar/SidebarLinks.tsx index 6af8512410a8..a9830e42ffbe 100644 --- a/src/pages/inbox/sidebar/SidebarLinks.tsx +++ b/src/pages/inbox/sidebar/SidebarLinks.tsx @@ -1,5 +1,6 @@ import LHNEmptyState from '@components/LHNOptionsList/LHNEmptyState'; import LHNOptionsList from '@components/LHNOptionsList/LHNOptionsList'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import useOnyx from '@hooks/useOnyx'; @@ -31,7 +32,8 @@ type SidebarLinksProps = { /** Safe area insets required for mobile devices margins */ insets: EdgeInsets; - optionListItems: Report[]; + /** The ordered IDs of the reports to show in the LHN */ + reportIDs: string[]; /** Whether the full (unfiltered) LHN report set is empty. Used to distinguish an Onyx-cleared reload from a per-tab empty view. */ hasReportData: boolean; @@ -40,7 +42,7 @@ type SidebarLinksProps = { priorityMode?: OnyxEntry>; }; -function SidebarLinks({insets, optionListItems, hasReportData, priorityMode = CONST.PRIORITY_MODE.DEFAULT}: SidebarLinksProps) { +function SidebarLinks({insets, reportIDs, hasReportData, priorityMode = CONST.PRIORITY_MODE.DEFAULT}: SidebarLinksProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); @@ -81,7 +83,8 @@ function SidebarLinks({insets, optionListItems, hasReportData, priorityMode = CO const contentContainerStyles = StyleSheet.flatten([styles.pt2, {paddingBottom: StyleUtils.getSafeAreaMargins(insets).marginBottom}]); - const shouldShowEmptyLHN = optionListItems.length === 0; + const shouldShowEmptyLHN = reportIDs.length === 0; + const {contentContainerStyle: emptyLHNStyle} = useTabRootScrollProps([styles.flex1, isInLandscapeMode ? styles.pv4 : styles.emptyLHNWrapper], {fillsVisibleArea: true}); // Show the loading skeleton only while report data is loading AND the entire LHN report set is empty // (e.g. switching accounts/delegate sessions or re-authenticating after an Onyx clear, where openApp() repopulates reports). @@ -93,14 +96,14 @@ function SidebarLinks({insets, optionListItems, hasReportData, priorityMode = CO {shouldShowEmptyLHN ? ( - + ) : ( 0} /> diff --git a/src/pages/inbox/usePusherDraftPacing.ts b/src/pages/inbox/usePusherDraftPacing.ts index 76b00cf02c9a..07d7cd95291c 100644 --- a/src/pages/inbox/usePusherDraftPacing.ts +++ b/src/pages/inbox/usePusherDraftPacing.ts @@ -1,3 +1,4 @@ +import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap'; import useOnyx from '@hooks/useOnyx'; import {getReportChannelName} from '@libs/actions/Report'; @@ -43,6 +44,7 @@ type PusherDraftPaceRefs = { }; type PusherDraftPacingRuntime = PusherDraftPaceRefs & { + accountIDToNameRef: MutableRef>; completedReportActionIDsRef: MutableRef>; currentDraftRef: MutableRef; isGroupPolicyReport: boolean; @@ -222,7 +224,7 @@ function publishVisibleEvent( status?: ConciergeDraftEvent['status'], finalRenderedHTML?: string, ) { - const {isGroupPolicyReport, reportID, setDraft, visibleBodyMarkdownRef, visibleSequenceRef, visibleSourceMarkdownRef, visibleSourceOffsetRef} = runtime; + const {accountIDToNameRef, isGroupPolicyReport, reportID, setDraft, visibleBodyMarkdownRef, visibleSequenceRef, visibleSourceMarkdownRef, visibleSourceOffsetRef} = runtime; if (visibleMarkdown) { visibleBodyMarkdownRef.current = visibleMarkdown.bodyMarkdown; @@ -242,7 +244,7 @@ function publishVisibleEvent( status: visibleStatus, }; setDraft((currentDraft) => { - const next = applyConciergeDraftEvent(currentDraft, visibleEvent, reportID, isGroupPolicyReport); + const next = applyConciergeDraftEvent(currentDraft, visibleEvent, reportID, isGroupPolicyReport, accountIDToNameRef.current); return cacheDraftWithPusherPaceState(runtime, next); }); } @@ -866,9 +868,6 @@ function resumeCachedPusherDraftPace(runtime: PusherDraftPacingRuntime) { } function usePusherDraftPacing(reportID: string, isGroupPolicyReport: boolean) { - const [pendingLocalReportActionID] = useOnyx(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`, { - selector: (pendingResponse) => pendingResponse?.reportAction.reportActionID, - }); // Lazy-init from the module-level cache so a remount (ReportScreen // unmount/remount on chat-switch) restores the in-progress draft on the // first paint instead of flashing the synthetic bubble away. @@ -890,9 +889,18 @@ function usePusherDraftPacing(reportID: string, isGroupPolicyReport: boolean) { const finalRenderedHTMLRevealTokensRef = useRef([]); const finalRenderedHTMLRevealStartedAtRef = useRef(0); const finalRenderedHTMLRevealLastStageRef = useRef(0); + const accountIDToName = useAccountIDToNameMap(); + const accountIDToNameRef = useRef(accountIDToName); + useEffect(() => { + accountIDToNameRef.current = accountIDToName; + }, [accountIDToName]); + const [pendingLocalReportActionID] = useOnyx(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`, { + selector: (pendingResponse) => pendingResponse?.reportAction.reportActionID, + }); const clearDraft = () => { clearCachedPusherDraft({ + accountIDToNameRef, completedReportActionIDsRef, completedPusherDraftEventRef, currentDraftRef, @@ -934,7 +942,7 @@ function usePusherDraftPacing(reportID: string, isGroupPolicyReport: boolean) { visibleSourceOffsetRef, }); setDraft((currentDraft) => { - const next = applyConciergeDraftEvent(currentDraft, event, reportID, isGroupPolicyReport); + const next = applyConciergeDraftEvent(currentDraft, event, reportID, isGroupPolicyReport, accountIDToNameRef.current); currentDraftRef.current = next; setCachedDraft(reportID, next); return next; @@ -970,6 +978,7 @@ function usePusherDraftPacing(reportID: string, isGroupPolicyReport: boolean) { const sequence = Math.max(currentDraft.sequence, latestPusherDraftEvent?.sequence ?? 0, visibleSequenceRef.current) + 1; startFinalRenderedHTMLReveal( { + accountIDToNameRef, completedReportActionIDsRef, completedPusherDraftEventRef, currentDraftRef, @@ -1007,6 +1016,7 @@ function usePusherDraftPacing(reportID: string, isGroupPolicyReport: boolean) { useEffect(() => { const runtime = { + accountIDToNameRef, completedReportActionIDsRef, completedPusherDraftEventRef, currentDraftRef, diff --git a/src/pages/iou/DynamicSplitBillDetailsPage.tsx b/src/pages/iou/DynamicSplitBillDetailsPage.tsx index 9a2ce52f8d2b..e6de0c1b0877 100644 --- a/src/pages/iou/DynamicSplitBillDetailsPage.tsx +++ b/src/pages/iou/DynamicSplitBillDetailsPage.tsx @@ -89,7 +89,7 @@ function DynamicSplitBillDetailsPage({report, reportAction}: SplitBillDetailsPag personalDetails, report, policy, - {translate, dateFnsLocale, convertToDisplayString}, + {translate, dateFnsLocale, convertToDisplayString, formatPhoneNumber}, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, rules, reportAttributesDerived, diff --git a/src/pages/iou/DynamicSplitExpenseEditPage.tsx b/src/pages/iou/DynamicSplitExpenseEditPage.tsx index 189dd2f1d8fc..ed58bbbfc4ea 100644 --- a/src/pages/iou/DynamicSplitExpenseEditPage.tsx +++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx @@ -45,7 +45,15 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; -import {arePolicyRulesEnabled, getDistanceRateCustomUnitRate, getTagLists, hasAnyPaidPolicy, isGroupPolicyByType, isTaxTrackingEnabled} from '@libs/PolicyUtils'; +import { + arePolicyRulesEnabled, + canShowReimbursableToggleForPolicy, + getDistanceRateCustomUnitRate, + getTagLists, + hasAnyPaidPolicy, + isGroupPolicyByType, + isTaxTrackingEnabled, +} from '@libs/PolicyUtils'; import {getReportName} from '@libs/ReportNameUtils'; import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; import type {TransactionDetails} from '@libs/ReportUtils'; @@ -265,10 +273,9 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!splitExpenseDraftTransactionDetails?.billable || isBillableEnabledOnPolicy(effectivePolicy)); const currentReimbursable = splitExpenseDraftTransaction?.reimbursable ?? transaction?.reimbursable; - const isReimbursableDifferentFromPolicyDefault = effectivePolicy?.defaultReimbursable !== undefined && !!currentReimbursable !== effectivePolicy.defaultReimbursable; const shouldShowReimbursable = (isPolicyExpenseChat || (isExpenseUnreported && !!effectivePolicy)) && - (effectivePolicy?.disabledFields?.reimbursable !== true || isReimbursableDifferentFromPolicyDefault) && + canShowReimbursableToggleForPolicy(effectivePolicy, currentReimbursable) && !isManagedCardTransaction(transaction); const isDistance = isDistanceRequest(splitExpenseDraftTransaction); diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index b080a903a391..8a5bdf509caa 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -14,7 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; -import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; +import {createOnyxTabNavigator, TabScreenWithFocusTrapWrapper} from '@libs/Navigation/OnyxTabNavigator'; import {isMapOrGPSRequired} from '@libs/PolicyDistanceRatesUtils'; import {getActivePolicies, isGroupPolicy} from '@libs/PolicyUtils'; import {getPayeeName, isExpenseReport, isPolicyExpenseChat, isSelfDM} from '@libs/ReportUtils'; @@ -25,9 +25,10 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import type {SelectedTabRequest} from '@src/types/onyx'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import type {TupleToUnion} from 'type-fest'; + import React, {useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; @@ -38,8 +39,11 @@ import IOURequestStepDistanceGPS from './step/IOURequestStepDistanceGPS'; import IOURequestStepDistanceMap from './step/IOURequestStepDistanceMap'; import IOURequestStepDistanceOdometer from './step/IOURequestStepDistanceOdometer'; +const tabNames = [CONST.TAB_REQUEST.DISTANCE_MAP, CONST.TAB_REQUEST.DISTANCE_MANUAL, CONST.TAB_REQUEST.DISTANCE_GPS, CONST.TAB_REQUEST.DISTANCE_ODOMETER] as const; +const {OnyxTabNavigator, TopTab} = createOnyxTabNavigator(tabNames); + type DistanceRequestStartPageProps = WithWritableReportOrNotFoundProps & { - defaultSelectedTab: SelectedTabRequest; + defaultSelectedTab: TupleToUnion; }; function DistanceRequestStartPage({ diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index 8799dfbf82e5..7640e2f2f025 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -23,7 +23,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldShowPerDiemTabOption} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; -import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; +import {createOnyxTabNavigator, TabScreenWithFocusTrapWrapper} from '@libs/Navigation/OnyxTabNavigator'; import {isPerDiemEligiblePolicy, isTimeTrackingEnabled} from '@libs/PolicyUtils'; import {getPayeeName} from '@libs/ReportUtils'; import {endSpan} from '@libs/telemetry/activeSpans'; @@ -40,6 +40,8 @@ import type {SelectedTabRequest} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import type {TupleToUnion} from 'type-fest'; + import React, {useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -54,8 +56,11 @@ import IOURequestStepPerDiemWorkspace from './step/IOURequestStepPerDiemWorkspac import IOURequestStepScan from './step/IOURequestStepScan'; import IOURequestStepTimeWorkspace from './step/IOURequestStepTimeWorkspace'; +const tabNames = [CONST.TAB_REQUEST.MANUAL, CONST.TAB_REQUEST.SCAN, CONST.TAB_REQUEST.DISTANCE, CONST.TAB_REQUEST.PER_DIEM, CONST.TAB_REQUEST.TIME] as const; +const {OnyxTabNavigator, TopTab} = createOnyxTabNavigator(tabNames); + type IOURequestStartPageProps = WithWritableReportOrNotFoundProps & { - defaultSelectedTab: SelectedTabRequest; + defaultSelectedTab: TupleToUnion; }; // Tab indices for IOURequestStartPage @@ -202,8 +207,16 @@ function IOURequestStartPage({ // REMOVE_FULLSCREEN_UNDER_RHP branch is used. Otherwise closeRHPFlow pops the RHP first and the // confirmation's unmount cleanup restores the original tab a frame later, briefly flashing the // pre-inserted Search/Spend tab. This is a no-op when nothing was pre-inserted. + const hadPreInsertedFullscreen = Navigation.getIsFullscreenPreInsertedUnderRHP(); Navigation.removePreInsertedFullscreenIfNeeded(); - Navigation.closeRHPFlow(); + if (!hadPreInsertedFullscreen) { + Navigation.closeRHPFlow(); + return; + } + + // Close the RHP a frame later. Closed in the same update as the restore, Android dismisses it with the + // pre-insert's `none` animation instead of the restored slide. + requestAnimationFrame(() => Navigation.closeRHPFlow()); }; const [headerWithBackBtnContainerElement, setHeaderWithBackButtonContainerElement] = useState(null); diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx index 3636556290aa..ae4aa5f7a2c5 100644 --- a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -70,7 +70,6 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde // accountID derived from their login (generateAccountID is deterministic), so they can be tracked by accountID like everyone else. const initialSelectedOptions = attendees.map((attendee) => { // Use || to fall back to displayName for name-only attendees (empty email) - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const login = attendee.email || attendee.displayName; const personalDetail = attendeesPersonalDetails[attendee.email ?? '']; const accountID = personalDetail?.accountID ?? generateAccountID(login); @@ -119,7 +118,6 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde return { email: option.login ?? '', // Use || to fall back for name-only attendees - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing displayName: option.text || option.login || '', avatarUrl: icon, }; diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index 9c5353b4ad5f..3f858e7c96c5 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -2,6 +2,7 @@ import AlwaysPaintedView from '@components/AlwaysPaintedView'; import type {SelectionListWithSectionsHandle} from '@components/SelectionList/SelectionListWithSections/types'; import useDeferVisibleUntilFocusTransitionEnd from '@hooks/useDeferVisibleUntilFocusTransitionEnd'; +import useOpenRecipientPicker from '@hooks/useOpenRecipientPicker'; import getPlatform from '@libs/getPlatform'; @@ -101,6 +102,8 @@ function MoneyRequestParticipantsSelector({ const [textInputAutoFocus, setTextInputAutoFocus] = useState(!isNative); const selectionListRef = useRef(null); + useOpenRecipientPicker(shouldExcludeWorkspaces); + useImperativeHandle(ref, () => ({ focus: () => { if (!textInputAutoFocus) { diff --git a/src/pages/iou/request/ParticipantSearchResults.tsx b/src/pages/iou/request/ParticipantSearchResults.tsx index ab0454f99e79..ae2d54fe1464 100644 --- a/src/pages/iou/request/ParticipantSearchResults.tsx +++ b/src/pages/iou/request/ParticipantSearchResults.tsx @@ -162,7 +162,7 @@ function ParticipantSearchResults({ action !== CONST.IOU.ACTION.SUBMIT && action !== CONST.IOU.ACTION.CATEGORIZE; const icons = useMemoizedLazyExpensifyIcons(['UserPlus']); - const {translate, dateFnsLocale} = useLocalize(); + const {translate, dateFnsLocale, formatPhoneNumber} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const {contactPermissionState, contacts, setContactPermissionState} = useContactImport(); const {isOffline} = useNetwork(); @@ -200,11 +200,13 @@ function ParticipantSearchResults({ const isGroupPolicy = isGroupPolicyUtil(policy); const activeAdminWorkspaces = getActiveAdminWorkspaces(allPolicies, currentUserLogin); + const includeOwnedWorkspaceChats = + !shouldExcludeWorkspaces && (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.TRACK); + const getValidOptionsConfig = { selectedOptions: participants as Participant[], excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - includeOwnedWorkspaceChats: - !shouldExcludeWorkspaces && (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.TRACK), + includeOwnedWorkspaceChats, excludeNonAdminWorkspaces: action === CONST.IOU.ACTION.SHARE, includeP2P: !isCategorizeOrShareAction && !isPerDiemRequest && !isTimeRequest && !isTransactionFromCreditCardImport && !shouldExcludeP2P, includeInvoiceRooms: iouType === CONST.IOU.TYPE.INVOICE, @@ -219,6 +221,8 @@ function ParticipantSearchResults({ preferRecentExpenseReports: action === CONST.IOU.ACTION.CREATE, isRestrictedToPreferredPolicy, preferredPolicyID, + // Only boost the default workspace chat when workspace chats can be shown, so it doesn't take a slot in the recent-reports limit + activePolicyID: includeOwnedWorkspaceChats ? activePolicyID : undefined, }; /** @@ -324,6 +328,7 @@ function ParticipantSearchResults({ translate, convertToDisplayString, dateFnsLocale, + formatPhoneNumber, getReportByID, rules, personalDetails, @@ -400,7 +405,7 @@ function ParticipantSearchResults({ personalDetails, userToInviteExpenseReport, userToInviteExpenseReportPolicy, - {translate, dateFnsLocale, convertToDisplayString}, + {translate, dateFnsLocale, convertToDisplayString, formatPhoneNumber}, currentUserAccountID, rules, reportAttributesDerived, diff --git a/src/pages/iou/request/step/BaseRequestStepWorkspace.tsx b/src/pages/iou/request/step/BaseRequestStepWorkspace.tsx index c7695c5458ed..9165ed97aebb 100644 --- a/src/pages/iou/request/step/BaseRequestStepWorkspace.tsx +++ b/src/pages/iou/request/step/BaseRequestStepWorkspace.tsx @@ -7,6 +7,7 @@ import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useOpenRecipientPicker from '@hooks/useOpenRecipientPicker'; import useSearchResults from '@hooks/useSearchResults'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -51,6 +52,7 @@ function BaseRequestStepWorkspace({transaction, getPolicies, onSelectWorkspace}: const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + useOpenRecipientPicker(); const selectedWorkspace = transaction?.participants?.[0]; const customUnitPolicy = getPolicyByCustomUnitID(transaction, allPolicies); const initiallyFocusedKey = selectedWorkspace?.policyID ?? customUnitPolicy?.id; diff --git a/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx b/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx index d5b747707863..e6b935ca4414 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx @@ -29,7 +29,7 @@ import {getSelectedWorkspacePolicyID, pickReportForPolicy} from '@libs/IOUUtils' import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; -import {canEditWorkspaceSettings, hasAccountingConnections, isGroupPolicy} from '@libs/PolicyUtils'; +import {canCreateCategoryInSitu, canEditWorkspaceSettings, isGroupPolicy} from '@libs/PolicyUtils'; import {getTransactionDetails, isSelfDM} from '@libs/ReportUtils'; import {getRequestType} from '@libs/TransactionUtils'; @@ -100,9 +100,7 @@ function DynamicIOURequestStepCategory({ const categoryForDisplay = isCategoryMissing(transactionCategory) ? '' : transactionCategory; - const canCreateCategoryInSitu = canEditWorkspaceSettings(policy, currentUserEmailParam) && !hasAccountingConnections(policy) && !!policy?.areCategoriesEnabled; - - const createCategoryMenuItems = canCreateCategoryInSitu + const createCategoryMenuItems = canCreateCategoryInSitu(policy, currentUserEmailParam) ? [ { icon: expensifyIcons.Plus, diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx index 1966103dbaf9..bf627403c9b7 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx @@ -75,6 +75,7 @@ function DynamicIOURequestStepDescription({ const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); const allTransactionViolations = useAllTransactionViolations(transaction?.transactionID); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); + const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${report?.policyID}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); @@ -167,6 +168,7 @@ function DynamicIOURequestStepDescription({ policy, policyTagList: policyTags, policyCategories, + policyVendors, currentUserAccountIDParam, currentUserEmailParam, isASAPSubmitBetaEnabled, diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx index 0a572f2b8d7b..eaa76a092e2e 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx @@ -253,7 +253,9 @@ function DynamicIOURequestStepDestination({ shouldStyleAsCard={false} icon={illustrations.EmptyStateExpenses} title={translate('workspace.perDiem.requestEmptyList.title')} - subtitle={translate(isPolicyAdmin(policy) ? 'workspace.perDiem.requestEmptyList.adminSubtitle' : 'workspace.perDiem.requestEmptyList.subtitle')} + subtitle={ + isPolicyAdmin(policy) ? translate('workspace.perDiem.requestEmptyList.adminSubtitle') : translate('workspace.perDiem.requestEmptyList.subtitle', policy?.name) + } containerStyle={[styles.flex1, styles.justifyContentCenter]} /> {isPolicyAdmin(policy) && ( diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDistanceManual.tsx index 4397aaa438ec..1a66c3b064e2 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDistanceManual.tsx @@ -24,6 +24,7 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import useReportAttributes from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportTransactions from '@hooks/useReportTransactions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -92,6 +93,7 @@ function DynamicIOURequestStepDistanceManual({ const {isExtraSmallScreenHeight} = useResponsiveLayout(); const isArchived = useReportIsArchived(report?.reportID); + const moneyRequestReportTransactions = useReportTransactions(report?.reportID); const selfDMReport = useSelfDMReport(); const {policy} = usePolicyForTransaction({ reportPolicyID: report?.policyID, @@ -312,6 +314,7 @@ function DynamicIOURequestStepDistanceManual({ iouType, action, report, + moneyRequestReportTransactions, isDraftChatReport: !!reportDraft, policy, transaction, diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDistanceRate.tsx index 33e77e963418..8b01b9581228 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDistanceRate.tsx @@ -140,7 +140,7 @@ function DynamicIOURequestStepDistanceRate({ const rates = DistanceRequestUtils.getMileageRates(policy, false, currentRateID); const isMovingTransactionFromTrackExpense = isMovingTransactionFromTrackExpenseUtil(action); const transactionUnit = transaction?.comment?.customUnit?.distanceUnit; - const sortedRates = [...Object.values(rates)].sort((a, b) => localeCompare(a.name ?? '', b.name ?? '')); + const sortedRates = [...Object.values(rates)].sort((a, b) => DistanceRequestUtils.compareRatesByDisplayName(a, b, localeCompare)); const navigateBack = () => { Navigation.goBack(backTo); @@ -164,7 +164,7 @@ function DynamicIOURequestStepDistanceRate({ const dateLabel = DistanceRequestUtils.getRateDateLabel(rate, translate, dateFnsLocale); const alternateText = [rate.name ? rateForDisplay : '', dateLabel].filter(Boolean).join(' • '); return { - text: rate.name ?? rateForDisplay, + text: DistanceRequestUtils.getRateDisplayName(rate) ?? rateForDisplay, alternateText, keyForList: rate.customUnitRateID ?? rateForDisplay, value: rate.customUnitRateID, diff --git a/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx b/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx index 4e626a5fe607..f900c977883d 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx @@ -26,7 +26,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {isMerchantRequired} from '@libs/MoneyRequestUtils'; import {getTransactionDetails} from '@libs/ReportUtils'; import {hasReceipt} from '@libs/TransactionUtils'; -import {getMerchantError, isInvalidMerchantValue} from '@libs/ValidationUtils'; +import {getMerchantError, getMerchantErrorMessage, isInvalidMerchantValue} from '@libs/ValidationUtils'; import {clearMoneyRequestMerchant, setMoneyRequestMerchant} from '@userActions/IOU/MoneyRequest'; import {setDraftSplitTransaction} from '@userActions/IOU/Split'; @@ -105,12 +105,8 @@ function DynamicIOURequestStepMerchant({ const errors: FormInputErrors = {}; const merchantError = getMerchantError(value.moneyRequestMerchant, isMerchantFieldRequired); - if (merchantError?.type === 'required') { - errors.moneyRequestMerchant = translate('common.error.fieldRequired'); - } else if (merchantError?.type === 'invalidValue') { - errors.moneyRequestMerchant = translate('iou.error.invalidMerchant'); - } else if (merchantError?.type === 'tooLong') { - errors.moneyRequestMerchant = translate('common.error.characterLimitExceedCounter', merchantError.byteLength, CONST.MERCHANT_NAME_MAX_BYTES); + if (merchantError) { + errors.moneyRequestMerchant = getMerchantErrorMessage(translate, merchantError); } return errors; diff --git a/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx b/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx index 525482ef62ff..32ef3f6329da 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx @@ -161,7 +161,6 @@ function DynamicIOURequestStepTaxAmountPage({ setMoneyRequestTaxAmount(transactionID, taxAmountInSmallestCurrencyUnits); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing setMoneyRequestCurrency(transactionID, currency || CONST.CURRENCY.USD); navigateBack(); diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index fbf0e11d9326..8c46e004fb7a 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -242,7 +242,7 @@ function IOURequestStepAmount({ reportAttributesDerived, reportDraft, currentUserAccountID: currentUserPersonalDetails.accountID, - localize: {translate, dateFnsLocale, convertToDisplayString}, + localize: {translate, dateFnsLocale, convertToDisplayString, formatPhoneNumber}, rules, // Passing pendingDeleteMemberAccountIDs as undefined is intentional, getMoneyRequestParticipantsFromReport only report-backs policy expense chats, self DMs and invoice rooms. pendingDeleteMemberAccountIDs: undefined, diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx similarity index 56% rename from src/pages/iou/request/step/IOURequestStepConfirmation.tsx rename to src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx index 35c22c33c8ca..8118e000cc1a 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx @@ -5,7 +5,6 @@ import DropZoneUI from '@components/DropZone/DropZoneUI'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import LoadingIndicator from '@components/LoadingIndicator'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; import MoneyRequestConfirmationList from '@components/MoneyRequestConfirmationList'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import ParticipantPicker from '@components/ParticipantPicker'; @@ -13,10 +12,8 @@ import PrevNextButtons from '@components/PrevNextButtons'; import ScreenWrapper from '@components/ScreenWrapper'; import useBlockDistanceRequest from '@hooks/useBlockDistanceRequest'; -import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDefaultParticipants from '@hooks/useDefaultParticipants'; import useFetchRoute from '@hooks/useFetchRoute'; import useFilesValidation from '@hooks/useFilesValidation'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -24,35 +21,23 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOdometerReceiptStitcher from '@hooks/useOdometerReceiptStitcher'; import useOnyx from '@hooks/useOnyx'; -import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransactions'; import useParticipantsPolicies from '@hooks/useParticipantsPolicies'; -import usePersonalPolicy from '@hooks/usePersonalPolicy'; -import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; -import usePreMountDestination from '@hooks/usePreMountDestination'; -import usePreviousDefined from '@hooks/usePreviousDefined'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useReportAttributes from '@hooks/useReportAttributes'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; -import useSelfDMReport from '@hooks/useSelfDMReport'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {setMoneyRequestBillable, setMoneyRequestReimbursable} from '@libs/actions/IOU/MoneyRequest'; -import {clearPreMountedDraftReport, clearPreMountedDraftReportMarker, preMountDraftReport} from '@libs/actions/Report/PreMountedDraftReport'; -import {setTransactionReport} from '@libs/actions/Transaction'; import {isMobileSafari} from '@libs/Browser'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import { getIsWorkspacesOnlyForTransaction, - getReusableP2PReportID, getSelectedWorkspacePolicyID, isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils, - isParticipantP2P, isSelfDMSoleDestination, - isLookingAroundSearchRoutingActive, navigateToStartMoneyRequestStep, pickReportForPolicy, resolveOptimisticChatReportID, @@ -63,10 +48,8 @@ import { import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import {submitWithDismissFirst} from '@libs/Navigation/helpers/submitWithDismissFirst'; import Navigation from '@libs/Navigation/Navigation'; -import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; -import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; -import {findSelfDMReportID, generateReportID, getParticipantsChatKey, getReportOrDraftReport, isMoneyRequestReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; +import {getReportOrDraftReport, isMoneyRequestReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import {cancelTracking, getPendingSubmitFollowUpAction, isTracking} from '@libs/telemetry/submitFollowUpAction'; import { getRequestType, @@ -78,58 +61,40 @@ import { isScanRequest, } from '@libs/TransactionUtils'; -import { - getIOURequestPolicyID, - setCustomUnitRateID, - setMoneyRequestCategory, - setMoneyRequestParticipants, - setMoneyRequestParticipantsFromReport, - setMoneyRequestTag, -} from '@userActions/IOU/MoneyRequest'; +import CategoryDefaultsSetter from '@pages/iou/request/step/confirmation/CategoryDefaultsSetter'; +import DraftWorkspaceOpener from '@pages/iou/request/step/confirmation/DraftWorkspaceOpener'; +import ExpenseDefaultsSetter from '@pages/iou/request/step/confirmation/ExpenseDefaultsSetter'; +import MoneyRequestInitializer from '@pages/iou/request/step/confirmation/MoneyRequestInitializer'; +import ReceiptFileValidator from '@pages/iou/request/step/confirmation/ReceiptFileValidator'; +import useSubmissionOnboardingIntent from '@pages/iou/request/step/confirmation/submission/useSubmissionOnboardingIntent'; +import useSubmitLock from '@pages/iou/request/step/confirmation/submission/useSubmitLock'; +import SubmitExpenseOrchestrator from '@pages/iou/request/step/confirmation/SubmitExpenseOrchestrator'; +import TelemetrySpanManager from '@pages/iou/request/step/confirmation/TelemetrySpanManager'; +import useExpenseSubmission from '@pages/iou/request/step/confirmation/useExpenseSubmission'; +import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound'; +import withWritableReportOrNotFound from '@pages/iou/request/step/withWritableReportOrNotFound'; + +import {getIOURequestPolicyID, setMoneyRequestParticipantsFromReport} from '@userActions/IOU/MoneyRequest'; import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt'; -import {removeDraftTransaction, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {OneOnOneChatReportIDsDerivedValue, Policy} from '@src/types/onyx'; -import type {Participant} from '@src/types/onyx/IOU'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type {Receipt} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; -import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; - -import type {OnyxEntry} from 'react-native-onyx'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import React, {startTransition, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {startTransition, useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; -import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; -import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; - -import CategoryDefaultsSetter from './confirmation/CategoryDefaultsSetter'; -import DraftWorkspaceOpener from './confirmation/DraftWorkspaceOpener'; -import ExpenseDefaultsSetter from './confirmation/ExpenseDefaultsSetter'; -import getSubmitExpensePreMountDestinationRoute from './confirmation/getSubmitExpensePreMountDestinationRoute'; -import MoneyRequestInitializer from './confirmation/MoneyRequestInitializer'; -import ReceiptFileValidator from './confirmation/ReceiptFileValidator'; -import SubmitExpenseOrchestrator from './confirmation/SubmitExpenseOrchestrator'; -import TelemetrySpanManager from './confirmation/TelemetrySpanManager'; -import useExpenseSubmission from './confirmation/useExpenseSubmission'; -import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; -import withWritableReportOrNotFound from './withWritableReportOrNotFound'; - -type IOURequestStepConfirmationIncomingRouteName = typeof SCREENS.MONEY_REQUEST.STEP_CONFIRMATION | typeof SCREENS.MONEY_REQUEST.CREATE; +import type {IOURequestStepConfirmationProps, StepConfirmationParams} from './types'; -type StepConfirmationParams = MoneyRequestNavigatorParamList[typeof SCREENS.MONEY_REQUEST.STEP_CONFIRMATION]; - -type IOURequestStepConfirmationProps = WithWritableReportOrNotFoundProps & - WithFullTransactionOrNotFoundProps & { - shouldHideHeader?: boolean; - }; +import useConfirmationTransactionPager from './useConfirmationTransactionPager'; +import useParticipantPickerState from './useParticipantPickerState'; +import useSubmitDestinationPreMount from './useSubmitDestinationPreMount'; function IOURequestStepConfirmationContent({ report: reportReal, @@ -140,40 +105,31 @@ function IOURequestStepConfirmationContent({ shouldHideHeader = false, navigation, }: IOURequestStepConfirmationProps) { - const {getCurrencyDecimals, convertToDisplayString} = useCurrencyListActions(); + const {convertToDisplayString} = useCurrencyListActions(); const params = route.params; const {iouType, reportID, transactionID: initialTransactionID, action, backToReport, backTo} = params; const participantsAutoAssignedFromRoute = route.name === SCREENS.MONEY_REQUEST.STEP_CONFIRMATION ? (params as StepConfirmationParams).participantsAutoAssigned : undefined; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const personalPolicy = usePersonalPolicy(); - const selfDMReport = useSelfDMReport(); const personalDetails = usePersonalDetails(); - const [transactions] = useOptimisticDraftTransactions(initialTransaction); + const { + transactions, + transaction, + existingTransaction, + currentTransactionID, + currentTransactionIndex, + setCurrentTransactionID, + showNextTransaction, + showPreviousTransaction, + confirmRemoveCurrentTransaction, + } = useConfirmationTransactionPager(initialTransaction, initialTransactionID); const [participantReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${initialTransaction?.participants?.at(0)?.reportID}`); const hasMultipleTransactions = transactions.length > 1; // Depend on transactions.length to avoid updating transactionIDs when only the transaction details change // eslint-disable-next-line react-hooks/exhaustive-deps - const transactionIDs = useMemo(() => transactions?.map((transaction) => transaction.transactionID), [transactions.length]); - // We will use setCurrentTransactionID later to switch between transactions - - const [currentTransactionID, setCurrentTransactionID] = useState(initialTransactionID); - const currentTransactionIndex = useMemo(() => transactions.findIndex((transaction) => transaction.transactionID === currentTransactionID), [transactions, currentTransactionID]); - const [existingTransaction, existingTransactionResult] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(currentTransactionID)}`); - const [optimisticTransaction, optimisticTransactionResult] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${getNonEmptyStringOnyxID(currentTransactionID)}`); - const isLoadingCurrentTransaction = isLoadingOnyxValue(existingTransactionResult, optimisticTransactionResult); - const currentTransaction = useMemo( - () => (!isLoadingCurrentTransaction ? (optimisticTransaction ?? existingTransaction) : undefined), - [existingTransaction, optimisticTransaction, isLoadingCurrentTransaction], - ); - // `useOnyx` drops back to a loading state whenever its key changes, so switching between transactions leaves - // `currentTransaction` undefined for a render. Hold the previous one across that gap: `MoneyRequestConfirmationList` - // picks its variant from the request type, so an undefined transaction falls to the manual variant and remounts the - // whole list, losing the state the scan variant holds. - const lastDefinedTransaction = usePreviousDefined(currentTransaction); - const transaction = isLoadingCurrentTransaction ? lastDefinedTransaction : currentTransaction; + const transactionIDs = useMemo(() => transactions?.map((item) => item.transactionID), [transactions.length]); const requestType = getRequestType(transaction); const isPerDiemRequest = requestType === CONST.IOU.REQUEST_TYPE.PER_DIEM; const isUnreported = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; @@ -242,9 +198,8 @@ function IOURequestStepConfirmationContent({ const styles = useThemeStyles(); const theme = useTheme(); - const {translate, dateFnsLocale} = useLocalize(); + const {translate, dateFnsLocale, formatPhoneNumber} = useLocalize(); const {isOffline} = useNetwork(); - const {showConfirmModal} = useConfirmModal(); // isConfirming, selectedParticipantList, and startLocationPermissionFlow state // moved to SubmitExpenseOrchestrator. @@ -260,10 +215,7 @@ function IOURequestStepConfirmationContent({ isOdometerDistanceRequest, }); const isTimeRequest = requestType === CONST.IOU.REQUEST_TYPE.TIME; - const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); - const {policyForMovingExpenses} = usePolicyForMovingExpenses(); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const isLookingAroundUser = isLookingAroundSearchRoutingActive(introSelected?.choice === CONST.ONBOARDING_CHOICES.LOOKING_AROUND, isOffline); + const {isLookingAroundUser} = useSubmissionOnboardingIntent(); const privateIsArchivedMap = usePrivateIsArchivedMap(); const receiptFilename = transaction?.receipt?.filename; @@ -336,7 +288,7 @@ function IOURequestStepConfirmationContent({ reportAttributesDerived, reportDraft: participantReportDraft, currentUserAccountID: currentUserPersonalDetails.accountID, - localize: {translate, dateFnsLocale, convertToDisplayString}, + localize: {translate, dateFnsLocale, convertToDisplayString, formatPhoneNumber}, rules, // Passing pendingDeleteMemberAccountIDs as undefined is intentional, isValidReport keeps group chats out of this list because the config here leaves includeMultipleParticipantReports false. pendingDeleteMemberAccountIDs: undefined, @@ -356,245 +308,30 @@ function IOURequestStepConfirmationContent({ translate, convertToDisplayString, currentUserPersonalDetails.accountID, + formatPhoneNumber, rules, ], ); - const sourceReportID = transaction?.reportID ?? reportID; - const sourceReport = useMemo( - () => (sourceReportID ? getReportOrDraftReport(sourceReportID, undefined, undefined, reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${sourceReportID}`] ?? {}) : undefined), - [sourceReportID, reportDrafts], - ); - const {participants: resolvedDefaultParticipants, isLoading: isLoadingDefaultParticipants} = useDefaultParticipants({sourceReport, transaction, iouType}); - const hasSelectedParticipants = (transaction?.participants ?? []).some((participant) => participant?.selected); - const defaultParticipants = useMemo(() => { - // Don't override the participants the user has already selected, and bail when there is no source report. - if (hasSelectedParticipants || !sourceReportID) { - return []; - } - return resolvedDefaultParticipants; - }, [hasSelectedParticipants, sourceReportID, resolvedDefaultParticipants]); - - const shouldAutoOpenParticipantPicker = useMemo(() => { - if (!transaction?.transactionID) { - return false; - } - const transactionParticipants = transaction?.participants ?? []; - const hasTransactionParticipants = transactionParticipants.length > 0; - const hasDefaultParticipants = defaultParticipants.length > 0; - return !hasTransactionParticipants && !hasDefaultParticipants && !isLoadingDefaultParticipants && isManualRequest; - }, [transaction?.transactionID, transaction?.participants, defaultParticipants.length, isLoadingDefaultParticipants, isManualRequest]); - const activeTransactionID = transaction?.transactionID; - const [manuallyOpenedParticipantPickerForTransactionID, setManuallyOpenedParticipantPickerForTransactionID] = useState(); - const [dismissedAutoOpenParticipantPickerForTransactionID, setDismissedAutoOpenParticipantPickerForTransactionID] = useState(); - const participantPickerIOUType = iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.TRACK ? CONST.IOU.TYPE.CREATE : iouType; - const isParticipantPickerVisible = - !!activeTransactionID && - (manuallyOpenedParticipantPickerForTransactionID === activeTransactionID || - (shouldAutoOpenParticipantPicker && dismissedAutoOpenParticipantPickerForTransactionID !== activeTransactionID)); - - const closeParticipantPicker = useCallback(() => { - setManuallyOpenedParticipantPickerForTransactionID(undefined); - if (!activeTransactionID) { - return; - } - setDismissedAutoOpenParticipantPickerForTransactionID(activeTransactionID); - }, [activeTransactionID]); - - const shouldReopenParticipantPickerOnFocusRef = useRef(false); - - // The referral banner inside the picker navigates to its own RHP, which the picker would otherwise cover, so the - // picker closes first and is reopened when the user comes back. This goes through `closeParticipantPicker`, which - // permanently marks the auto-open as dismissed, so the reopen below deliberately re-enters through the manual path. - // That is what we want: after the round trip a genuine dismissal must close the picker for good. - const closeParticipantPickerForReferralNavigation = useCallback(() => { - shouldReopenParticipantPickerOnFocusRef.current = isParticipantPickerVisible; - closeParticipantPicker(); - }, [closeParticipantPicker, isParticipantPickerVisible]); - - useEffect( - () => - // This screen is also rendered embedded by `IOURequestStartPage`, so the listener fires on every refocus of - // that screen, not only on back from the referral page. Re-checking that the expense still has no recipient - // keeps an unrelated RHP round trip (or a recipient resolved meanwhile) from slamming the picker open over a - // form the user wasn't editing. - navigation.addListener('focus', () => { - if (!shouldReopenParticipantPickerOnFocusRef.current) { - return; - } - shouldReopenParticipantPickerOnFocusRef.current = false; - if (!activeTransactionID || hasSelectedParticipants) { - return; - } - setManuallyOpenedParticipantPickerForTransactionID(activeTransactionID); - }), - [navigation, activeTransactionID, hasSelectedParticipants], - ); - - const handleParticipantsAdded = useCallback( - (participantsList: Participant[], selectedPolicy?: OnyxEntry) => { - if (!activeTransactionID) { - return; - } - const selectedParticipant = participantsList.at(0); - const selectedPolicyID = - selectedParticipant?.policyID ?? - (selectedParticipant?.reportID - ? getReportOrDraftReport(selectedParticipant.reportID, undefined, undefined, reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${selectedParticipant.reportID}`] ?? {}) - ?.policyID - : undefined); - if (blockDistanceRequestIfNeeded(selectedPolicyID)) { - return; - } - // P2P chats don't support negative amounts. When a negative amount was entered before a participant - // was selected (e.g. "Submit it to someone" from a self DM), assigning it to a P2P participant would - // fail at submit, so keep the expense on the self DM (its default) instead of assigning the P2P - // participant, stopping the user at selection rather than at submit. This only applies while the - // expense is still on the self DM — a negative expense already bound to a policy expense chat (e.g. - // global create auto-assigned the default workspace) must stay on that workspace rather than being - // silently converted into a personal track expense. - const isTransactionOnPolicyExpenseChat = transaction?.participants?.some((participant) => participant?.isPolicyExpenseChat); - const shouldKeepOnSelfDM = !!selectedParticipant?.isSelfDM || ((transaction?.amount ?? 0) < 0 && !isTransactionOnPolicyExpenseChat && isParticipantP2P(selectedParticipant)); - if (shouldKeepOnSelfDM) { - setMoneyRequestParticipantsFromReport(activeTransactionID, selfDMReport, currentUserPersonalDetails.accountID); - setTransactionReport(activeTransactionID, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, true); - - // The rate the expense picked up from a workspace does not exist outside it, so leaving it in place - // makes the Rate field read "Pending..." and the amount go blank once the expense is back on the self - // DM. Re-resolve the rate the self DM itself uses, the same way starting a track distance expense does. - if (isDistanceRequest) { - const selfDMRateID = DistanceRequestUtils.getCustomUnitRateID({ - reportID: selfDMReport?.reportID, - isPolicyExpenseChat: false, - isTrackDistanceExpense: true, - policy: policyForMovingExpenses, - lastSelectedDistanceRates, - expenseDate: transaction?.created, - }); - setCustomUnitRateID( - activeTransactionID, - selfDMRateID, - transaction, - policyForMovingExpenses, - false, - policyForMovingExpenses?.outputCurrency ?? personalPolicy?.outputCurrency, - ); - } - - if (iouType !== CONST.IOU.TYPE.TRACK) { - navigation.setParams({iouType: CONST.IOU.TYPE.TRACK}); - } - } else { - if (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.TRACK) { - navigation.setParams({iouType: CONST.IOU.TYPE.CREATE}); - } - setMoneyRequestParticipants(activeTransactionID, participantsList); - const firstParticipant = participantsList.at(0); - if (iouType !== CONST.IOU.TYPE.SPLIT && firstParticipant) { - const isPolicyExpenseChatParticipant = !!firstParticipant.isPolicyExpenseChat; - - // A brand-new recipient picked by email has no chat yet (no reportID). Reusing the route's `reportID` - // (which points at the flow's origin report - e.g. the default workspace chat this distance flow was - // seeded with) leaves the expense bound to that workspace, so the backend rejects it with - // "There is a previously existing chat between these users." Generate a fresh optimistic reportID for - // P2P recipients, mirroring the legacy participants-step flow (useParticipantSubmission). - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const participantReportID = firstParticipant.reportID || (isPolicyExpenseChatParticipant ? reportID : generateReportID()); - setTransactionReport(activeTransactionID, {reportID: participantReportID}, true); - - // When switching from the auto-assigned default workspace to a P2P recipient we must also undo the - // workspace-specific defaults the distance step applied: reset the mileage rate to the P2P rate and - // clear the workspace's default category (and the category-derived tax). Otherwise the confirmation - // keeps the workspace "Default Rate"/category and the expense stays bound to that workspace. This - // mirrors what the legacy addParticipant/goToNextStep path does when a P2P recipient is selected. - if (!isPolicyExpenseChatParticipant) { - if (isDistanceRequest) { - const p2pRateID = DistanceRequestUtils.getCustomUnitRateID({ - reportID: firstParticipant.reportID, - isPolicyExpenseChat: false, - policy: undefined, - lastSelectedDistanceRates, - expenseDate: transaction?.created, - }); - setCustomUnitRateID(activeTransactionID, p2pRateID, transaction, undefined, false, personalPolicy?.outputCurrency); - } - setMoneyRequestCategory(activeTransactionID, '', undefined, getCurrencyDecimals); - setMoneyRequestTag(activeTransactionID, ''); - } else { - const workspacePolicy = selectedPolicy; - if (isDistanceRequest) { - const currentRateID = transaction?.comment?.customUnit?.customUnitRateID; - const isCurrentRateFromWorkspace = !!currentRateID && !!DistanceRequestUtils.getMileageRates(workspacePolicy)[currentRateID]; - if (!isCurrentRateFromWorkspace) { - const workspaceRateID = DistanceRequestUtils.getCustomUnitRateID({ - reportID: participantReportID, - isPolicyExpenseChat: true, - policy: workspacePolicy, - lastSelectedDistanceRates, - expenseDate: transaction?.created, - }); - setCustomUnitRateID(activeTransactionID, workspaceRateID, transaction, workspacePolicy, false, workspacePolicy?.outputCurrency); - } - } - - // Switching to a different workspace: the previous workspace's category and tag no longer apply, - // so reset them to the destination workspace's defaults. This mirrors the legacy participants-step - // flow (useParticipantSubmission.goToNextStep), which resets both on every selection and passes no - // policy so the previous workspace's category-derived tax is cleared along with the category. - if (firstParticipant.policyID && firstParticipant.policyID !== policyID) { - const defaultCategory = isDistanceRequest ? (getDistanceRateCustomUnit(workspacePolicy)?.defaultCategory ?? '') : ''; - setMoneyRequestCategory(activeTransactionID, defaultCategory, undefined, getCurrencyDecimals); - setMoneyRequestTag(activeTransactionID, ''); - } - } - } - } - if (participantsList.length > 0) { - closeParticipantPicker(); - } - }, - [ - activeTransactionID, - closeParticipantPicker, - reportDrafts, - currentUserPersonalDetails.accountID, - navigation, - selfDMReport, - iouType, - reportID, - isDistanceRequest, - lastSelectedDistanceRates, - transaction, - personalPolicy?.outputCurrency, - blockDistanceRequestIfNeeded, - getCurrencyDecimals, - policyID, - policyForMovingExpenses, - ], - ); - - useEffect(() => { - if (!transaction?.transactionID) { - return; - } - - const transactionParticipants = transaction?.participants ?? []; - const hasTransactionParticipants = transactionParticipants.length > 0; - const hasDefaultParticipants = defaultParticipants.length > 0; - - if (hasTransactionParticipants || !hasDefaultParticipants) { - return; - } - - setMoneyRequestParticipants(transaction.transactionID, defaultParticipants); - const firstDefault = defaultParticipants.at(0); - if (firstDefault?.isSelfDM) { - setTransactionReport(transaction.transactionID, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, true); - navigation.setParams({iouType: CONST.IOU.TYPE.TRACK}); - } else if (firstDefault?.reportID) { - setTransactionReport(transaction.transactionID, {reportID: firstDefault.reportID}, true); - } - }, [transaction?.transactionID, transaction?.participants, defaultParticipants, isManualRequest, navigation]); + const { + defaultParticipants, + isParticipantPickerVisible, + participantPickerIOUType, + openParticipantPicker, + closeParticipantPicker, + closeParticipantPickerForReferralNavigation, + handleParticipantsAdded, + } = useParticipantPickerState({ + transaction, + iouType, + reportID, + reportDrafts, + navigation, + policyID, + isDistanceRequest, + isManualRequest, + blockDistanceRequestIfNeeded, + }); const isPolicyExpenseChat = useMemo(() => { const hasPolicyExpenseChat = (participantList: typeof defaultParticipants) => @@ -649,14 +386,28 @@ function IOURequestStepConfirmationContent({ const isSelfDMDestination = isSelfDMSoleDestination(participants, iouType, currentUserPersonalDetails.accountID); - // PAY, SPLIT, and TRACK navigate to a specific destination report - // (not Search) after submission. A self-DM CREATE is effectively a TRACK, so it is - // excluded too. Pre-inserting the Search route would leave a stale entry in the navigation stack. - const canPreInsertSearch = iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.SPLIT && iouType !== CONST.IOU.TYPE.TRACK && !isSelfDMDestination; + const submitLock = useSubmitLock(); + const {isConfirmed, setIsConfirmed, formHasBeenSubmitted} = submitLock; - const preMountedDraftReportIDRef = useRef(undefined); + const {destinationReportID, optimisticP2PDestinationReportID, preMountDestinationReportID, revealPreMountDestination, cleanupPreMount, onExpenseWriteWillStart} = + useSubmitDestinationPreMount({ + transaction, + report, + reportDrafts, + participants, + iouType, + backToReport, + currentUserAccountID: currentUserPersonalDetails.accountID, + isPerDiemRequest, + isFromGlobalCreate, + isCreatingTrackExpense, + isSelfDMDestination, + isLookingAroundUser, + isMovingTransactionFromTrackExpense, + formHasBeenSubmitted, + }); - const {createTransaction, sendMoney, isConfirmed, setIsConfirmed, formHasBeenSubmitted} = useExpenseSubmission({ + const {createTransaction, sendMoney} = useExpenseSubmission({ reportDrafts, transaction, transactions, @@ -685,14 +436,8 @@ function IOURequestStepConfirmationContent({ draftTransactionIDs, privateIsArchivedMap, backToReport, - onExpenseWriteWillStart: () => { - const preMountedReportID = preMountedDraftReportIDRef.current; - if (!preMountedReportID) { - return; - } - preMountedDraftReportIDRef.current = undefined; - clearPreMountedDraftReportMarker(preMountedReportID); - }, + onExpenseWriteWillStart, + submitLock, }); // handleSearchDismiss doesn't pre-insert - it just dismisses the modal when search is @@ -702,122 +447,6 @@ function IOURequestStepConfirmationContent({ // other tabs. const canDismissFromSearch = iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.SPLIT; - const isTransactionReady = !!transaction; - const selfDMReportID = iouType === CONST.IOU.TYPE.TRACK || isSelfDMDestination ? findSelfDMReportID() : undefined; - const isMRReport = isMoneyRequestReport(report); - const shouldUsePerDiemChatReport = isPerDiemRequest && isMRReport && Navigation.getTopmostReportId() !== report?.reportID; - const routeDestinationReportID = shouldUsePerDiemChatReport ? report?.chatReportID : report?.reportID; - const destinationReportID = (isSelfDMDestination ? selfDMReportID : (backToReport ?? routeDestinationReportID)) ?? selfDMReportID; - - // The user can swap recipients here without a remount, so `report` can still lag behind the current - // pick. Resolve the P2P participant separately: existing chats win; only a genuinely new chat reuses - // the optimistic reportID useParticipantSubmission committed. - const firstParticipant = participants.at(0); - - // Split creates or resolves its own group chat report ID, so it cannot reuse the transaction's P2P report ID. - // A self-DM participant is not a policy expense chat either, but it carries accountID 0, so leaving it in here - // sends `getChatByParticipants` looking for a chat with account 0 that can never exist. - const isP2PDestination = iouType !== CONST.IOU.TYPE.SPLIT && !!firstParticipant && !firstParticipant.isPolicyExpenseChat && !isSelfDMDestination; - const reusableP2PReportID = isP2PDestination ? getReusableP2PReportID(firstParticipant, transaction?.reportID) : undefined; - const p2pRecipientAccountID = firstParticipant?.accountID ?? CONST.DEFAULT_NUMBER_ID; - - // Read reports reactively: if the chat lands mid-flow the optimistic ID must drop out, or we'd reveal an uncreated report. - const existingP2PChatSelector = useCallback( - (chatReportIDs: OnyxEntry) => - isP2PDestination ? chatReportIDs?.reportIDs?.[getParticipantsChatKey([p2pRecipientAccountID, currentUserPersonalDetails.accountID])] : undefined, - [isP2PDestination, p2pRecipientAccountID, currentUserPersonalDetails.accountID], - ); - const [existingP2PDestinationReportID] = useOnyx(ONYXKEYS.DERIVED.ONE_ON_ONE_CHAT_REPORT_IDS, {selector: existingP2PChatSelector}); - const optimisticP2PDestinationReportID = !existingP2PDestinationReportID && reusableP2PReportID ? reusableP2PReportID : undefined; - // Trust `report` when it already belongs to this participant (their chat, or an IOU report under it), so a - // flow started from an IOU report keeps that report as destination instead of falling back to the chat. - const isReportParticipantChat = !!report?.reportID && report.reportID === existingP2PDestinationReportID; - const isReportUnderParticipantChat = !!report?.reportID && report.chatReportID === existingP2PDestinationReportID; - const isReportAlignedWithParticipant = isReportParticipantChat || isReportUnderParticipantChat; - const shouldPreferRouteDestination = isReportAlignedWithParticipant || !!backToReport; - const preMountDestinationReportID = optimisticP2PDestinationReportID ?? (shouldPreferRouteDestination ? destinationReportID : (existingP2PDestinationReportID ?? destinationReportID)); - const [destinationReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${preMountDestinationReportID}`); - const destinationReportDraft = reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${preMountDestinationReportID}`]; - - // All reactive inputs are in the deps; the builder's own live Navigation reads aren't reactive values so they don't belong - // here. A recompute driven by a non-route-determining dep yields the same string route - a no-op for usePreMountDestination's - // value-compared [route] effect. A genuine destinationReportID/iouType change yields a new route string and re-pre-inserts, - // which is intended. The builder's hasPreInsertedFullscreen guard keeps its eligibility (not the route value) stable across - // its own pre-insert, so it never tears down the route it just inserted. - const preMountDestinationRoute = useMemo( - () => - getSubmitExpensePreMountDestinationRoute({ - isTransactionReady, - destinationReportID: preMountDestinationReportID, - destinationReport: destinationReport ?? destinationReportDraft, - isFromGlobalCreate, - canPreInsertSearch, - iouType, - isCreatingTrackExpense, - isSelfDMDestination, - isOptimisticNewChatDestination: !!optimisticP2PDestinationReportID, - isLookingAroundUser, - isMovingTransactionFromTrackExpense, - }), - [ - isTransactionReady, - preMountDestinationReportID, - optimisticP2PDestinationReportID, - destinationReport, - destinationReportDraft, - isFromGlobalCreate, - canPreInsertSearch, - iouType, - isCreatingTrackExpense, - isSelfDMDestination, - isLookingAroundUser, - isMovingTransactionFromTrackExpense, - ], - ); - - // Excludes the optimistic P2P case explicitly (never has a draft to pre-mount), rather than relying only - // on the route string, so this can't silently break if that route ever gains a query param. - const preMountDestinationReportRoute = preMountDestinationReportID ? ROUTES.REPORT_WITH_ID.getRoute(preMountDestinationReportID) : undefined; - const shouldPreMountDestinationDraft = !optimisticP2PDestinationReportID && !!preMountDestinationReportRoute && preMountDestinationRoute === preMountDestinationReportRoute; - - // DraftWorkspaceOpener creates a draft policy expense chat, under the reportID the real backend - // commit will eventually use, before this screen mounts. Copy it into the real report collection only - // when it's the eligible pre-mount target; the backend overwrites it with confirmed data on submit. - useEffect(() => { - if (!shouldPreMountDestinationDraft || !preMountDestinationReportID || destinationReport || !destinationReportDraft) { - return; - } - - preMountedDraftReportIDRef.current = preMountDestinationReportID; - preMountDraftReport(preMountDestinationReportID, destinationReportDraft); - }, [shouldPreMountDestinationDraft, preMountDestinationReportID, destinationReport, destinationReportDraft]); - - const {reveal: revealPreMountDestination, cleanupPreMount} = usePreMountDestination(preMountDestinationRoute, { - shouldPreservePreInsertedRouteOnUnmount: () => formHasBeenSubmitted.current, - }); - - // Only remove the speculative report row once the pre-mounted screen reading it is confirmed gone. - useEffect(() => { - return () => { - const preMountedReportID = preMountedDraftReportIDRef.current; - // Read the latest submission state at cleanup time because submission can start or finish after this effect runs. - const hasSubmitIntent = !!getPendingSubmitFollowUpAction(); - if (!preMountedReportID || preMountedReportID !== preMountDestinationReportID || Navigation.getIsFullscreenPreInsertedUnderRHP()) { - return; - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - if (hasSubmitIntent || formHasBeenSubmitted.current) { - // The real write's own callback clears the marker once it runs, which may race this cleanup - leave - // it alone here, or the row could end up unmarked before that write actually happens. - return; - } - - preMountedDraftReportIDRef.current = undefined; - clearPreMountedDraftReport(preMountedReportID); - }; - }, [preMountDestinationReportID, formHasBeenSubmitted]); - // Cancel the telemetry span when confirmation unmounts without a completed submission. // If getPendingSubmitFollowUpAction() is set, the orchestrator (or sendMoney flow) has // already taken ownership of the span lifecycle - do not interfere. @@ -888,6 +517,12 @@ function IOURequestStepConfirmationContent({ Navigation.goBack(backTo); return; } + // If the transaction was seeded by reusing a previous route, go back to the previous screen in the stack + // (e.g. the reuse route list) instead of navigating to the start money request step. + if (transaction?.isReusedRoute) { + Navigation.goBack(); + return; + } // If the action is categorize and there's no policies other than personal one, we simply call goBack(), i.e: dismiss the whole flow together // We don't need to subscribe to policy_ collection as we only need to check on the latest collection value if (action === CONST.IOU.ACTION.CATEGORIZE) { @@ -919,7 +554,6 @@ function IOURequestStepConfirmationContent({ if (!transaction?.participantsAutoAssigned && participantsAutoAssignedFromRoute !== 'true') { Navigation.goBack( createDynamicRoute( - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing DYNAMIC_ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute({action, iouType, transactionID: initialTransactionID, reportID: transaction?.reportID || reportID}), ROUTES.MONEY_REQUEST_CREATE.getRoute(action, iouType, initialTransactionID, reportID, backToReport), ), @@ -941,6 +575,7 @@ function IOURequestStepConfirmationContent({ isPerDiemRequest, isCreatingTrackExpense, transaction?.isFromGlobalCreate, + transaction?.isReusedRoute, transaction?.receipt?.isTestReceipt, transaction?.participantsAutoAssigned, transaction?.reportID, @@ -1013,45 +648,6 @@ function IOURequestStepConfirmationContent({ ); } - const showNextTransaction = () => { - const nextTransaction = transactions.at(currentTransactionIndex + 1); - if (nextTransaction) { - setCurrentTransactionID(nextTransaction.transactionID); - } - }; - - const showPreviousTransaction = () => { - const previousTransaction = transactions.at(currentTransactionIndex - 1); - if (previousTransaction) { - setCurrentTransactionID(previousTransaction.transactionID); - } - }; - - const removeCurrentTransaction = () => { - if (currentTransactionID === CONST.IOU.OPTIMISTIC_TRANSACTION_ID) { - const nextTransaction = transactions.at(currentTransactionIndex + 1); - replaceDefaultDraftTransaction(nextTransaction); - return; - } - - removeDraftTransaction(currentTransactionID); - showPreviousTransaction(); - }; - - const confirmRemoveCurrentTransaction = async () => { - const result = await showConfirmModal({ - title: translate('iou.removeExpense'), - prompt: translate('iou.removeExpenseConfirmation'), - confirmText: translate('common.remove'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }); - if (result.action !== ModalActions.CONFIRM) { - return; - } - removeCurrentTransaction(); - }; - const showReceiptEmptyState = shouldShowReceiptEmptyState(iouType, action, policy, isPerDiemRequest); const shouldShowSmartScanFields = @@ -1178,12 +774,7 @@ function IOURequestStepConfirmationContent({ transaction={transaction} selectedParticipants={participants} isParticipantPickerVisible={isParticipantPickerVisible} - onOpenParticipantPicker={() => { - if (!activeTransactionID) { - return; - } - setManuallyOpenedParticipantPickerForTransactionID(activeTransactionID); - }} + onOpenParticipantPicker={openParticipantPicker} onToggleBillable={setBillable} onConfirm={onConfirm} onSendMoney={handleSendMoney} diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation/types.ts b/src/pages/iou/request/step/IOURequestStepConfirmation/types.ts new file mode 100644 index 000000000000..9f3edc466e89 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepConfirmation/types.ts @@ -0,0 +1,17 @@ +import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; + +import type {WithFullTransactionOrNotFoundProps} from '@pages/iou/request/step/withFullTransactionOrNotFound'; +import type {WithWritableReportOrNotFoundProps} from '@pages/iou/request/step/withWritableReportOrNotFound'; + +import type SCREENS from '@src/SCREENS'; + +type IOURequestStepConfirmationIncomingRouteName = typeof SCREENS.MONEY_REQUEST.STEP_CONFIRMATION | typeof SCREENS.MONEY_REQUEST.CREATE; + +type StepConfirmationParams = MoneyRequestNavigatorParamList[typeof SCREENS.MONEY_REQUEST.STEP_CONFIRMATION]; + +type IOURequestStepConfirmationProps = WithWritableReportOrNotFoundProps & + WithFullTransactionOrNotFoundProps & { + shouldHideHeader?: boolean; + }; + +export type {IOURequestStepConfirmationProps, StepConfirmationParams}; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation/useConfirmationTransactionPager.ts b/src/pages/iou/request/step/IOURequestStepConfirmation/useConfirmationTransactionPager.ts new file mode 100644 index 000000000000..7bdac49cc869 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepConfirmation/useConfirmationTransactionPager.ts @@ -0,0 +1,97 @@ +import {ModalActions} from '@components/Modal/Global/ModalContext'; + +import useConfirmModal from '@hooks/useConfirmModal'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransactions'; +import usePreviousDefined from '@hooks/usePreviousDefined'; + +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; + +import {removeDraftTransaction, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type Transaction from '@src/types/onyx/Transaction'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {useState} from 'react'; + +/** + * Tracks which of the confirmation's draft transactions is shown, and lets the user page between them or remove one. + */ +function useConfirmationTransactionPager(initialTransaction: OnyxEntry, initialTransactionID: string) { + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + + const [transactions] = useOptimisticDraftTransactions(initialTransaction); + + const [currentTransactionID, setCurrentTransactionID] = useState(initialTransactionID); + const currentTransactionIndex = transactions.findIndex((transaction) => transaction.transactionID === currentTransactionID); + const [existingTransaction, existingTransactionResult] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(currentTransactionID)}`); + const [optimisticTransaction, optimisticTransactionResult] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${getNonEmptyStringOnyxID(currentTransactionID)}`); + const isLoadingCurrentTransaction = isLoadingOnyxValue(existingTransactionResult, optimisticTransactionResult); + const currentTransaction = !isLoadingCurrentTransaction ? (optimisticTransaction ?? existingTransaction) : undefined; + + // `useOnyx` drops back to a loading state whenever its key changes, so switching between transactions leaves + // `currentTransaction` undefined for a render. Hold the previous one across that gap: `MoneyRequestConfirmationList` + // picks its variant from the request type, so an undefined transaction falls to the manual variant and remounts the + // whole list, losing the state the scan variant holds. + const lastDefinedTransaction = usePreviousDefined(currentTransaction); + const transaction = isLoadingCurrentTransaction ? lastDefinedTransaction : currentTransaction; + + const showNextTransaction = () => { + const nextTransaction = transactions.at(currentTransactionIndex + 1); + if (nextTransaction) { + setCurrentTransactionID(nextTransaction.transactionID); + } + }; + + const showPreviousTransaction = () => { + const previousTransaction = transactions.at(currentTransactionIndex - 1); + if (previousTransaction) { + setCurrentTransactionID(previousTransaction.transactionID); + } + }; + + const removeCurrentTransaction = () => { + if (currentTransactionID === CONST.IOU.OPTIMISTIC_TRANSACTION_ID) { + const nextTransaction = transactions.at(currentTransactionIndex + 1); + replaceDefaultDraftTransaction(nextTransaction); + return; + } + + removeDraftTransaction(currentTransactionID); + showPreviousTransaction(); + }; + + const confirmRemoveCurrentTransaction = async () => { + const result = await showConfirmModal({ + title: translate('iou.removeExpense'), + prompt: translate('iou.removeExpenseConfirmation'), + confirmText: translate('common.remove'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + removeCurrentTransaction(); + }; + + return { + transactions, + transaction, + existingTransaction, + currentTransactionID, + currentTransactionIndex, + setCurrentTransactionID, + showNextTransaction, + showPreviousTransaction, + confirmRemoveCurrentTransaction, + }; +} + +export default useConfirmationTransactionPager; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation/useParticipantPickerState.ts b/src/pages/iou/request/step/IOURequestStepConfirmation/useParticipantPickerState.ts new file mode 100644 index 000000000000..fbd31bf151ab --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepConfirmation/useParticipantPickerState.ts @@ -0,0 +1,288 @@ +import type useBlockDistanceRequest from '@hooks/useBlockDistanceRequest'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDefaultParticipants from '@hooks/useDefaultParticipants'; +import useOnyx from '@hooks/useOnyx'; +import usePersonalPolicy from '@hooks/usePersonalPolicy'; +import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; +import useSelfDMReport from '@hooks/useSelfDMReport'; + +import {setTransactionReport} from '@libs/actions/Transaction'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import {isParticipantP2P} from '@libs/IOUUtils'; +import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; +import {generateReportID, getReportOrDraftReport} from '@libs/ReportUtils'; + +import {setCustomUnitRateID, setMoneyRequestCategory, setMoneyRequestParticipants, setMoneyRequestParticipantsFromReport, setMoneyRequestTag} from '@userActions/IOU/MoneyRequest'; + +import CONST from '@src/CONST'; +import type {IOUType} from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report} from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; +import type Transaction from '@src/types/onyx/Transaction'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +import {useEffect, useRef, useState} from 'react'; + +import type {IOURequestStepConfirmationProps} from './types'; + +type UseParticipantPickerStateParams = { + transaction: OnyxEntry; + iouType: IOUType; + reportID: string; + reportDrafts: OnyxCollection; + navigation: IOURequestStepConfirmationProps['navigation']; + policyID: string | undefined; + isDistanceRequest: boolean; + isManualRequest: boolean; + blockDistanceRequestIfNeeded: ReturnType; +}; + +/** + * Owns the confirmation's in-place "To" picker: the participants auto-assigned from the source report, when the + * picker opens and closes, and what selecting a participant writes to the transaction. + */ +function useParticipantPickerState({ + transaction, + iouType, + reportID, + reportDrafts, + navigation, + policyID, + isDistanceRequest, + isManualRequest, + blockDistanceRequestIfNeeded, +}: UseParticipantPickerStateParams) { + const {getCurrencyDecimals} = useCurrencyListActions(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const personalPolicy = usePersonalPolicy(); + const selfDMReport = useSelfDMReport(); + const {policyForMovingExpenses} = usePolicyForMovingExpenses(); + const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); + + const sourceReportID = transaction?.reportID ?? reportID; + const sourceReport = sourceReportID + ? getReportOrDraftReport(sourceReportID, undefined, undefined, reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${sourceReportID}`] ?? {}) + : undefined; + const {participants: resolvedDefaultParticipants, isLoading: isLoadingDefaultParticipants} = useDefaultParticipants({sourceReport, transaction, iouType}); + const hasSelectedParticipants = (transaction?.participants ?? []).some((participant) => participant?.selected); + + // Don't override the participants the user has already selected, and bail when there is no source report. The shared + // empty array keeps the reference stable, so the auto-assign effect below doesn't re-run on every render. + const defaultParticipants = hasSelectedParticipants || !sourceReportID ? getEmptyArray() : resolvedDefaultParticipants; + + const transactionParticipants = transaction?.participants ?? []; + const shouldAutoOpenParticipantPicker = + !!transaction?.transactionID && transactionParticipants.length === 0 && defaultParticipants.length === 0 && !isLoadingDefaultParticipants && isManualRequest; + const activeTransactionID = transaction?.transactionID; + const [manuallyOpenedParticipantPickerForTransactionID, setManuallyOpenedParticipantPickerForTransactionID] = useState(); + const [dismissedAutoOpenParticipantPickerForTransactionID, setDismissedAutoOpenParticipantPickerForTransactionID] = useState(); + const participantPickerIOUType = iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.TRACK ? CONST.IOU.TYPE.CREATE : iouType; + const isParticipantPickerVisible = + !!activeTransactionID && + (manuallyOpenedParticipantPickerForTransactionID === activeTransactionID || + (shouldAutoOpenParticipantPicker && dismissedAutoOpenParticipantPickerForTransactionID !== activeTransactionID)); + + const openParticipantPicker = () => { + if (!activeTransactionID) { + return; + } + setManuallyOpenedParticipantPickerForTransactionID(activeTransactionID); + }; + + const closeParticipantPicker = () => { + setManuallyOpenedParticipantPickerForTransactionID(undefined); + if (!activeTransactionID) { + return; + } + setDismissedAutoOpenParticipantPickerForTransactionID(activeTransactionID); + }; + + const shouldReopenParticipantPickerOnFocusRef = useRef(false); + + // The referral banner inside the picker navigates to its own RHP, which the picker would otherwise cover, so the + // picker closes first and is reopened when the user comes back. This goes through `closeParticipantPicker`, which + // permanently marks the auto-open as dismissed, so the reopen below deliberately re-enters through the manual path. + // That is what we want: after the round trip a genuine dismissal must close the picker for good. + const closeParticipantPickerForReferralNavigation = () => { + shouldReopenParticipantPickerOnFocusRef.current = isParticipantPickerVisible; + closeParticipantPicker(); + }; + + useEffect( + () => + // This screen is also rendered embedded by `IOURequestStartPage`, so the listener fires on every refocus of + // that screen, not only on back from the referral page. Re-checking that the expense still has no recipient + // keeps an unrelated RHP round trip (or a recipient resolved meanwhile) from slamming the picker open over a + // form the user wasn't editing. + navigation.addListener('focus', () => { + if (!shouldReopenParticipantPickerOnFocusRef.current) { + return; + } + shouldReopenParticipantPickerOnFocusRef.current = false; + if (!activeTransactionID || hasSelectedParticipants) { + return; + } + setManuallyOpenedParticipantPickerForTransactionID(activeTransactionID); + }), + [navigation, activeTransactionID, hasSelectedParticipants], + ); + + const handleParticipantsAdded = (participantsList: Participant[], selectedPolicy?: OnyxEntry) => { + if (!activeTransactionID) { + return; + } + const selectedParticipant = participantsList.at(0); + const selectedPolicyID = + selectedParticipant?.policyID ?? + (selectedParticipant?.reportID + ? getReportOrDraftReport(selectedParticipant.reportID, undefined, undefined, reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${selectedParticipant.reportID}`] ?? {}) + ?.policyID + : undefined); + if (blockDistanceRequestIfNeeded(selectedPolicyID)) { + return; + } + // P2P chats don't support negative amounts. When a negative amount was entered before a participant + // was selected (e.g. "Submit it to someone" from a self DM), assigning it to a P2P participant would + // fail at submit, so keep the expense on the self DM (its default) instead of assigning the P2P + // participant, stopping the user at selection rather than at submit. This only applies while the + // expense is still on the self DM — a negative expense already bound to a policy expense chat (e.g. + // global create auto-assigned the default workspace) must stay on that workspace rather than being + // silently converted into a personal track expense. + const isTransactionOnPolicyExpenseChat = transaction?.participants?.some((participant) => participant?.isPolicyExpenseChat); + const shouldKeepOnSelfDM = !!selectedParticipant?.isSelfDM || ((transaction?.amount ?? 0) < 0 && !isTransactionOnPolicyExpenseChat && isParticipantP2P(selectedParticipant)); + if (shouldKeepOnSelfDM) { + setMoneyRequestParticipantsFromReport(activeTransactionID, selfDMReport, currentUserPersonalDetails.accountID); + setTransactionReport(activeTransactionID, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, true); + + // The rate the expense picked up from a workspace does not exist outside it, so leaving it in place + // makes the Rate field read "Pending..." and the amount go blank once the expense is back on the self + // DM. Re-resolve the rate the self DM itself uses, the same way starting a track distance expense does. + if (isDistanceRequest) { + const selfDMRateID = DistanceRequestUtils.getCustomUnitRateID({ + reportID: selfDMReport?.reportID, + isPolicyExpenseChat: false, + isTrackDistanceExpense: true, + policy: policyForMovingExpenses, + lastSelectedDistanceRates, + expenseDate: transaction?.created, + }); + setCustomUnitRateID( + activeTransactionID, + selfDMRateID, + transaction, + policyForMovingExpenses, + false, + policyForMovingExpenses?.outputCurrency ?? personalPolicy?.outputCurrency, + ); + } + + if (iouType !== CONST.IOU.TYPE.TRACK) { + navigation.setParams({iouType: CONST.IOU.TYPE.TRACK}); + } + } else { + if (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.TRACK) { + navigation.setParams({iouType: CONST.IOU.TYPE.CREATE}); + } + setMoneyRequestParticipants(activeTransactionID, participantsList); + const firstParticipant = participantsList.at(0); + if (iouType !== CONST.IOU.TYPE.SPLIT && firstParticipant) { + const isPolicyExpenseChatParticipant = !!firstParticipant.isPolicyExpenseChat; + + // A brand-new recipient picked by email has no chat yet (no reportID). Reusing the route's `reportID` + // (which points at the flow's origin report - e.g. the default workspace chat this distance flow was + // seeded with) leaves the expense bound to that workspace, so the backend rejects it with + // "There is a previously existing chat between these users." Generate a fresh optimistic reportID for + // P2P recipients, mirroring the legacy participants-step flow (useParticipantSubmission). + const participantReportID = firstParticipant.reportID || (isPolicyExpenseChatParticipant ? reportID : generateReportID()); + setTransactionReport(activeTransactionID, {reportID: participantReportID}, true); + + // When switching from the auto-assigned default workspace to a P2P recipient we must also undo the + // workspace-specific defaults the distance step applied: reset the mileage rate to the P2P rate and + // clear the workspace's default category (and the category-derived tax). Otherwise the confirmation + // keeps the workspace "Default Rate"/category and the expense stays bound to that workspace. This + // mirrors what the legacy addParticipant/goToNextStep path does when a P2P recipient is selected. + if (!isPolicyExpenseChatParticipant) { + if (isDistanceRequest) { + const p2pRateID = DistanceRequestUtils.getCustomUnitRateID({ + reportID: firstParticipant.reportID, + isPolicyExpenseChat: false, + policy: undefined, + lastSelectedDistanceRates, + expenseDate: transaction?.created, + }); + setCustomUnitRateID(activeTransactionID, p2pRateID, transaction, undefined, false, personalPolicy?.outputCurrency); + } + setMoneyRequestCategory(activeTransactionID, '', undefined, getCurrencyDecimals); + setMoneyRequestTag(activeTransactionID, ''); + } else { + const workspacePolicy = selectedPolicy; + if (isDistanceRequest) { + const currentRateID = transaction?.comment?.customUnit?.customUnitRateID; + const isCurrentRateFromWorkspace = !!currentRateID && !!DistanceRequestUtils.getMileageRates(workspacePolicy)[currentRateID]; + if (!isCurrentRateFromWorkspace) { + const workspaceRateID = DistanceRequestUtils.getCustomUnitRateID({ + reportID: participantReportID, + isPolicyExpenseChat: true, + policy: workspacePolicy, + lastSelectedDistanceRates, + expenseDate: transaction?.created, + }); + setCustomUnitRateID(activeTransactionID, workspaceRateID, transaction, workspacePolicy, false, workspacePolicy?.outputCurrency); + } + } + + // Switching to a different workspace: the previous workspace's category and tag no longer apply, + // so reset them to the destination workspace's defaults. This mirrors the legacy participants-step + // flow (useParticipantSubmission.goToNextStep), which resets both on every selection and passes no + // policy so the previous workspace's category-derived tax is cleared along with the category. + if (firstParticipant.policyID && firstParticipant.policyID !== policyID) { + const defaultCategory = isDistanceRequest ? (getDistanceRateCustomUnit(workspacePolicy)?.defaultCategory ?? '') : ''; + setMoneyRequestCategory(activeTransactionID, defaultCategory, undefined, getCurrencyDecimals); + setMoneyRequestTag(activeTransactionID, ''); + } + } + } + } + if (participantsList.length > 0) { + closeParticipantPicker(); + } + }; + + useEffect(() => { + if (!transaction?.transactionID) { + return; + } + + const hasTransactionParticipants = (transaction?.participants ?? []).length > 0; + const hasDefaultParticipants = defaultParticipants.length > 0; + + if (hasTransactionParticipants || !hasDefaultParticipants) { + return; + } + + setMoneyRequestParticipants(transaction.transactionID, defaultParticipants); + const firstDefault = defaultParticipants.at(0); + if (firstDefault?.isSelfDM) { + setTransactionReport(transaction.transactionID, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, true); + navigation.setParams({iouType: CONST.IOU.TYPE.TRACK}); + } else if (firstDefault?.reportID) { + setTransactionReport(transaction.transactionID, {reportID: firstDefault.reportID}, true); + } + }, [transaction?.transactionID, transaction?.participants, defaultParticipants, navigation]); + + return { + defaultParticipants, + isParticipantPickerVisible, + participantPickerIOUType, + openParticipantPicker, + closeParticipantPicker, + closeParticipantPickerForReferralNavigation, + handleParticipantsAdded, + }; +} + +export default useParticipantPickerState; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation/useSubmitDestinationPreMount.ts b/src/pages/iou/request/step/IOURequestStepConfirmation/useSubmitDestinationPreMount.ts new file mode 100644 index 000000000000..8dc196aa16d0 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepConfirmation/useSubmitDestinationPreMount.ts @@ -0,0 +1,190 @@ +import useOnyx from '@hooks/useOnyx'; +import usePreMountDestination from '@hooks/usePreMountDestination'; + +import {clearPreMountedDraftReport, clearPreMountedDraftReportMarker, preMountDraftReport} from '@libs/actions/Report/PreMountedDraftReport'; +import {getReusableP2PReportID} from '@libs/IOUUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {findSelfDMReportID, getParticipantsChatKey, isMoneyRequestReport} from '@libs/ReportUtils'; +import {getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; + +import getSubmitExpensePreMountDestinationRoute from '@pages/iou/request/step/confirmation/getSubmitExpensePreMountDestinationRoute'; + +import CONST from '@src/CONST'; +import type {IOUType} from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {OneOnOneChatReportIDsDerivedValue, Report} from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; +import type Transaction from '@src/types/onyx/Transaction'; + +import type {RefObject} from 'react'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +import {useEffect, useRef} from 'react'; + +type UseSubmitDestinationPreMountParams = { + transaction: OnyxEntry; + report: OnyxEntry; + reportDrafts: OnyxCollection; + participants: Participant[]; + iouType: IOUType; + backToReport: string | undefined; + currentUserAccountID: number; + isPerDiemRequest: boolean; + isFromGlobalCreate: boolean; + isCreatingTrackExpense: boolean; + isSelfDMDestination: boolean; + isLookingAroundUser: boolean; + isMovingTransactionFromTrackExpense: boolean; + formHasBeenSubmitted: RefObject; +}; + +/** + * Resolves the report the expense lands on after submit and pre-mounts it under the confirmation, so revealing it + * after the write is instant. + */ +function useSubmitDestinationPreMount({ + transaction, + report, + reportDrafts, + participants, + iouType, + backToReport, + currentUserAccountID, + isPerDiemRequest, + isFromGlobalCreate, + isCreatingTrackExpense, + isSelfDMDestination, + isLookingAroundUser, + isMovingTransactionFromTrackExpense, + formHasBeenSubmitted, +}: UseSubmitDestinationPreMountParams) { + // PAY, SPLIT, and TRACK navigate to a specific destination report + // (not Search) after submission. A self-DM CREATE is effectively a TRACK, so it is + // excluded too. Pre-inserting the Search route would leave a stale entry in the navigation stack. + const canPreInsertSearch = iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.SPLIT && iouType !== CONST.IOU.TYPE.TRACK && !isSelfDMDestination; + + const preMountedDraftReportIDRef = useRef(undefined); + + /** + * Called once validation has passed and the write is guaranteed to happen. The real write now owns the + * pre-mounted row, so drop its marker here rather than letting unmount cleanup delete it. + */ + const onExpenseWriteWillStart = () => { + const preMountedReportID = preMountedDraftReportIDRef.current; + if (!preMountedReportID) { + return; + } + preMountedDraftReportIDRef.current = undefined; + clearPreMountedDraftReportMarker(preMountedReportID); + }; + + const isTransactionReady = !!transaction; + const selfDMReportID = iouType === CONST.IOU.TYPE.TRACK || isSelfDMDestination ? findSelfDMReportID() : undefined; + const isMRReport = isMoneyRequestReport(report); + const shouldUsePerDiemChatReport = isPerDiemRequest && isMRReport && Navigation.getTopmostReportId() !== report?.reportID; + const routeDestinationReportID = shouldUsePerDiemChatReport ? report?.chatReportID : report?.reportID; + const destinationReportID = (isSelfDMDestination ? selfDMReportID : (backToReport ?? routeDestinationReportID)) ?? selfDMReportID; + + // The user can swap recipients here without a remount, so `report` can still lag behind the current + // pick. Resolve the P2P participant separately: existing chats win; only a genuinely new chat reuses + // the optimistic reportID useParticipantSubmission committed. + const firstParticipant = participants.at(0); + + // Split creates or resolves its own group chat report ID, so it cannot reuse the transaction's P2P report ID. + // A self-DM participant is not a policy expense chat either, but it carries accountID 0, so leaving it in here + // sends `getChatByParticipants` looking for a chat with account 0 that can never exist. + const isP2PDestination = iouType !== CONST.IOU.TYPE.SPLIT && !!firstParticipant && !firstParticipant.isPolicyExpenseChat && !isSelfDMDestination; + const reusableP2PReportID = isP2PDestination ? getReusableP2PReportID(firstParticipant, transaction?.reportID) : undefined; + const p2pRecipientAccountID = firstParticipant?.accountID ?? CONST.DEFAULT_NUMBER_ID; + + // Read reports reactively: if the chat lands mid-flow the optimistic ID must drop out, or we'd reveal an uncreated report. + const existingP2PChatSelector = (chatReportIDs: OnyxEntry) => + isP2PDestination ? chatReportIDs?.reportIDs?.[getParticipantsChatKey([p2pRecipientAccountID, currentUserAccountID])] : undefined; + const [existingP2PDestinationReportID] = useOnyx(ONYXKEYS.DERIVED.ONE_ON_ONE_CHAT_REPORT_IDS, {selector: existingP2PChatSelector}); + const optimisticP2PDestinationReportID = !existingP2PDestinationReportID && reusableP2PReportID ? reusableP2PReportID : undefined; + + // Trust `report` when it already belongs to this participant (their chat, or an IOU report under it), so a + // flow started from an IOU report keeps that report as destination instead of falling back to the chat. + const isReportParticipantChat = !!report?.reportID && report.reportID === existingP2PDestinationReportID; + const isReportUnderParticipantChat = !!report?.reportID && report.chatReportID === existingP2PDestinationReportID; + const isReportAlignedWithParticipant = isReportParticipantChat || isReportUnderParticipantChat; + const shouldPreferRouteDestination = isReportAlignedWithParticipant || !!backToReport; + const preMountDestinationReportID = optimisticP2PDestinationReportID ?? (shouldPreferRouteDestination ? destinationReportID : (existingP2PDestinationReportID ?? destinationReportID)); + const [destinationReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${preMountDestinationReportID}`); + const destinationReportDraft = reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${preMountDestinationReportID}`]; + + // The builder's own live Navigation reads aren't reactive values, so they don't drive a recompute. A recompute driven by a + // non-route-determining input yields the same string route - a no-op for usePreMountDestination's value-compared [route] + // effect. A genuine destinationReportID/iouType change yields a new route string and re-pre-inserts, which is intended. The + // builder's hasPreInsertedFullscreen guard keeps its eligibility (not the route value) stable across its own pre-insert, so + // it never tears down the route it just inserted. + const preMountDestinationRoute = getSubmitExpensePreMountDestinationRoute({ + isTransactionReady, + destinationReportID: preMountDestinationReportID, + destinationReport: destinationReport ?? destinationReportDraft, + isFromGlobalCreate, + canPreInsertSearch, + iouType, + isCreatingTrackExpense, + isSelfDMDestination, + isOptimisticNewChatDestination: !!optimisticP2PDestinationReportID, + isLookingAroundUser, + isMovingTransactionFromTrackExpense, + }); + + // Excludes the optimistic P2P case explicitly (never has a draft to pre-mount), rather than relying only + // on the route string, so this can't silently break if that route ever gains a query param. + const preMountDestinationReportRoute = preMountDestinationReportID ? ROUTES.REPORT_WITH_ID.getRoute(preMountDestinationReportID) : undefined; + const shouldPreMountDestinationDraft = !optimisticP2PDestinationReportID && !!preMountDestinationReportRoute && preMountDestinationRoute === preMountDestinationReportRoute; + + // DraftWorkspaceOpener creates a draft policy expense chat, under the reportID the real backend + // commit will eventually use, before this screen mounts. Copy it into the real report collection only + // when it's the eligible pre-mount target; the backend overwrites it with confirmed data on submit. + useEffect(() => { + if (!shouldPreMountDestinationDraft || !preMountDestinationReportID || destinationReport || !destinationReportDraft) { + return; + } + + preMountedDraftReportIDRef.current = preMountDestinationReportID; + preMountDraftReport(preMountDestinationReportID, destinationReportDraft); + }, [shouldPreMountDestinationDraft, preMountDestinationReportID, destinationReport, destinationReportDraft]); + + const {reveal: revealPreMountDestination, cleanupPreMount} = usePreMountDestination(preMountDestinationRoute, { + shouldPreservePreInsertedRouteOnUnmount: () => formHasBeenSubmitted.current, + }); + + // Only remove the speculative report row once the pre-mounted screen reading it is confirmed gone. + useEffect(() => { + return () => { + const preMountedReportID = preMountedDraftReportIDRef.current; + + // Read the latest submission state at cleanup time because submission can start or finish after this effect runs. + const hasSubmitIntent = !!getPendingSubmitFollowUpAction(); + if (!preMountedReportID || preMountedReportID !== preMountDestinationReportID || Navigation.getIsFullscreenPreInsertedUnderRHP()) { + return; + } + + // eslint-disable-next-line react-hooks/exhaustive-deps -- we need the latest formHasBeenSubmitted value at cleanup time, not the value from mount. + if (hasSubmitIntent || formHasBeenSubmitted.current) { + // The real write's own callback clears the marker once it runs, which may race this cleanup - leave + // it alone here, or the row could end up unmarked before that write actually happens. + return; + } + + preMountedDraftReportIDRef.current = undefined; + clearPreMountedDraftReport(preMountedReportID); + }; + }, [preMountDestinationReportID, formHasBeenSubmitted]); + + return { + destinationReportID, + optimisticP2PDestinationReportID, + preMountDestinationReportID, + revealPreMountDestination, + cleanupPreMount, + onExpenseWriteWillStart, + }; +} + +export default useSubmitDestinationPreMount; diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts index 371972c1fa47..3c088a61ef22 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -49,6 +49,7 @@ import type { Rule, Transaction, TransactionViolation, + ReusableDistanceRoute, } from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {Unit} from '@src/types/onyx/Policy'; @@ -117,6 +118,10 @@ type MoneyRequestStepDistanceNavigationParams = { participantsPolicyTags: ParticipantsPolicyTags; rules: OnyxCollection; isVendorMatchingBetaEnabled: boolean | undefined; + moneyRequestReportTransactions: Transaction[]; + + /** Current "Reuse route" list value, so the just used route can be saved locally */ + reusableDistanceRoutes?: ReusableDistanceRoute[]; }; /** Amount + merchant for a manual-distance submit; pending placeholders otherwise (waypoint/GPS distance is computed server-side). */ @@ -223,6 +228,8 @@ function handleMoneyRequestStepDistanceNavigation({ isOffline = false, rules, isVendorMatchingBetaEnabled, + moneyRequestReportTransactions, + reusableDistanceRoutes, }: MoneyRequestStepDistanceNavigationParams): void { const isManualDistance = manualDistance !== undefined; const isOdometerDistance = odometerDistance !== undefined; @@ -356,6 +363,7 @@ function handleMoneyRequestStepDistanceNavigation({ reportActionsList: undefined, getCurrencyDecimals, rules, + reusableDistanceRoutes, }); cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { report, @@ -392,6 +400,7 @@ function handleMoneyRequestStepDistanceNavigation({ const {transactionID: writtenDistanceTransactionID} = createDistanceRequest({ isVendorMatchingBetaEnabled, report, + moneyRequestReportTransactions, participants, currentUserLogin: currentUserLogin ?? '', currentUserAccountID, @@ -443,6 +452,7 @@ function handleMoneyRequestStepDistanceNavigation({ getCurrencyDecimals, participantsPolicyTags, rules, + reusableDistanceRoutes, }); cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { report, diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts index a1056136b76c..478683bdb6a6 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts @@ -8,6 +8,7 @@ import useMoneyRequestPolicyTagsForReport from '@hooks/useMoneyRequestPolicyTags import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import useReportTransactions from '@hooks/useReportTransactions'; import {rand64} from '@libs/NumberUtils'; import {generateReportID, isMoneyRequestReport as isMoneyRequestReportReportUtils} from '@libs/ReportUtils'; @@ -154,6 +155,8 @@ function useDistanceNavigation({ const [reportDraft] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportIDToCheck}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const moneyRequestReportTransactions = useReportTransactions(report?.reportID); + const [reusableDistanceRoutes] = useOnyx(ONYXKEYS.REUSABLE_DISTANCE_ROUTES); const delegateAccountID = useDelegateAccountID(); const {formatPhoneNumber, dateFnsLocale} = useLocalize(); @@ -183,6 +186,7 @@ function useDistanceNavigation({ iouType, action, report, + moneyRequestReportTransactions, isDraftChatReport: !!reportDraft, policy, transaction, @@ -227,6 +231,7 @@ function useDistanceNavigation({ participants, participantsPolicyTags, rules, + reusableDistanceRoutes, }); }; } diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts index 3e62d3d57b70..5a5d817e3cd9 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts @@ -8,6 +8,7 @@ import useMoneyRequestPolicyTagsForReport from '@hooks/useMoneyRequestPolicyTags import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import useReportTransactions from '@hooks/useReportTransactions'; import {rand64} from '@libs/NumberUtils'; import {generateReportID, isMoneyRequestReport as isMoneyRequestReportReportUtils} from '@libs/ReportUtils'; @@ -157,6 +158,7 @@ function useOdometerNavigation({ const [reportDraft] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportIDToCheck}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const moneyRequestReportTransactions = useReportTransactions(report?.reportID); const {formatPhoneNumber, dateFnsLocale} = useLocalize(); const policyTagList = useMoneyRequestPolicyTagsForReport({report, currentUserAccountID}); @@ -186,6 +188,7 @@ function useOdometerNavigation({ iouType, action, report, + moneyRequestReportTransactions, isDraftChatReport: !!reportDraft, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx index babdfae8ca5a..c40234f0be40 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx @@ -9,6 +9,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getEffectiveDistance} from '@libs/GPSDraftDetailsUtils'; +import {formatDistanceToParts} from '@libs/NumberFormatUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Unit} from '@src/types/onyx/Policy'; @@ -20,12 +21,12 @@ type DistanceCounterProps = { function DistanceCounter({unit}: DistanceCounterProps) { const styles = useThemeStyles(); - const {translate} = useLocalize(); + const {translate, preferredLocale} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Crosshair']); const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); - const distance = DistanceRequestUtils.getRoundedDistanceInUnits(getEffectiveDistance(gpsDraftDetails), unit); + const distanceParts = formatDistanceToParts(preferredLocale, DistanceRequestUtils.convertDistanceUnit(getEffectiveDistance(gpsDraftDetails), unit), unit); return ( @@ -34,9 +35,19 @@ function DistanceCounter({unit}: DistanceCounterProps) { - - {distance} - {` ${unit}`} + + {distanceParts.map((part) => + part.type === 'unit' ? ( + + {part.value} + + ) : ( + part.value + ), + )} diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx index 96f1f205f212..b12d760f4fe5 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx @@ -18,6 +18,7 @@ import usePolicy from '@hooks/usePolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useReportAttributes from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportTransactions from '@hooks/useReportTransactions'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -72,6 +73,7 @@ function IOURequestStepDistanceGPS({ const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); const isArchived = useReportIsArchived(report?.reportID); + const moneyRequestReportTransactions = useReportTransactions(report?.reportID); const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); const [personalDetails] = useAllPersonalDetails(); @@ -158,6 +160,7 @@ function IOURequestStepDistanceGPS({ iouType, action, report, + moneyRequestReportTransactions, isDraftChatReport: !!reportDraft, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index 78f64a3cbcb8..5c346163b03c 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -200,7 +200,7 @@ function IOURequestStepDistanceMap({ }, [action]); const navigateToReuseRoutePage = () => { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_REUSE_ROUTE.getRoute(action, iouType, transactionID, reportID)); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_REUSE_ROUTE.getRoute(action, iouType, transactionID, reportID, backToReport)); }; useEffect(() => { diff --git a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx index 674b9e75b314..39a9dd8cff13 100644 --- a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx +++ b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx @@ -10,7 +10,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {GestureDetector, usePanGesture} from 'react-native-gesture-handler'; import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -36,16 +36,18 @@ function Slider({onSliderRatioChange}: SliderProps) { const minSliderRatio = useSharedValue(minRatio); const sliderWidthShared = useSharedValue(0); - const panGesture = Gesture.Pan().onChange((e) => { - 'worklet'; + const panGesture = usePanGesture({ + onUpdate: (e) => { + 'worklet'; - if (sliderWidthShared.get() === 0) { - return; - } + if (sliderWidthShared.get() === 0) { + return; + } - const newRatio = Math.min(1, Math.max(minSliderRatio.get(), sliderRatio.get() + e.changeX / sliderWidthShared.get())); - sliderRatio.set(newRatio); - scheduleOnRN(onSliderRatioChange, newRatio); + const newRatio = Math.min(1, Math.max(minSliderRatio.get(), sliderRatio.get() + e.changeX / sliderWidthShared.get())); + sliderRatio.set(newRatio); + scheduleOnRN(onSliderRatioChange, newRatio); + }, }); const thumbStyle = useAnimatedStyle(() => ({ diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx index 6253bfcf9abb..eb9e52bf50e6 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx @@ -30,6 +30,7 @@ import ScannerControlsBar from '@pages/iou/request/step/IOURequestStepScan/compo import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; import getCameraAspectRatio from '@pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio'; +import isCameraClosedError from '@pages/iou/request/step/IOURequestStepScan/isCameraClosedError'; import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper'; import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound'; import type {WithFullTransactionOrNotFoundProps} from '@pages/iou/request/step/withFullTransactionOrNotFound'; @@ -57,6 +58,7 @@ function IOURequestStepOdometerImage({ route: { params: {action, iouType, transactionID, reportID, backToReport, imageType, isEditingConfirmation}, }, + navigation, transaction, }: IOURequestStepOdometerImageProps) { const {translate} = useLocalize(); @@ -236,16 +238,26 @@ function IOURequestStepOdometerImage({ .catch((error: unknown) => { cancelSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_CAPTURE); isCapturingPhoto.current = false; - showCameraAlert(); + if (navigation.isFocused()) { + showCameraAlert(); + } Log.warn('Error cropping photo', error instanceof Error ? error.message : String(error)); }); }) .catch((error: unknown) => { cancelSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_CAPTURE); isCapturingPhoto.current = false; - showCameraAlert(); const errorMessage = error instanceof Error ? error.message : String(error); - Log.warn('Error taking photo', errorMessage); + const isScreenFocused = navigation.isFocused(); + // Leaving the screen closes the camera, which cancels a capture still in flight (e.g. a flash shot). + if (!isScreenFocused && isCameraClosedError(error)) { + Log.info('Photo capture cancelled by leaving the screen', false, {error: errorMessage}); + } else { + Log.warn('Error taking photo', errorMessage); + } + if (isScreenFocused) { + showCameraAlert(); + } }); }); }; diff --git a/src/pages/iou/request/step/IOURequestStepReuseRoute.tsx b/src/pages/iou/request/step/IOURequestStepReuseRoute.tsx index dbfac60b39f9..5910cb93b9ac 100644 --- a/src/pages/iou/request/step/IOURequestStepReuseRoute.tsx +++ b/src/pages/iou/request/step/IOURequestStepReuseRoute.tsx @@ -6,6 +6,7 @@ import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentU import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; @@ -19,15 +20,18 @@ import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; import {selectReusableRoute} from '@libs/actions/ReusableDistanceRoutes'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; -import {filterRoutes, getRouteEndpoints} from '@libs/ReusableDistanceRoutesUtils'; +import {filterRoutes, getAvailableReusableDistanceRoutes, getRouteEndpoints} from '@libs/ReusableDistanceRoutesUtils'; import {getRateID} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import type {ReusableDistanceRoute} from '@src/types/onyx'; +import type {ReusableDistanceRoute, Transaction} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -50,13 +54,14 @@ type IOURequestStepReuseRouteProps = WithCurrentUserPersonalDetailsProps & function IOURequestStepReuseRoute({ report, route: { - params: {action, iouType, transactionID, reportID}, + params: {action, iouType, transactionID, reportID, backToReport}, }, transaction, currentUserPersonalDetails, }: IOURequestStepReuseRouteProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const {isOffline} = useNetwork(); const {isBetaEnabled} = usePermissions(); const {policy} = usePolicyForTransaction({transaction, reportPolicyID: report?.policyID, action, iouType}); const personalPolicy = usePersonalPolicy(); @@ -70,6 +75,13 @@ function IOURequestStepReuseRoute({ const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); const [reusableDistanceRoutes] = useOnyx(ONYXKEYS.REUSABLE_DISTANCE_ROUTES); + const availableRoutesSelector = useCallback( + (transactions: OnyxCollection) => getAvailableReusableDistanceRoutes(reusableDistanceRoutes, transactions, isOffline), + [reusableDistanceRoutes, isOffline], + ); + const [availableRoutes] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { + selector: availableRoutesSelector, + }); const [isLoadingReusableDistanceRoutes] = useOnyx(ONYXKEYS.IS_LOADING_REUSABLE_DISTANCE_ROUTES); const [searchValue, setSearchValue] = useState(''); const [selectedRoute, setSelectedRoute] = useState(null); @@ -125,7 +137,7 @@ function IOURequestStepReuseRoute({ currentUserAccountID: currentUserPersonalDetails.accountID, currentUserLocalCurrency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, backTo: undefined, - backToReport: undefined, + backToReport, shouldSkipConfirmation, defaultExpensePolicy, isArchived, @@ -148,19 +160,21 @@ function IOURequestStepReuseRoute({ // eslint-disable-next-line react-hooks/exhaustive-deps -- navigateToNextStep is rebuilt every render, only a new selection should trigger navigation }, [selectedRoute]); + const distanceUnit = DistanceRequestUtils.getUpdatedDistanceUnit({transaction, policy}); + const selectRoute = (item: ReuseRouteListItemData) => { if (isNavigatingRef.current || selectedRoute) { return; } isNavigatingRef.current = true; - selectReusableRoute(transactionID, item.route, transaction?.comment?.waypoints) + selectReusableRoute(transactionID, item.route, transaction?.comment?.waypoints, distanceUnit) .then(() => setSelectedRoute(item.route)) .catch(() => { isNavigatingRef.current = false; }); }; - const routes = reusableDistanceRoutes ?? []; + const routes = availableRoutes ?? []; const filteredRoutes = filterRoutes(routes, searchValue); const data: ReuseRouteListItemData[] = filteredRoutes.map((route) => ({ route, @@ -181,31 +195,27 @@ function IOURequestStepReuseRoute({ testID="IOURequestStepReuseRoute" includeSafeAreaPaddingBottom > - {({didScreenTransitionEnd}) => { - if (!didScreenTransitionEnd) { - return null; - } - return ( - <> - {translate('distance.choosePreviousRoute')} - } - shouldShowListEmptyContent={false} - shouldSingleExecuteRowSelect - /> - - ); - }} + {({didScreenTransitionEnd}) => ( + <> + {translate('distance.choosePreviousRoute')} + } + shouldShowListEmptyContent={false} + shouldSingleExecuteRowSelect + disableMaintainingScrollPosition + /> + + )} ); } diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx index 0df861abdeab..8da685cea636 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx @@ -23,6 +23,7 @@ import ScannerControlsBar from '@pages/iou/request/step/IOURequestStepScan/compo import getCameraAspectRatio from '@pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio'; import useCameraInitTelemetry from '@pages/iou/request/step/IOURequestStepScan/hooks/useCameraInitTelemetry'; import usePhotoUpgrade from '@pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade'; +import isCameraClosedError from '@pages/iou/request/step/IOURequestStepScan/isCameraClosedError'; import startReceiptPrepareSpan from '@pages/iou/request/step/IOURequestStepScan/utils/startReceiptPrepareSpan'; import CONST from '@src/CONST'; @@ -30,6 +31,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type {PhotoFile} from 'react-native-vision-camera'; +import {useNavigation} from '@react-navigation/native'; import React, {useRef} from 'react'; import {Alert, Platform, View} from 'react-native'; import {RESULTS} from 'react-native-permissions'; @@ -53,6 +55,7 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou const {windowWidth, windowHeight} = useWindowDimensions(); const {isMultiScanEnabled, canUseMultiScan} = useMultiScanState(); const {toggleMultiScan} = useMultiScanActions(); + const navigation = useNavigation(); // Ref for double-tap protection (doesn't trigger re-render) const isCapturingPhoto = useRef(false); @@ -210,11 +213,20 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou onCapture(cameraFile, source); }) - .catch((error: string) => { + .catch((error: unknown) => { isCapturingPhoto.current = false; maybeCancelShutterSpan(); - showCameraAlert(); - Log.warn('Error taking photo', error); + const errorMessage = error instanceof Error ? error.message : String(error); + const isScreenFocused = navigation.isFocused(); + // Leaving the screen closes the camera, which cancels a capture still in flight (e.g. a flash shot). + if (!isScreenFocused && isCameraClosedError(error)) { + Log.info('Photo capture cancelled by leaving the screen', false, {error: errorMessage}); + } else { + Log.warn('Error taking photo', errorMessage); + } + if (isScreenFocused) { + showCameraAlert(); + } discardPendingPhoto(); }); diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx index da62ad57d6a2..0582fc4ec332 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -129,6 +129,7 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, translate, convertToDisplayString, dateFnsLocale, + formatPhoneNumber, }); const participantsPolicyTags = useParticipantsPolicyTags(participants); diff --git a/src/pages/iou/request/step/IOURequestStepScan/isCameraClosedError.ts b/src/pages/iou/request/step/IOURequestStepScan/isCameraClosedError.ts new file mode 100644 index 000000000000..f3b3c8526bce --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/isCameraClosedError.ts @@ -0,0 +1,19 @@ +// CameraX rejects an in-flight capture with this message when the camera closes, and vision-camera passes it through as `unknown/unknown`. +const CAMERA_CLOSED_MESSAGE = 'Camera is closed'; + +// vision-camera rejects with this code when the session is already gone by the time the capture reaches native. +const CAMERA_SESSION_GONE_CODE = 'session/camera-not-ready'; + +function isCameraClosedError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false; + } + + if ('code' in error && error.code === CAMERA_SESSION_GONE_CODE) { + return true; + } + + return 'message' in error && typeof error.message === 'string' && error.message.includes(CAMERA_CLOSED_MESSAGE); +} + +export default isCameraClosedError; diff --git a/src/pages/iou/request/step/IOURequestStepVendor.tsx b/src/pages/iou/request/step/IOURequestStepVendor.tsx index af98674fe953..2dca9fd0201d 100644 --- a/src/pages/iou/request/step/IOURequestStepVendor.tsx +++ b/src/pages/iou/request/step/IOURequestStepVendor.tsx @@ -1,3 +1,4 @@ +import ActivityIndicator from '@components/ActivityIndicator'; import BlockingView from '@components/BlockingViews/BlockingView'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; @@ -6,6 +7,7 @@ import type {ListItem} from '@components/SelectionList/types'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; @@ -13,9 +15,10 @@ import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateMoneyRequestVendor} from '@libs/actions/IOU/UpdateMoneyRequest'; +import {openPolicyVendorsPage} from '@libs/actions/Policy/Vendor'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; -import {getMatchingVendors, getVendorEmptyState, hasVendorFeature, isXeroActiveMatchingSource, sortVendors} from '@libs/PolicyUtils'; +import {getVendorEmptyState, hasVendorFeature, sortVendors} from '@libs/PolicyUtils'; import {isPerDiemRequest} from '@libs/TransactionUtils'; import variables from '@styles/variables'; @@ -24,7 +27,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -61,46 +64,81 @@ function IOURequestStepVendor({ }); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${getNonEmptyStringOnyxID(transactionID)}`); + const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policy?.id}`); const delegateAccountID = useDelegateAccountID(); const isVendorMatchingBetaEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING); const isFeatureAvailable = hasVendorFeature(policy, isVendorMatchingBetaEnabled); - const isOnXero = isXeroActiveMatchingSource(policy); + // Stale sessions may never have received the policyVendors_ collection, so fetch it if missing. + const fetchVendorsIfMissing = () => { + if (!isFeatureAvailable || !policy?.id || policyVendors !== undefined) { + return; + } + openPolicyVendorsPage(policy.id); + }; + + const {isOffline} = useNetwork({onReconnect: fetchVendorsIfMissing}); + + useEffect(() => { + fetchVendorsIfMissing(); + // Re-run when the policy loads or the collection is cleared, so a missing fetch retries. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isFeatureAvailable, policy?.id, policyVendors]); const emptyState = getVendorEmptyState(policy, translate); // Vendor is scoped to non-reimbursable expenses on a policy expense chat; block deep-link / stale-open access if the transaction is reimbursable or is an invoice (invoices are non-reimbursable but don't route through the vendor-matching flow). const isReimbursable = !!transaction?.reimbursable; const isInvoice = iouType === CONST.IOU.TYPE.INVOICE; - const vendors = getMatchingVendors(policy); - const sortedVendors = sortVendors(vendors, localeCompare); + const currentVendorID = transaction?.comment?.vendor?.externalID; - const vendorLabel = isOnXero ? translate('common.supplier') : translate('common.vendor'); + const currentVendor = currentVendorID ? policyVendors?.[currentVendorID] : undefined; + const isCurrentVendorDisabled = !!currentVendorID && policyVendors !== undefined && !currentVendor?.enabled; + + const isLoading = !isOffline && policyVendors === undefined; + + const enabledVendors = Object.values(policyVendors ?? {}).filter((vendor) => vendor.enabled); + const sortedEnabledVendors = sortVendors(enabledVendors, localeCompare); const trimmedSearch = searchValue.trim().toLowerCase(); - const vendorRows: VendorListItem[] = sortedVendors + const enabledRows: VendorListItem[] = sortedEnabledVendors .filter((vendor) => !trimmedSearch || vendor.name.toLowerCase().includes(trimmedSearch)) .map((vendor) => ({ - value: vendor.id, + value: vendor.externalID, text: vendor.name, - keyForList: vendor.id, - isSelected: vendor.id === currentVendorID, + keyForList: vendor.externalID, + isSelected: vendor.externalID === currentVendorID, searchText: vendor.name, })); - // When a vendor is currently set, offer a "None" row so the user can clear a stale (e.g. removed from the accounting integration) vendor without picking a replacement, which resolves an inactiveVendor violation. Hidden during search to keep results clean. + const disabledCurrentVendorRow: VendorListItem | undefined = + !trimmedSearch && isCurrentVendorDisabled + ? { + value: currentVendorID, + text: currentVendor?.name ?? transaction?.comment?.vendor?.name ?? '', + keyForList: currentVendorID, + isSelected: true, + searchText: currentVendor?.name ?? transaction?.comment?.vendor?.name ?? '', + alternateText: translate('common.disabled'), + } + : undefined; + const shouldShowNoneRow = !!currentVendorID && !trimmedSearch; - const data: VendorListItem[] = shouldShowNoneRow - ? [ - { - value: '', - text: translate('common.none'), - keyForList: 'clear-vendor', - isSelected: false, - searchText: '', - }, - ...vendorRows, - ] - : vendorRows; + + const data: VendorListItem[] = [ + ...(shouldShowNoneRow + ? [ + { + value: '', + text: translate('common.none'), + keyForList: 'clear-vendor', + isSelected: false, + searchText: '', + }, + ] + : []), + ...(disabledCurrentVendorRow ? [disabledCurrentVendorRow] : []), + ...enabledRows, + ]; const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, transaction) || !isFeatureAvailable || isReimbursable || isInvoice; @@ -130,7 +168,7 @@ function IOURequestStepVendor({ const headerMessage = searchValue && data.length === 0 ? translate('common.noResultsFound') : ''; const listEmptyContent = - vendors.length === 0 ? ( + enabledVendors.length === 0 && !disabledCurrentVendorRow ? ( + ); + } return ( { + const onSuccess = (file: FileObject) => { const receipt: Receipt = file; - // Rebuilding the receipt from disk makes a fresh File without the trace id from capture, so copy it + // Rebuilding the receipt from disk makes a fresh file object without the trace id from capture, so copy it // back from the saved draft. This keeps the capture, submit, and enqueue logs tied to one receipt. receipt.receiptTraceId = item.receipt?.receiptTraceId; if (item?.receipt?.isTestReceipt) { diff --git a/src/pages/iou/request/step/confirmation/submission/useDistanceSubmission.ts b/src/pages/iou/request/step/confirmation/submission/useDistanceSubmission.ts index 890acd1ec6b0..9bb700fe0e2e 100644 --- a/src/pages/iou/request/step/confirmation/submission/useDistanceSubmission.ts +++ b/src/pages/iou/request/step/confirmation/submission/useDistanceSubmission.ts @@ -5,7 +5,7 @@ import usePermissions from '@hooks/usePermissions'; import {getStringifiedGPSCoordinates} from '@libs/GPSDraftDetailsUtils'; import {getReusableP2PReportID} from '@libs/IOUUtils'; -import {getReportOrDraftReport, isMoneyRequestReport as isMoneyRequestReportReportUtils} from '@libs/ReportUtils'; +import {getChatReportFromMoneyRequestReport, isMoneyRequestReport as isMoneyRequestReportReportUtils} from '@libs/ReportUtils'; import markSubmitExpenseEnd from '@libs/telemetry/markSubmitExpenseEnd'; import {getDistanceRequestType, getIsFromGlobalCreate, getRateID, getSelectedRouteDistance, getValidWaypoints, hasAppliedCommuterExclusion} from '@libs/TransactionUtils'; @@ -64,6 +64,7 @@ type UseDistanceSubmissionParams = TransactionTaxValues & { distanceDraftData: DistanceDraftData; delegateAccountID: number | undefined; participantsPolicyTags: ParticipantsPolicyTags; + reportTransactions: Transaction[]; }; /** Hook implementing the distance-request submission path (CreateDistanceRequest) for the expense confirmation screen. */ @@ -99,6 +100,7 @@ function useDistanceSubmission({ distanceDraftData, delegateAccountID, participantsPolicyTags, + reportTransactions, }: UseDistanceSubmissionParams): SubmissionHandle { const {formatPhoneNumber} = useLocalize(); const {getCurrencyDecimals} = useCurrencyListActions(); @@ -111,15 +113,14 @@ function useDistanceSubmission({ const selectedParticipantsForRequest = getSelectedParticipantsForSubmission({transaction, iouType, selectedParticipants}); const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); - const currentChatReport = isMoneyRequestReport - ? getReportOrDraftReport(report?.chatReportID, undefined, undefined, reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${report?.chatReportID}`] ?? {}) - : report; + const currentChatReport = getChatReportFromMoneyRequestReport(report, reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${report?.chatReportID}`] ?? {}); const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReportID}`); const firstSelectedParticipantReportID = selectedParticipantsForRequest.at(0)?.reportID; const [selectedParticipantsReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${firstSelectedParticipantReportID}`); const iouReportPolicyID = (moneyRequestReportID ? moneyRequestReport?.policyID : undefined) ?? currentChatReport?.policyID ?? selectedParticipantsReport?.policyID; const [iouReportPolicyTagList] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${iouReportPolicyID}`); + const [reusableDistanceRoutes] = useOnyx(ONYXKEYS.REUSABLE_DISTANCE_ROUTES); function createTransaction({shouldHandleNavigation = true, writeBarrier}: CreateTransactionParams) { if (!transaction) { @@ -145,6 +146,7 @@ function useDistanceSubmission({ getCurrencyDecimals, writeBarrier, report, + moneyRequestReportTransactions: reportTransactions, participants: selectedParticipantsForRequest, optimisticChatReportID, currentUserLogin: currentUserPersonalDetails.login ?? '', @@ -199,6 +201,7 @@ function useDistanceSubmission({ formatPhoneNumber, participantsPolicyTags, rules, + reusableDistanceRoutes, }); const isExpenseReport = isMoneyRequestReportReportUtils(report); diff --git a/src/pages/iou/request/step/confirmation/submission/usePerDiemSubmission.ts b/src/pages/iou/request/step/confirmation/submission/usePerDiemSubmission.ts index 3132f3b1a9fc..760636724e6b 100644 --- a/src/pages/iou/request/step/confirmation/submission/usePerDiemSubmission.ts +++ b/src/pages/iou/request/step/confirmation/submission/usePerDiemSubmission.ts @@ -9,7 +9,13 @@ import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpe import dismissModalAndOpenReportInInboxTab from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab'; import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; -import {generateReportID, getReportOrDraftReport, hasViolations as hasViolationsReportUtils, isMoneyRequestReport as isMoneyRequestReportReportUtils} from '@libs/ReportUtils'; +import { + generateReportID, + getChatReportFromMoneyRequestReport, + getReportOrDraftReport, + hasViolations as hasViolationsReportUtils, + isMoneyRequestReport as isMoneyRequestReportReportUtils, +} from '@libs/ReportUtils'; import markSubmitExpenseEnd from '@libs/telemetry/markSubmitExpenseEnd'; import {getIsFromGlobalCreate} from '@libs/TransactionUtils'; @@ -176,7 +182,7 @@ function usePerDiemSubmission({ const isExpenseReport = isMoneyRequestReportReportUtils(report); let existingChatReport = report; if (isExpenseReport) { - existingChatReport = getReportOrDraftReport(report?.chatReportID, undefined, undefined, reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${report?.chatReportID}`] ?? {}); + existingChatReport = getChatReportFromMoneyRequestReport(report, reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${report?.chatReportID}`] ?? {}); } else if (!report?.reportID && participant.isPolicyExpenseChat && participant.reportID) { existingChatReport = getReportOrDraftReport(participant.reportID, undefined, undefined, reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${participant.reportID}`] ?? {}); } diff --git a/src/pages/iou/request/step/confirmation/submission/useSubmissionOnboardingIntent.ts b/src/pages/iou/request/step/confirmation/submission/useSubmissionOnboardingIntent.ts new file mode 100644 index 000000000000..6492680de4f2 --- /dev/null +++ b/src/pages/iou/request/step/confirmation/submission/useSubmissionOnboardingIntent.ts @@ -0,0 +1,22 @@ +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; + +import {isLookingAroundSearchRoutingActive} from '@libs/IOUUtils'; +import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +/** The onboarding choice flags that decide where a submitted expense lands. */ +function useSubmissionOnboardingIntent() { + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const {isOffline} = useNetwork(); + + return { + introSelected, + isTrackIntentUser: isTrackOnboardingChoice(introSelected?.choice), + isLookingAroundUser: isLookingAroundSearchRoutingActive(introSelected?.choice === CONST.ONBOARDING_CHOICES.LOOKING_AROUND, isOffline), + }; +} + +export default useSubmissionOnboardingIntent; diff --git a/src/pages/iou/request/step/confirmation/submission/useSubmitLock.ts b/src/pages/iou/request/step/confirmation/submission/useSubmitLock.ts new file mode 100644 index 000000000000..2c321935d263 --- /dev/null +++ b/src/pages/iou/request/step/confirmation/submission/useSubmitLock.ts @@ -0,0 +1,48 @@ +import type {RefObject} from 'react'; + +import {useRef, useState} from 'react'; + +type SubmitLock = { + /** Whether the user has confirmed the expense, which disables the confirm button. */ + isConfirmed: boolean; + + /** Sets `isConfirmed` directly, for flows that confirm outside `lockSubmission` (e.g. send money's dismiss-first submit). */ + setIsConfirmed: (isConfirmed: boolean) => void; + + /** Set once a submission starts and never reset on its own, so the navigator can't double-submit while it moves away. */ + formHasBeenSubmitted: RefObject; + + /** Hands the page back after a submit returned without writing, or every later tap is swallowed. */ + unlockSubmission: () => void; + + /** Marks the expense confirmed and takes the lock. Returns false when a submission already holds it. */ + lockSubmission: () => boolean; +}; + +/** Owns the confirmation's submit lock. */ +function useSubmitLock(): SubmitLock { + const [isConfirmed, setIsConfirmed] = useState(false); + const formHasBeenSubmitted = useRef(false); + + const unlockSubmission = () => { + formHasBeenSubmitted.current = false; + setIsConfirmed(false); + }; + + const lockSubmission = () => { + setIsConfirmed(true); + + // Don't let the form be submitted multiple times while the navigator is waiting to take the user to a different page + if (formHasBeenSubmitted.current) { + return false; + } + + formHasBeenSubmitted.current = true; + return true; + }; + + return {isConfirmed, setIsConfirmed, formHasBeenSubmitted, unlockSubmission, lockSubmission}; +} + +export default useSubmitLock; +export type {SubmitLock}; diff --git a/src/pages/iou/request/step/confirmation/submission/useTrackExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/submission/useTrackExpenseSubmission.ts index 0f6e06b25dff..3f7272862eb5 100644 --- a/src/pages/iou/request/step/confirmation/submission/useTrackExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/submission/useTrackExpenseSubmission.ts @@ -13,7 +13,7 @@ import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getStringifiedGPSCoordinates} from '@libs/GPSDraftDetailsUtils'; import {rand64} from '@libs/NumberUtils'; import {getNewAccountIDsAndLogins} from '@libs/PersonalDetailsUtils'; -import {generateReportID, getAllPolicyExpenseChatReportActions, getReportOrDraftReport, isMoneyRequestReport as isMoneyRequestReportReportUtils} from '@libs/ReportUtils'; +import {generateReportID, getAllPolicyExpenseChatReportActions, getChatReportFromMoneyRequestReport} from '@libs/ReportUtils'; import {getDistanceRequestType, getIsFromGlobalCreate, getRateID, getSelectedRouteDistance, getValidWaypoints} from '@libs/TransactionUtils'; import {trackExpense as trackExpenseIOUActions} from '@userActions/IOU/TrackExpense'; @@ -134,11 +134,9 @@ function useTrackExpenseSubmission({ const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const [reusableDistanceRoutes] = useOnyx(ONYXKEYS.REUSABLE_DISTANCE_ROUTES); - const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); - const currentChatReport = isMoneyRequestReport - ? getReportOrDraftReport(report?.chatReportID, undefined, undefined, reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${report?.chatReportID}`] ?? {}) - : report; + const currentChatReport = getChatReportFromMoneyRequestReport(report, reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${report?.chatReportID}`] ?? {}); // A self-DM destination passes `undefined` as the chat to trackExpense, which then resolves the chat to the self-DM — a real report that is never a draft const destinationChatReportID = isSelfDMDestination ? undefined : currentChatReport?.reportID; @@ -273,6 +271,7 @@ function useTrackExpenseSubmission({ delegateAccountID, rules, personalDetailsByLogins: employeePersonalDetails, + reusableDistanceRoutes, }); } performPostBatchCleanup({ diff --git a/src/pages/iou/request/step/confirmation/submission/utils/guardSubmission.ts b/src/pages/iou/request/step/confirmation/submission/utils/guardSubmission.ts new file mode 100644 index 000000000000..bb0deafbcad2 --- /dev/null +++ b/src/pages/iou/request/step/confirmation/submission/utils/guardSubmission.ts @@ -0,0 +1,29 @@ +import {getSpan} from '@libs/telemetry/activeSpans'; + +import type {CreateTransactionParams} from '@pages/iou/request/step/confirmation/submission/types'; +import type {SubmitLock} from '@pages/iou/request/step/confirmation/submission/useSubmitLock'; + +import CONST from '@src/CONST'; + +/** + * Wraps a submission path's `createTransaction` with the steps every path runs first: tag the submit span, let a + * distance block stop it, and lock the submission so a second tap can't submit again. + */ +function guardSubmission(submitLock: SubmitLock, submit: (params: CreateTransactionParams) => boolean, blockDistanceRequestIfNeeded?: () => boolean) { + return (params: CreateTransactionParams): boolean => { + getSpan(CONST.TELEMETRY.SPAN_SUBMIT_EXPENSE)?.setAttribute(CONST.TELEMETRY.ATTRIBUTE_LOCATION_SOURCE, CONST.TELEMETRY.SUBMIT_EXPENSE_LOCATION_SOURCE.NONE); + if (blockDistanceRequestIfNeeded?.()) { + return false; + } + + if (!submitLock.lockSubmission()) { + return false; + } + + // Telemetry spans (SPAN_SUBMIT_EXPENSE, SPAN_SUBMIT_TO_DESTINATION_VISIBLE) + // are started by SubmitExpenseOrchestrator before calling createTransaction. + return submit(params); + }; +} + +export default guardSubmission; diff --git a/src/pages/iou/request/step/confirmation/submitDismissStrategies.ts b/src/pages/iou/request/step/confirmation/submitDismissStrategies.ts index b70dfd8444d3..3ef19300640f 100644 --- a/src/pages/iou/request/step/confirmation/submitDismissStrategies.ts +++ b/src/pages/iou/request/step/confirmation/submitDismissStrategies.ts @@ -26,9 +26,8 @@ function dismissOnly(runAfterDismiss: () => void) { }); } -// runAfterDismiss (and therefore the API write) is gated on TransitionTracker rather than on this -// navigation's own callback, because dismissModalWithReport has no afterTransition hook. The write's -// barrier was armed by the caller before this ran, so it releases with the same transition. +// Gated on TransitionTracker, not dismissModalWithReport's afterTransition, which on the open-report path waits for the follow-up navigate. +// The write's barrier was armed by the caller before this ran, so it releases with the same transition. function dismissNarrowWithReport(reportID: string, runAfterDismiss: () => void) { setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY, reportID); Navigation.dismissModalWithReport({reportID}, undefined, { diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 811c4d8a0caa..764b719368d2 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -1,14 +1,11 @@ import useBlockDistanceRequest from '@hooks/useBlockDistanceRequest'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useParticipantsPolicyTags from '@hooks/useParticipantsPolicyTags'; import useReportTransactions from '@hooks/useReportTransactions'; -import {isLookingAroundSearchRoutingActive, isSelfDMSoleDestination} from '@libs/IOUUtils'; -import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; +import {isSelfDMSoleDestination} from '@libs/IOUUtils'; import {findSelfDMReportID} from '@libs/ReportUtils'; -import {getSpan} from '@libs/telemetry/activeSpans'; import {isGPSDistanceRequest as isGPSDistanceRequestTransactionUtils} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; @@ -24,9 +21,9 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {hasSeenTourSelector} from '@selectors/Onboarding'; -import {useRef, useState} from 'react'; import type {CreateTransactionParams} from './submission/types'; +import type {SubmitLock} from './submission/useSubmitLock'; import type {SubmissionPath} from './submission/utils/resolveSubmissionPath'; import useDistanceDraftData from './submission/useDistanceDraftData'; @@ -37,10 +34,12 @@ import usePerDiemSubmission from './submission/usePerDiemSubmission'; import useRequestMoneySubmission from './submission/useRequestMoneySubmission'; import useSendMoneySubmission from './submission/useSendMoneySubmission'; import useSplitSubmission from './submission/useSplitSubmission'; +import useSubmissionOnboardingIntent from './submission/useSubmissionOnboardingIntent'; import useSubmissionRecentlyUsedData from './submission/useSubmissionRecentlyUsedData'; import useSubmissionViolations from './submission/useSubmissionViolations'; import useTrackExpenseSubmission from './submission/useTrackExpenseSubmission'; import getTransactionTaxValues from './submission/utils/getTransactionTaxValues'; +import guardSubmission from './submission/utils/guardSubmission'; import {resolveSubmissionPath, SUBMISSION_PATH} from './submission/utils/resolveSubmissionPath'; type UseExpenseSubmissionParams = { @@ -96,6 +95,9 @@ type UseExpenseSubmissionParams = { * the pre-mounted report if validation then bails with no write. */ onExpenseWriteWillStart?: () => void; + + /** The page-owned submit lock, shared with the page's destination pre-mount. */ + submitLock: SubmitLock; }; function useExpenseSubmission(params: UseExpenseSubmissionParams) { @@ -129,24 +131,14 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { privateIsArchivedMap, backToReport, onExpenseWriteWillStart, + submitLock, } = params; - - const [isConfirmed, setIsConfirmed] = useState(false); - const formHasBeenSubmitted = useRef(false); - - // formHasBeenSubmitted is never reset on its own, so a submit that returns without writing has to hand the page back or every later tap is swallowed. - const releaseSubmitLock = () => { - formHasBeenSubmitted.current = false; - setIsConfirmed(false); - }; + const {setIsConfirmed, unlockSubmission} = submitLock; const isSelfDMDestination = isSelfDMSoleDestination(participants, iouType, currentUserPersonalDetails.accountID); const selectedParticipants = participants.filter((participant) => participant.selected); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); - const {isOffline} = useNetwork(); - const isLookingAroundUser = isLookingAroundSearchRoutingActive(introSelected?.choice === CONST.ONBOARDING_CHOICES.LOOKING_AROUND, isOffline); + const {introSelected, isTrackIntentUser, isLookingAroundUser} = useSubmissionOnboardingIntent(); const isTrackExpense = iouType === CONST.IOU.TYPE.TRACK; const isGPSDistanceRequest = isGPSDistanceRequestTransactionUtils(transaction); @@ -303,7 +295,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { selectedParticipants, iouType, isTrackIntentUser, - releaseSubmitLock, + releaseSubmitLock: unlockSubmission, transactionTaxCode, transactionTaxAmount, transactionTaxValue, @@ -348,6 +340,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { distanceDraftData, delegateAccountID, participantsPolicyTags, + reportTransactions, }); const perDiemSubmission = usePerDiemSubmission({ @@ -414,27 +407,14 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { [SUBMISSION_PATH.REQUEST_MONEY]: requestMoneySubmission.createTransaction, }; - function createTransaction({locationPermissionGranted = false, shouldHandleNavigation = true, writeBarrier}: CreateTransactionParams): boolean { - getSpan(CONST.TELEMETRY.SPAN_SUBMIT_EXPENSE)?.setAttribute(CONST.TELEMETRY.ATTRIBUTE_LOCATION_SOURCE, CONST.TELEMETRY.SUBMIT_EXPENSE_LOCATION_SOURCE.NONE); - if (blockDistanceRequestIfNeeded()) { - return false; - } - - setIsConfirmed(true); - - // Don't let the form be submitted multiple times while the navigator is waiting to take the user to a different page - if (formHasBeenSubmitted.current) { - return false; - } - - formHasBeenSubmitted.current = true; - - // Telemetry spans (SPAN_SUBMIT_EXPENSE, SPAN_SUBMIT_TO_DESTINATION_VISIBLE) - // are started by SubmitExpenseOrchestrator before calling createTransaction. - return submitByPath[submissionPath]({locationPermissionGranted, shouldHandleNavigation, writeBarrier}); - } + const createTransaction = guardSubmission( + submitLock, + ({locationPermissionGranted = false, shouldHandleNavigation = true, writeBarrier}: CreateTransactionParams) => + submitByPath[submissionPath]({locationPermissionGranted, shouldHandleNavigation, writeBarrier}), + blockDistanceRequestIfNeeded, + ); - return {createTransaction, sendMoney, isConfirmed, setIsConfirmed, formHasBeenSubmitted}; + return {createTransaction, sendMoney}; } export default useExpenseSubmission; diff --git a/src/pages/iou/updateSplitTransactionsFromSplitExpensesFlow.ts b/src/pages/iou/updateSplitTransactionsFromSplitExpensesFlow.ts index 40645eb1935a..c22b1c8b3d91 100644 --- a/src/pages/iou/updateSplitTransactionsFromSplitExpensesFlow.ts +++ b/src/pages/iou/updateSplitTransactionsFromSplitExpensesFlow.ts @@ -7,7 +7,7 @@ import {getChildTransactions} from '@libs/TransactionUtils'; import signalExpenseAddedGrowl from '@userActions/IOU/signalExpenseAddedGrowl'; import type {UpdateSplitTransactionsParams} from '@userActions/IOU/SplitTransactionUpdate'; -import {hasEditableSplitExpenseLeft, updateSplitTransactions} from '@userActions/IOU/SplitTransactionUpdate'; +import {hasEditableSplitExpenseLeft, updateSplitTransactions, willSplitUpdateEmptyExpenseReport} from '@userActions/IOU/SplitTransactionUpdate'; import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -32,7 +32,7 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const hasEditableSplitExpensesLeft = hasEditableSplitExpenseLeft(splitExpenses, params.allTransactionsList, params.allReportsList, currentSnapshotDataForSplits); // Unfiltered, so a pure selfDM 2-split still collapses via REVERT_SPLIT_TRANSACTION. The mixed - // workspace/selfDM case is guarded below via reverseSplitKeepsOriginalInExpenseReport instead. + // workspace/selfDM case is guarded below by willSplitUpdateEmptyExpenseReport instead. const isReverseSplitOperation = splitExpenses.length === 1 && allChildTransactions.length > 0 && hasEditableSplitExpensesLeft; // Newly created split transaction IDs, excluding ones already present in allChildTransactions. @@ -48,14 +48,10 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac // splits belonging to the current expense report, or the only remaining split moved to selfDM. // In any of these cases we must navigate away from the soon-to-be-empty report so the user // isn't stranded on a "Not Found" page. - const expenseReportTransactions = expenseReportID ? Object.values(params.allTransactionsList ?? {}).filter((itemTransaction) => itemTransaction?.reportID === expenseReportID) : []; - const areAllExpenseReportTransactionsSplitChildren = - expenseReportTransactions.length > 0 && expenseReportTransactions.every((itemTransaction) => itemTransaction?.comment?.originalTransactionID === originalTransactionID); - const anyRemainingSplitStaysInExpenseReport = splitExpenses.some((expense) => expense.reportID === expenseReportID); - const reverseSplitKeepsOriginalInExpenseReport = isReverseSplitOperation && splitExpenses.at(0)?.reportID === expenseReportID; - const willExpenseReportBecomeEmpty = - !!expenseReportID && areAllExpenseReportTransactionsSplitChildren && !anyRemainingSplitStaysInExpenseReport && !reverseSplitKeepsOriginalInExpenseReport; - const isLastTransactionInReport = willExpenseReportBecomeEmpty || (isReverseSplitOperation && !reverseSplitKeepsOriginalInExpenseReport && expenseReportTransactions.length === 1); + // Same check updateSplitTransactions uses to delete the report, so we never navigate back into a report it removes. It covers + // the reverse split too and skips transactions already pending deletion, so both sides agree on when the report is emptied. + // Creating splits never removes the report, so it's skipped here just like in updateSplitTransactions. + const isLastTransactionInReport = allChildTransactions.length > 0 && willSplitUpdateEmptyExpenseReport(expenseReportID, originalTransactionID, splitExpenses, params.allTransactionsList); const fallbackReportID = params.expenseReport?.chatReportID ?? params.expenseReport?.parentReportID; if (isLastTransactionInReport && fallbackReportID) { diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx index 11b096d48148..86f14694f1a2 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx @@ -115,24 +115,8 @@ function AttachmentModalBaseContent({ }); const fallbackFile = useMemo(() => (originalFileName ? {name: originalFileName} : undefined), [originalFileName]); - const [files, setFilesInternal] = useState(() => filesProp ?? fallbackFile); - const [isMultipleFiles, setIsMultipleFiles] = useState(() => Array.isArray(files)); - const fileToDisplay = useMemo(() => { - if (isMultipleFiles) { - return (files as FileObject[])?.at(fileToDisplayIndex); - } - return files as FileObject; - }, [files, fileToDisplayIndex, isMultipleFiles]); - - const setFile = useCallback((newFile: FileObject | FileObject[] | undefined) => { - if (Array.isArray(newFile)) { - setFilesInternal(newFile); - setIsMultipleFiles(true); - } else { - setFilesInternal(newFile); - setIsMultipleFiles(false); - } - }, []); + const [files, setFile] = useState(() => filesProp ?? fallbackFile); + const fileToDisplay = Array.isArray(files) ? files.at(fileToDisplayIndex) : files; useEffect(() => { setFile(filesProp ?? fallbackFile); @@ -179,7 +163,7 @@ function AttachmentModalBaseContent({ return; } - const confirm = () => onConfirm?.(Object.assign(files ?? {}, {source} as FileObject)); + const confirm = () => onConfirm?.(Object.assign(files ?? {}, {source})); // Sequence close and confirm so the screen underneath never flashes between them. On native the modal must close // first, otherwise the back navigation latches onto the confirm navigation and loops. diff --git a/src/pages/media/AttachmentModalScreen/index.tsx b/src/pages/media/AttachmentModalScreen/index.tsx index daaba46808f6..27d7cdd9c48d 100644 --- a/src/pages/media/AttachmentModalScreen/index.tsx +++ b/src/pages/media/AttachmentModalScreen/index.tsx @@ -7,6 +7,7 @@ import React, {useContext, useMemo} from 'react'; import type {AttachmentModalScreenProps, AttachmentModalScreenType} from './types'; import AttachmentModalContext from './AttachmentModalContext'; +import BillingReceiptModalContent from './routes/BillingReceiptModalContent'; import ProfileAvatarModalContent from './routes/ProfileAvatarModalContent'; import ReportAddAttachmentModalContent from './routes/report/ReportAddAttachmentModalContent'; import ReportAttachmentModalContent from './routes/report/ReportAttachmentModalContent'; @@ -88,6 +89,10 @@ function AttachmentModalScreen({route, ); } + if (route.name === SCREENS.BILLING_RECEIPT) { + return ; + } + if (route.name === SCREENS.REPORT_AVATAR) { return ( >(); + const navigation = useNavigation>(); + const [purchases] = useOnyx(ONYXKEYS.PURCHASES); + + const {purchaseID} = route.params; + const numericPurchaseID = Number(purchaseID); + const purchase = purchases?.[purchaseID]; + + useEffect(() => { + if (purchase?.isLoaded || isOffline) { + return; + } + openPaymentDetailsPage(numericPurchaseID); + }, [purchase?.isLoaded, isOffline, numericPurchaseID]); + + const source = getReceiptSource(purchases, numericPurchaseID) ?? ''; + const onDownloadAttachment = useDownloadAttachment({isAuthTokenRequired: true}); + + if (!isBetaEnabled(CONST.BETAS.PAYMENT_HISTORY)) { + return ; + } + + if (!purchase?.isLoaded) { + return ; + } + + const originalFileName = getReceiptFileName(source, numericPurchaseID); + const isInvoice = isInvoiceBilling(purchases, numericPurchaseID); + + const contentProps: AttachmentModalBaseContentProps = { + source, + originalFileName, + headerTitle: translate(isInvoice ? 'common.invoice' : 'common.receipt'), + isAuthTokenRequired: true, + shouldShowNotFoundPage: !source, + onDownloadAttachment, + shouldAllowDownloadOutsideReportContext: true, + shouldCloseOnSwipeDown: true, + }; + + return ( + + ); +} + +export default BillingReceiptModalContent; diff --git a/src/pages/media/AttachmentModalScreen/routes/ProfileAvatarModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/ProfileAvatarModalContent.tsx index 86006f97d34d..579ef1e83320 100644 --- a/src/pages/media/AttachmentModalScreen/routes/ProfileAvatarModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/ProfileAvatarModalContent.tsx @@ -43,9 +43,7 @@ function ProfileAvatarModalContent({navigation, route}: AttachmentModalScreenPro }, [accountID]); // Temp variables are coming as '' therefore || is needed - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const source = tempSource || getFullSizeAvatar({avatarSource: avatarURL, accountID, defaultAvatars}); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const originalFileName = tempOriginalFileName || (personalDetail?.originalFileName ?? ''); const shouldShowNotFoundPage = !avatarURL; diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index a76898e60477..4f28b9153b63 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -71,7 +71,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const {isBetaEnabledOrUnknown} = usePermissions(); const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); const {isOffline} = useNetwork(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Camera', 'Download', 'Crop', 'Trashcan', 'Rotate', 'Close', 'Checkmark']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Camera', 'Download', 'Crop', 'Trashcan', 'Rotate', 'Close', 'Checkmark', 'AttachmentNotFound']); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [hasReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: Boolean}); @@ -188,7 +188,8 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre }); const receiptFilename = transaction?.receipt?.filename; - const isStitchedOdometerReceipt = isOdometerDistanceRequest(transaction) && !imageType; + // Odometer photos are stitched into the receipt only while the expense is a draft, so a created expense's receipt can be replaced like any other + const isStitchedOdometerReceipt = isDraftTransaction && isOdometerDistanceRequest(transaction) && !imageType; const canEditReceiptButtons = ((canEditReceipt && !readonly) || isDraftTransaction) && !transaction?.receipt?.isTestDriveReceipt; const shouldShowReplaceReceiptButton = canEditReceiptButtons && !isStitchedOdometerReceipt; @@ -744,6 +745,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre footerActionButtons, customAttachmentContent, attachmentViewContainerStyles: [styles.pv5, styles.ph2], + fallbackSource: expensifyIcons.AttachmentNotFound, }), [ source, @@ -763,6 +765,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre customAttachmentContent, styles.pv5, styles.ph2, + expensifyIcons.AttachmentNotFound, ], ); diff --git a/src/pages/media/AttachmentModalScreen/types.ts b/src/pages/media/AttachmentModalScreen/types.ts index ca5f2047cf52..8f3740d9bbc3 100644 --- a/src/pages/media/AttachmentModalScreen/types.ts +++ b/src/pages/media/AttachmentModalScreen/types.ts @@ -25,6 +25,7 @@ type AttachmentModalScreenType = | typeof SCREENS.DYNAMIC_PROFILE_AVATAR | typeof SCREENS.WORKSPACE_AVATAR | typeof SCREENS.WORKSPACE_DOCUMENT + | typeof SCREENS.BILLING_RECEIPT | typeof SCREENS.TRANSACTION_RECEIPT | typeof SCREENS.MONEY_REQUEST.RECEIPT_PREVIEW | typeof SCREENS.MONEY_REQUEST.ODOMETER_PREVIEW diff --git a/src/pages/settings/AboutPage/AboutPage.tsx b/src/pages/settings/AboutPage/AboutPage.tsx index 3b1c6f495f5a..29eecc032c43 100644 --- a/src/pages/settings/AboutPage/AboutPage.tsx +++ b/src/pages/settings/AboutPage/AboutPage.tsx @@ -37,7 +37,7 @@ import type WithSentryLabel from '@src/types/utils/SentryLabel'; import type {ComponentRef} from 'react'; import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useCallback, useMemo, useRef} from 'react'; import {View} from 'react-native'; import DeviceInfo from 'react-native-device-info'; @@ -77,7 +77,9 @@ function AboutPage() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const menuItems = useMemo(() => { @@ -121,7 +123,15 @@ function AboutPage() { icon: icons.Bug, sentryLabel: CONST.SENTRY_LABEL.SETTINGS_ABOUT.REPORT_A_BUG, action: waitForNavigate(() => { - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); + navigateToConciergeChat({ + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + shouldDismissModal: false, + }); }), }, ]; @@ -148,7 +158,7 @@ function AboutPage() { wrapperStyle: [styles.sectionMenuItemTopDescription], sentryLabel, })); - }, [icons, styles, translate, waitForNavigate, conciergeReportID, introSelected, isSelfTourViewed, currentUserAccountID, conciergePersonalDetails]); + }, [icons, styles, translate, waitForNavigate, conciergeReportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, currentUserAccountID, conciergePersonalDetails]); const overlayContent = useCallback( () => ( diff --git a/src/pages/settings/Agents/AgentsPage.tsx b/src/pages/settings/Agents/AgentsPage.tsx index deb62181cbee..5d5302c1cc91 100644 --- a/src/pages/settings/Agents/AgentsPage.tsx +++ b/src/pages/settings/Agents/AgentsPage.tsx @@ -3,194 +3,63 @@ import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; -import {usePersonalDetails} from '@components/OnyxListItemProvider'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; -import type {TableHandle} from '@components/Table'; -import type {AgentRowData, AgentsTableColumnKey} from '@components/Tables/AgentsTable'; import AgentsTable from '@components/Tables/AgentsTable'; -import useChatWithAgent from '@hooks/useChatWithAgent'; -import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; -import useConfirmModal from '@hooks/useConfirmModal'; +import useAgents from '@hooks/useAgents'; import useDocumentTitle from '@hooks/useDocumentTitle'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; -import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; -import useSearchBackPress from '@hooks/useSearchBackPress'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; -import useSwitchToDelegator from '@hooks/useSwitchToDelegator'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; -import {getRuleBotEnforcedPolicy} from '@libs/AgentRulesUtils'; -import {getLatestError} from '@libs/ErrorUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; -import {clearAgentDeleteError, clearAgentError, clearAgentUpdateError, deleteAgent, openAgentsPage} from '@userActions/Agent'; - import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; -import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; function AgentsPage() { const {translate} = useLocalize(); const styles = useThemeStyles(); const {pageGutter} = useLayoutSpacing(); - const {isOffline} = useNetwork(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isMobileSelectionModeEnabled = useMobileSelectionMode(); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); + const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Trashcan']); - const chatWithAgent = useChatWithAgent(); - const switchToDelegator = useSwitchToDelegator(); - const {showConfirmModal} = useConfirmModal(); - const showRuleBotGuardModal = useRuleBotGuardModal(); - const [selectedAgents, setSelectedAgents] = useState([]); - const isMobileSelectionModeEnabled = useMobileSelectionMode(); - useDocumentTitle(translate('agentsPage.title')); - - const [agentPrompts] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const personalDetailsList = usePersonalDetails(); + const {agents, selectedAgentKeys, setSelectedAgents, clearSelectedAgents, askForConfirmationToDelete, tableRef} = useAgents(); + const hasAgents = agents.length > 0; const canSelectMultiple = shouldUseNarrowLayout ? isMobileSelectionModeEnabled : true; + const shouldShowBulkActionsButton = shouldUseNarrowLayout ? canSelectMultiple : selectedAgentKeys.length > 0; - useEffect(() => { - openAgentsPage(); - }, []); - - const handleErrorClose = (pendingAction: PendingAction | null | undefined, accountID: number) => { - if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { - clearAgentError(accountID); - } else if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - clearAgentDeleteError(accountID); - } else { - clearAgentUpdateError(accountID); - } - }; - - const agents: AgentRowData[] = Object.entries(agentPrompts ?? {}).flatMap(([key, agentPrompt]) => { - const accountID = Number(key.slice(ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT.length)); - const details = personalDetailsList?.[accountID]; - if (!details) { - return []; - } - const pendingAction = agentPrompt?.pendingAction; - const isPendingDeletion = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - - if (!isOffline && isPendingDeletion) { - return []; - } - - const mergedErrors = { - ...getLatestError(agentPrompt?.errors ?? undefined), - ...getLatestError(agentPrompt?.nameErrors ?? undefined), - ...getLatestError(agentPrompt?.promptErrors ?? undefined), - ...getLatestError(agentPrompt?.avatarErrors ?? undefined), - }; - const rowErrors = getLatestError(mergedErrors); - - return [ - { - keyForList: String(accountID), - accountID, - displayName: details.displayName ?? details.login ?? '', - login: details.login ?? '', - pendingAction, - errors: Object.keys(rowErrors).length > 0 ? rowErrors : undefined, - disabled: isPendingDeletion, - action: () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID)), - onChatPress: () => chatWithAgent(accountID), - onCopilotPress: () => switchToDelegator(details.login ?? ''), - dismissError: () => handleErrorClose(pendingAction, accountID), - }, - ]; - }); - - const tableRef = useRef>(null); - const agentKeys = agents.map((agent) => agent.keyForList); - const prevAgentKeys = usePrevious(agentKeys); - - // Highlight (and scroll to) a newly created agent's row once it appears in the table, mirroring - // the same pattern used for newly-invited workspace members (see WorkspaceMembersPage). Not - // gated on useIsFocused: on wide layouts this page is the central pane of a split navigator and - // stays visible (but unfocused per react-navigation) while the new agent's DM opens in the RHP. - useEffect(() => { - const newAgentKeys = agentKeys.filter((key) => !prevAgentKeys.includes(key)); - if (!newAgentKeys.length) { - return; - } - - const tableAgents = tableRef.current?.getProcessedData() ?? []; - const newAgentIndex = tableAgents.findIndex((agent) => newAgentKeys.includes(agent.keyForList)); - if (newAgentIndex !== -1) { - tableRef.current?.scrollToIndex({ - index: newAgentIndex, - animated: false, - viewPosition: 0.5, - }); - } - tableRef.current?.highlightItems(newAgentKeys); - }, [agentKeys, prevAgentKeys]); - - const agentsByAccountID = new Map(agents.map((agent) => [agent.keyForList, agent])); - const selectedAgentKeys = selectedAgents.filter((accountIDString) => { - const agent = agentsByAccountID.get(accountIDString); - return !!agent && agent.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - }); - - const clearSelectedAgents = () => { - setSelectedAgents((prevSelectedAgents) => (prevSelectedAgents.length > 0 ? [] : prevSelectedAgents)); - }; - - useCleanupSelectedOptions(clearSelectedAgents); - - useSearchBackPress({ - onClearSelection: clearSelectedAgents, - onNavigationCallBack: () => Navigation.goBack(), - }); - - const removeSelectedAgents = () => { - for (const accountIDString of selectedAgentKeys) { - const accountID = Number(accountIDString); - const agentLogin = personalDetailsList?.[accountID]?.login; - deleteAgent(accountID, agentLogin, allPolicies, false); - } - clearSelectedAgents(); - }; - - const askForConfirmationToDelete = async () => { - const ruleBotEnforcedPolicy = selectedAgentKeys.map((accountIDString) => getRuleBotEnforcedPolicy(Number(accountIDString), allPolicies)).find(Boolean); - if (ruleBotEnforcedPolicy) { - showRuleBotGuardModal('deleteAgent', ruleBotEnforcedPolicy.id); - return; - } - const result = await showConfirmModal({ - title: translate('agentsPage.deleteAgentsTitle', {count: selectedAgentKeys.length}), - prompt: translate('agentsPage.deleteAgentsMessage', {count: selectedAgentKeys.length}), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - shouldHandleNavigationBack: false, - }); - - if (result.action !== ModalActions.CONFIRM) { - return; - } + useDocumentTitle(translate('agentsPage.title')); - removeSelectedAgents(); - }; + const newAgentButton = hasAgents ? ( + + ) : null; + const emptyStateButtons = [ + { + icon: icons.Plus, + buttonText: translate('agentsPage.newAgent'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + buttonAction: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_NEW.getRoute())), + }, + ]; const bulkActionsButtonOptions: Array>> = [ { @@ -202,20 +71,6 @@ function AgentsPage() { }, ]; - const hasAgents = agents.length > 0; - const shouldShowBulkActionsButton = shouldUseNarrowLayout ? canSelectMultiple : selectedAgentKeys.length > 0; - const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; - - const newAgentButton = ( - - ); - const headerButtons = shouldShowBulkActionsButton ? ( > variant={CONST.BUTTON_VARIANT.SUCCESS} @@ -226,7 +81,6 @@ function AgentsPage() { options={bulkActionsButtonOptions} isSplitButton={false} isDisabled={!selectedAgentKeys.length} - testID="AgentsPage-header-dropdown-menu-button" /> ) : ( newAgentButton @@ -243,6 +97,17 @@ function AgentsPage() { ); + const headerContent = shouldDisplayButtonsInSeparateLine ? undefined : headerButtons; + + const goBackOrClearSelection = () => { + if (isMobileSelectionModeEnabled) { + clearSelectedAgents(); + turnOffMobileSelectionMode(); + return; + } + Navigation.goBack(); + }; + return ( { - if (isMobileSelectionModeEnabled) { - clearSelectedAgents(); - turnOffMobileSelectionMode(); - return; - } - Navigation.goBack(); - }} + onBackButtonPress={goBackOrClearSelection} shouldShowBackButton={shouldUseNarrowLayout} shouldUseHeadlineHeader={!selectionModeHeader} shouldDisplaySearchRouter shouldDisplayHelpButton title={selectionModeHeader ? translate('common.selectMultiple') : translate('agentsPage.title')} > - {!shouldDisplayButtonsInSeparateLine && headerButtons} + {headerContent} ); diff --git a/src/pages/settings/Agents/AddAgentPage.tsx b/src/pages/settings/Agents/DynamicAddAgentPage.tsx similarity index 52% rename from src/pages/settings/Agents/AddAgentPage.tsx rename to src/pages/settings/Agents/DynamicAddAgentPage.tsx index beb59393a788..7254ad98bba4 100644 --- a/src/pages/settings/Agents/AddAgentPage.tsx +++ b/src/pages/settings/Agents/DynamicAddAgentPage.tsx @@ -1,5 +1,6 @@ import UserAvatar from '@components/Avatar/UserAvatar'; import AvatarButtonWithIcon from '@components/AvatarButtonWithIcon'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; @@ -9,13 +10,17 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import ValuePicker from '@components/ValuePicker'; +import {getAssignableWorkspaceMemberRoleItems} from '@components/WorkspaceMemberRoleList'; import useBeforeRemove from '@hooks/useBeforeRemove'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useKeyboardState from '@hooks/useKeyboardState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -24,16 +29,19 @@ import {buildFileFromAvatarCropResult} from '@libs/AvatarCropUtils'; import {AGENT_AVATARS} from '@libs/Avatars/AgentAvatarCatalog'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; +import Log from '@libs/Log'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {canMemberWrite} from '@libs/PolicyUtils'; import type {AvatarSource} from '@libs/UserAvatarUtils'; -import {clearNewAgentAvatarDraft, clearNewAgentTemplate, createAgent, setNewAgentAvatarPreset} from '@userActions/Agent'; +import {clearNewAgentAvatarDraft, clearNewAgentTemplate, createAgent, createCompanyAgent, setNewAgentAvatarPreset} from '@userActions/Agent'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/AddAgentForm'; import type NewAgentTemplate from '@src/types/onyx/NewAgentTemplate'; @@ -48,19 +56,20 @@ import {Platform, View} from 'react-native'; import {PROMPT_MAX_AUTO_GROW_HEIGHT, PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE} from './const'; import scrollToMultilineInput from './scrollToMultilineInput'; -type AddAgentPageProps = PlatformStackScreenProps; +type DynamicAddAgentPageProps = PlatformStackScreenProps; -type AddAgentPageContentProps = { +type DynamicAddAgentPageContentProps = { /** Route params (policyID) forwarded from the screen */ - route: AddAgentPageProps['route']; + route: DynamicAddAgentPageProps['route']; /** Template picked in the "New agent" screen used to pre-fill the fields, or undefined for a blank agent */ template: NewAgentTemplate | undefined; }; -function AddAgentPageContent({route, template}: AddAgentPageContentProps) { +function DynamicAddAgentPageContent({route, template}: DynamicAddAgentPageContentProps) { const StyleUtils = useStyleUtils(); const policyID = route.params?.policyID; + const policy = usePolicy(policyID); const {translate} = useLocalize(); const styles = useThemeStyles(); const {windowWidth, windowHeight} = useWindowDimensions(); @@ -71,7 +80,7 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { const shouldAutoGrowPromptInput = Platform.OS !== 'web' && !isInLandscapeMode; const shouldUseScrollableLayout = shouldAutoGrowPromptInput || isInLandscapeMode; const shouldShrinkPromptInput = isInLandscapeMode && isKeyboardActive; - const {accountID: ownerAccountID, login: ownerLogin, displayName} = useCurrentUserPersonalDetails(); + const {accountID: currentUserAccountID, login: currentUserLogin, displayName} = useCurrentUserPersonalDetails(); const defaultAgentName = template?.name ?? (displayName ? translate('addAgentPage.defaultAgentName', displayName) : undefined); const defaultPrompt = template?.prompt ?? translate('addAgentPage.defaultPrompt'); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Pencil']); @@ -80,6 +89,16 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { const hasSubmittedRef = useRef(false); const hasLeftPageRef = useRef(false); const formRef = useRef(null); + const availableRoleItems = getAssignableWorkspaceMemberRoleItems(translate, undefined, policy, currentUserLogin ?? ''); + const defaultRole = availableRoleItems.at(0)?.value; + const roleOptions = availableRoleItems.map((roleItem) => ({ + label: roleItem.text, + value: roleItem.value, + description: roleItem.alternateText, + })); + const isAddingCompanyAgent = !!policyID; + const canWriteAgents = isAddingCompanyAgent ? canMemberWrite(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.AGENTS) : true; + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_ADD.path); const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { if (!('key' in event)) { @@ -133,6 +152,9 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { if (!values[INPUT_IDS.PROMPT].trim()) { errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired'); } + if (!values[INPUT_IDS.ROLE]) { + errors[INPUT_IDS.ROLE] = translate('common.error.fieldRequired'); + } return errors; }; @@ -140,13 +162,39 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { hasSubmittedRef.current = true; const firstName = values[INPUT_IDS.FIRST_NAME].trim() || defaultAgentName; const prompt = values[INPUT_IDS.PROMPT].trim(); - - // Pure optimistic flow: `createAgent` writes the agent and the owner<->agent DM to Onyx under a - // reportID it generates client-side, and CreateAgent creates the DM under that exact ID (see - // CreateAgent.cpp), so we can navigate to the DM immediately, online or offline, without waiting. - const {optimisticReportID, optimisticPersonalDetailPromise} = uploadedAvatar?.uri - ? createAgent(firstName, prompt, ownerAccountID, ownerLogin, undefined, buildFileFromAvatarCropResult(uploadedAvatar), uploadedAvatar.uri, policyID) - : createAgent(firstName, prompt, ownerAccountID, ownerLogin, selectedPresetID ?? AGENT_AVATARS.getRandomID(), undefined, undefined, policyID); + const role = values[INPUT_IDS.ROLE]; + + const avatarArgs = uploadedAvatar?.uri + ? {file: buildFileFromAvatarCropResult(uploadedAvatar), optimisticAvatarURI: uploadedAvatar.uri} + : {customExpensifyAvatarID: selectedPresetID ?? AGENT_AVATARS.getRandomID()}; + + let result: { + optimisticAccountID: number; + avatarURI: string | undefined; + optimisticReportID: string; + optimisticPersonalDetailPromise: Promise; + }; + if (isAddingCompanyAgent) { + if (!policy) { + Log.warn('Invalid policy is used to when trying to create company agent'); + return; + } + result = createCompanyAgent( + currentUserAccountID, + currentUserLogin, + prompt, + policy, + role, + firstName, + avatarArgs.customExpensifyAvatarID, + avatarArgs.file, + avatarArgs.optimisticAvatarURI, + ); + } else { + result = createAgent(firstName, prompt, currentUserAccountID, currentUserLogin, avatarArgs.customExpensifyAvatarID, avatarArgs.file, avatarArgs.optimisticAvatarURI); + } + const {optimisticReportID, optimisticPersonalDetailPromise} = result; + const backToAgentsListRoute = isAddingCompanyAgent ? ROUTES.WORKSPACE_AGENTS.getRoute(policy?.id) : ROUTES.SETTINGS_AGENTS; clearNewAgentTemplate(); @@ -155,6 +203,9 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { // regardless of window size. getIsNarrowLayout() reflects the actual window width. const isNarrowLayout = getIsNarrowLayout(); + // Pure optimistic flow: `createAgent/createCompanyAgent` writes the agent and the owner<->agent DM to Onyx under a + // reportID it generates client-side, and CreateAgent/CreateCompanyAgent creates the DM under that exact ID, + // so we can navigate to the DM immediately, online or offline, without waiting. optimisticPersonalDetailPromise.then(() => { // The user left the builder before the write resolved, so don't pull them into the DM from whatever screen is now active. if (hasLeftPageRef.current) { @@ -165,7 +216,7 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { if (isNarrowLayout) { // Reveal the DM under the modal before dismissing so we navigate directly to it in one animation, // instead of dismissing to the agents list first and navigating to the DM afterward. - Navigation.revealRouteBeforeDismissingModal(ROUTES.REPORT_WITH_ID.getRoute(optimisticReportID, undefined, undefined, ROUTES.SETTINGS_AGENTS), { + Navigation.revealRouteBeforeDismissingModal(ROUTES.REPORT_WITH_ID.getRoute(optimisticReportID, undefined, undefined, backToAgentsListRoute), { afterTransition: () => { clearNewAgentAvatarDraft(); }, @@ -198,89 +249,103 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { return ( - - Navigation.goBack(ROUTES.SETTINGS_AGENTS_NEW.getRoute(policyID ? {policyID} : undefined))} - /> - - - - - Navigation.navigate(ROUTES.SETTINGS_AGENTS_ADD_AVATAR)} - editIcon={expensifyIcons.Pencil} - editIconStyle={styles.smallEditIconAccount} - sentryLabel={CONST.SENTRY_LABEL.ADD_AGENT_PAGE.AVATAR} - /> - - + + Navigation.goBack(backPath)} /> - { - promptTopOffsetRef.current = event.nativeEvent.layout.y; - }} - > + + + + + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD_AVATAR.getRoute()))} + editIcon={expensifyIcons.Pencil} + editIconStyle={styles.smallEditIconAccount} + sentryLabel={CONST.SENTRY_LABEL.ADD_AGENT_PAGE.AVATAR} + /> + + {isAddingCompanyAgent && ( + + + + )} + { + promptTopOffsetRef.current = event.nativeEvent.layout.y; + }} + > + + + {`${translate('addAgentPage.copilotNote')} ${translate('workspace.rules.agentRules.disclaimer')}`} - {`${translate('addAgentPage.copilotNote')} ${translate('workspace.rules.agentRules.disclaimer')}`} - - + + ); } -function AddAgentPage({route}: AddAgentPageProps) { +function DynamicAddAgentPage({route}: DynamicAddAgentPageProps) { const [template, templateMetadata] = useOnyx(ONYXKEYS.NEW_AGENT_TEMPLATE); if (isLoadingOnyxValue(templateMetadata)) { @@ -288,13 +353,13 @@ function AddAgentPage({route}: AddAgentPageProps) { } return ( - ); } -AddAgentPage.displayName = 'AddAgentPage'; +DynamicAddAgentPage.displayName = 'DynamicAddAgentPage'; -export default AddAgentPage; +export default DynamicAddAgentPage; diff --git a/src/pages/settings/Agents/EditAgentPage.tsx b/src/pages/settings/Agents/DynamicEditAgentPage.tsx similarity index 87% rename from src/pages/settings/Agents/EditAgentPage.tsx rename to src/pages/settings/Agents/DynamicEditAgentPage.tsx index 1a950b1497a2..1084def7e73e 100644 --- a/src/pages/settings/Agents/EditAgentPage.tsx +++ b/src/pages/settings/Agents/DynamicEditAgentPage.tsx @@ -11,6 +11,7 @@ import ScrollView from '@components/ScrollView'; import useChatWithAgent from '@hooks/useChatWithAgent'; import useConfirmModal from '@hooks/useConfirmModal'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -21,6 +22,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearAgentAvatarUpdateError, clearAgentNameUpdateError, clearAgentPromptUpdateError, deleteAgent} from '@libs/actions/Agent'; import {getRuleBotEnforcedPolicy} from '@libs/AgentRulesUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -31,21 +33,21 @@ import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {Str} from 'expensify-common'; import React from 'react'; import {View} from 'react-native'; -type EditAgentPageProps = PlatformStackScreenProps; +type DynamicEditAgentPageProps = PlatformStackScreenProps; -function EditAgentPage({route}: EditAgentPageProps) { +function DynamicEditAgentPage({route}: DynamicEditAgentPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['Trashcan', 'ChatBubble', 'MagnifyingGlass', 'Users']); const accountID = route.params.accountID; - const [agent, agentMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); + const [agent, agentMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`); const [personalDetails, personalDetailsMetadata] = usePersonalDetail(accountID); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {showConfirmModal} = useConfirmModal(); @@ -55,12 +57,13 @@ function EditAgentPage({route}: EditAgentPageProps) { const isOnyxLoaded = agentMetadata.status === 'loaded' && personalDetailsMetadata.status === 'loaded'; const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const shouldShowNotFoundPage = isLoadingApp === false && isOnyxLoaded && !agent && !personalDetails; + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_EDIT.path); const agentLogin = personalDetails?.login ?? ''; - const handleBackPress = () => Navigation.goBack(); - const handleEditAvatarPress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_AVATAR.getRoute(accountID)); - const handleEditNamePress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_NAME.getRoute(accountID)); - const handleEditPromptPress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_PROMPT.getRoute(accountID)); + const handleBackPress = () => Navigation.goBack(backPath); + const handleEditAvatarPress = () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT_AVATAR.getRoute())); + const handleEditNamePress = () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT_NAME.getRoute())); + const handleEditPromptPress = () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT_PROMPT.getRoute())); const handleDeletePress = async () => { const ruleBotEnforcedPolicy = getRuleBotEnforcedPolicy(accountID, allPolicies); if (ruleBotEnforcedPolicy) { @@ -78,7 +81,7 @@ function EditAgentPage({route}: EditAgentPageProps) { if (result.action !== ModalActions.CONFIRM) { return; } - deleteAgent(accountID, agentLogin, allPolicies); + deleteAgent(accountID, agentLogin, allPolicies, true, backPath); }; const isPendingAddOrDelete = agent?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD || agent?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const areActionsDisabled = isPendingAddOrDelete || accountID <= 0 || !agentLogin; @@ -111,7 +114,7 @@ function EditAgentPage({route}: EditAgentPageProps) { return ( @@ -187,6 +190,6 @@ function EditAgentPage({route}: EditAgentPageProps) { ); } -EditAgentPage.displayName = 'EditAgentPage'; +DynamicEditAgentPage.displayName = 'DynamicEditAgentPage'; -export default EditAgentPage; +export default DynamicEditAgentPage; diff --git a/src/pages/settings/Agents/NewAgentPage.tsx b/src/pages/settings/Agents/DynamicNewAgentPage.tsx similarity index 81% rename from src/pages/settings/Agents/NewAgentPage.tsx rename to src/pages/settings/Agents/DynamicNewAgentPage.tsx index cc36f4ffef38..aadb3e3884fb 100644 --- a/src/pages/settings/Agents/NewAgentPage.tsx +++ b/src/pages/settings/Agents/DynamicNewAgentPage.tsx @@ -1,5 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import BlockingView from '@components/BlockingViews/BlockingView'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; @@ -7,23 +8,28 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import usePolicy from '@hooks/usePolicy'; import useStyleUtils from '@hooks/useStyleUtils'; import useSuggestedAgents from '@hooks/useSuggestedAgents'; import useThemeStyles from '@hooks/useThemeStyles'; import {AGENT_AVATARS} from '@libs/Avatars/AgentAvatarCatalog'; import type {AgentAvatarID} from '@libs/Avatars/AgentAvatarCatalog'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {canMemberWrite} from '@libs/PolicyUtils'; import {clearNewAgentAvatarDraft, clearNewAgentTemplate, setNewAgentTemplate, getAgentTemplates} from '@userActions/Agent'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type SuggestedAgent from '@src/types/onyx/SuggestedAgent'; @@ -73,20 +79,25 @@ function AgentTemplateCard({template, avatarID, onAdd}: AgentTemplateCardProps) ); } -type NewAgentPageProps = PlatformStackScreenProps; +type DynamicNewAgentPageProps = PlatformStackScreenProps; -function NewAgentPage({route}: NewAgentPageProps) { +function DynamicNewAgentPage({route}: DynamicNewAgentPageProps) { const policyID = route.params?.policyID; + const policy = usePolicy(policyID); const {translate} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); const icons = useMemoizedLazyExpensifyIcons(['Bot']); const illustrations = useMemoizedLazyIllustrations(['Lightbulb']); const {data: templates, isLoading} = useSuggestedAgents(); + const {login: currentUserLogin} = useCurrentUserPersonalDetails(); const hasTemplates = templates.length > 0; const shouldShowLoadingIndicator = isLoading && !hasTemplates && !isOffline; const shouldShowEmptyState = !hasTemplates && (!isLoading || isOffline); + const isAddingCompanyAgent = !!policyID; + const canWriteAgents = isAddingCompanyAgent ? canMemberWrite(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.AGENTS) : true; + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_NEW.path); useEffect(() => { if (isOffline) { @@ -98,14 +109,14 @@ function NewAgentPage({route}: NewAgentPageProps) { const handleBuildCustomAgent = () => { // Start from scratch — drop any previously stashed template and avatar draft. Promise.all([clearNewAgentTemplate(), clearNewAgentAvatarDraft()]).then(() => { - Navigation.navigate(ROUTES.SETTINGS_AGENTS_ADD.getRoute(policyID ? {policyID} : undefined)); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD.getRoute(policyID ? {policyID} : undefined))); }); }; const handleAddTemplate = (template: SuggestedAgent, avatarID: AgentAvatarID) => { // Stash the template in Onyx (persists across refresh) and drop any stale avatar draft. Promise.all([setNewAgentTemplate({name: template.name, prompt: template.prompt, avatarID}), clearNewAgentAvatarDraft()]).then(() => { - Navigation.navigate(ROUTES.SETTINGS_AGENTS_ADD.getRoute(policyID ? {policyID} : undefined)); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD.getRoute(policyID ? {policyID} : undefined))); }); }; @@ -177,20 +188,22 @@ function NewAgentPage({route}: NewAgentPageProps) { return ( - Navigation.goBack()} - /> - {body} + + Navigation.goBack(backPath)} + /> + {body} + ); } -NewAgentPage.displayName = 'NewAgentPage'; +DynamicNewAgentPage.displayName = 'DynamicNewAgentPage'; -export default NewAgentPage; +export default DynamicNewAgentPage; diff --git a/src/pages/settings/Agents/Fields/AddAgentAvatarPage.tsx b/src/pages/settings/Agents/Fields/DynamicAddAgentAvatarPage.tsx similarity index 81% rename from src/pages/settings/Agents/Fields/AddAgentAvatarPage.tsx rename to src/pages/settings/Agents/Fields/DynamicAddAgentAvatarPage.tsx index 4682d0128bc0..8e8e6c870505 100644 --- a/src/pages/settings/Agents/Fields/AddAgentAvatarPage.tsx +++ b/src/pages/settings/Agents/Fields/DynamicAddAgentAvatarPage.tsx @@ -1,5 +1,6 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useOnyx from '@hooks/useOnyx'; import Log from '@libs/Log'; @@ -8,19 +9,19 @@ import Navigation from '@libs/Navigation/Navigation'; import {setNewAgentAvatarPreset, setNewAgentUploadedAvatar} from '@userActions/Agent'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {useNavigation} from '@react-navigation/native'; import React from 'react'; -import type {OnSaveParams} from './EditAgentAvatarPage'; +import type {OnSaveParams} from './DynamicEditAgentAvatarPage'; -import {EditAgentAvatarContent} from './EditAgentAvatarPage'; +import {EditAgentAvatarContent} from './DynamicEditAgentAvatarPage'; -function AddAgentAvatarPage() { +function DynamicAddAgentAvatarPage() { const navigation = useNavigation(); - const returnRoute = ROUTES.SETTINGS_AGENTS_ADD.getRoute(); + const returnRoute = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_ADD_AVATAR.path); const [avatarDraft, avatarDraftMetadata] = useOnyx(ONYXKEYS.AGENT_NEW_AVATAR_DRAFT); const initialPresetID = avatarDraft?.customExpensifyAvatarID; @@ -55,4 +56,4 @@ function AddAgentAvatarPage() { ); } -export default AddAgentAvatarPage; +export default DynamicAddAgentAvatarPage; diff --git a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx b/src/pages/settings/Agents/Fields/DynamicEditAgentAvatarPage.tsx similarity index 94% rename from src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx rename to src/pages/settings/Agents/Fields/DynamicEditAgentAvatarPage.tsx index 14443666d669..e982f779ac2c 100644 --- a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx +++ b/src/pages/settings/Agents/Fields/DynamicEditAgentAvatarPage.tsx @@ -11,6 +11,7 @@ import Text from '@components/Text'; import useAvatarCrop from '@hooks/useAvatarCrop'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; @@ -30,7 +31,7 @@ import {updateAgentAvatar} from '@userActions/Agent'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -38,7 +39,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; -type EditAgentAvatarPageProps = PlatformStackScreenProps; +type DynamicEditAgentAvatarPageProps = PlatformStackScreenProps; type ImageData = { uri: string; @@ -231,18 +232,19 @@ function EditAgentAvatarContent({accountID, fallbackRoute, onSave, initialPreset EditAgentAvatarContent.displayName = 'EditAgentAvatarContent'; -function EditAgentAvatarPage({route}: EditAgentAvatarPageProps) { +function DynamicEditAgentAvatarPage({route}: DynamicEditAgentAvatarPageProps) { const {accountID} = route.params; + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_EDIT_AVATAR.path); return ( ); } -EditAgentAvatarPage.displayName = 'EditAgentAvatarPage'; +DynamicEditAgentAvatarPage.displayName = 'DynamicEditAgentAvatarPage'; export type {OnSaveParams}; export {EditAgentAvatarContent}; -export default EditAgentAvatarPage; +export default DynamicEditAgentAvatarPage; diff --git a/src/pages/settings/Agents/Fields/EditNamePage.tsx b/src/pages/settings/Agents/Fields/DynamicEditNamePage.tsx similarity index 80% rename from src/pages/settings/Agents/Fields/EditNamePage.tsx rename to src/pages/settings/Agents/Fields/DynamicEditNamePage.tsx index 092d13abb51f..208db2aaaa82 100644 --- a/src/pages/settings/Agents/Fields/EditNamePage.tsx +++ b/src/pages/settings/Agents/Fields/DynamicEditNamePage.tsx @@ -6,6 +6,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -17,37 +18,38 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/EditAgentNameForm'; import React from 'react'; -type EditNamePageProps = PlatformStackScreenProps; +type DynamicEditNamePageProps = PlatformStackScreenProps; -function EditNamePage({route}: EditNamePageProps) { +function DynamicEditNamePage({route}: DynamicEditNamePageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const accountID = route.params.accountID; const [personalDetails] = usePersonalDetail(accountID); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_EDIT_NAME.path); const {inputCallbackRef} = useAutoFocusInput(); const handleSubmit = (values: FormOnyxValues) => { updateAgentName(accountID, values[INPUT_IDS.FIRST_NAME].trim(), personalDetails?.displayName ?? ''); - Navigation.goBack(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID)); + Navigation.goBack(backPath); }; return ( Navigation.goBack(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID))} + onBackButtonPress={() => Navigation.goBack(backPath)} /> ; +type DynamicEditPromptPageProps = PlatformStackScreenProps; -function EditPromptPage({route}: EditPromptPageProps) { +function DynamicEditPromptPage({route}: DynamicEditPromptPageProps) { const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -46,10 +48,11 @@ function EditPromptPage({route}: EditPromptPageProps) { const shouldUseScrollableLayout = shouldAutoGrowPromptInput || isInLandscapeMode; const shouldShrinkPromptInput = isInLandscapeMode && isKeyboardActive; const accountID = route.params.accountID; - const [agentPrompt] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); + const [agentPrompt] = useOnyx(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`); const formRef = useRef(null); const promptTopOffsetRef = useRef(0); const scrollToInput = () => scrollToMultilineInput(formRef, shouldUseScrollableLayout, promptTopOffsetRef.current); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_EDIT_PROMPT.path); const validate = (values: FormOnyxValues): FormInputErrors => { const errors: FormInputErrors = {}; @@ -61,7 +64,7 @@ function EditPromptPage({route}: EditPromptPageProps) { const handleSubmit = (values: FormOnyxValues) => { updateAgentPrompt(accountID, values[INPUT_IDS.PROMPT].trim(), agentPrompt?.prompt ?? ''); - Navigation.goBack(); + Navigation.goBack(backPath); }; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, (e) => { @@ -85,7 +88,7 @@ function EditPromptPage({route}: EditPromptPageProps) { return ( Navigation.goBack()} + onBackButtonPress={() => Navigation.goBack(backPath)} /> (null); const switchToDelegator = useSwitchToDelegator(); @@ -259,8 +261,9 @@ function CopilotPage() { const delegateMenuItems: MenuItemProps[] = useMemo(() => { return filteredDelegates.map(({email, role, pendingAction, pendingFields}) => { const personalDetail = personalDetailsByLogin[email.toLowerCase()]; - const addDelegateErrors = errorFields?.addDelegate?.[email]; - const error = getLatestError(addDelegateErrors); + const addDelegateError = getLatestError(errorFields?.addDelegate?.[email]); + const removeDelegateError = getLatestError(errorFields?.removeDelegate?.[email]); + const error = getLatestError({...addDelegateError, ...removeDelegateError}); const isOwnerRow = isAgentAccount === true && !!actingDelegateEmail && email.toLowerCase() === actingDelegateEmail; const isPendingDelete = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; @@ -293,7 +296,10 @@ function CopilotPage() { shouldShowRightIcon: !isOwnerRow && !isPendingDelete, pendingAction, shouldForceOpacity: !!pendingAction, - onPendingActionDismiss: () => clearDelegateErrorsByField({email, fieldName: 'addDelegate', delegatedAccess: account?.delegatedAccess}), + onPendingActionDismiss: () => { + clearDelegateErrorsByField({email, fieldName: 'addDelegate', delegatedAccess: account?.delegatedAccess}); + clearDelegateErrorsByField({email, fieldName: 'removeDelegate', delegatedAccess: account?.delegatedAccess}); + }, error, disabled: isPendingDelete, onPress: isOwnerRow ? undefined : onPress, @@ -395,7 +401,16 @@ function CopilotPage() { showDelegateNoAccessModal(); return; } - removeDelegate({email: selectedDelegate?.email ?? '', delegatedAccess: account?.delegatedAccess}); + if (selectedDelegate?.email === account?.delegatedAccess?.delegate && isActingAsDelegate) { + removeSelfAsDelegate({ + delegatorEmail: session?.email ?? '', + delegateEmail: selectedDelegate?.email ?? '', + stashedCredentials, + stashedSession, + }); + } else { + removeDelegate({email: selectedDelegate?.email ?? '', delegatedAccess: account?.delegatedAccess}); + } setSelectedDelegate(undefined); } }); diff --git a/src/pages/settings/EmailIssue/index.tsx b/src/pages/settings/EmailIssue/index.tsx index fc65099fe46a..363fc8aa439b 100644 --- a/src/pages/settings/EmailIssue/index.tsx +++ b/src/pages/settings/EmailIssue/index.tsx @@ -26,7 +26,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {useIsFocused} from '@react-navigation/native'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useEffect} from 'react'; import {View} from 'react-native'; @@ -42,7 +42,9 @@ function EmailIssuePage() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const hasEmailDeliveryFailure = !!account?.hasEmailDeliveryFailure; @@ -122,7 +124,15 @@ function EmailIssuePage() { + ) : null; + const emptyStateButtons = canWriteAgents + ? [ + { + icon: icons.Plus, + buttonText: translate('agentsPage.newAgent'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + buttonAction: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_NEW.getRoute())), + }, + ] + : undefined; + + const bulkActionsButtonOptions: Array>> = [ + { + text: translate('agentsPage.deleteAgentsTitle', {count: selectedAgentKeys.length}), + value: CONST.AGENTS.BULK_ACTION_TYPES.DELETE, + icon: icons.Trashcan, + shouldSkipFocusRestore: true, + onSelected: askForConfirmationToDelete, + }, + ]; + + const headerButtons = shouldShowBulkActionsButton ? ( + > + variant={CONST.BUTTON_VARIANT.SUCCESS} + shouldAlwaysShowDropdownMenu + customText={translate('workspace.common.selected', {count: selectedAgentKeys.length})} + size={CONST.BUTTON_SIZE.MEDIUM} + onPress={() => null} + options={bulkActionsButtonOptions} + isSplitButton={false} + isDisabled={!selectedAgentKeys.length} + /> + ) : ( + newAgentButton + ); + const agentsTableHeaderComponent = ( + <> + {shouldDisplayButtonsInSeparateLine && {headerButtons}} + {hasAgents && ( + + + + )} + + ); + + const headerContent = shouldDisplayButtonsInSeparateLine ? undefined : headerButtons; + + const goBackOrClearSelection = () => { + if (isMobileSelectionModeEnabled) { + clearSelectedAgents(); + turnOffMobileSelectionMode(); + return; + } + Navigation.goBack(); + }; + + return ( + + + + + + ); +} + +export default WorkspaceAgentsPage; diff --git a/src/pages/workspace/WorkspaceConfirmationSuccessPage.tsx b/src/pages/workspace/WorkspaceConfirmationSuccessPage.tsx index 6da83ce792a0..3ab5e1333431 100644 --- a/src/pages/workspace/WorkspaceConfirmationSuccessPage.tsx +++ b/src/pages/workspace/WorkspaceConfirmationSuccessPage.tsx @@ -1,4 +1,4 @@ -import ConfirmationPage from '@components/ConfirmationPage'; +import ConfirmationPageDefault from '@components/ConfirmationPage/composed/ConfirmationPageDefault'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -25,11 +25,10 @@ function WorkspaceConfirmationSuccessPage() { title={translate('workspace.new.confirmWorkspace')} onBackButtonPress={closePage} /> - diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 013aeabeba37..f7c6489b674d 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -2,13 +2,15 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; import useHasApprovalWorkflowWithNonMemberApprover from '@hooks/useHasApprovalWorkflowWithNonMemberApprover'; import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; @@ -75,11 +77,14 @@ function dismissError(policyID: string | undefined, pendingAction: PendingAction function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: WorkspaceInitialPageProps) { const styles = useThemeStyles(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.WORKSPACES); + const tabRootScrollProps = useTabRootScrollProps(styles.flexColumn); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const {isBetaEnabled} = usePermissions(); const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const isFocused = useIsFocused(); const isWorkspacesTabFocused = useIsWorkspacesTabFocused(); @@ -191,6 +196,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac policy, policyID, currentUserLogin: currentUserLogin ?? undefined, + currentUserPersonalDetails, icons: expensifyIcons, isConnectionInProgress: isConnectionInProgress(connectionSyncProgress, policy), policyCategories, @@ -201,6 +207,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac hasApprovalWorkflowWithNonMemberApprover, isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), isRecruitingBetaEnabled: isBetaEnabled(CONST.BETAS.MERGE_ATS), + isCompanyAgentsBetaEnabled: isBetaEnabled(CONST.BETAS.COMPANY_AGENTS), isUnifiedConnectionsBetaEnabled, convertToDisplayString, }).map((item) => ({ @@ -257,8 +264,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac } - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 503d07628eb1..18805ee36440 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -2,7 +2,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption, WorkspaceMemberBulkActionType} from '@components/ButtonWithDropdownMenu/types'; -import DecisionModal from '@components/DecisionModal'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; import MessagesRow from '@components/MessagesRow'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -12,6 +11,7 @@ import WorkspaceMembersTable from '@components/Tables/WorkspaceMembersTable'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import useAlertModals from '@hooks/useAlertModals'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; @@ -21,6 +21,7 @@ import useMergeSyncResultsPage from '@hooks/useMergeSyncResultsPage'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; import usePrevious from '@hooks/usePrevious'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; @@ -30,6 +31,7 @@ import useSearchBackPress from '@hooks/useSearchBackPress'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; +import useWorkspaceMembers from '@hooks/useWorkspaceMembers'; import {isConnectionInProgress, syncConnection} from '@libs/actions/connections'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -41,9 +43,9 @@ import { clearUpdateMemberRoleError, clearWorkspaceOwnerChangeFlow, downloadMembersCSV, - openWorkspaceMembersPage, removeMembers, - updateWorkspaceMembersRole, + setMembersSelectedForRoleChange, + setWorkspaceMembersSelectedForBulkEdit, } from '@libs/actions/Policy/Member'; import {removeApprovalWorkflow as removeApprovalWorkflowAction, updateApprovalWorkflow} from '@libs/actions/Workflow'; import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; @@ -54,33 +56,32 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; +import {getAssignableOfficeLocations, getMemberOfficeLocation, hasOfficeLocations} from '@libs/OfficeLocationUtils'; import {isPersonalDetailsReady} from '@libs/OptionsListUtils'; import {getPersonalDetailsByID, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {isPolicyReimburser} from '@libs/PolicyMemberRoleUtils'; import { + arePaymentsEnabled, canEditWorkspaceSettings as canEditWorkspaceSettingsUtil, canMemberAssignRole, canMemberManageMemberWithRole, - canMemberRead, canMemberWrite, canRolePay, getConnectionExporters, getMemberAccountIDsForWorkspace, getReimburserEmail, + getSelectableRoles, hasActiveExpensifyCard, isControlPolicy, - isDeletedPolicyEmployee, - isExpensifyTeam, isGroupPolicy, isPaidGroupPolicy, + isPolicyAdmin, isPolicyApprover, isSubmitPolicy, PAYER_ROLES, - shouldFilterExpensifyTeam, } from '@libs/PolicyUtils'; import {getDisplayNameForParticipant, isApproverOfOutstandingPolicyReports} from '@libs/ReportUtils'; import getShouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; -import {generateAccountID} from '@libs/UserUtils'; import {convertPolicyEmployeesToApprovalWorkflows, updateWorkflowDataOnApproverRemoval} from '@libs/WorkflowUtils'; import {close} from '@userActions/Modal'; @@ -89,16 +90,16 @@ import {dismissAddedWithPrimaryLoginMessages} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {PersonalDetails, PolicyEmployee} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; -import {isEmptyObject, isEmptyValueObject} from '@src/types/utils/EmptyObject'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {ValueOf} from 'type-fest'; import {useIsFocused} from '@react-navigation/native'; import {createOutstandingReportsForPolicySelector} from '@selectors/Report'; -import React, {useCallback, useEffect, useEffectEvent, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; @@ -119,7 +120,7 @@ function invertObject(object: Record): Record { function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembersPageProps) { useWorkspaceDocumentTitle(policy?.name, 'common.members'); const tableRef = useRef>(null); - const icons = useMemoizedLazyExpensifyIcons(['Download', 'FallbackAvatar', 'MakeAdmin', 'Plus', 'RemoveMembers', 'Sync', 'Table', 'User', 'UserEye']); + const icons = useMemoizedLazyExpensifyIcons(['Building', 'Download', 'FallbackAvatar', 'Location', 'Plus', 'RemoveMembers', 'Sync', 'Table', 'UserPencil']); const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); const policyMemberEmailsToAccountIDs = useMemo( () => getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails, true), @@ -130,24 +131,27 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const {pageGutter} = useLayoutSpacing(); const {showConfirmModal} = useConfirmModal(); const showRuleBotGuardModal = useRuleBotGuardModal(); - const getWorkspaceMembers = () => { - if (!canMemberRead(policy, currentUserPersonalDetails.login ?? '', CONST.POLICY.POLICY_FEATURE.MEMBERS)) { - return; - } - const clientMemberEmails = Object.keys(getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails)); - openWorkspaceMembersPage(route.params.policyID, clientMemberEmails); - }; - const {isOffline} = useNetwork({onReconnect: getWorkspaceMembers}); - const [isDownloadFailureModalVisible, setIsDownloadFailureModalVisible] = useState(false); + const {isOffline} = useNetwork(); const isOfflineAndNoMemberDataAvailable = isEmptyObject(policy?.employeeList) && isOffline; const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); const [selectedEmployees, setSelectedEmployees] = useState([]); + const {isBetaEnabled} = usePermissions(); + const [membersSelectedForBulkEditCount = 0] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, {selector: (selection) => selection?.accountIDs.length}); + + // A bulk office location or work arrangement change clears the members it was given once it is applied, which + // ends the selection here too + const [prevMembersSelectedForBulkEditCount, setPrevMembersSelectedForBulkEditCount] = useState(membersSelectedForBulkEditCount); + if (prevMembersSelectedForBulkEditCount !== membersSelectedForBulkEditCount) { + setPrevMembersSelectedForBulkEditCount(membersSelectedForBulkEditCount); + if (!membersSelectedForBulkEditCount) { + setSelectedEmployees([]); + } + } - // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); + const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); + const {showDownloadErrorModal} = useAlertModals(); const currentUserLogin = currentUserPersonalDetails.login; const canEditWorkspaceSettings = canEditWorkspaceSettingsUtil(policy, currentUserLogin); const canWriteMembers = canMemberWrite(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.MEMBERS); @@ -168,6 +172,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const [outstandingReportsForPolicy] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {selector: outstandingReportsForPolicySelector}); const privateIsArchivedMap = usePrivateIsArchivedMap(); const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`); + const [memberBankAccounts] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_MEMBER_BANK_ACCOUNTS}${policyID}`); const accountIDs = useMemo(() => Object.values(policyMemberEmailsToAccountIDs ?? {}).map((accountID) => Number(accountID)), [policyMemberEmailsToAccountIDs]); const prevAccountIDs = usePrevious(accountIDs); @@ -221,11 +226,6 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers }); }, [selectedEmployees, policyMemberEmailsToAccountIDs, translate, policy, formatPhoneNumber, personalDetails, outstandingReportsForPolicy, privateIsArchivedMap]); - const getWorkspaceMembersEvent = useEffectEvent(() => getWorkspaceMembers()); - useEffect(() => { - getWorkspaceMembersEvent(); - }, []); - /** * Open the modal to invite a user */ @@ -371,55 +371,19 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers updateMemberRoleInline(policy, login, accountID, currentRole, newRole); }; - const policyOwner = policy?.owner; - const canAssignElevatedRoles = canMemberWrite(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.ASSIGN_ELEVATED_ROLES); const invitedPrimaryToSecondaryLogins = useMemo(() => invertObject(policy?.primaryLoginsInvited ?? {}), [policy?.primaryLoginsInvited]); const isControlPolicyWithWideLayout = !shouldUseNarrowLayout && isControlPolicy(policy); - const filteredMembers = useMemo(() => { - const shouldFilter = shouldFilterExpensifyTeam(policyOwner, currentUserLogin); - const result: Array<{email: string; policyEmployee: PolicyEmployee; accountID: number; details: PersonalDetails}> = []; - - for (const [email, policyEmployee] of Object.entries(policy?.employeeList ?? {})) { - // Inviting a secondary login leaves an empty employeeList entry: the backend nulls that key, then - // successData merges {pendingAction: null} back onto it. Skip it so it doesn't render as a second - // member. A real member whose personal details haven't loaded still has a role and stays visible. - if (isEmptyValueObject(policyEmployee) || isDeletedPolicyEmployee(policyEmployee, isOffline)) { - continue; - } - - // The accountID normally comes from the personal-details join. When a member's personal details - // haven't loaded (e.g. the backend under-returns them), that join is empty, so we fall back to a - // generated accountID. This keeps the rendered count in sync with employeeList and matches OldDot, - // which shows every member rather than silently dropping the ones without loaded details. - const accountID = policyMemberEmailsToAccountIDs[email] ? Number(policyMemberEmailsToAccountIDs[email]) : generateAccountID(email); - - // Render a fallback identity (email as display name) when personal details are missing so the member - // is still shown instead of being dropped from the list. - const details = - personalDetails?.[accountID] ?? - ({ - accountID, - login: email, - displayName: formatPhoneNumber(email), - } as PersonalDetails); - - // If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails - // We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they - // see random people added to their policy, but guides having access to the policies help set them up. - if (shouldFilter && isExpensifyTeam(details?.login ?? details?.displayName)) { - continue; - } - - result.push({email, policyEmployee, accountID, details}); - } - return result; - }, [policy?.employeeList, policyMemberEmailsToAccountIDs, isOffline, personalDetails, policyOwner, currentUserLogin, formatPhoneNumber]); + const filteredMembers = useWorkspaceMembers(policy); const hasAnyCustomField1 = useMemo(() => filteredMembers.some(({policyEmployee}) => !!policyEmployee.employeeUserID), [filteredMembers]); const hasAnyCustomField2 = useMemo(() => filteredMembers.some(({policyEmployee}) => !!policyEmployee.employeePayrollID), [filteredMembers]); const shouldShowCustomField1Column = isControlPolicyWithWideLayout && hasAnyCustomField1; const shouldShowCustomField2Column = isControlPolicyWithWideLayout && hasAnyCustomField2; + const shouldShowOfficeLocations = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && hasOfficeLocations(policy); + const shouldShowOfficeLocationColumn = !shouldUseNarrowLayout && shouldShowOfficeLocations; + const officeLocations = getAssignableOfficeLocations(policy, translate, localeCompare); + const shouldShowBankAccountColumn = !shouldUseNarrowLayout && !isMediumScreenWidth && isPolicyAdmin(policy) && arePaymentsEnabled(policy); // Submit workspaces have a flat role model where every member, including the owner, is an Editor. const isSubmitWorkspace = isSubmitPolicy(policy); @@ -463,6 +427,8 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers email: memberEmail, employeeUserID: policyEmployee.employeeUserID, employeePayrollID: policyEmployee.employeePayrollID, + officeLocationName: shouldShowOfficeLocationColumn ? getMemberOfficeLocation(officeLocations, policyEmployee.officeID)?.name : undefined, + bankAccountLastFour: memberBankAccounts?.[accountID]?.bankAccountLastFour, isInteractive: !details.isOptimisticPersonalDetail, isSelectionDisabled: !canWriteMembers || @@ -496,6 +462,9 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers session?.accountID, shouldShowCustomField1Column, shouldShowCustomField2Column, + shouldShowOfficeLocationColumn, + officeLocations, + memberBankAccounts, invitedPrimaryToSecondaryLogins, openMemberDetails, dismissError, @@ -587,21 +556,25 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers onNavigationCallBack: () => Navigation.goBack(), }); - const hasSelectedRuleBot = selectedEmployees.some((email) => isRuleBotEnforcingRules(policyMemberEmailsToAccountIDs[email], policy)); + // The role change happens on its own screen, which reports the change back by emptying the shared selection. + // Backing out of that screen drops the selection instead, so only a drop to zero clears the rows here. + const [membersSelectedForRoleChange] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE); + const isRoleChangeSaved = membersSelectedForRoleChange?.policyID === policyID && membersSelectedForRoleChange.logins.length === 0; + const [wasRoleChangeSaved, setWasRoleChangeSaved] = useState(isRoleChangeSaved); + if (wasRoleChangeSaved !== isRoleChangeSaved) { + setWasRoleChangeSaved(isRoleChangeSaved); - const changeUserRole = (role: ValueOf) => { - if (role !== CONST.POLICY.ROLE.ADMIN && hasSelectedRuleBot) { - showRuleBotGuardModal('changeRole', policyID); - return; + if (isRoleChangeSaved) { + setSelectedEmployees([]); } - const loginsToUpdate = selectedEmployees.filter((login) => { - return policy?.employeeList?.[login]?.role !== role; - }); - - const accountIDsToUpdate = loginsToUpdate.map((login) => policyMemberEmailsToAccountIDs[login]).filter((id) => id !== undefined); + } - setSelectedEmployees([]); - updateWorkspaceMembersRole(policy, loginsToUpdate, accountIDsToUpdate, role); + const editSelectedEmployees = (bulkEditRoute: Route) => { + setWorkspaceMembersSelectedForBulkEdit( + policyID, + selectedEmployees.map((login) => policyMemberEmailsToAccountIDs[login]).filter((accountID) => accountID !== undefined), + ); + Navigation.navigate(bulkEditRoute); }; const getBulkActionsButtonOptions = () => { @@ -611,120 +584,54 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const canManageSelectedEmployees = selectedEmployeesRoles.every((role) => canMemberManageMemberWithRole(policy, currentUserLogin ?? '', role)); const options: Array> = []; - if (canManageSelectedEmployees) { - options.push({ - text: translate('workspace.people.removeMembersTitle', {count: selectedEmployees.length}), - value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.REMOVE, - icon: icons.RemoveMembers, - shouldSkipFocusRestore: true, - onSelected: askForConfirmationToRemove, - }); - } - - if (!isPaidGroupPolicy(policy)) { - return options; - } - - const memberOption = { - text: translate('workspace.people.makeMember', {count: selectedEmployees.length}), - value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_MEMBER, - icon: icons.User, - shouldSkipFocusRestore: hasSelectedRuleBot, - onSelected: () => changeUserRole(CONST.POLICY.ROLE.USER), - }; - const adminOption = { - text: translate('workspace.people.makeAdmin', {count: selectedEmployees.length}), - value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_ADMIN, - icon: icons.MakeAdmin, - onSelected: () => changeUserRole(CONST.POLICY.ROLE.ADMIN), - }; - - const auditorOption = { - text: translate('workspace.people.makeAuditor', {count: selectedEmployees.length}), - value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_AUDITOR, - icon: icons.UserEye, - shouldSkipFocusRestore: hasSelectedRuleBot, - onSelected: () => changeUserRole(CONST.POLICY.ROLE.AUDITOR), - }; - const guestOption = { - text: translate('workspace.people.makeGuest', {count: selectedEmployees.length}), - value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_GUEST, - icon: icons.User, - shouldSkipFocusRestore: hasSelectedRuleBot, - onSelected: () => changeUserRole(CONST.POLICY.ROLE.GUEST), - }; - const cardAdminOption = { - text: translate('workspace.people.makeCardAdmin', {count: selectedEmployees.length}), - value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_CARD_ADMIN, - icon: icons.MakeAdmin, - shouldSkipFocusRestore: hasSelectedRuleBot, - onSelected: () => changeUserRole(CONST.POLICY.ROLE.CARD_ADMIN), - }; - const peopleAdminOption = { - text: translate('workspace.people.makePeopleAdmin', {count: selectedEmployees.length}), - value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_PEOPLE_ADMIN, - icon: icons.MakeAdmin, - shouldSkipFocusRestore: hasSelectedRuleBot, - onSelected: () => changeUserRole(CONST.POLICY.ROLE.PEOPLE_ADMIN), - }; - const paymentsAdminOption = { - text: translate('workspace.people.makePaymentsAdmin', {count: selectedEmployees.length}), - value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_PAYMENTS_ADMIN, - icon: icons.MakeAdmin, - shouldSkipFocusRestore: hasSelectedRuleBot, - onSelected: () => changeUserRole(CONST.POLICY.ROLE.PAYMENTS_ADMIN), - }; - - const hasAtLeastOneNonAuditorRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.AUDITOR); - const hasAtLeastOneNonGuestRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.GUEST); - const hasAtLeastOneNonCardAdminRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.CARD_ADMIN); - const hasAtLeastOneNonPeopleAdminRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.PEOPLE_ADMIN); - const hasAtLeastOneNonPaymentsAdminRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.PAYMENTS_ADMIN); - const hasAtLeastOneNonMemberRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.USER); - const hasAtLeastOneNonAdminRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.ADMIN); + // The Authorized Payer (reimburser) must stay a valid payer, so a selection holding them may only be moved to a role that can pay. const reimburserEmail = getReimburserEmail(policy); const hasAtLeastOnePayer = !!reimburserEmail && selectedEmployees.includes(reimburserEmail); + const selectableRoles = getSelectableRoles(policy, currentUserLogin ?? '', hasAtLeastOnePayer ? [...PAYER_ROLES] : undefined); - if (hasAtLeastOneNonMemberRole && !hasAtLeastOnePayer && canManageSelectedEmployees && canMemberAssignRole(policy, currentUserLogin ?? '', CONST.POLICY.ROLE.USER)) { - options.push(memberOption); - } - - // Admin is a valid payer role, so the payer may be promoted to Admin (Admin and Payments Admin are the two roles that can pay). - if (hasAtLeastOneNonAdminRole && canAssignElevatedRoles) { - options.push(adminOption); + if (isPaidGroupPolicy(policy) && canManageSelectedEmployees && selectableRoles.length > 0) { + options.push({ + text: translate('workspace.people.changeRole'), + value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.CHANGE_ROLE, + icon: icons.UserPencil, + shouldSkipFocusRestore: true, + onSelected: () => { + setMembersSelectedForRoleChange(policyID, selectedEmployees); + Navigation.navigate(ROUTES.WORKSPACE_MEMBERS_ROLE.getRoute(policyID)); + }, + }); } - if ( - hasAtLeastOneNonAuditorRole && - isControlPolicy(policy) && - !hasAtLeastOnePayer && - canManageSelectedEmployees && - canMemberAssignRole(policy, currentUserLogin ?? '', CONST.POLICY.ROLE.AUDITOR) - ) { - options.push(auditorOption); + if (isPaidGroupPolicy(policy) && shouldShowOfficeLocations) { + options.push({ + text: translate('workspace.people.changeOfficeLocation'), + value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.CHANGE_OFFICE_LOCATION, + icon: icons.Location, + onSelected: () => editSelectedEmployees(ROUTES.WORKSPACE_MEMBERS_CHANGE_OFFICE_LOCATION.getRoute(policyID)), + }); } if ( - hasAtLeastOneNonGuestRole && - isControlPolicy(policy) && - !hasAtLeastOnePayer && - canManageSelectedEmployees && - canMemberAssignRole(policy, currentUserLogin ?? '', CONST.POLICY.ROLE.GUEST) + isPaidGroupPolicy(policy) && + policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && + isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) ) { - options.push(guestOption); - } - - if (hasAtLeastOneNonCardAdminRole && isControlPolicy(policy) && !hasAtLeastOnePayer && canAssignElevatedRoles) { - options.push(cardAdminOption); - } - - if (hasAtLeastOneNonPeopleAdminRole && isControlPolicy(policy) && !hasAtLeastOnePayer && canAssignElevatedRoles) { - options.push(peopleAdminOption); + options.push({ + text: translate('workspace.people.changeWorkArrangement'), + value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.CHANGE_WORK_ARRANGEMENT, + icon: icons.Building, + onSelected: () => editSelectedEmployees(ROUTES.WORKSPACE_MEMBERS_CHANGE_WORK_ARRANGEMENT.getRoute(policyID)), + }); } - // Payments Admin is a valid payer role, so the payer may be changed to Payments Admin (Admin and Payments Admin are the two roles that can pay). - if (hasAtLeastOneNonPaymentsAdminRole && isControlPolicy(policy) && canAssignElevatedRoles) { - options.push(paymentsAdminOption); + if (canManageSelectedEmployees) { + options.push({ + text: translate('workspace.people.removeMembersTitle', {count: selectedEmployees.length}), + value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.REMOVE, + icon: icons.RemoveMembers, + shouldSkipFocusRestore: true, + onSelected: askForConfirmationToRemove, + }); } return options; @@ -772,13 +679,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers } close(() => { - downloadMembersCSV( - policyID, - () => { - setIsDownloadFailureModalVisible(true); - }, - translate, - ); + downloadMembersCSV(policyID, showDownloadErrorModal, translate); }); }, value: CONST.POLICY.SECONDARY_ACTIONS.DOWNLOAD_CSV, @@ -820,6 +721,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers showLockedAccountModal, showRequiresInternetModal, showConfirmModal, + showDownloadErrorModal, isHRSyncInProgress, policy, ]); @@ -831,7 +733,10 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers return null; } const bulkActionOptions = getBulkActionsButtonOptions(); - return (shouldUseNarrowLayout ? canSelectMultiple : selectedEmployees.length > 0) ? ( + + // The wide layout offers these actions in the floating bar over the table instead, so its header keeps the + // page's own buttons no matter what is selected. + return shouldUseNarrowLayout && canSelectMultiple ? ( variant={CONST.BUTTON_VARIANT.SUCCESS} shouldAlwaysShowDropdownMenu @@ -911,16 +816,6 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers {() => ( <> {shouldDisplayButtonsInSeparateLine && {getHeaderButtons()}} - setIsDownloadFailureModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isDownloadFailureModalVisible} - onClose={() => setIsDownloadFailureModalVisible(false)} - /> - {!shouldUseNarrowLayout && data.length === 0 && ( <> {!!headerMessage && ( @@ -940,8 +835,11 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers selectedKeys={selectedEmployees} shouldShowCustomField1Column={shouldShowCustomField1Column} shouldShowCustomField2Column={shouldShowCustomField2Column} + shouldShowOfficeLocationColumn={shouldShowOfficeLocationColumn} + shouldShowBankAccountColumn={shouldShowBankAccountColumn} onRowSelectionChange={setSelectedEmployees} headerComponent={tableHeaderComponent} + bulkActionOptions={canWriteMembers ? getBulkActionsButtonOptions() : undefined} /> )} diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx index ff1098f77ce4..53afa54bbcea 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx @@ -84,7 +84,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useEffect} from 'react'; import MoreFeaturesSection from './MoreFeaturesSection'; @@ -143,7 +143,9 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID.toString()}_${CONST.EXPENSIFY_CARD.BANK}`, { selector: filterInactiveCards, @@ -159,6 +161,8 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const paymentBankAccountID = settings?.paymentBankAccountID; const isTravelBillingEnabled = getIsTravelBillingEnabled(getCardSettings(travelCardSettings, CONST.TRAVEL.PROGRAM_TRAVEL_US)); const {canWrite: canWriteMoreFeatures, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); + const hasHomeAndOfficeCommuterExclusions = policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; + const isDistanceRatesLockedByCommuterExclusions = !!policy?.areDistanceRatesEnabled && hasHomeAndOfficeCommuterExclusions; // The Vendors toggle reads policy.connections (via hasVendorFeature), which is empty on a // non-active workspace until a connections-aware read runs. OpenPolicyMoreFeaturesPage doesn't @@ -296,7 +300,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro if (action !== ModalActions.CONFIRM) { return; } - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails, shouldDismissModal: false}); }; const promptDisableTravelViaInvoicing = async () => { @@ -322,7 +326,16 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro if (action !== ModalActions.CONFIRM) { return; } - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails, shouldDismissModal: false}); + }; + + const promptDisableDistanceRatesForCommuterExclusions = async () => { + await showConfirmModal({ + title: translate('workspace.moreFeatures.distanceRates.commuterExclusionsOnTitle'), + prompt: translate('workspace.moreFeatures.distanceRates.disableLockedByCommuterExclusionsPrompt'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); }; const promptDisableSmartLimitForWorkflows = async () => { @@ -670,12 +683,16 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro subtitle={translate('workspace.moreFeatures.distanceRates.subtitle')} isActive={policy?.areDistanceRatesEnabled ?? false} pendingAction={policy?.pendingFields?.areDistanceRatesEnabled} - disabled={!canWriteMoreFeatures} - disabledAction={withReadOnlyFallback()} + disabled={!canWriteMoreFeatures || isDistanceRatesLockedByCommuterExclusions} + disabledAction={withReadOnlyFallback(promptDisableDistanceRatesForCommuterExclusions)} onToggle={(isEnabled) => { if (!policyID) { return; } + if (!isEnabled && hasHomeAndOfficeCommuterExclusions) { + promptDisableDistanceRatesForCommuterExclusions(); + return; + } enablePolicyDistanceRates(policyID, isEnabled, distanceRateCustomUnit); }} onPress={() => { diff --git a/src/pages/workspace/WorkspaceNewRoomPage.tsx b/src/pages/workspace/WorkspaceNewRoomPage.tsx index 2dd3f7fd04ee..7db4b2c06deb 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.tsx +++ b/src/pages/workspace/WorkspaceNewRoomPage.tsx @@ -2,7 +2,7 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import Button from '@components/Button'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; -import type {FormOnyxValues} from '@components/Form/types'; +import type {FormOnyxValues, FormValue} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; @@ -44,6 +44,10 @@ import {useIsFocused} from '@react-navigation/core'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; +function findRoomOption(options: Array<{value: TValue}>, value: FormValue | undefined) { + return options.find((option) => option.value === value); +} + function EmptyWorkspaceView() { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -346,7 +350,8 @@ function WorkspaceNewRoomPage({ref, policyID: lockedPolicyID}: WorkspaceNewRoomP label={translate('workspace.common.workspace')} items={workspaceOptions} value={policyID} - onValueChange={(value) => setPolicyID(value as typeof policyID)} + valueType="string" + onValueChange={(value) => setPolicyID(value)} /> )} @@ -358,7 +363,12 @@ function WorkspaceNewRoomPage({ref, policyID: lockedPolicyID}: WorkspaceNewRoomP label={translate('writeCapabilityPage.label')} items={writeCapabilityOptions} value={writeCapability} - onValueChange={(value) => setWriteCapability(value as typeof writeCapability)} + onValueChange={(value) => { + const selectedOption = findRoomOption(writeCapabilityOptions, value); + if (selectedOption) { + setWriteCapability(selectedOption.value); + } + }} /> )} @@ -368,7 +378,12 @@ function WorkspaceNewRoomPage({ref, policyID: lockedPolicyID}: WorkspaceNewRoomP inputID={INPUT_IDS.VISIBILITY} label={translate('newRoomPage.visibility')} items={visibilityOptions} - onValueChange={(value) => setVisibility(value as typeof visibility)} + onValueChange={(value) => { + const selectedOption = findRoomOption(visibilityOptions, value); + if (selectedOption) { + setVisibility(selectedOption.value); + } + }} value={visibility} furtherDetails={visibilityDescription} shouldShowTooltips={false} diff --git a/src/pages/workspace/WorkspaceOfficeLocationAddPage.tsx b/src/pages/workspace/WorkspaceOfficeLocationAddPage.tsx new file mode 100644 index 000000000000..248c5afbbfe2 --- /dev/null +++ b/src/pages/workspace/WorkspaceOfficeLocationAddPage.tsx @@ -0,0 +1,85 @@ +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import ScreenWrapper from '@components/ScreenWrapper'; + +import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; +import usePolicy from '@hooks/usePolicy'; + +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {hasCompanyAddress} from '@libs/PolicyUtils'; + +import {addOfficeLocation} from '@userActions/Policy/DistanceRate'; + +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; +import type {CompanyAddress} from '@src/types/onyx/Policy'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import React from 'react'; + +import AccessOrNotFoundWrapper from './AccessOrNotFoundWrapper'; +import WorkspaceOfficeLocationForm from './WorkspaceOfficeLocationForm'; + +type WorkspaceOfficeLocationAddPageProps = PlatformStackScreenProps; + +function WorkspaceOfficeLocationAddPage({route}: WorkspaceOfficeLocationAddPageProps) { + const {policyID} = route.params; + const {translate} = useLocalize(); + const {isBetaEnabled} = usePermissions(); + const policy = usePolicy(policyID); + const officeLocations = policy?.officeLocations; + + // Offices being deleted and additions that failed aren't on the server, so they don't count + const savedOfficeLocations = Object.values(officeLocations ?? {}).filter( + (officeLocation) => + officeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && + !(officeLocation.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && !isEmptyObject(officeLocation.errors)), + ); + const officeCount = savedOfficeLocations.length; + + // The company address counts as an office, the primary one while no office is, so only a workspace without one makes + // its first office primary + const isCompanyAddressShown = hasCompanyAddress(policy); + const isOnlyOffice = officeCount === 0 && !isCompanyAddressShown; + + // The company address row is listed with the offices, so its label is taken too + const takenNames = [...savedOfficeLocations.map((officeLocation) => officeLocation.name), ...(isCompanyAddressShown ? [translate('common.companyAddress')] : [])]; + + const addOffice = (name: string, address: CompanyAddress, isPrimary: boolean) => { + // An office added without a name shows this one until the server names it the same way: numbered after the existing + // offices, skipping names already in use regardless of case + const lowerCaseTakenNames = new Set(takenNames.map((takenName) => takenName.toLowerCase())); + let officeNumber = officeCount + 1; + while (lowerCaseTakenNames.has(translate('workspace.officeLocations.defaultName', {officeNumber}).toLowerCase())) { + officeNumber++; + } + addOfficeLocation(policyID, officeLocations, address, isPrimary, name, translate('workspace.officeLocations.defaultName', {officeNumber})); + Navigation.goBack(); + }; + + return ( + + + + + + + ); +} + +export default WorkspaceOfficeLocationAddPage; diff --git a/src/pages/workspace/WorkspaceOfficeLocationEditPage.tsx b/src/pages/workspace/WorkspaceOfficeLocationEditPage.tsx new file mode 100644 index 000000000000..87120c26e412 --- /dev/null +++ b/src/pages/workspace/WorkspaceOfficeLocationEditPage.tsx @@ -0,0 +1,124 @@ +import Header from '@components/Header'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import ScreenWrapper from '@components/ScreenWrapper'; + +import useConfirmModal from '@hooks/useConfirmModal'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; +import usePolicy from '@hooks/usePolicy'; + +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {hasCompanyAddress} from '@libs/PolicyUtils'; + +import {deleteOfficeLocation, updateOfficeLocation} from '@userActions/Policy/DistanceRate'; + +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; +import type {CompanyAddress} from '@src/types/onyx/Policy'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import {deepEqual} from 'fast-equals'; +import React from 'react'; + +import AccessOrNotFoundWrapper from './AccessOrNotFoundWrapper'; +import WorkspaceOfficeLocationForm from './WorkspaceOfficeLocationForm'; + +type WorkspaceOfficeLocationEditPageProps = PlatformStackScreenProps; + +function WorkspaceOfficeLocationEditPage({route}: WorkspaceOfficeLocationEditPageProps) { + const {policyID, officeID} = route.params; + const {translate} = useLocalize(); + const {isBetaEnabled} = usePermissions(); + const {showConfirmModal} = useConfirmModal(); + const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); + const policy = usePolicy(policyID); + const officeLocations = policy?.officeLocations; + const officeLocation = officeLocations?.[officeID]; + + // Excluding commutes by home and office needs an office, and the company address counts as one, so only a workspace + // excluding commutes that way without a company address keeps its last office. Offices being deleted and additions + // that failed aren't on the server, so they don't count. + const savedOfficeLocations = Object.entries(officeLocations ?? {}).filter( + ([, otherOfficeLocation]) => + otherOfficeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && + !(otherOfficeLocation.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && !isEmptyObject(otherOfficeLocation.errors)), + ); + const isCompanyAddressShown = hasCompanyAddress(policy); + const isHomeAndOfficeMethod = policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; + const canDelete = savedOfficeLocations.length > 1 || isCompanyAddressShown || !isHomeAndOfficeMethod; + + // The company address row is listed with the offices, so its label is taken too + const takenNames = [ + ...savedOfficeLocations.filter(([otherOfficeID]) => otherOfficeID !== officeID).map(([, otherOfficeLocation]) => otherOfficeLocation.name), + ...(isCompanyAddressShown ? [translate('common.companyAddress')] : []), + ]; + + const saveOffice = (name: string, address: CompanyAddress, isPrimary: boolean) => { + if (!officeLocation) { + return; + } + const changes = { + ...(name !== officeLocation.name ? {name} : {}), + ...(!deepEqual(address, officeLocation.address) ? {address} : {}), + ...(isPrimary && !officeLocation.isDefault ? {isDefault: true} : {}), + }; + if (!isEmptyObject(changes)) { + updateOfficeLocation(policyID, officeLocations, officeID, changes); + } + Navigation.goBack(); + }; + + const confirmDelete = () => { + showConfirmModal({ + title: translate('workspace.officeLocations.deleteOfficeLocation'), + prompt: translate('workspace.officeLocations.deleteOfficeLocationConfirmation'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + deleteOfficeLocation(policyID, officeID, officeLocation); + Navigation.goBack(); + }); + }; + + return ( + + + + {canDelete && ( + + )} + + + + + ); +} + +export default WorkspaceOfficeLocationEditPage; diff --git a/src/pages/workspace/WorkspaceOfficeLocationForm.tsx b/src/pages/workspace/WorkspaceOfficeLocationForm.tsx new file mode 100644 index 000000000000..15c37db411d5 --- /dev/null +++ b/src/pages/workspace/WorkspaceOfficeLocationForm.tsx @@ -0,0 +1,150 @@ +import AddressForm from '@components/AddressForm'; +import CheckboxWithLabel from '@components/CheckboxWithLabel'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormOnyxValues} from '@components/Form/types'; +import TextInput from '@components/TextInput'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getWorkspaceAddressStreetLines} from '@libs/WorkspaceDisplayUtils'; + +import type {Country} from '@src/CONST'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/WorkspaceOfficeLocationForm'; +import type {CompanyAddress, OfficeLocation} from '@src/types/onyx/Policy'; + +import React, {useState} from 'react'; +import {View} from 'react-native'; + +type WorkspaceOfficeLocationFormValues = FormOnyxValues; + +type WorkspaceOfficeLocationFormProps = { + /** The office being edited, undefined when adding one */ + officeLocation?: OfficeLocation; + + /** Whether "Set as primary" starts checked */ + isPrimary: boolean; + + /** Whether "Set as primary" can't be changed, for an office that has to be the primary one */ + isPrimaryLocked: boolean; + + /** Whether the location name can't be left empty */ + isNameRequired: boolean; + + /** Names of the other locations of the workspace, which the location name can't match regardless of case */ + takenNames: string[]; + + /** Called with the trimmed name, the office address and whether the office is the primary one */ + onSubmit: (name: string, address: CompanyAddress, isPrimary: boolean) => void; +}; + +/** The location name, address and "Set as primary" fields shared by the pages that add and edit workspace offices */ +function WorkspaceOfficeLocationForm({officeLocation, isPrimary, isPrimaryLocked, isNameRequired, takenNames, onSubmit}: WorkspaceOfficeLocationFormProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const address = officeLocation?.address; + const {streetLineOne, streetLineTwo} = getWorkspaceAddressStreetLines(address?.addressStreet, address?.addressStreet2); + const [currentCountry, setCurrentCountry] = useState(address?.country ?? ''); + const [state, setState] = useState(address?.state ?? ''); + const [city, setCity] = useState(address?.city ?? ''); + const [zipCode, setZipCode] = useState(address?.zipCode ?? ''); + + const handleAddressChange = (value: unknown, key: unknown) => { + const addressPart = typeof value === 'string' ? value : ''; + if (key === INPUT_IDS.COUNTRY && addressPart !== currentCountry) { + setCurrentCountry(Object.keys(CONST.ALL_COUNTRIES).find((countryCode): countryCode is Country => countryCode === addressPart) ?? ''); + setState(''); + setCity(''); + setZipCode(''); + return; + } + if (key === INPUT_IDS.STATE) { + setState(addressPart); + setCity(''); + setZipCode(''); + return; + } + if (key === INPUT_IDS.CITY) { + setCity(addressPart); + setZipCode(''); + return; + } + if (key === INPUT_IDS.ZIP_POST_CODE) { + setZipCode(addressPart); + } + }; + + const validate = (values: WorkspaceOfficeLocationFormValues): Record => { + const errors: Record = {}; + const name = values[INPUT_IDS.LOCATION_NAME].trim(); + if (isNameRequired && !name) { + errors[INPUT_IDS.LOCATION_NAME] = translate('common.error.fieldRequired'); + } else if (name.length > CONST.FORM_CHARACTER_LIMIT) { + errors[INPUT_IDS.LOCATION_NAME] = translate('common.error.characterLimitExceedCounter', name.length, CONST.FORM_CHARACTER_LIMIT); + } else if (!!name && name !== officeLocation?.name && takenNames.some((takenName) => takenName.trim().toLowerCase() === name.toLowerCase())) { + // Like the server, only a changed name is checked, so an office whose name was already taken can still save other changes + errors[INPUT_IDS.LOCATION_NAME] = translate('workspace.officeLocations.existingOfficeLocationError'); + } + return errors; + }; + + const submit = (values: WorkspaceOfficeLocationFormValues) => { + const addressLine1 = values[INPUT_IDS.ADDRESS_LINE_1].trim(); + const addressLine2 = values[INPUT_IDS.ADDRESS_LINE_2].trim(); + onSubmit( + values[INPUT_IDS.LOCATION_NAME].trim(), + { + // Offices store both address lines in addressStreet, the same shape the workspace address has + addressStreet: addressLine2 ? `${addressLine1}\n${addressLine2}` : addressLine1, + city: values[INPUT_IDS.CITY].trim(), + state: values[INPUT_IDS.STATE].trim(), + zipCode: values[INPUT_IDS.ZIP_POST_CODE].trim().toUpperCase(), + country: values[INPUT_IDS.COUNTRY], + }, + values[INPUT_IDS.IS_PRIMARY], + ); + }; + + return ( + + + + + + + + ); +} + +export default WorkspaceOfficeLocationForm; diff --git a/src/pages/workspace/WorkspaceOverviewAddressPage.tsx b/src/pages/workspace/WorkspaceOverviewAddressPage.tsx index 7d6dc46e7f40..bd09877b97aa 100644 --- a/src/pages/workspace/WorkspaceOverviewAddressPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewAddressPage.tsx @@ -6,7 +6,7 @@ import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {getWorkspaceAddressStreetLines} from '@libs/WorkspacesSettingsUtils'; +import {getWorkspaceAddressStreetLines} from '@libs/WorkspaceDisplayUtils'; import AddressPage from '@pages/AddressPage'; diff --git a/src/pages/workspace/WorkspaceOverviewDescriptionPage.tsx b/src/pages/workspace/WorkspaceOverviewDescriptionPage.tsx index 7a99c502978e..49582b706491 100644 --- a/src/pages/workspace/WorkspaceOverviewDescriptionPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewDescriptionPage.tsx @@ -8,6 +8,7 @@ import TextInput from '@components/TextInput'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useLocalize from '@hooks/useLocalize'; +import useReportIDToNameMap from '@hooks/useReportIDToNameMap'; import useReviewWorkspaceSettingsTaskCompletion from '@hooks/useReviewWorkspaceSettingsTaskCompletion'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -37,7 +38,8 @@ function WorkspaceOverviewDescriptionPage({policy}: Props) { const {translate} = useLocalize(); const getReviewWorkspaceSettingsTaskCompletion = useReviewWorkspaceSettingsTaskCompletion(); const isInputInitializedRef = useRef(false); - const [description, setDescription] = useState(() => Parser.htmlToMarkdown(policy?.description ?? translate('workspace.common.defaultDescription'))); + const reportIDToName = useReportIDToNameMap(); + const [description, setDescription] = useState(() => Parser.htmlToMarkdown(policy?.description ?? translate('workspace.common.defaultDescription'), {reportIDToName})); /** * @param {Object} values - form input values passed by the Form component diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index b7ea0ad06a48..87c82167b321 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -1,6 +1,7 @@ import AttachmentPicker from '@components/AttachmentPicker'; import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; +import Badge from '@components/Badge'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; @@ -40,6 +41,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {close} from '@libs/actions/Modal'; +import {clearOfficeLocationErrors, updateOfficeLocation} from '@libs/actions/Policy/DistanceRate'; import {clearInviteDraft, clearWorkspaceOwnerChangeFlow, requestWorkspaceOwnerChange} from '@libs/actions/Policy/Member'; import { clearAvatarErrors, @@ -63,6 +65,7 @@ import { getRulesDocumentSourceURL, getUserFriendlyWorkspaceType, goBackFromInvalidPolicy, + hasCompanyAddress, isArchivedPolicy, isPendingDeletePolicy, isPolicyOwner, @@ -104,13 +107,13 @@ const rulesDocumentMenuPositionStyle = {top: variables.spacing2, right: variable function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: WorkspaceOverviewPageProps) { const styles = useThemeStyles(); - const {translate} = useLocalize(); + const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {cardPadding, pageGutter} = useLayoutSpacing(); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {getCurrencySymbol} = useCurrencyListActions(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['ArrowCircleClockwise', 'Box', 'Exit', 'ImageCropSquareMask', 'QrCode', 'Transfer', 'Trashcan', 'Upload', 'UserPlus']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['ArrowCircleClockwise', 'Box', 'Exit', 'ImageCropSquareMask', 'Plus', 'QrCode', 'Transfer', 'Trashcan', 'Upload', 'UserPlus']); const buildDynamicRoute = useScreenBoundDynamicRoute(); const {isBetaEnabled} = usePermissions(); const canArchivePolicies = isBetaEnabled(CONST.BETAS.ARCHIVE_POLICIES); @@ -258,6 +261,33 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa [translate, expensifyIcons, handleRulesDocumentPicked, policyID, rulesDocumentURL], ); const shouldShowExpensePolicySection = isPolicyAdmin || hasRulesDocument || hasCustomRulesText; + + // The primary office is listed first and the rest by name, since the server returns them keyed by random officeIDs + const officeLocationEntries = Object.entries(policy?.officeLocations ?? {}).sort( + ([, officeLocation], [, otherOfficeLocation]) => + Number(!!otherOfficeLocation.isDefault) - Number(!!officeLocation.isDefault) || localeCompare(officeLocation.name, otherOfficeLocation.name), + ); + const shouldShowCompanyAddressOffice = hasCompanyAddress(policy); + + // The company address is the workspace's default office while no office is + const defaultOfficeID = officeLocationEntries.find(([, officeLocation]) => officeLocation.isDefault && officeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)?.[0]; + const isCompanyAddressPrimary = !defaultOfficeID; + + // Taking the primary flag off the primary office makes the company address the primary one + const setCompanyAddressAsPrimary = () => { + showConfirmModal({ + title: translate('common.companyAddress'), + prompt: translate('workspace.officeLocations.setCompanyAddressAsPrimaryConfirmation'), + confirmText: translate('workspace.officeLocations.setAsPrimary'), + cancelText: translate('common.cancel'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM || !defaultOfficeID) { + return; + } + updateOfficeLocation(routePolicyID, policy?.officeLocations, defaultOfficeID, {isDefault: false}); + }); + }; + const shouldShowOfficeLocationsSection = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && (!readOnly || shouldShowCompanyAddressOffice || officeLocationEntries.length > 0); const shouldShowRulesDocumentSubSection = isPolicyAdmin || hasRulesDocument; const personalDetails = usePersonalDetails(); @@ -738,6 +768,88 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa )} + {shouldShowOfficeLocationsSection && ( +

+ {shouldShowCompanyAddressOffice && ( + + + + + {translate('common.companyAddress')} + {formattedAddress} + + + {isCompanyAddressPrimary && ( + + )} + + + + + )} + {officeLocationEntries.map(([officeID, officeLocation]) => { + const isEditable = !readOnly && officeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + return ( + clearOfficeLocationErrors(routePolicyID, officeID, officeLocation.pendingAction)} + > + Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW_OFFICE_LOCATION_EDIT.getRoute(routePolicyID, officeID)) : undefined} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.OVERVIEW.OFFICE_LOCATION} + > + + + {officeLocation.name} + {formatAddressToString(officeLocation.address)} + + + {!!officeLocation.isDefault && ( + + )} + {isEditable && } + + + + + ); + })} + {!readOnly && ( + Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW_OFFICE_LOCATION_ADD.getRoute(routePolicyID))} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.OVERVIEW.ADD_OFFICE_LOCATION} + > + + + + + + {translate('workspace.officeLocations.addOfficeLocation')} + + + + )} +
+ )} {shouldShowExpensePolicySection ? (
; +}; + +function ExactOnlineWaitlistButton({policy}: ExactOnlineWaitlistButtonProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); + const [signupTimestamp, signupTimestampMetadata] = useOnyx(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST); + const [pendingAction, pendingActionMetadata] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.pendingFields?.exactOnlineWaitlist}); + const [errors] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.errorFields?.exactOnlineWaitlist}); + const isJoiningWaitlist = useRef(false); + + // Queued writes finish through Onyx, including retries after the press handler has returned. + useEffect(() => { + if (!isJoiningWaitlist.current || pendingAction || pendingActionMetadata.status === 'loading') { + return; + } + if (errors) { + isJoiningWaitlist.current = false; + return; + } + if (!signupTimestamp) { + return; + } + + isJoiningWaitlist.current = false; + showConfirmModal({ + title: translate('workspace.accounting.exactOnline.onWaitlist'), + prompt: translate('workspace.accounting.exactOnline.waitlistDescription'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }, [errors, pendingAction, pendingActionMetadata.status, showConfirmModal, signupTimestamp, translate]); + + if (signupTimestamp && !pendingAction) { + return {translate('workspace.accounting.exactOnline.onWaitlist')}; + } + + const joinWaitlist = () => { + if (isJoiningWaitlist.current || pendingAction) { + return; + } + if (!canWrite) { + showReadOnlyModal(); + return; + } + // Prevent repeated presses before the pending account state reaches this component. + isJoiningWaitlist.current = true; + joinExactOnlineWaitlist(); + }; + + return ( + + ); +} + +export default ExactOnlineWaitlistButton; diff --git a/src/pages/workspace/accounting/ExactOnlineWaitlistRow.tsx b/src/pages/workspace/accounting/ExactOnlineWaitlistRow.tsx new file mode 100644 index 000000000000..4c3a21376252 --- /dev/null +++ b/src/pages/workspace/accounting/ExactOnlineWaitlistRow.tsx @@ -0,0 +1,85 @@ +/** Shared Exact Online identity and signup state for Accounting and Connections. */ +import UserAvatar from '@components/Avatar/UserAvatar'; +import Badge from '@components/Badge'; +import MenuItem from '@components/MenuItem'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import getPlatform from '@libs/getPlatform'; + +import {clearExactOnlineWaitlistError} from '@userActions/User'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +import type {StyleProp, ViewStyle} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; +import {View} from 'react-native'; + +import ExactOnlineWaitlistButton from './ExactOnlineWaitlistButton'; + +type ExactOnlineWaitlistRowProps = { + policy: OnyxEntry; + style?: StyleProp; + titleWrapperStyle?: StyleProp; + isBadgeCondensed?: boolean; +}; + +function ExactOnlineWaitlistRow({policy, style, titleWrapperStyle, isBadgeCondensed}: ExactOnlineWaitlistRowProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['ExactOnlineSquare']); + const [signupTimestamp] = useOnyx(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST); + const [errors] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.errorFields?.exactOnlineWaitlist}); + const [pendingAction] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.pendingFields?.exactOnlineWaitlist}); + const title = CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.exactOnline; + const comingSoon = translate('workspace.accounting.exactOnline.comingSoon'); + const accessibilityLabel = [title, comingSoon, signupTimestamp && !pendingAction && translate('workspace.accounting.exactOnline.onWaitlist')].filter(Boolean).join(', '); + + return ( + + + + + + + + + {title} + + + + + + + + + + ); +} + +export default ExactOnlineWaitlistRow; diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 76519f4b39af..b685899c8e3c 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -47,6 +47,8 @@ import {View} from 'react-native'; import type {MenuItemData, PolicyAccountingPageProps} from './types'; import {AccountingContextProvider, useAccountingActions, useAccountingState} from './AccountingContext'; +import ExactOnlineWaitlistRow from './ExactOnlineWaitlistRow'; +import {getNetSuiteOAuthMigrationSeverity, shouldShowNetSuiteOAuthMigrationNudge} from './netsuite/utils'; import useConnectedAccountingIntegration from './useConnectedAccountingIntegration'; type RouteParams = { @@ -59,6 +61,7 @@ type RouteParams = { function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { useWorkspaceDocumentTitle(policy?.name, 'workspace.common.accounting'); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -223,6 +226,25 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { ); } + let netSuiteOAuthMigrationHint; + const netSuiteAccountID = policy?.connections?.netsuite?.accountID; + if (connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.NETSUITE && shouldShowNetSuiteOAuthMigrationNudge(policy, allPolicies) && canWriteAccounting && netSuiteAccountID) { + const severity = getNetSuiteOAuthMigrationSeverity(); + netSuiteOAuthMigrationHint = ( + <> + {translate(severity === 'danger' ? 'workspace.accounting.netSuiteOAuthMigrationRequiredUrgent' : 'workspace.accounting.netSuiteOAuthMigrationRequired')}{' '} + + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID, CONST.NETSUITE_CONFIG.TOKEN_INPUT.PAGE_NAME.INSTALL, undefined, true)) + } + > + {translate('workspace.accounting.netSuiteOAuthUpgradeNow')} + + + ); + } + return [ { ...iconProps, @@ -234,7 +256,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { errorTextStyle: [styles.mt5], shouldShowRedDotIndicator: true, description: connectionDetails.connectionMessage, - hintText: qboTokenExpiryHint, + hintText: qboTokenExpiryHint ?? netSuiteOAuthMigrationHint, rightComponent, }, ...connectionDetails.settingsMenuItems, @@ -398,6 +420,11 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { /> )} + {!!account?.guideDetails?.email && !hasAccountingConnections(policy) && canWriteAccounting && ( ({ + value: accountingMethodItem, + text: translate(`workspace.businessCentral.advanced.accountingMethods.values.${accountingMethodItem}`), + alternateText: translate(`workspace.businessCentral.advanced.accountingMethods.alternateText.${accountingMethodItem}`), + keyForList: accountingMethodItem, + isSelected: accountingMethod === accountingMethodItem, + })); + + const headerContent = ( + + {translate('workspace.businessCentral.advanced.accountingMethods.description')} + + ); + + const selectAccountingMethod = (item: AccountingMethodListItem) => { + if (item.value !== accountingMethod && policyID) { + updateBusinessCentralAccountingMethod(policyID, item.value, accountingMethod); + } + Navigation.goBack(backPath); + }; + + return ( + Navigation.goBack(backPath)} + connectionName={CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL} + pendingAction={settingsPendingAction([CONST.BUSINESS_CENTRAL_CONFIG.ACCOUNTING_METHOD], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig, CONST.BUSINESS_CENTRAL_CONFIG.ACCOUNTING_METHOD)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => policyID && clearBusinessCentralErrorField(policyID, CONST.BUSINESS_CENTRAL_CONFIG.ACCOUNTING_METHOD)} + /> + ); +} + +export default withPolicyConnections(BusinessCentralAccountingMethodSelectPage); diff --git a/src/pages/workspace/accounting/businessCentral/advanced/BusinessCentralAdvancedPage.tsx b/src/pages/workspace/accounting/businessCentral/advanced/BusinessCentralAdvancedPage.tsx new file mode 100644 index 000000000000..e91c73e7e4ed --- /dev/null +++ b/src/pages/workspace/accounting/businessCentral/advanced/BusinessCentralAdvancedPage.tsx @@ -0,0 +1,219 @@ +import Accordion from '@components/Accordion'; +import ConnectionLayout from '@components/ConnectionLayout'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import useAccordionAnimation from '@hooks/useAccordionAnimation'; +import useExpensifyCardFeeds from '@hooks/useExpensifyCardFeeds'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import { + clearBusinessCentralErrorField, + updateBusinessCentralAutoCreateEntities, + updateBusinessCentralAutoSync, + updateBusinessCentralExportToNextOpenPeriod, + updateBusinessCentralSyncExpensifyCardSettlements, + updateBusinessCentralSyncReimbursedReports, +} from '@libs/actions/connections/BusinessCentral'; +import {isExpensifyCardFullySetUp} from '@libs/CardUtils'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils'; + +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; + +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import {CONST as COMMON_CONST} from 'expensify-common'; +import React from 'react'; +import {View} from 'react-native'; + +function BusinessCentralAdvancedPage({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policyID = policy?.id; + const businessCentralConfig = policy?.connections?.businessCentral?.config; + const bankAccounts = policy?.connections?.businessCentral?.data?.bankAccounts; + const autoSync = businessCentralConfig?.autoSync?.enabled ?? false; + const accountingMethod = businessCentralConfig?.export?.accountingMethod ?? COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL; + const postingMode = businessCentralConfig?.export?.postingMode ?? CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_AND_POST; + const autoCreateEntities = businessCentralConfig?.export?.autoCreateEntities ?? true; + const exportToNextOpenPeriod = businessCentralConfig?.export?.exportToNextOpenPeriod ?? false; + const syncReimbursedReports = businessCentralConfig?.sync?.syncReimbursedReports ?? true; + const reimbursementBankAccount = bankAccounts?.find((bankAccount) => bankAccount.id === businessCentralConfig?.sync?.reimbursementBankAccountID); + const syncExpensifyCardSettlements = businessCentralConfig?.sync?.syncExpensifyCardSettlements ?? false; + const settlementsBankAccount = bankAccounts?.find((bankAccount) => bankAccount.id === businessCentralConfig?.sync?.settlementsBankAccountID); + const allCardSettings = useExpensifyCardFeeds(policyID); + const isExpensifyCardsEnabled = Object.values(allCardSettings ?? {}).some((cardSetting) => isExpensifyCardFullySetUp(policy, cardSetting)); + + const {isAccordionExpanded: isAutoSyncAccordionExpanded, shouldAnimateAccordionSection: shouldAnimateAutoSyncAccordionSection} = useAccordionAnimation(autoSync); + const {isAccordionExpanded: isSyncReimbursedReportsAccordionExpanded, shouldAnimateAccordionSection: shouldAnimateSyncReimbursedReportsAccordionSection} = + useAccordionAnimation(syncReimbursedReports); + const {isAccordionExpanded: isSyncExpensifyCardSettlementsAccordionExpanded, shouldAnimateAccordionSection: shouldAnimateSyncExpensifyCardSettlementsAccordionSection} = + useAccordionAnimation(syncExpensifyCardSettlements); + + return ( + + policyID && updateBusinessCentralAutoSync(policyID, !autoSync, autoSync)} + pendingAction={settingsPendingAction([CONST.BUSINESS_CENTRAL_CONFIG.AUTO_SYNC], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig ?? {}, CONST.BUSINESS_CENTRAL_CONFIG.AUTO_SYNC)} + onCloseError={() => policyID && clearBusinessCentralErrorField(policyID, CONST.BUSINESS_CENTRAL_CONFIG.AUTO_SYNC)} + /> + + + + policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_ACCOUNTING_METHOD.getRoute(policyID)) : undefined, + )} + > + + {areSettingsInErrorFields([CONST.BUSINESS_CENTRAL_CONFIG.ACCOUNTING_METHOD], businessCentralConfig?.errorFields) && ( + + )} + + + + + + + + + (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_ADVANCED_POSTING_MODE.getRoute(policyID)) : undefined)} + value={translate(`workspace.businessCentral.advanced.postingMode.values.${postingMode}.label`)} + > + {areSettingsInErrorFields([CONST.BUSINESS_CENTRAL_CONFIG.POSTING_MODE], businessCentralConfig?.errorFields) && ( + + )} + + + policyID && updateBusinessCentralAutoCreateEntities(policyID, !autoCreateEntities, autoCreateEntities)} + pendingAction={settingsPendingAction([CONST.BUSINESS_CENTRAL_CONFIG.AUTO_CREATE_ENTITIES], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig ?? {}, CONST.BUSINESS_CENTRAL_CONFIG.AUTO_CREATE_ENTITIES)} + onCloseError={() => policyID && clearBusinessCentralErrorField(policyID, CONST.BUSINESS_CENTRAL_CONFIG.AUTO_CREATE_ENTITIES)} + /> + policyID && updateBusinessCentralExportToNextOpenPeriod(policyID, !exportToNextOpenPeriod, exportToNextOpenPeriod)} + pendingAction={settingsPendingAction([CONST.BUSINESS_CENTRAL_CONFIG.EXPORT_TO_NEXT_OPEN_PERIOD], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig ?? {}, CONST.BUSINESS_CENTRAL_CONFIG.EXPORT_TO_NEXT_OPEN_PERIOD)} + onCloseError={() => policyID && clearBusinessCentralErrorField(policyID, CONST.BUSINESS_CENTRAL_CONFIG.EXPORT_TO_NEXT_OPEN_PERIOD)} + /> + + policyID && updateBusinessCentralSyncReimbursedReports(policyID, !syncReimbursedReports, syncReimbursedReports)} + pendingAction={settingsPendingAction([CONST.BUSINESS_CENTRAL_CONFIG.SYNC_REIMBURSED_REPORTS], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig ?? {}, CONST.BUSINESS_CENTRAL_CONFIG.SYNC_REIMBURSED_REPORTS)} + onCloseError={() => policyID && clearBusinessCentralErrorField(policyID, CONST.BUSINESS_CENTRAL_CONFIG.SYNC_REIMBURSED_REPORTS)} + /> + + + + policyID + ? Navigation.navigate( + ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_BANK_ACCOUNT_SELECTOR.getRoute(policyID, CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSEMENT_BANK_ACCOUNT_ID), + ) + : undefined + } + value={reimbursementBankAccount ? `${reimbursementBankAccount.number} ${reimbursementBankAccount.name}` : undefined} + > + {areSettingsInErrorFields([CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSEMENT_BANK_ACCOUNT_ID], businessCentralConfig?.errorFields) && ( + + )} + + + + {isExpensifyCardsEnabled && ( + <> + + policyID && updateBusinessCentralSyncExpensifyCardSettlements(policyID, !syncExpensifyCardSettlements, syncExpensifyCardSettlements)} + pendingAction={settingsPendingAction([CONST.BUSINESS_CENTRAL_CONFIG.SYNC_EXPENSIFY_CARD_SETTLEMENTS], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig ?? {}, CONST.BUSINESS_CENTRAL_CONFIG.SYNC_EXPENSIFY_CARD_SETTLEMENTS)} + onCloseError={() => policyID && clearBusinessCentralErrorField(policyID, CONST.BUSINESS_CENTRAL_CONFIG.SYNC_EXPENSIFY_CARD_SETTLEMENTS)} + /> + + + + policyID + ? Navigation.navigate( + ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_BANK_ACCOUNT_SELECTOR.getRoute(policyID, CONST.BUSINESS_CENTRAL_CONFIG.SETTLEMENTS_BANK_ACCOUNT_ID), + ) + : undefined + } + value={settlementsBankAccount ? `${settlementsBankAccount.number} ${settlementsBankAccount.name}` : undefined} + > + {areSettingsInErrorFields([CONST.BUSINESS_CENTRAL_CONFIG.SETTLEMENTS_BANK_ACCOUNT_ID], businessCentralConfig?.errorFields) && ( + + )} + + + + + )} + + ); +} + +export default withPolicyConnections(BusinessCentralAdvancedPage); diff --git a/src/pages/workspace/accounting/businessCentral/advanced/BusinessCentralBankAccountSelectPage.tsx b/src/pages/workspace/accounting/businessCentral/advanced/BusinessCentralBankAccountSelectPage.tsx new file mode 100644 index 000000000000..980d7182cdc8 --- /dev/null +++ b/src/pages/workspace/accounting/businessCentral/advanced/BusinessCentralBankAccountSelectPage.tsx @@ -0,0 +1,119 @@ +import BlockingView from '@components/BlockingViews/BlockingView'; +import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; +import Text from '@components/Text'; + +import useExpensifyCardFeeds from '@hooks/useExpensifyCardFeeds'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useSelectionListSearch from '@hooks/useSelectionListSearch'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearBusinessCentralErrorField, updateBusinessCentralReimbursementBankAccount, updateBusinessCentralSettlementsBankAccount} from '@libs/actions/connections/BusinessCentral'; +import {isExpensifyCardFullySetUp} from '@libs/CardUtils'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {settingsPendingAction} from '@libs/PolicyUtils'; + +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; +import {View} from 'react-native'; + +type BankAccountListItem = ListItem & { + value: string; +}; + +type BusinessCentralBankAccountSelectPageProps = WithPolicyConnectionsProps & + PlatformStackScreenProps; + +/** Picks the bank account Business Central records reimbursements or Expensify Card settlements against, depending on the setting in the route. */ +function BusinessCentralBankAccountSelectPage({policy, route}: BusinessCentralBankAccountSelectPageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + const policyID = policy?.id; + const settingName = route.params.settingName; + const isSettlementsBankAccount = settingName === CONST.BUSINESS_CENTRAL_CONFIG.SETTLEMENTS_BANK_ACCOUNT_ID; + const businessCentralConfig = policy?.connections?.businessCentral?.config; + const selectedBankAccountID = businessCentralConfig?.sync?.[settingName]; + const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_ADVANCED.getRoute(policyID) : undefined; + const allCardSettings = useExpensifyCardFeeds(policyID); + const isExpensifyCardsEnabled = Object.values(allCardSettings ?? {}).some((cardSetting) => isExpensifyCardFullySetUp(policy, cardSetting)); + const shouldBeBlocked = isSettlementsBankAccount + ? !isExpensifyCardsEnabled || !(businessCentralConfig?.sync?.syncExpensifyCardSettlements ?? false) + : !(businessCentralConfig?.sync?.syncReimbursedReports ?? true); + const translationKey = isSettlementsBankAccount ? 'settlementsBankAccount' : 'reimbursementBankAccount'; + + const data: BankAccountListItem[] = + policy?.connections?.businessCentral?.data?.bankAccounts?.map((bankAccount) => ({ + value: bankAccount.id, + text: `${bankAccount.number} ${bankAccount.name}`, + keyForList: bankAccount.id, + isSelected: selectedBankAccountID === bankAccount.id, + })) ?? []; + const {filteredData, textInputOptions} = useSelectionListSearch(data); + + const headerContent = ( + + {translate(`workspace.businessCentral.advanced.${translationKey}.description`)} + + ); + + const listEmptyContent = ( + + ); + + const selectBankAccount = (item: BankAccountListItem) => { + if (item.value !== selectedBankAccountID && policyID) { + if (isSettlementsBankAccount) { + updateBusinessCentralSettlementsBankAccount(policyID, item.value, selectedBankAccountID); + } else { + updateBusinessCentralReimbursementBankAccount(policyID, item.value, selectedBankAccountID); + } + } + Navigation.goBack(backPath); + }; + + return ( + Navigation.goBack(backPath)} + connectionName={CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL} + pendingAction={settingsPendingAction([settingName], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig, settingName)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => policyID && clearBusinessCentralErrorField(policyID, settingName)} + /> + ); +} + +export default withPolicyConnections(BusinessCentralBankAccountSelectPage); diff --git a/src/pages/workspace/accounting/businessCentral/advanced/BusinessCentralPostingModeSelectPage.tsx b/src/pages/workspace/accounting/businessCentral/advanced/BusinessCentralPostingModeSelectPage.tsx new file mode 100644 index 000000000000..2c831a19edf7 --- /dev/null +++ b/src/pages/workspace/accounting/businessCentral/advanced/BusinessCentralPostingModeSelectPage.tsx @@ -0,0 +1,79 @@ +import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearBusinessCentralErrorField, updateBusinessCentralPostingMode} from '@libs/actions/connections/BusinessCentral'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {settingsPendingAction} from '@libs/PolicyUtils'; + +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {BusinessCentralExport} from '@src/types/onyx/Policy'; + +import React from 'react'; +import {View} from 'react-native'; + +type PostingModeListItem = ListItem & { + value: BusinessCentralExport['postingMode']; +}; + +function BusinessCentralPostingModeSelectPage({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policyID = policy?.id; + const businessCentralConfig = policy?.connections?.businessCentral?.config; + const postingMode = businessCentralConfig?.export?.postingMode ?? CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_AND_POST; + const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_ADVANCED.getRoute(policyID) : undefined; + + const data: PostingModeListItem[] = [CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_AND_POST, CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_ONLY].map((postingModeItem) => ({ + value: postingModeItem, + text: translate(`workspace.businessCentral.advanced.postingMode.values.${postingModeItem}.label`), + alternateText: translate(`workspace.businessCentral.advanced.postingMode.values.${postingModeItem}.description`), + keyForList: postingModeItem, + isSelected: postingMode === postingModeItem, + })); + + const headerContent = ( + + {translate('workspace.businessCentral.advanced.postingMode.description')} + {translate('workspace.businessCentral.advanced.postingMode.note')} + + ); + + const selectPostingMode = (item: PostingModeListItem) => { + if (item.value !== postingMode && policyID) { + updateBusinessCentralPostingMode(policyID, item.value, postingMode); + } + Navigation.goBack(backPath); + }; + + return ( + Navigation.goBack(backPath)} + connectionName={CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL} + pendingAction={settingsPendingAction([CONST.BUSINESS_CENTRAL_CONFIG.POSTING_MODE], businessCentralConfig?.pendingFields)} + errors={getLatestErrorField(businessCentralConfig, CONST.BUSINESS_CENTRAL_CONFIG.POSTING_MODE)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => policyID && clearBusinessCentralErrorField(policyID, CONST.BUSINESS_CENTRAL_CONFIG.POSTING_MODE)} + /> + ); +} + +export default withPolicyConnections(BusinessCentralPostingModeSelectPage); diff --git a/src/pages/workspace/accounting/businessCentral/export/BusinessCentralExportPage.tsx b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralExportPage.tsx index 9265ce487c2c..b2963fb47308 100644 --- a/src/pages/workspace/accounting/businessCentral/export/BusinessCentralExportPage.tsx +++ b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralExportPage.tsx @@ -38,7 +38,6 @@ function BusinessCentralExportPage({policy}: WithPolicyConnectionsProps) { const exportConfig = businessCentralConfig?.export; // We use the logical OR (||) here instead of ?? because `exporter` could be an empty string on a fresh connection - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const exporter = exportConfig?.exporter || policy?.owner; const exportDate = exportConfig?.exportDate ?? CONST.BUSINESS_CENTRAL_EXPORT_DATE.LAST_EXPENSE; const reimbursable = exportConfig?.reimbursable ?? CONST.BUSINESS_CENTRAL_EXPORT_DESTINATION.JOURNAL_ENTRY; diff --git a/src/pages/workspace/accounting/businessCentral/export/BusinessCentralPaymentMethodSelectPage.tsx b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralPaymentMethodSelectPage.tsx index 2c5264566764..7dd2788fdea7 100644 --- a/src/pages/workspace/accounting/businessCentral/export/BusinessCentralPaymentMethodSelectPage.tsx +++ b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralPaymentMethodSelectPage.tsx @@ -10,6 +10,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearBusinessCentralErrorField, updateBusinessCentralPaymentMethod} from '@libs/actions/connections/BusinessCentral'; import {getLatestErrorField} from '@libs/ErrorUtils'; +import {withClearRow} from '@libs/ListUtils'; import Navigation from '@libs/Navigation/Navigation'; import {settingsPendingAction} from '@libs/PolicyUtils'; @@ -44,7 +45,7 @@ function BusinessCentralPaymentMethodSelectPage({policy}: WithPolicyConnectionsP keyForList: paymentMethod.code, isSelected: paymentMethodCode === paymentMethod.code, })) ?? []; - const {filteredData, textInputOptions} = useSelectionListSearch(paymentMethodOptions); + const {filteredData, textInputOptions, pinnedValues} = useSelectionListSearch(paymentMethodOptions); // The payment method is optional, and an empty code clears it const noneOption: PaymentMethodListItem = { @@ -53,7 +54,10 @@ function BusinessCentralPaymentMethodSelectPage({policy}: WithPolicyConnectionsP keyForList: '', isSelected: !paymentMethodCode, }; - const data = paymentMethodOptions.length > 0 ? [noneOption, ...filteredData] : []; + // Place the clear row below the top option only when that option is the one the hook actually pinned. + const firstOptionValue = filteredData.at(0)?.value; + const isSelectionPinnedToTop = firstOptionValue !== undefined && pinnedValues.includes(firstOptionValue); + const data = paymentMethodOptions.length > 0 ? withClearRow(filteredData, noneOption, isSelectionPinnedToTop) : []; const headerContent = ( diff --git a/src/pages/workspace/accounting/businessCentral/export/BusinessCentralPreferredExporterSelectPage.tsx b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralPreferredExporterSelectPage.tsx index 21e5b4e56c89..e3f8c83f6faf 100644 --- a/src/pages/workspace/accounting/businessCentral/export/BusinessCentralPreferredExporterSelectPage.tsx +++ b/src/pages/workspace/accounting/businessCentral/export/BusinessCentralPreferredExporterSelectPage.tsx @@ -35,7 +35,6 @@ function BusinessCentralPreferredExporterSelectPage({policy}: WithPolicyConnecti const savedExporter = businessCentralConfig?.export?.exporter; // We use the logical OR (||) here instead of ?? because `exporter` could be an empty string on a fresh connection - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const exporter = savedExporter || policyOwner; const exporters = getAdminEmployees(policy); const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_EXPORT.getRoute(policyID) : undefined; diff --git a/src/pages/workspace/accounting/campfire/export/CampfireCardProgramAccountSelectorPage.tsx b/src/pages/workspace/accounting/campfire/export/CampfireCardProgramAccountSelectorPage.tsx index ded0d93e8075..4e7f75192e7e 100644 --- a/src/pages/workspace/accounting/campfire/export/CampfireCardProgramAccountSelectorPage.tsx +++ b/src/pages/workspace/accounting/campfire/export/CampfireCardProgramAccountSelectorPage.tsx @@ -14,7 +14,7 @@ import {clearCampfireErrorField, updateCampfireCardProgramAccount} from '@libs/a import {findMatchingCards} from '@libs/CardFeedUtils'; import {getCustomOrFormattedFeedName} from '@libs/CardUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import {sortDefaultToTop} from '@libs/ListUtils'; +import {clearTrailingDefaultSeparator, sortDefaultToTop} from '@libs/ListUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -75,8 +75,11 @@ function CampfireCardProgramAccountSelectorPage({ keyForList: accountItem.id, isSelected: cardProgramAccountID === accountItem.id, })) ?? []; - const {filteredData: filteredUnprocessedData, textInputOptions} = useSelectionListSearch(data); - const filteredData = sortDefaultToTop(filteredUnprocessedData, (accountItem) => creditCardAccountID === accountItem.keyForList, styles); + // Sort the default account to the top first, then let useSelectionListSearch pin the selected account above it, + // so the currently selected account stays at the very top. + const sortedData = sortDefaultToTop(data, (accountItem) => creditCardAccountID === accountItem.keyForList, styles); + const {filteredData: pinnedData, textInputOptions} = useSelectionListSearch(sortedData); + const filteredData = clearTrailingDefaultSeparator(pinnedData, (accountItem) => creditCardAccountID === accountItem.keyForList); const headerContent = ( diff --git a/src/pages/workspace/accounting/campfire/export/CampfireDefaultCompanyCardVendorPage.tsx b/src/pages/workspace/accounting/campfire/export/CampfireDefaultCompanyCardVendorPage.tsx index 418a697d90ea..294acd1b1b1d 100644 --- a/src/pages/workspace/accounting/campfire/export/CampfireDefaultCompanyCardVendorPage.tsx +++ b/src/pages/workspace/accounting/campfire/export/CampfireDefaultCompanyCardVendorPage.tsx @@ -10,6 +10,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearCampfireErrorField, updateCampfireDefaultVendor} from '@libs/actions/connections/Campfire'; import {getLatestErrorField} from '@libs/ErrorUtils'; +import {withClearRow} from '@libs/ListUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getCampfireVendors, settingsPendingAction, sortVendors} from '@libs/PolicyUtils'; @@ -52,8 +53,11 @@ function CampfireDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsPro isSelected: !defaultCompanyCardVendorID, }; const shouldShowClearOption = !!defaultCompanyCardVendorID || vendorOptions.length > 0; - const {filteredData: filteredVendorOptions, textInputOptions} = useSelectionListSearch(vendorOptions); - const data: VendorListItem[] = shouldShowClearOption ? [clearOption, ...filteredVendorOptions] : filteredVendorOptions; + const {filteredData: filteredVendorOptions, textInputOptions, pinnedValues} = useSelectionListSearch(vendorOptions); + // Place the clear row below the top vendor only when that vendor is the one the hook actually pinned. + const firstVendorValue = filteredVendorOptions.at(0)?.value; + const isSelectionPinnedToTop = firstVendorValue !== undefined && pinnedValues.includes(firstVendorValue); + const data: VendorListItem[] = shouldShowClearOption ? withClearRow(filteredVendorOptions, clearOption, isSelectionPinnedToTop) : filteredVendorOptions; const headerContent = ( diff --git a/src/pages/workspace/accounting/certinia/export/CertiniaExportPage.tsx b/src/pages/workspace/accounting/certinia/export/CertiniaExportPage.tsx index aca9fef4a834..2e71189b9999 100644 --- a/src/pages/workspace/accounting/certinia/export/CertiniaExportPage.tsx +++ b/src/pages/workspace/accounting/certinia/export/CertiniaExportPage.tsx @@ -50,7 +50,6 @@ function CertiniaExportPage({policy}: WithPolicyConnectionsProps) { const preferredExporterRow: ExportRow = { description: translate('workspace.accounting.preferredExporter'), // We use the logical OR (||) here instead of ?? because `exporter` could be an empty string on a fresh connection - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing title: exportConfig?.exporter || policyOwner, onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_PREFERRED_EXPORTER.path)), subscribedSettings: [CONST.CERTINIA_CONFIG.EXPORTER], diff --git a/src/pages/workspace/accounting/certinia/export/CertiniaPreferredExporterPage.tsx b/src/pages/workspace/accounting/certinia/export/CertiniaPreferredExporterPage.tsx index d12f9ec068d8..b87c0cf802df 100644 --- a/src/pages/workspace/accounting/certinia/export/CertiniaPreferredExporterPage.tsx +++ b/src/pages/workspace/accounting/certinia/export/CertiniaPreferredExporterPage.tsx @@ -34,7 +34,6 @@ function CertiniaPreferredExporterPage({policy}: WithPolicyConnectionsProps) { const {config} = policy?.connections?.financialforce ?? {}; const exportConfig = config?.export; // We use the logical OR (||) here instead of ?? because `exporter` could be an empty string on a fresh connection - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const selectedExporter = exportConfig?.exporter || policyOwner; const exporters = getAdminEmployees(policy); const {login: currentUserLogin} = useCurrentUserPersonalDetails(); diff --git a/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountSelectorPage.tsx b/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountSelectorPage.tsx index 53d89a078e01..606aaf23fb1d 100644 --- a/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountSelectorPage.tsx +++ b/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountSelectorPage.tsx @@ -14,7 +14,7 @@ import {clearDualEntryErrorField, updateDualEntryCardProgramAccount} from '@libs import {findMatchingCards} from '@libs/CardFeedUtils'; import {getCustomOrFormattedFeedName} from '@libs/CardUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import {sortDefaultToTop} from '@libs/ListUtils'; +import {clearTrailingDefaultSeparator, sortDefaultToTop} from '@libs/ListUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -75,8 +75,11 @@ function DualEntryCardProgramAccountSelectorPage({ keyForList: accountItem.id, isSelected: cardProgramAccountID === accountItem.id, })) ?? []; - const {filteredData: filteredUnprocessedData, textInputOptions} = useSelectionListSearch(data); - const filteredData = sortDefaultToTop(filteredUnprocessedData, (accountItem) => creditCardAccountID === accountItem.keyForList, styles); + // Sort the default account to the top first, then let useSelectionListSearch pin the selected account above it, + // so the currently selected account stays at the very top. + const sortedData = sortDefaultToTop(data, (accountItem) => creditCardAccountID === accountItem.keyForList, styles); + const {filteredData: pinnedData, textInputOptions} = useSelectionListSearch(sortedData); + const filteredData = clearTrailingDefaultSeparator(pinnedData, (accountItem) => creditCardAccountID === accountItem.keyForList); const headerContent = ( diff --git a/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx b/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx index d756b29002fd..1a82261da242 100644 --- a/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx +++ b/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx @@ -10,6 +10,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearDualEntryErrorField, updateDualEntryDefaultVendor} from '@libs/actions/connections/DualEntry'; import {getLatestErrorField} from '@libs/ErrorUtils'; +import {withClearRow} from '@libs/ListUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getDualEntryVendors, settingsPendingAction, sortVendors} from '@libs/PolicyUtils'; @@ -52,8 +53,11 @@ function DualEntryDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsPr isSelected: !defaultCompanyCardVendorID, }; const shouldShowClearOption = !!defaultCompanyCardVendorID || vendorOptions.length > 0; - const {filteredData: filteredVendorOptions, textInputOptions} = useSelectionListSearch(vendorOptions); - const data: VendorListItem[] = shouldShowClearOption ? [clearOption, ...filteredVendorOptions] : filteredVendorOptions; + const {filteredData: filteredVendorOptions, textInputOptions, pinnedValues} = useSelectionListSearch(vendorOptions); + // Place the clear row below the top vendor only when that vendor is the one the hook actually pinned. + const firstVendorValue = filteredVendorOptions.at(0)?.value; + const isSelectionPinnedToTop = firstVendorValue !== undefined && pinnedValues.includes(firstVendorValue); + const data: VendorListItem[] = shouldShowClearOption ? withClearRow(filteredVendorOptions, clearOption, isSelectionPinnedToTop) : filteredVendorOptions; const headerContent = ( diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctExportPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctExportPage.tsx index 5a84b0ac259c..ba5c88ab00e2 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctExportPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctExportPage.tsx @@ -11,11 +11,12 @@ import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import {getCardSettings} from '@libs/CardUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; -import replaceCompanyCardsRoute from '@libs/Navigation/helpers/replaceCompanyCardsRoute'; import Navigation from '@libs/Navigation/Navigation'; import {areSettingsInErrorFields, getCurrentSageIntacctEntityName, settingsPendingAction} from '@libs/PolicyUtils'; import {getIsTravelBillingEnabled, getTravelBillingCardSettingsKey} from '@libs/TravelBillingUtils'; +import goBackFromExportConnection from '@navigation/helpers/goBackFromExportConnection'; + import type {WithPolicyProps} from '@pages/workspace/withPolicy'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; @@ -30,6 +31,10 @@ function DynamicSageIntacctExportPage({policy}: WithPolicyProps) { const policyID = policy?.id; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path); const {export: exportConfig, pendingFields, errorFields} = policy?.connections?.intacct?.config ?? {}; + // Skip the per-card list on back unless Intacct is exporting as Vendor bill or Credit card charge (see the SAGE_INTACCT case in getExportMenuItem) + const sageConfig = exportConfig?.nonReimbursable ?? exportConfig?.reimbursable; + const shouldGoBackToSpecificRoute = + sageConfig !== CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL && sageConfig !== CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE; const travelPayableAccount = policy?.connections?.intacct?.data?.creditCards?.find((account) => account.name === exportConfig?.travelInvoicingPayableAccountID); const workspaceAccountID = useWorkspaceAccountID(policyID); @@ -88,7 +93,7 @@ function DynamicSageIntacctExportPage({policy}: WithPolicyProps) { title="workspace.sageIntacct.exportDescription" accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} policyID={policyID} - onBackButtonPress={() => Navigation.goBack(replaceCompanyCardsRoute(backPath))} + onBackButtonPress={() => goBackFromExportConnection(shouldGoBackToSpecificRoute, backPath)} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} contentContainerStyle={styles.pb2} titleStyle={styles.ph5} diff --git a/src/pages/workspace/accounting/netsuite/utils.ts b/src/pages/workspace/accounting/netsuite/utils.ts index 452c3b755ec0..8e2cde3ea35c 100644 --- a/src/pages/workspace/accounting/netsuite/utils.ts +++ b/src/pages/workspace/accounting/netsuite/utils.ts @@ -1,11 +1,12 @@ import {isAuthenticationError} from '@libs/actions/connections'; import {canUseProvincialTaxNetSuite, canUseTaxNetSuite} from '@libs/NetSuiteUtils'; +import {isArchivedPolicy} from '@libs/PolicyUtils/policyType'; import CONST from '@src/CONST'; import type {NetSuiteConnectionConfig, NetSuiteSubsidiary} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; function shouldHideReimbursedReportsSection(config?: NetSuiteConnectionConfig) { @@ -91,6 +92,67 @@ function getInitialSubPageForNetsuiteTokenInput(policy: OnyxEntry) { return CONST.NETSUITE_CONFIG.TOKEN_INPUT.PAGE_NAME.INSTALL; } +type NetSuiteOAuthMigrationSeverity = 'info' | 'warning' | 'danger'; + +const NETSUITE_OAUTH_DEADLINE = new Date('2027-02-01'); +const NETSUITE_OAUTH_DANGER_THRESHOLD_DAYS = 30; +const NETSUITE_OAUTH_WARNING_THRESHOLD_DAYS = 90; + +function hasSimpleNetSuiteSyncConfiguration(policy?: Policy): boolean { + const syncOptions = policy?.connections?.netsuite?.options?.config?.syncOptions; + return ( + !!syncOptions && + !syncOptions.syncTax && + !syncOptions.syncReimbursedReports && + !syncOptions.syncCustomSegments && + !syncOptions.customSegments?.length && + !syncOptions.syncCustomLists && + !syncOptions.customLists?.length + ); +} + +function shouldShowNetSuiteOAuthMigrationNudge(policy: Policy | undefined, allPolicies: OnyxCollection | undefined): boolean { + const netsuite = policy?.connections?.netsuite; + if ( + !netsuite || + netsuite.verified === false || + netsuite.config?.authType === CONST.NETSUITE_CONFIG.TOKEN_INPUT.AUTH_TYPE.OAUTH2 || + !netsuite.accountID || + !netsuite.tokenID || + !netsuite.tokenSecret || + !hasSimpleNetSuiteSyncConfiguration(policy) || + !allPolicies + ) { + return false; + } + + // OAuth updates all policies that share a NetSuite account. + // Therefore each policy must have a simple configuration before we show the nudge. + return !Object.values(allPolicies).some((sharedPolicy) => { + const sharedNetSuite = sharedPolicy?.connections?.netsuite; + return ( + !!sharedPolicy && + sharedPolicy.role === CONST.POLICY.ROLE.ADMIN && + !isArchivedPolicy(sharedPolicy) && + sharedNetSuite?.accountID === netsuite.accountID && + sharedNetSuite.config?.authType !== CONST.NETSUITE_CONFIG.TOKEN_INPUT.AUTH_TYPE.OAUTH2 && + !hasSimpleNetSuiteSyncConfiguration(sharedPolicy) + ); + }); +} + +function getNetSuiteOAuthMigrationSeverity(): NetSuiteOAuthMigrationSeverity { + const now = new Date(); + const daysRemaining = (NETSUITE_OAUTH_DEADLINE.getTime() - now.getTime()) / (1000 * 60 * 60 * 24); + if (daysRemaining <= NETSUITE_OAUTH_DANGER_THRESHOLD_DAYS) { + return 'danger'; + } + if (daysRemaining <= NETSUITE_OAUTH_WARNING_THRESHOLD_DAYS) { + return 'warning'; + } + return 'info'; +} + export { shouldHideReimbursedReportsSection, shouldHideReportsExportTo, @@ -108,4 +170,6 @@ export { shouldHideExportForeignCurrencyAmount, getImportCustomFieldsSettings, getInitialSubPageForNetsuiteTokenInput, + shouldShowNetSuiteOAuthMigrationNudge, + getNetSuiteOAuthMigrationSeverity, }; diff --git a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopExportPage.tsx b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopExportPage.tsx index fdc163382946..7127fa394e56 100644 --- a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopExportPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopExportPage.tsx @@ -62,7 +62,6 @@ function DynamicQuickbooksDesktopExportPage({policy}: WithPolicyConnectionsProps description: translate('workspace.accounting.preferredExporter'), onPress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_PREFERRED_EXPORTER.path)), // We use the logical OR (||) here instead of ?? because `exporter` could be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing title: qbdConfig?.export?.exporter || policyOwner, subscribedSettings: [CONST.QUICKBOOKS_DESKTOP_CONFIG.EXPORTER], }, diff --git a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopPreferredExporterConfigurationPage.tsx b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopPreferredExporterConfigurationPage.tsx index af487ac568cf..2e09b96c34c8 100644 --- a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopPreferredExporterConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopPreferredExporterConfigurationPage.tsx @@ -57,7 +57,6 @@ function DynamicQuickbooksDesktopPreferredExporterConfigurationPage({policy}: Wi text: exporter.email, keyForList: exporter.email, // We use the logical OR (||) here instead of ?? because `exporter` could be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing isSelected: (currentExporter || policy?.owner) === exporter.email, }); return options; diff --git a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksExportConfigurationPage.tsx b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksExportConfigurationPage.tsx index 699134dae69d..eccdfed91f40 100644 --- a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksExportConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksExportConfigurationPage.tsx @@ -10,10 +10,9 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; -import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; import {getCardSettings} from '@libs/CardUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; -import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils'; +import {areSettingsInErrorFields, settingsPendingAction, shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/PolicyUtils'; import {getIsTravelBillingEnabled, getTravelBillingCardSettingsKey} from '@libs/TravelBillingUtils'; import goBackFromExportConnection from '@navigation/helpers/goBackFromExportConnection'; diff --git a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx index 72a976f03840..aac90ab953ad 100644 --- a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx @@ -7,9 +7,8 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; -import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils'; +import {areSettingsInErrorFields, settingsPendingAction, shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; diff --git a/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccountSelector.tsx b/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccountSelector.tsx index 8e6c1212b267..0fafca4588e6 100644 --- a/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccountSelector.tsx +++ b/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccountSelector.tsx @@ -14,7 +14,7 @@ import {clearRilletErrorField, updateRilletCardProgramAccount} from '@libs/actio import {findMatchingCards} from '@libs/CardFeedUtils'; import {getCustomOrFormattedFeedName} from '@libs/CardUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import {sortDefaultToTop} from '@libs/ListUtils'; +import {clearTrailingDefaultSeparator, sortDefaultToTop} from '@libs/ListUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -77,8 +77,11 @@ function RilletCardProgramAccountSelector({ keyForList: accountItem.code, isSelected: cardProgramAccountCode === accountItem.code, })) ?? []; - const {filteredData: filteredUnprocessedData, textInputOptions} = useSelectionListSearch(data); - const filteredData = sortDefaultToTop(filteredUnprocessedData, (accountItem) => creditCardAccountCode === accountItem.keyForList, styles); + // Sort the default account to the top first, then let useSelectionListSearch pin the selected account above it, + // so the currently selected account stays at the very top. + const sortedData = sortDefaultToTop(data, (accountItem) => creditCardAccountCode === accountItem.keyForList, styles); + const {filteredData: pinnedData, textInputOptions} = useSelectionListSearch(sortedData); + const filteredData = clearTrailingDefaultSeparator(pinnedData, (accountItem) => creditCardAccountCode === accountItem.keyForList); const headerContent = ( diff --git a/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx b/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx index 560b686872ee..8c5f7e8a2e97 100644 --- a/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx +++ b/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx @@ -10,6 +10,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearRilletErrorField, updateRilletDefaultVendor} from '@libs/actions/connections/Rillet'; import {getLatestErrorField} from '@libs/ErrorUtils'; +import {withClearRow} from '@libs/ListUtils'; import Navigation from '@libs/Navigation/Navigation'; import {settingsPendingAction, sortVendors} from '@libs/PolicyUtils'; @@ -54,8 +55,11 @@ function RilletDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsProps isSelected: !defaultCompanyCardVendorID, }; const shouldShowClearOption = !!defaultCompanyCardVendorID || vendorOptions.length > 0; - const {filteredData: filteredVendorOptions, textInputOptions} = useSelectionListSearch(vendorOptions); - const data: VendorListItem[] = shouldShowClearOption ? [clearOption, ...filteredVendorOptions] : filteredVendorOptions; + const {filteredData: filteredVendorOptions, textInputOptions, pinnedValues} = useSelectionListSearch(vendorOptions); + // Place the clear row below the top vendor only when that vendor is the one the hook actually pinned. + const firstVendorValue = filteredVendorOptions.at(0)?.value; + const isSelectionPinnedToTop = firstVendorValue !== undefined && pinnedValues.includes(firstVendorValue); + const data: VendorListItem[] = shouldShowClearOption ? withClearRow(filteredVendorOptions, clearOption, isSelectionPinnedToTop) : filteredVendorOptions; const headerContent = ( diff --git a/src/pages/workspace/accounting/useConnectedAccountingIntegration.tsx b/src/pages/workspace/accounting/useConnectedAccountingIntegration.tsx index 85f72d36710c..6e65bd66bf51 100644 --- a/src/pages/workspace/accounting/useConnectedAccountingIntegration.tsx +++ b/src/pages/workspace/accounting/useConnectedAccountingIntegration.tsx @@ -20,7 +20,6 @@ import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import {getQBORefreshTokenExpiryDate, getQBORefreshTokenExpiryStatus} from '@libs/AccountingUtils'; import {isAuthenticationError, isConnectionInProgress, isConnectionUnverified, removePolicyConnection, syncConnection} from '@libs/actions/connections'; -import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; import {isExpensifyCardFullySetUp} from '@libs/CardUtils'; import DateUtils from '@libs/DateUtils'; import {getOldDotURLFromEnvironment} from '@libs/Environment/Environment'; @@ -35,6 +34,7 @@ import { hasAccountingConnections, hasSupportedOnlyOnOldDotIntegration, settingsPendingAction, + shouldShowQBOReimbursableExportDestinationAccountError, shouldShowSyncError, } from '@libs/PolicyUtils'; @@ -105,6 +105,7 @@ function useConnectedAccountingIntegration(policy: OnyxEntry, {menuItemW 'DualEntrySquare', 'CampfireSquare', 'BusinessCentralSquare', + 'ZohoBooksSquare', ]); const [cardFeeds] = useCardFeeds(policyID); const [cardLists] = useCardsLists(); @@ -133,10 +134,14 @@ function useConnectedAccountingIntegration(policy: OnyxEntry, {menuItemW const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); const connectionSyncStage = connectionSyncProgress?.stageInProgress; const canUseBusinessCentralIntegration = isBetaEnabled(CONST.BETAS.BUSINESS_CENTRAL) || !!policy?.connections?.businessCentral; + const canUseZohoBooksIntegration = isBetaEnabled(CONST.BETAS.ZOHO_BOOKS) || !!policy?.connections?.zohoBooks; const accountingIntegrations = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.filter((name) => { if (name === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { return canUseBusinessCentralIntegration; } + if (name === CONST.POLICY.CONNECTIONS.NAME.ZOHO_BOOKS) { + return canUseZohoBooksIntegration; + } return true; }); const syncingAccountingIntegration = accountingIntegrations.find((integration) => integration === connectionSyncProgress?.connectionName); @@ -217,7 +222,7 @@ function useConnectedAccountingIntegration(policy: OnyxEntry, {menuItemW { icon: icons.Sync, text: translate('workspace.accounting.syncNow'), - onSelected: () => syncConnection(policy, connectedIntegration), + onSelected: () => syncConnection(policy, connectedIntegration, connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBD), disabled: isOffline, }, ] @@ -258,6 +263,7 @@ function useConnectedAccountingIntegration(policy: OnyxEntry, {menuItemW const dualEntryCompanyList = policy?.connections?.dualEntry?.data?.companies; const campfireSubsidiaryList = policy?.connections?.campfire?.data?.subsidiaries; const businessCentralCompanyList = policy?.connections?.businessCentral?.data?.companies; + const zohoBooksOrganizationList = policy?.connections?.zohoBooks?.data?.organizations; const certiniaConfig = policy?.connections?.financialforce?.config; const certiniaCompanies = policy?.connections?.financialforce?.data?.companies ?? []; const certiniaCompanyID = getCertiniaSelectedCompanyID(certiniaConfig); @@ -435,6 +441,19 @@ function useConnectedAccountingIntegration(policy: OnyxEntry, {menuItemW ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_COMPANY_SELECTOR.getRoute(policyID)) : undefined, }; + case CONST.POLICY.CONNECTIONS.NAME.ZOHO_BOOKS: + return !zohoBooksOrganizationList?.length + ? {} + : { + description: translate('workspace.zohoBooks.organization'), + title: zohoBooksOrganizationList.find((organization) => organization.id === policy?.connections?.zohoBooks?.config?.subsidiaryID)?.name ?? '', + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowDescriptionOnTop: true, + interactive: false, + pendingAction: policy?.connections?.zohoBooks?.config?.pendingFields?.subsidiaryID, + brickRoadIndicator: policy?.connections?.zohoBooks?.config?.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + }; default: return undefined; @@ -465,7 +484,9 @@ function useConnectedAccountingIntegration(policy: OnyxEntry, {menuItemW connectionMessage = translate('workspace.accounting.lastSync', datetimeToRelative); } - const configurationOptions = canWriteAccounting + // Zoho Books import, export, and advanced screens are added in a later release, so hide their entry points until then. + const shouldShowConfigurationOptions = canWriteAccounting && connectedIntegration !== CONST.POLICY.CONNECTIONS.NAME.ZOHO_BOOKS; + const configurationOptions = shouldShowConfigurationOptions ? [ { icon: icons.Pencil, diff --git a/src/pages/workspace/accounting/utils.tsx b/src/pages/workspace/accounting/utils.tsx index ccc6a79b486e..cde9eb2370b8 100644 --- a/src/pages/workspace/accounting/utils.tsx +++ b/src/pages/workspace/accounting/utils.tsx @@ -8,6 +8,7 @@ import ConnectToQuickbooksOnlineFlow from '@components/ConnectToQuickbooksOnline import ConnectToRilletFlow from '@components/ConnectToRilletFlow'; import ConnectToSageIntacctFlow from '@components/ConnectToSageIntacctFlow'; import ConnectToXeroFlow from '@components/ConnectToXeroFlow'; +import ConnectToZohoBooksFlow from '@components/ConnectToZohoBooksFlow'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; @@ -76,7 +77,8 @@ type AccountingIntegrationIcons = Record< | 'RilletSquare' | 'DualEntrySquare' | 'CampfireSquare' - | 'BusinessCentralSquare', + | 'BusinessCentralSquare' + | 'ZohoBooksSquare', IconAsset >; @@ -682,6 +684,26 @@ function getAccountingIntegrationData( errorFields: policy?.connections?.campfire?.config?.errorFields, }; } + case CONST.POLICY.CONNECTIONS.NAME.ZOHO_BOOKS: + return { + title: translate('workspace.accounting.zohoBooks'), + icon: expensifyIcons?.ZohoBooksSquare, + setupConnectionFlow: ( + + ), + // Import, export, and advanced screens are added in later releases. + onImportPagePress: () => null, + subscribedImportSettings: [], + onExportPagePress: () => null, + subscribedExportSettings: [], + onAdvancedPagePress: () => null, + subscribedAdvancedSettings: [], + pendingFields: policy?.connections?.zohoBooks?.config?.pendingFields, + errorFields: policy?.connections?.zohoBooks?.config?.errorFields, + }; case CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL: { return { title: translate('workspace.accounting.businessCentral'), @@ -712,8 +734,18 @@ function getAccountingIntegrationData( CONST.BUSINESS_CENTRAL_CONFIG.NON_REIMBURSABLE_ACCOUNT, CONST.BUSINESS_CENTRAL_CONFIG.PAYMENT_METHOD_CODE, ], - onAdvancedPagePress: () => null, - subscribedAdvancedSettings: [], + onAdvancedPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_ADVANCED.getRoute(policyID)), + subscribedAdvancedSettings: [ + CONST.BUSINESS_CENTRAL_CONFIG.AUTO_SYNC, + CONST.BUSINESS_CENTRAL_CONFIG.ACCOUNTING_METHOD, + CONST.BUSINESS_CENTRAL_CONFIG.POSTING_MODE, + CONST.BUSINESS_CENTRAL_CONFIG.AUTO_CREATE_ENTITIES, + CONST.BUSINESS_CENTRAL_CONFIG.EXPORT_TO_NEXT_OPEN_PERIOD, + CONST.BUSINESS_CENTRAL_CONFIG.SYNC_REIMBURSED_REPORTS, + CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSEMENT_BANK_ACCOUNT_ID, + CONST.BUSINESS_CENTRAL_CONFIG.SYNC_EXPENSIFY_CARD_SETTLEMENTS, + CONST.BUSINESS_CENTRAL_CONFIG.SETTLEMENTS_BANK_ACCOUNT_ID, + ], workspaceUpgradeNavigationDetails: { integrationAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.businessCentral.alias, backToAfterWorkspaceUpgradeRoute: integrationToDisconnect diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx index 6986442cc594..50497207b12e 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -4,6 +4,7 @@ import SelectionScreen from '@components/SelectionScreen'; import Text from '@components/Text'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useInitialValue from '@hooks/useInitialValue'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -13,6 +14,7 @@ import {clearXeroErrorField} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getXeroSuppliers, isXeroVendorMatchingActive, settingsPendingAction, sortVendors} from '@libs/PolicyUtils'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; @@ -80,21 +82,22 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy // doesn't need the extra search row. const shouldShowTextInput = supplierOptions.length >= CONST.STANDARD_LIST_ITEM_LIMIT; - const filteredSupplierOptions = useMemo( - () => (shouldShowTextInput ? tokenizedSearch(supplierOptions, searchText, (option) => [option.text ?? '']) : supplierOptions), - [shouldShowTextInput, supplierOptions, searchText], - ); - - // Only prepend the clear row when there's a default to clear or there are suppliers to choose - // between. Without this guard, an unsynced workspace with no defaultVendor set would render - // `[None]` and never show the noSuppliersFound BlockingView (SelectionScreen only renders - // listEmptyContent when data is empty). The clear row stays visible regardless of the search - // term so it's always reachable for clearing an existing default. + // Only include the clear row when there's a default to clear or there are suppliers to choose between. Without this + // guard, an unsynced workspace with no defaultVendor set would render `[None]` and never show the noVendorsFound + // BlockingView (SelectionScreen only renders listEmptyContent when data is empty). const shouldShowClearOption = !!currentContactID || supplierOptions.length > 0; - const data: SelectorType[] = useMemo( - () => (shouldShowClearOption ? [clearOption, ...filteredSupplierOptions] : filteredSupplierOptions), - [shouldShowClearOption, clearOption, filteredSupplierOptions], - ); + + // Keep the clear row inside the ordered list so a pinned selection sits above it (matching the other account + // pickers, where "Default"/"None" shows below the selected item). Freeze the pinned value at mount so the optimistic + // update on select doesn't reorder the list for a frame before the page navigates back. + const initialContactID = useInitialValue(() => currentContactID); + const allOptions = shouldShowClearOption ? [clearOption, ...supplierOptions] : supplierOptions; + const orderedOptions = shouldShowTextInput ? moveInitialSelectionToTop(allOptions, initialContactID ? [initialContactID] : []) : allOptions; + + // Keep the clear row visible regardless of the search term so it stays reachable while filtering. + const data: SelectorType[] = shouldShowTextInput + ? orderedOptions.filter((option) => option.value === CLEAR_DEFAULT_VENDOR_VALUE || tokenizedSearch([option], searchText, (item) => [item.text ?? '']).length > 0) + : orderedOptions; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT.path); @@ -145,8 +148,8 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy icon={illustrations.Telescope} iconWidth={variables.emptyListIconWidth} iconHeight={variables.emptyListIconHeight} - title={translate('workspace.xero.noSuppliersFound')} - subtitle={translate('workspace.xero.noSuppliersFoundDescription')} + title={translate('workspace.xero.noVendorsFound')} + subtitle={translate('workspace.xero.noVendorsFoundDescription')} containerStyle={styles.pb10} /> ), diff --git a/src/pages/workspace/accounting/zohoBooks/ZohoBooksOAuthPage/index.native.tsx b/src/pages/workspace/accounting/zohoBooks/ZohoBooksOAuthPage/index.native.tsx new file mode 100644 index 000000000000..da7ece9341be --- /dev/null +++ b/src/pages/workspace/accounting/zohoBooks/ZohoBooksOAuthPage/index.native.tsx @@ -0,0 +1,30 @@ +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; + +import {getZohoBooksSetupLink} from '@libs/actions/connections/ZohoBooks'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import AccountingSetupWebViewPage from '@pages/workspace/accounting/AccountingSetupWebViewPage'; +import {getAccountingConnectionsRoute} from '@pages/workspace/connections/utils'; + +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +type ZohoBooksOAuthPageProps = PlatformStackScreenProps; + +function ZohoBooksOAuthPage({route}: ZohoBooksOAuthPageProps) { + const policyID = route.params.policyID; + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + + return ( + + ); +} + +export default ZohoBooksOAuthPage; diff --git a/src/pages/workspace/accounting/zohoBooks/ZohoBooksOAuthPage/index.tsx b/src/pages/workspace/accounting/zohoBooks/ZohoBooksOAuthPage/index.tsx new file mode 100644 index 000000000000..b3bb8ac33796 --- /dev/null +++ b/src/pages/workspace/accounting/zohoBooks/ZohoBooksOAuthPage/index.tsx @@ -0,0 +1,30 @@ +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import {useEffect} from 'react'; + +type ZohoBooksOAuthPageProps = PlatformStackScreenProps; + +function ZohoBooksOAuthPage({route}: ZohoBooksOAuthPageProps) { + const policyID = route.params.policyID; + + useEffect(() => { + // On web the setup form opens the Zoho Books OAuth link inline (new tab), so this screen has no content. It is + // only reached via a deep link, so just return to the workspace accounting page. We wait for the RHP open + // transition to finish first, otherwise goBack fires mid-transition and is dropped. + const handle = TransitionTracker.runAfterTransitions({ + callback: () => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)), + waitForUpcomingTransition: true, + }); + return () => handle.cancel(); + }, [policyID]); + + return null; +} + +export default ZohoBooksOAuthPage; diff --git a/src/pages/workspace/archiveWorkspace/ArchiveWorkspaceFlow.tsx b/src/pages/workspace/archiveWorkspace/ArchiveWorkspaceFlow.tsx index 1f38cde13ee1..d65d06e8826a 100644 --- a/src/pages/workspace/archiveWorkspace/ArchiveWorkspaceFlow.tsx +++ b/src/pages/workspace/archiveWorkspace/ArchiveWorkspaceFlow.tsx @@ -62,7 +62,7 @@ function ArchiveWorkspaceFlow({policyID, onDismiss, onArchiveComplete}: ArchiveW const hasCardFeedOrExpensifyCard = !isEmptyObject(cardFeeds) || !isEmptyObject(cardsList) || - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- both flags are `boolean | undefined`, so we need a logical OR here; `??` would stop at an explicit `false` and never check the second flag + // both flags are `boolean | undefined`, so we need a logical OR here; `??` would stop at an explicit `false` and never check the second flag ((policy?.areExpensifyCardsEnabled || policy?.areCompanyCardsEnabled) && policy?.policyAccountID); const hasExpensifyCardsEnabledOnWorkspace = !!policy?.areExpensifyCardsEnabled && !!policy?.policyAccountID; diff --git a/src/pages/workspace/categories/CategoryForm.tsx b/src/pages/workspace/categories/CategoryForm.tsx index 3742095facff..5dacb68f9d5a 100644 --- a/src/pages/workspace/categories/CategoryForm.tsx +++ b/src/pages/workspace/categories/CategoryForm.tsx @@ -64,8 +64,7 @@ function CategoryForm({onSubmit, policyCategories, categoryName, validateEdit, a formID={ONYXKEYS.FORMS.WORKSPACE_CATEGORY_FORM} onSubmit={submit} submitButtonText={translate('common.save')} - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - validate={validateEdit || validate} + validate={validateEdit ?? validate} style={[styles.mh5, styles.flex1]} enabledWhenOffline shouldHideFixErrorsAlert diff --git a/src/pages/workspace/categories/DynamicSpendCategorySelectorPage.tsx b/src/pages/workspace/categories/DynamicSpendCategorySelectorPage.tsx deleted file mode 100644 index 2030bc5d3eec..000000000000 --- a/src/pages/workspace/categories/DynamicSpendCategorySelectorPage.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import CategoryPicker from '@components/CategoryPicker'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import type {ListItem} from '@components/SelectionList/types'; - -import useDynamicBackPath from '@hooks/useDynamicBackPath'; -import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; - -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; - -import {setWorkspaceDefaultSpendCategory} from '@userActions/Policy/Policy'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; - -import React from 'react'; - -type DynamicSpendCategorySelectorPageProps = PlatformStackScreenProps; - -function DynamicSpendCategorySelectorPage({route}: DynamicSpendCategorySelectorPageProps) { - const {policyID, groupID} = route.params; - const styles = useThemeStyles(); - const backPath = useDynamicBackPath(DYNAMIC_ROUTES.SPEND_CATEGORY_SELECTOR.path); - - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(policyID)}`); - const label = groupID.charAt(0).toUpperCase() + groupID.slice(1); - const currentCategory = policy?.mccGroup?.[groupID]?.category ?? ''; - - const onCategorySelected = (selectedCategory: ListItem) => { - if (!selectedCategory.keyForList) { - return; - } - if (currentCategory === selectedCategory.keyForList) { - Navigation.goBack(backPath); - return; - } - setWorkspaceDefaultSpendCategory(policyID, groupID, selectedCategory.keyForList, policy?.mccGroup); - Navigation.goBack(backPath); - }; - - return ( - - - Navigation.goBack(backPath)} - /> - - - - ); -} - -export default DynamicSpendCategorySelectorPage; diff --git a/src/pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage.tsx b/src/pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage.tsx deleted file mode 100644 index 0772828a0ca5..000000000000 --- a/src/pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; - -import useDynamicBackPath from '@hooks/useDynamicBackPath'; -import useLocalize from '@hooks/useLocalize'; -import usePolicyData from '@hooks/usePolicyData'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {hasEnabledOptions} from '@libs/OptionsListUtils'; - -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; -import withPolicyConnections from '@pages/workspace/withPolicyConnections'; -import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; - -import {setPolicyAutoCategorizeNewExpenses, setPolicyShowCategoryGLCodes} from '@userActions/Policy/Category'; -import {clearPolicyErrorField} from '@userActions/Policy/Policy'; - -import CONST from '@src/CONST'; -import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; - -import React from 'react'; - -type DynamicWorkspaceCategoriesSettingsPageProps = WithPolicyConnectionsProps & - ( - | PlatformStackScreenProps - | PlatformStackScreenProps - ); - -function DynamicWorkspaceCategoriesSettingsPage({policy, route}: DynamicWorkspaceCategoriesSettingsPageProps) { - const {policyID} = route.params; - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const policyData = usePolicyData(policyID); - const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORIES_SETTINGS; - const backPath = useDynamicBackPath(DYNAMIC_ROUTES.SETTINGS_CATEGORIES_SETTINGS.path); - - const updateAutoCategorizeNewExpenses = (value: boolean) => { - setPolicyAutoCategorizeNewExpenses(policyID, value); - }; - - const updateShowCategoryGLCodes = (value: boolean) => { - setPolicyShowCategoryGLCodes(policyID, value); - }; - - const hasEnabledCategories = hasEnabledOptions(policyData.categories); - - return ( - - - Navigation.goBack(isQuickSettingsFlow ? backPath : undefined)} - /> - - clearPolicyErrorField(policy?.id, 'autoCategorizeNewExpenses')} - /> - {!!policy?.glCodes && ( - clearPolicyErrorField(policy?.id, 'showCategoryGLCodes')} - /> - )} - - - - ); -} - -export default withPolicyConnections(DynamicWorkspaceCategoriesSettingsPage); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index d48c6aa6040b..ccca3200bc3a 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -2,7 +2,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; -import DecisionModal from '@components/DecisionModal'; import type {EmptyStateButton} from '@components/EmptyStateComponent/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ImportedFromAccountingSoftware from '@components/ImportedFromAccountingSoftware'; @@ -13,6 +12,7 @@ import type {WorkspaceCategoryTableRowData} from '@components/Tables/WorkspaceCa import WorkspaceCategoriesTable from '@components/Tables/WorkspaceCategoriesTable'; import Text from '@components/Text'; +import useAlertModals from '@hooks/useAlertModals'; import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -42,14 +42,24 @@ import {getCategoryApproverRule, getDecodedCategoryName} from '@libs/CategoryUti import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {isDisablingOrDeletingLastEnabledCategory} from '@libs/OptionsListUtils'; +import {hasEnabledOptions, isDisablingOrDeletingLastEnabledCategory} from '@libs/OptionsListUtils'; import {arePolicyRulesEnabled, getConnectedIntegration, hasAccountingConnections, hasTags, isControlPolicy, shouldShowSyncError} from '@libs/PolicyUtils'; +import showInlineEditError from '@libs/showInlineEditError'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import {getCurrentAccountingIntegrationName} from '@pages/workspace/accounting/utils'; import {close} from '@userActions/Modal'; -import {clearCategoryErrors, deleteWorkspaceCategories, downloadCategoriesCSV, openPolicyCategoriesPage, setWorkspaceCategoryEnabled} from '@userActions/Policy/Category'; +import { + clearCategoryErrors, + deleteWorkspaceCategories, + downloadCategoriesCSV, + openPolicyCategoriesPage, + setPolicyAutoCategorizeNewExpenses, + setPolicyShowCategoryGLCodes, + setWorkspaceCategoryEnabled, +} from '@userActions/Policy/Category'; +import {clearPolicyErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -66,7 +76,7 @@ type WorkspaceCategoriesPageProps = | PlatformStackScreenProps; function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { - // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal + // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use the selection mode only on small screens // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const {pageGutter} = useLayoutSpacing(); @@ -74,8 +84,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const {translate, formatPhoneNumber} = useLocalize(); const {isBetaEnabledOrUnknown} = usePermissions(); const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); - const [isDownloadFailureModalVisible, setIsDownloadFailureModalVisible] = useState(false); const {showConfirmModal} = useConfirmModal(); + const {showDownloadErrorModal} = useAlertModals(); const {environmentURL} = useEnvironment(); const {backTo, policyID: policyId} = route.params; const isMobileSelectionModeEnabled = useMobileSelectionMode(); @@ -300,7 +310,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { canEditName: canWriteCategories && !isDisabled && !isSelectionModeActive, action: () => navigateToCategory(value), onToggleEnabled: (enabled: boolean) => handleCategoryToggle(enabled, value), - onRenameName: (newName: string) => renameCategoryInline(policyData, value.name, newName, isVendorMatchingBetaEnabled), + onRenameName: (newName: string) => showInlineEditError(renameCategoryInline(translate, policyData, value.name, newName, isVendorMatchingBetaEnabled)), dismissError: () => clearCategoryErrors(policyId, value.name, policyCategories), }); @@ -317,6 +327,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { policy, policyCategories, navigateToCategory, + translate, handleCategoryToggle, policyId, employeePersonalDetails, @@ -324,10 +335,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { isVendorMatchingBetaEnabled, ]); - const navigateToCategoriesSettings = useCallback(() => { - Navigation.navigate(buildDynamicRoute(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_CATEGORIES_SETTINGS.path : DYNAMIC_ROUTES.WORKSPACE_CATEGORIES_SETTINGS.path)); - }, [buildDynamicRoute, isQuickSettingsFlow]); - const navigateToCreateCategoryPage = () => { Navigation.navigate(buildDynamicRoute(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_CATEGORY_CREATE.path : DYNAMIC_ROUTES.WORKSPACE_CATEGORY_CREATE.path)); }; @@ -351,6 +358,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }; const hasVisibleCategories = categoryRows.some((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); + const hasEnabledCategories = hasEnabledOptions(policyCategories); const policyHasAccountingConnections = hasAccountingConnections(policy); @@ -379,13 +387,48 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const secondaryActions = useMemo(() => { const menuItems = []; - // The other settings moved to Rules, so this is only worth showing for the GL codes toggle. + // The former Settings page's toggles are surfaced directly in this menu. Selecting a toggle row (click or Enter) + // flips it; the Switch is a display-only indicator and the menu stays open on select. if (canWriteCategories) { menuItems.push({ - icon: icons.Gear, - text: translate('common.settings'), - onSelected: navigateToCategoriesSettings, + text: translate('workspace.categories.autoCategorizeNewExpenses'), + value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + shouldCloseModalOnSelect: false, + onSelected: () => setPolicyAutoCategorizeNewExpenses(policyId, !(policy?.autoCategorizeNewExpenses ?? true)), + numberOfLinesTitle: 0, + shouldIgnoreCompactStyle: true, + innerContainerStyle: styles.alignItemsCenter, + titleStyle: [styles.textLabel, styles.fontWeightNormal], + pendingAction: policy?.pendingFields?.autoCategorizeNewExpenses, + errors: policy?.errorFields?.autoCategorizeNewExpenses, + onCloseError: () => clearPolicyErrorField(policyId, 'autoCategorizeNewExpenses'), + switchProps: { + isOn: policy?.autoCategorizeNewExpenses ?? true, + accessibilityLabel: translate('workspace.categories.autoCategorizeNewExpenses'), + onToggle: (value: boolean) => setPolicyAutoCategorizeNewExpenses(policyId, value), + disabled: !policy?.areCategoriesEnabled || !hasEnabledCategories, + }, + }); + } + if (canWriteCategories && !!policy?.glCodes) { + menuItems.push({ + text: translate('workspace.categories.showCategoryGLCodes'), value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + shouldCloseModalOnSelect: false, + onSelected: () => setPolicyShowCategoryGLCodes(policyId, !(policy?.showCategoryGLCodes ?? false)), + numberOfLinesTitle: 0, + shouldIgnoreCompactStyle: true, + innerContainerStyle: styles.alignItemsCenter, + titleStyle: [styles.textLabel, styles.fontWeightNormal], + pendingAction: policy?.pendingFields?.showCategoryGLCodes, + errors: policy?.errorFields?.showCategoryGLCodes, + onCloseError: () => clearPolicyErrorField(policyId, 'showCategoryGLCodes'), + switchProps: { + isOn: policy?.showCategoryGLCodes ?? false, + accessibilityLabel: translate('workspace.categories.showCategoryGLCodes'), + onToggle: (value: boolean) => setPolicyShowCategoryGLCodes(policyId, value), + disabled: !policy?.areCategoriesEnabled, + }, }); } if (canWriteCategories && !policyHasAccountingConnections) { @@ -394,6 +437,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { text: translate('spreadsheet.importSpreadsheet'), onSelected: navigateToImportSpreadsheet, value: CONST.POLICY.SECONDARY_ACTIONS.IMPORT_SPREADSHEET, + // Group the toggle rows apart from the spreadsheet actions. + addSeparatorBefore: true, }); } if (hasVisibleCategories) { @@ -406,13 +451,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { return; } close(() => { - downloadCategoriesCSV( - policyId, - () => { - setIsDownloadFailureModalVisible(true); - }, - translate, - ); + downloadCategoriesCSV(policyId, showDownloadErrorModal, translate); }); }, value: CONST.POLICY.SECONDARY_ACTIONS.DOWNLOAD_CSV, @@ -422,17 +461,28 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { return menuItems; }, [ showOfflineModal, + showDownloadErrorModal, icons.Download, - icons.Gear, icons.Table, translate, canWriteCategories, - navigateToCategoriesSettings, + policy?.glCodes, + policy?.showCategoryGLCodes, + policy?.autoCategorizeNewExpenses, + policy?.areCategoriesEnabled, + policy?.pendingFields?.showCategoryGLCodes, + policy?.errorFields?.showCategoryGLCodes, + policy?.pendingFields?.autoCategorizeNewExpenses, + policy?.errorFields?.autoCategorizeNewExpenses, + hasEnabledCategories, policyHasAccountingConnections, hasVisibleCategories, navigateToImportSpreadsheet, isOffline, policyId, + styles.alignItemsCenter, + styles.textLabel, + styles.fontWeightNormal, ]); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); @@ -719,15 +769,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { headerComponent={hasVisibleCategories ? headerContent : undefined} /> )} - setIsDownloadFailureModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isDownloadFailureModalVisible} - onClose={() => setIsDownloadFailureModalVisible(false)} - /> ); diff --git a/src/pages/workspace/companyCards/BankConnection/index.native.tsx b/src/pages/workspace/companyCards/BankConnection/index.native.tsx index f0e0ea84804e..43a6d5f3da22 100644 --- a/src/pages/workspace/companyCards/BankConnection/index.native.tsx +++ b/src/pages/workspace/companyCards/BankConnection/index.native.tsx @@ -1,6 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import ConfirmationPage from '@components/ConfirmationPage'; +import ConfirmationPageDefault from '@components/ConfirmationPage/composed/ConfirmationPageDefault'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -217,13 +217,11 @@ function BankConnection({policyID, feed, title}: BankConnectionProps) { /> )} {hasImportError && ( - Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID))} /> diff --git a/src/pages/workspace/companyCards/BankConnection/index.tsx b/src/pages/workspace/companyCards/BankConnection/index.tsx index a01e820cdaa1..0d649ed6cc9c 100644 --- a/src/pages/workspace/companyCards/BankConnection/index.tsx +++ b/src/pages/workspace/companyCards/BankConnection/index.tsx @@ -1,7 +1,7 @@ import ActivityIndicator from '@components/ActivityIndicator'; import BlockingView from '@components/BlockingViews/BlockingView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import ConfirmationPage from '@components/ConfirmationPage'; +import ConfirmationPageDefault from '@components/ConfirmationPage/composed/ConfirmationPageDefault'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; @@ -205,13 +205,11 @@ function BankConnection({policyID, feed, title}: BankConnectionProps) { const getContent = () => { if (hasImportError) { return ( - Navigation.closeRHPFlow()} /> diff --git a/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardAccountSelectCardPage.tsx b/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardAccountSelectCardPage.tsx index 7997aa4871f0..39e12dbc658c 100644 --- a/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardAccountSelectCardPage.tsx +++ b/src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardAccountSelectCardPage.tsx @@ -108,6 +108,10 @@ function DynamicWorkspaceCompanyCardAccountSelectCardPage({route}: DynamicWorksp featureName={featureName} policyFeature={policyFeature} policyFeatureAccess={CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE} + shouldBeBlocked={!!getConnectedIntegration(policy) && exportMenuItem?.shouldShowMenuItem !== true} + fullPageNotFoundViewProps={{ + onBackButtonPress: () => Navigation.goBack(backPath), + }} > (undefined); - const {companyCardFeeds, feedName: selectedFeedName} = useCompanyCards({policyID}); + const {companyCardFeeds: liveCompanyCardFeeds, feedName: selectedFeedName} = useCompanyCards({policyID}); const {shouldShowRbrForFeedNameWithDomainID} = useCardFeedErrors(); - const otherFeeds = useOtherFeedsForFeedSelector(policyID); + const liveOtherFeeds = useOtherFeedsForFeedSelector(policyID); + + // The link response reaches Onyx before the RHP starts closing, so the lists are frozen while linking to keep the feed from jumping between sections. + const [frozenFeeds, setFrozenFeeds] = useState<{companyCardFeeds: typeof liveCompanyCardFeeds; otherFeeds: CardFeedListItem[]}>(); + const companyCardFeeds = frozenFeeds ? frozenFeeds.companyCardFeeds : liveCompanyCardFeeds; + const otherFeeds = frozenFeeds?.otherFeeds ?? liveOtherFeeds; const primaryContactMethod = usePrimaryContactMethod(); const [draftFeed, setDraftFeed] = useState(); @@ -163,12 +168,14 @@ function WorkspaceCompanyCardFeedSelectorPage({route}: WorkspaceCompanyCardFeedS return; } const feedValue = getCardFeedWithDomainID(feed.feed, feed.fundID); + setFrozenFeeds({companyCardFeeds: liveCompanyCardFeeds, otherFeeds: liveOtherFeeds}); linkCardFeedToPolicy(feed.fundID, policyID, CONST.COMPANY_CARD.LINK_FEED_TYPE.COMPANY_CARD, feed?.country, feed.feed) .then(() => { updateSelectedFeed(feedValue, policyID); goBack(); }) .catch((error: TranslationPaths) => { + setFrozenFeeds(undefined); setFeedWithError({ feed: feed.value, error: getMicroSecondOnyxErrorWithTranslationKey(error), @@ -176,12 +183,15 @@ function WorkspaceCompanyCardFeedSelectorPage({route}: WorkspaceCompanyCardFeedS }); }; + // Other-workspace feeds are selected by the key they get in the main list once linked, so the checkmark follows the feed when it moves there on Save. + const getSelectionValue = (feed: CardFeedListItem) => feed.selectionValue ?? feed.value; + const selectFeed = (feed: CardFeedListItem) => { setFeedWithError(undefined); - setDraftFeed(feed.value); + setDraftFeed(getSelectionValue(feed)); }; - const stagedOtherWorkspaceFeed = otherFeeds.find((feed) => feed.value === currentSelectedFeed); + const stagedOtherWorkspaceFeed = otherFeeds.find((feed) => getSelectionValue(feed) === currentSelectedFeed); const isStagedFeedOnPage = !!stagedOtherWorkspaceFeed || feeds.some((feed) => feed.value === currentSelectedFeed); const saveFeed = () => { @@ -228,7 +238,7 @@ function WorkspaceCompanyCardFeedSelectorPage({route}: WorkspaceCompanyCardFeedS const item = { ...feed, // The hook marks the committed feed as selected, but the checkmark has to follow the staged one. - isSelected: feed.value === currentSelectedFeed, + isSelected: getSelectionValue(feed) === currentSelectedFeed, errors: feedWithError?.feed === feed.value ? feedWithError.error : undefined, }; return ( diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsErrorConfirmation.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsErrorConfirmation.tsx index 7cbf7e0a8633..f01bd924eec8 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsErrorConfirmation.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsErrorConfirmation.tsx @@ -1,4 +1,5 @@ import ConfirmationPage from '@components/ConfirmationPage'; +import FixedFooter from '@components/FixedFooter'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; @@ -87,34 +88,40 @@ function WorkspaceCompanyCardsErrorConfirmation({policyID, newFeed, errorMessage }; return ( - - {!!errorMessage && `${errorMessage} `} - {translate('workspace.moreFeatures.companyCards.bankConnectionDescription')}{' '} - - {translate('workspace.moreFeatures.companyCards.connectWithPlaid')} - {' '} - {translate('common.or')}{' '} - - {translate('workspace.moreFeatures.companyCards.connectWithExpensifyCard')} - - - } - illustration={illustrations.BrokenCompanyCardBankConnection} - shouldShowButton - illustrationStyle={styles.errorStateCardIllustration} - onButtonPress={onButtonPress} - buttonText={translate('common.buttonConfirm')} - containerStyle={styles.h100} - /> + + + + {translate('workspace.moreFeatures.companyCards.bankConnectionError')} + + + {!!errorMessage && `${errorMessage} `} + {translate('workspace.moreFeatures.companyCards.bankConnectionDescription')}{' '} + + {translate('workspace.moreFeatures.companyCards.connectWithPlaid')} + {' '} + {translate('common.or')}{' '} + + {translate('workspace.moreFeatures.companyCards.connectWithExpensifyCard')} + + + + + + + + ); } diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsFeedPendingPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsFeedPendingPage.tsx index 159a9a5121e6..59bbcc2cc027 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsFeedPendingPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsFeedPendingPage.tsx @@ -15,7 +15,7 @@ import {navigateToConciergeChat} from '@libs/actions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React from 'react'; function WorkspaceCompanyCardsFeedPendingPage() { @@ -25,7 +25,9 @@ function WorkspaceCompanyCardsFeedPendingPage() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); return ( @@ -46,6 +48,7 @@ function WorkspaceCompanyCardsFeedPendingPage() { introSelected, currentUserAccountID, isSelfTourViewed, + hasCompletedGuidedSetupFlow, conciergePersonalDetails, shouldDismissModal: false, }); diff --git a/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx b/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx index 5cf5ba6f28f8..14b8cff9af63 100644 --- a/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx +++ b/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx @@ -28,7 +28,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useEffect} from 'react'; import {View} from 'react-native'; @@ -54,7 +54,9 @@ function DynamicAddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); @@ -110,7 +112,15 @@ function DynamicAddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) { if (result.action !== ModalActions.CONFIRM) { return; } - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); + navigateToConciergeChat({ + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + shouldDismissModal: false, + }); }); }; diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index bec30d8ee302..f53b3e4233c5 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -134,8 +134,8 @@ function AssigneeStep({route}: AssigneeStepProps) { cardToAssign.cardName = assignCard.cardToAssign.cardName; // Preserve any start date the user already picked based on the saved data rather than `isEditing`. // `isEditing` is false on the header-back-then-Next round trip, so keying off it here would wipe the - // chosen date. In a fresh flow `startDate`/`dateOption` are undefined, so both helpers still fall back - // to today/CUSTOM. + // chosen date. In a fresh flow `useAssignCard` seeds FROM_BEGINNING for CSV feeds. For other feeds + // `startDate`/`dateOption` are undefined, so both helpers fall back to today/CUSTOM. cardToAssign.startDate = getCardAssignmentStartDate(true, assignCard?.cardToAssign?.startDate); cardToAssign.dateOption = getCardAssignmentDateOption(true, assignCard?.cardToAssign?.dateOption); setAssignCardStepAndData({ diff --git a/src/pages/workspace/companyCards/utils.tsx b/src/pages/workspace/companyCards/utils.tsx index aaaa961df9bd..30631baefab0 100644 --- a/src/pages/workspace/companyCards/utils.tsx +++ b/src/pages/workspace/companyCards/utils.tsx @@ -388,7 +388,6 @@ function getPolicyCardExportSettings( defaultLabel: defaultCard, // A card with no export NVP of its own is titled from `defaultLabel`, so this id is never // looked up. It is kept for parity with the other integrations. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing workspaceDefaultAccountID: exportConfig?.nonReimbursableAccount || defaultVendorAccount?.id, }, }; diff --git a/src/pages/workspace/connections/ConnectionsGrid.tsx b/src/pages/workspace/connections/ConnectionsGrid.tsx index d4a6750e35da..851506d3a283 100644 --- a/src/pages/workspace/connections/ConnectionsGrid.tsx +++ b/src/pages/workspace/connections/ConnectionsGrid.tsx @@ -4,21 +4,25 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + import React from 'react'; import {View} from 'react-native'; import type {ConnectionListing} from './types'; import ConnectionCard from './ConnectionCard'; +import ExactOnlineWaitlistCard from './ExactOnlineWaitlistCard'; // Invisible fillers take the empty slots of the last row so its cards keep their column width const FILLER_COUNT = 2; type ConnectionsGridProps = { listings: ConnectionListing[]; + policyID?: string; }; -function ConnectionsGrid({listings}: ConnectionsGridProps) { +function ConnectionsGrid({listings, policyID}: ConnectionsGridProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); // Medium screens can leave less than a card's minimum width once both navigation panes are shown @@ -26,13 +30,20 @@ function ConnectionsGrid({listings}: ConnectionsGridProps) { return ( - {listings.map((listing) => ( - - ))} + {listings.map((listing) => + listing.key === CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE ? ( + + ) : ( + + ), + )} {!shouldStackCards && Array.from({length: FILLER_COUNT}, (_, index) => ( Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))}} > {isFetchingConnections ? ( - + ) : ( Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))}} > {isLoading ? ( - + ) : ( + + + ); +} + +export default ExactOnlineWaitlistCard; diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index 7c1dba68a69d..ea73b8b996d4 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -299,7 +299,10 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { > {connectedListings.length > 0 && ( - + )} 0 ? styles.mt6 : styles.mt3, !shouldUseSingleColumn && [styles.flexRow, styles.alignItemsCenter, styles.gap5]]}> @@ -340,7 +343,10 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { /> ) : ( - + )} {!shouldShowEmptyState && ( diff --git a/src/pages/workspace/connections/types.ts b/src/pages/workspace/connections/types.ts index 23f2b9381c27..a2fa41dd0ef6 100644 --- a/src/pages/workspace/connections/types.ts +++ b/src/pages/workspace/connections/types.ts @@ -35,7 +35,7 @@ type ConnectionListing = { /** Only set while the integration is connected to the workspace */ status?: ConnectionStatus; - onConnect: () => void; + onConnect?: () => void; /** Shows an "Offer" badge for a partner promotion. The badge only opens the offer when `onPress` is set. */ offer?: {onPress?: () => void}; diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.ts b/src/pages/workspace/connections/useAccountingConnectionListings.ts index 71bd9da67ed4..f3dd0639b920 100644 --- a/src/pages/workspace/connections/useAccountingConnectionListings.ts +++ b/src/pages/workspace/connections/useAccountingConnectionListings.ts @@ -11,9 +11,8 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useThemeStyles from '@hooks/useThemeStyles'; import {isConnectionInProgress, isConnectionUnverified} from '@libs/actions/connections'; -import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; import Navigation from '@libs/Navigation/Navigation'; -import {getConnectedIntegration, getIntegrationLastSuccessfulDate} from '@libs/PolicyUtils'; +import {getConnectedIntegration, getIntegrationLastSuccessfulDate, shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/PolicyUtils'; import {useAccountingActions, useAccountingState} from '@pages/workspace/accounting/AccountingContext'; import {getAccountingIntegrationData, getSynchronizationErrorMessage, isIntuitEnterpriseSuiteConnection} from '@pages/workspace/accounting/utils'; @@ -55,6 +54,7 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL 'DualEntrySquare', 'CampfireSquare', 'BusinessCentralSquare', + 'ZohoBooksSquare', ]); if (!policyID) { @@ -62,10 +62,14 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL } const canUseBusinessCentralIntegration = isBetaEnabled(CONST.BETAS.BUSINESS_CENTRAL) || !!policy?.connections?.businessCentral; + const canUseZohoBooksIntegration = isBetaEnabled(CONST.BETAS.ZOHO_BOOKS) || !!policy?.connections?.zohoBooks; const accountingIntegrations = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.filter((name) => { if (name === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { return canUseBusinessCentralIntegration; } + if (name === CONST.POLICY.CONNECTIONS.NAME.ZOHO_BOOKS) { + return canUseZohoBooksIntegration; + } return true; }); @@ -121,7 +125,7 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL ...(name === CONST.POLICY.CONNECTIONS.NAME.QBO ? [{name, isIntuitEnterpriseSuite: true}] : []), ]); - return integrationOptions.flatMap(({name, isIntuitEnterpriseSuite}) => { + const listings: ConnectionListing[] = integrationOptions.flatMap(({name, isIntuitEnterpriseSuite}) => { const integrationData = getAccountingIntegrationData(name, policyID, translate, NO_REUSABLE_CONNECTIONS, { expensifyIcons: accountingIcons, isIntuitEnterpriseSuiteOverride: isIntuitEnterpriseSuite, @@ -156,6 +160,14 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL }, }; }); + + // The connector flow arrives in a later release, so beta users also see the waitlist for now. + listings.push({ + key: CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE, + category: CONST.TAB.CONNECTIONS.ACCOUNTING, + title: CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.exactOnline, + }); + return listings; } export default useAccountingConnectionListings; diff --git a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsProgressModal.tsx b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsProgressModal.tsx index 627f636a9eb0..1b3b26c3b909 100644 --- a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsProgressModal.tsx +++ b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsProgressModal.tsx @@ -14,7 +14,7 @@ import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import {hasSeenTourSelector} from '@src/selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@src/selectors/Onboarding'; import React from 'react'; import {View} from 'react-native'; @@ -28,7 +28,9 @@ function useCopyPolicySettingsProgressModal() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const copyInProgressStep = copyPolicySettings?.currentStep === CONST.POLICY.COPY_SETTINGS_MODAL_STEP.LOADING; const requestNotificationStep = copyPolicySettings?.currentStep === CONST.POLICY.COPY_SETTINGS_MODAL_STEP.COMPLETE; @@ -124,7 +126,15 @@ function useCopyPolicySettingsProgressModal() { shouldShowCancelButton: true, onConfirm: () => { clearCopyPolicySettings(); - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); + navigateToConciergeChat({ + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + shouldDismissModal: false, + }); }, onCancel: () => { clearCopyPolicySettings(); diff --git a/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx b/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx index 03717d85e013..a4344944a2e6 100644 --- a/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx +++ b/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx @@ -109,10 +109,7 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork ); const hasCardFeedOrExpensifyCard = - !isEmptyObject(cardFeeds) || - !isEmptyObject(cardsList) || - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - ((policy?.areExpensifyCardsEnabled || policy?.areCompanyCardsEnabled) && policy?.policyAccountID); + !isEmptyObject(cardFeeds) || !isEmptyObject(cardsList) || ((policy?.areExpensifyCardsEnabled || policy?.areCompanyCardsEnabled) && policy?.policyAccountID); const hasExpensifyCardsEnabledOnWorkspace = !!policy?.areExpensifyCardsEnabled && !!policy?.policyAccountID; const hasTravelBillingEnabledOnWorkspace = getIsTravelBillingEnabled(getCardSettings(travelCardSettings, CONST.TRAVEL.PROGRAM_TRAVEL_US)); const hasExpensifyCards = Object.values(cardsList ?? {}).some(isCard); diff --git a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx index 09cc59520c9f..92f21755f438 100644 --- a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx @@ -23,8 +23,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {hasOfficeLocations} from '@libs/OfficeLocationUtils'; import {getDistanceRateCustomUnit, hasOfficeWorkArrangement} from '@libs/PolicyUtils'; -import {getUnitTranslationKey} from '@libs/WorkspacesSettingsUtils'; +import {getUnitTranslationKey} from '@libs/WorkspaceDisplayUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -68,7 +69,7 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps selector: (policy) => ({ commuterExclusions: policy?.commuterExclusions, unit: getDistanceRateCustomUnit(policy)?.attributes?.unit, - hasWorkspaceAddress: !!policy?.address?.addressStreet?.trim(), + hasOfficeLocations: hasOfficeLocations(policy), pendingFields: policy?.pendingFields, errorFields: policy?.errorFields, }), @@ -103,7 +104,7 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps if (item.keyForList === selectedKey) { return; } - if (item.keyForList === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && !policyData?.hasWorkspaceAddress) { + if (item.keyForList === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && !policyData?.hasOfficeLocations) { confirmModal.showConfirmModal({ title: translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.title'), prompt: ( @@ -152,7 +153,7 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps } if (selectedKey === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE) { - if (!policyData?.hasWorkspaceAddress) { + if (!policyData?.hasOfficeLocations) { return; } if (existingMethod === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE) { diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx index cc9c435cd52f..dc7982fae63d 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx @@ -18,6 +18,7 @@ import useTransactionViolation from '@hooks/useTransactionViolation'; import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -220,7 +221,7 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro ) => { const errors: FormInputErrors = {}; const existingRateNames = Object.values(customUnit?.rates ?? {}).map((existingRate) => existingRate.name ?? ''); - const nameError = getDistanceRateNameError(existingRateNames, values.rateName, currentRateName); + // The input starts from the name without the hidden date suffix, so check the full name that will be saved + const nameToSave = rate ? DistanceRequestUtils.getRateNameWithDateSuffix(rate, StringUtils.sanitizeName(values.rateName)) : values.rateName; + const nameError = getDistanceRateNameError(existingRateNames, nameToSave, currentRateName); if (nameError) { - errors.rateName = getDistanceRateNameErrorMessage(translate, nameError, values.rateName); + errors.rateName = getDistanceRateNameErrorMessage(translate, nameError, nameToSave); } return errors; }, - [customUnit?.rates, currentRateName, translate], + [customUnit?.rates, currentRateName, rate, translate], ); const submit = useCallback( @@ -65,12 +68,12 @@ function PolicyDistanceRateNameEditPage({route}: PolicyDistanceRateNameEditPageP if (!customUnit || !rate) { return; } - const sanitized = StringUtils.sanitizeName(values.rateName); - if (currentRateName === sanitized) { + const nameToSave = DistanceRequestUtils.getRateNameWithDateSuffix(rate, StringUtils.sanitizeName(values.rateName)); + if (currentRateName === nameToSave) { Navigation.goBack(); return; } - updatePolicyDistanceRateName(policyID, customUnit, [{...rate, name: sanitized}]); + updatePolicyDistanceRateName(policyID, customUnit, [{...rate, name: nameToSave}]); Keyboard.dismiss(); Navigation.goBack(); }, @@ -110,7 +113,7 @@ function PolicyDistanceRateNameEditPage({route}: PolicyDistanceRateNameEditPageP getDistanceRateCustomUnit(policy)}); + // eslint-disable-next-line rulesdir/no-inline-useOnyx-selector + const [distanceRateData] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + selector: (policy) => ({ + customUnit: getDistanceRateCustomUnit(policy), + commuterExclusions: policy?.commuterExclusions, + }), + }); + const customUnit = distanceRateData?.customUnit; const FullPageBlockingView = !customUnit ? FullPageOfflineBlockingView : View; @@ -41,7 +48,7 @@ function PolicyDistanceRateUnitPage({route}: PolicyDistanceRateUnitPageProps) { return; } const attributes = {...customUnit.attributes, unit: unit.value}; - setPolicyDistanceRatesUnit(policyID, customUnit, {...customUnit, attributes}); + setPolicyDistanceRatesUnit(policyID, customUnit, {...customUnit, attributes}, distanceRateData?.commuterExclusions); Navigation.goBack(ROUTES.WORKSPACE_DISTANCE_RATES_SETTINGS.getRoute(policyID)); }; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index a1e464a8e0c4..def05ecb9277 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -39,6 +39,7 @@ import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; +import showInlineEditError from '@libs/showInlineEditError'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; @@ -329,13 +330,13 @@ function PolicyDistanceRatesPage({ if (!customUnit) { return; } - renameDistanceRateInline(policyID, customUnit, rate, newName); + showInlineEditError(renameDistanceRateInline(translate, policyID, customUnit, rate, newName)); }, onChangeRate: (newRate: string) => { if (!customUnit) { return; } - updateDistanceRateValueInline(policyID, customUnit, rate, newRate, toLocaleDigit); + showInlineEditError(updateDistanceRateValueInline(translate, policyID, customUnit, rate, newRate, toLocaleDigit)); }, }; }), @@ -349,6 +350,7 @@ function PolicyDistanceRatesPage({ isSelectionModeActive, policyID, toLocaleDigit, + translate, openRateDetailsByID, dismissErrorByID, updateDistanceRateEnabled, diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx index 00aba65e9aa5..d035cf765e41 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx @@ -30,7 +30,7 @@ import { isSharedGovernmentRateCurrency, } from '@libs/PolicyDistanceRatesUtils'; import {getDistanceRateCustomUnit, isControlPolicy} from '@libs/PolicyUtils'; -import {getUnitTranslationKey} from '@libs/WorkspacesSettingsUtils'; +import {getUnitTranslationKey} from '@libs/WorkspaceDisplayUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; diff --git a/src/pages/workspace/downgrade/DowngradeConfirmation.tsx b/src/pages/workspace/downgrade/DowngradeConfirmation.tsx index f1ac3f0ce609..fc90728acffb 100644 --- a/src/pages/workspace/downgrade/DowngradeConfirmation.tsx +++ b/src/pages/workspace/downgrade/DowngradeConfirmation.tsx @@ -1,10 +1,10 @@ import ConfirmationPage from '@components/ConfirmationPage'; +import FixedFooter from '@components/FixedFooter'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import {hasOtherControlWorkspaces as hasOtherControlWorkspacesPolicyUtils} from '@libs/PolicyUtils'; @@ -23,7 +23,6 @@ type Props = { function DowngradeConfirmation({onConfirmDowngrade, policyID}: Props) { const {translate} = useLocalize(); - const styles = useThemeStyles(); const illustrations = useMemoizedLazyIllustrations(['MushroomTopHat']); const {login} = useCurrentUserPersonalDetails(); const selector = useCallback( @@ -36,15 +35,19 @@ function DowngradeConfirmation({onConfirmDowngrade, policyID}: Props) { const hasOtherControlWorkspaces = hasOtherControlWorkspacesPolicyUtils(adminPolicies, policyID); return ( - + + + + {translate('workspace.downgrade.completed.headline')} + {hasOtherControlWorkspaces && {translate('workspace.downgrade.completed.description')}} + + + + + ); } diff --git a/src/pages/workspace/duplicate/utils.ts b/src/pages/workspace/duplicate/utils.ts index a89edd3c44ba..193dc45fa9a1 100644 --- a/src/pages/workspace/duplicate/utils.ts +++ b/src/pages/workspace/duplicate/utils.ts @@ -44,6 +44,9 @@ function getWorkspaceRules(policy: Policy | undefined, translate: LocaleContextP if (policy?.preventSelfApproval && !workflowApprovalsUnavailable) { total.push(translate('workspace.rules.expenseReportRules.preventSelfApprovalsTitle')); } + if (policy?.preventPayoutNonReimbursableReports) { + total.push(translate('workspace.rules.expenseReportRules.preventPayoutNonReimbursableReportsTitle')); + } if (policy?.shouldShowAutoApprovalOptions && !workflowApprovalsUnavailable) { total.push(translate('workspace.rules.expenseReportRules.autoApproveCompliantReportsTitle')); } diff --git a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx index f808c0e709b6..877871cb7c0b 100644 --- a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx @@ -1,7 +1,6 @@ import cardScarf from '@assets/images/card-scarf.svg'; import Badge from '@components/Badge'; -import DecisionModal from '@components/DecisionModal'; import FrozenCardHeader from '@components/FrozenCardHeader'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ImageSVG from '@components/ImageSVG'; @@ -15,6 +14,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; +import useAlertModals from '@hooks/useAlertModals'; import useCardFeeds from '@hooks/useCardFeeds'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrencyForExpensifyCard from '@hooks/useCurrencyForExpensifyCard'; @@ -28,7 +28,6 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -67,7 +66,7 @@ import type {NavigationProp} from '@react-navigation/native'; import {useNavigation} from '@react-navigation/native'; import {cardByIdSelector} from '@selectors/Card'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo} from 'react'; import {View} from 'react-native'; type DynamicWorkspaceExpensifyCardDetailsPageProps = PlatformStackScreenProps< @@ -100,7 +99,6 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen const {convertToDisplayString} = useCurrencyListActions(); const defaultFundID = useDefaultFundID(policyID); - const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); const {translate, formatPhoneNumber, dateFnsLocale} = useLocalize(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const expensifyIcons = useMemoizedLazyExpensifyIcons([ @@ -118,16 +116,15 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen 'RilletSquare', 'DualEntrySquare', 'CampfireSquare', + 'ZohoBooksSquare', 'BusinessCentralSquare', 'GustoSquare', ]); const illustrations = useMemoizedLazyIllustrations(['ExpensifyCardImage']); - // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use the correct modal type for the decision modal - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {showConfirmModal} = useConfirmModal(); + const {showOfflineModal} = useAlertModals(); const [session] = useOnyx(ONYXKEYS.SESSION); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); @@ -459,7 +456,7 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen icon={expensifyIcons.Trashcan} title={translate('workspace.expensifyCard.deactivate')} style={styles.mb1} - onPress={() => (isOffline ? setIsOfflineModalVisible(true) : deactivateCard())} + onPress={() => (isOffline ? showOfflineModal() : deactivateCard())} /> )} @@ -500,15 +497,6 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen ) : null} - setIsOfflineModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isOfflineModalVisible} - onClose={() => setIsOfflineModalVisible(false)} - /> diff --git a/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx b/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx index 2dd55cf76df2..b633c23f4b25 100644 --- a/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx @@ -34,8 +34,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {BankName} from '@src/types/onyx/Bank'; -import type {ConnectionName} from '@src/types/onyx/Policy'; import {useNavigationState} from '@react-navigation/native'; import {isExpensifyCardContinuousReconciliationEnabledSelector} from '@selectors/Card'; @@ -104,7 +102,7 @@ function DynamicWorkspaceSettlementAccountPage({route}: WorkspaceSettlementAccou }, [cardSettings, hasActiveAccountingConnection, continuousReconciliation, reconciliationConnection, fetchPolicyAccountingData]); const eligibleBankAccountsOptions: BankAccountListItem[] = eligibleBankAccounts.map((bankAccount) => { - const bankName = (bankAccount.accountData?.addressName ?? '') as BankName; + const bankName = bankAccount.accountData?.addressName ?? ''; const bankAccountNumber = bankAccount.accountData?.accountNumber ?? ''; const bankAccountID = bankAccount.accountData?.bankAccountID ?? bankAccount.methodID; @@ -121,7 +119,7 @@ function DynamicWorkspaceSettlementAccountPage({route}: WorkspaceSettlementAccou const fallbackBankAccountOption: BankAccountListItem = { value: paymentBankAccountID, text: paymentBankAccountAddressName ?? '', - leftElement: , + leftElement: , alternateText: `${translate('workspace.expensifyCard.accountEndingIn')} ${getLastFourDigits(paymentBankAccountNumberFromCardSettings ?? '')}`, keyForList: paymentBankAccountID?.toString() ?? '', isSelected: true, @@ -141,8 +139,7 @@ function DynamicWorkspaceSettlementAccountPage({route}: WorkspaceSettlementAccou }; const getCustomListHeaderContent = () => { - const connectionName = reconciliationConnection ?? ''; - const connectionParam = getRouteParamForConnection(connectionName as ConnectionName); + const connectionParam = reconciliationConnection ? getRouteParamForConnection(reconciliationConnection) : undefined; return ( <> @@ -194,3 +191,4 @@ function DynamicWorkspaceSettlementAccountPage({route}: WorkspaceSettlementAccou } export default DynamicWorkspaceSettlementAccountPage; +export type {WorkspaceSettlementAccountPageProps}; diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx index 1e55083b91d4..ccbffd78e509 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx @@ -127,7 +127,15 @@ function WorkspaceCardsListLabel({type, value, style}: WorkspaceCardsListLabelPr if (isGuidedSetupPending && !conciergeReportID) { // No Concierge chat exists yet: navigateToConciergeChat creates it and enqueues the onboarding OpenReport on // its create path. Wait for that promise so the limit-increase write lands after it in the queue. - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}).then(() => { + navigateToConciergeChat({ + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + shouldDismissModal: false, + }).then(() => { requestExpensifyCardLimitIncrease(settings?.paymentBankAccountID, defaultFundID); }); return; @@ -150,7 +158,7 @@ function WorkspaceCardsListLabel({type, value, style}: WorkspaceCardsListLabelPr }); } requestExpensifyCardLimitIncrease(settings?.paymentBankAccountID, defaultFundID); - navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails, shouldDismissModal: false}); + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails, shouldDismissModal: false}); }; const isCurrentBalanceType = type === CONST.WORKSPACE_CARDS_LIST_LABEL_TYPE.CURRENT_BALANCE; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 0a4df299326c..08b49cfb5b11 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -36,7 +36,13 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import {clearIssueNewCardFormData, exportExpensifyCardListToCSV, setIssueNewCardStepAndData} from '@libs/actions/Card'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {clearDeletePaymentMethodError} from '@libs/actions/PaymentMethods'; -import {canUpdateExpensifyCardLimitTypeInline, getExpensifyCardLimitInlineUpdate, updateExpensifyCardLimitInline, updateExpensifyCardLimitTypeInline} from '@libs/actions/Policy/InlineEdit'; +import { + canUpdateExpensifyCardLimitTypeInline, + getExpensifyCardLimitInlineEditError, + getExpensifyCardLimitInlineUpdate, + updateExpensifyCardLimitInline, + updateExpensifyCardLimitTypeInline, +} from '@libs/actions/Policy/InlineEdit'; import { getCardsByCardholderName, getCardSettings, @@ -52,6 +58,7 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {getConnectedIntegration, getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; +import showInlineEditError from '@libs/showInlineEditError'; import Navigation from '@navigation/Navigation'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; @@ -106,7 +113,8 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); - const isBankAccountVerified = !cardOnWaitlist; + // The waitlist is for the workspace account and must not block an already-provisioned domain feed. + const isBankAccountVerified = !cardOnWaitlist || (!!policy?.policyAccountID && fundID !== policy.policyAccountID); const shouldChangeLayout = isMediumScreenWidth || shouldUseNarrowLayout; const {windowHeight} = useWindowDimensions(); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); @@ -199,6 +207,12 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp const changeCardLimit = useCallback( (card: Card, newLimit: string): InlineEditSaveResult => { const latestCard = cardsListRef.current?.[String(card.cardID)] ?? card; + const limitError = getExpensifyCardLimitInlineEditError(translate, newLimit); + if (limitError) { + showInlineEditError(limitError); + return true; + } + const nextLimit = getExpensifyCardLimitInlineUpdate(latestCard, newLimit); if (nextLimit === undefined) { return true; diff --git a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx index 002024bd7cae..9d40d915fd4c 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -68,7 +68,6 @@ function AssigneeStep({policy, stepNames, startStepIndex, route}: AssigneeStepPr const canInviteMembers = canMemberWrite(policy, session?.email ?? '', CONST.POLICY.POLICY_FEATURE.MEMBERS); const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); const currentAssigneeFirstName = usePersonalDetailByLogin(issueNewCard?.data?.assigneeEmail, (personalDetail) => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return formatPhoneNumber(personalDetail?.firstName || issueNewCard?.data?.assigneeEmail || ''); }); @@ -108,7 +107,6 @@ function AssigneeStep({policy, stepNames, startStepIndex, route}: AssigneeStepPr if (isEditing && issueNewCard?.data?.cardTitle === getCardDefaultName(currentAssigneeFirstName)) { // If the card title is the default card title, update it with the new assignee's name - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const newAssigneeFirstName = formatPhoneNumber(employeePersonalDetails[assignee?.login ?? '']?.firstName || assignee?.login || ''); data.cardTitle = getCardDefaultName(newAssigneeFirstName); } diff --git a/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx b/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx index 9a56227ab942..77536c33f735 100644 --- a/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx @@ -44,7 +44,6 @@ function CardNameStep({policyID, stepNames, startStepIndex}: CardNameStepProps) const isEditing = issueNewCard?.isEditing; const data = issueNewCard?.data; const userName = usePersonalDetailByLogin(data?.assigneeEmail, (personalDetail) => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return formatPhoneNumber(personalDetail?.firstName || data?.assigneeEmail || ''); }); const isVirtualCard = data?.cardType === CONST.EXPENSIFY_CARD.CARD_TYPE.VIRTUAL; diff --git a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx index ca62497849ed..9c2764c94fc1 100644 --- a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx @@ -55,7 +55,6 @@ function ConfirmationStep({policyID, stepNames, startStepIndex}: ConfirmationSte const data = issueNewCard?.data; const cardholder = usePersonalDetailByLogin(data?.assigneeEmail, (personalDetail) => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return formatPhoneNumber(personalDetail?.displayName || data?.assigneeEmail || ''); }); const isSuccessful = issueNewCard?.isSuccessful; diff --git a/src/pages/workspace/getWorkspaceMenuItems.ts b/src/pages/workspace/getWorkspaceMenuItems.ts index 06835db8f58f..e22df7e3f2a8 100644 --- a/src/pages/workspace/getWorkspaceMenuItems.ts +++ b/src/pages/workspace/getWorkspaceMenuItems.ts @@ -3,7 +3,6 @@ */ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; -import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; import {isAnyHRConnected, isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; import {isAnyRecruitingConnected} from '@libs/merge/RecruitingUtils'; import {getObjectKeys} from '@libs/ObjectUtils'; @@ -23,6 +22,7 @@ import { isQBORefreshTokenExpiringSoonSelector, isTimeTrackingEnabled, shouldShowEmployeeListError, + shouldShowQBOReimbursableExportDestinationAccountError, shouldShowSyncError, shouldShowTaxRateError, } from '@libs/PolicyUtils'; @@ -36,6 +36,7 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {PolicyFeatureName} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -102,6 +103,8 @@ type GetWorkspaceMenuItemsParams = { policyID: string | undefined; /** Login used to determine the member's feature access. */ currentUserLogin?: string; + /** Personal details used to determine the member's feature access. */ + currentUserPersonalDetails?: CurrentUserPersonalDetails; /** Lazily loaded icons used by the menu items. */ icons: WorkspaceMenuIconMap; /** Whether an accounting connection is currently syncing. */ @@ -122,6 +125,8 @@ type GetWorkspaceMenuItemsParams = { isVendorMatchingBetaEnabled?: boolean; /** Whether the Merge ATS beta gating the Recruiting feature is enabled. */ isRecruitingBetaEnabled?: boolean; + /** Whether the company agents beta is enabled. */ + isCompanyAgentsBetaEnabled?: boolean; /** Whether the unified Connections beta replaces the Accounting, HR, Recruiting, Receipt partners, and MCP items. */ isUnifiedConnectionsBetaEnabled?: boolean; /** Formats the invoice account balance for its menu badge. */ @@ -132,6 +137,7 @@ function getWorkspaceMenuItems({ policy, policyID, currentUserLogin, + currentUserPersonalDetails, icons, isConnectionInProgress = false, policyCategories, @@ -142,9 +148,11 @@ function getWorkspaceMenuItems({ hasApprovalWorkflowWithNonMemberApprover = false, isVendorMatchingBetaEnabled = false, isRecruitingBetaEnabled = false, + isCompanyAgentsBetaEnabled = false, isUnifiedConnectionsBetaEnabled = false, convertToDisplayString, }: GetWorkspaceMenuItemsParams): WorkspaceMenuItem[] { + const isAgentAccount = currentUserPersonalDetails?.isCustomAgent; const canReadPolicyFeature = (policyFeature: PolicyFeature) => canMemberRead(policy, currentUserLogin ?? '', policyFeature); const canReadMoreFeatures = canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); const shouldShowProtectedItems = [ @@ -249,6 +257,17 @@ function getWorkspaceMenuItems({ return canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.MEMBERS); }); + if (isGroupPolicy(policy) && canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.AGENTS) && isCompanyAgentsBetaEnabled && !isAgentAccount) { + const membersItemIndex = items.findIndex((item) => item.screenName === SCREENS.WORKSPACE.MEMBERS); + items.splice(membersItemIndex + 1, 0, { + translationKey: 'agentsPage.title', + icon: icons.Bot, + getRoute: () => ROUTES.WORKSPACE_AGENTS.getRoute(policyID), + screenName: SCREENS.WORKSPACE.AGENTS, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.AGENTS, + }); + } + if (isGroupPolicy(policy) && shouldShowProtectedItems) { if (canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.REPORT_FIELDS)) { items.push({ diff --git a/src/pages/workspace/hr/utils.ts b/src/pages/workspace/hr/utils.ts index ec1d08137f5f..3e8196adee7f 100644 --- a/src/pages/workspace/hr/utils.ts +++ b/src/pages/workspace/hr/utils.ts @@ -219,7 +219,7 @@ function getHRCards({ { field: 'finalApprover', description: translate('workspace.merge.finalApprover'), - title: getMergeFinalApproverDisplayName(config?.finalApprover, policyEmployeePersonalDetails, translate, formatPhoneNumber), + title: getMergeFinalApproverDisplayName(config?.finalApprover, policyEmployeePersonalDetails, translate, formatPhoneNumber) ?? translate('workspace.merge.notSet'), route: provider.finalApproverRoute.getRoute(policyID), pendingAction: config?.pendingFields?.finalApprover, errors: config?.errorFields?.finalApprover, @@ -268,7 +268,7 @@ function getHRCards({ { field: 'finalApprover', description: translate('workspace.merge.finalApprover'), - title: getMergeFinalApproverDisplayName(mergeConfig?.finalApprover, policyEmployeePersonalDetails, translate, formatPhoneNumber), + title: getMergeFinalApproverDisplayName(mergeConfig?.finalApprover, policyEmployeePersonalDetails, translate, formatPhoneNumber) ?? translate('workspace.merge.notSet'), route: ROUTES.WORKSPACE_HR_MERGE_FINAL_APPROVER.getRoute(policyID), pendingAction: mergeConfig?.pendingFields?.finalApprover, errors: mergeConfig?.errorFields?.finalApprover, diff --git a/src/pages/workspace/members/DynamicWorkspaceOwnerChangeSuccessPage.tsx b/src/pages/workspace/members/DynamicWorkspaceOwnerChangeSuccessPage.tsx index ab282547789f..2525d7b60eaa 100644 --- a/src/pages/workspace/members/DynamicWorkspaceOwnerChangeSuccessPage.tsx +++ b/src/pages/workspace/members/DynamicWorkspaceOwnerChangeSuccessPage.tsx @@ -1,4 +1,4 @@ -import ConfirmationPage from '@components/ConfirmationPage'; +import ConfirmationPageDefault from '@components/ConfirmationPage/composed/ConfirmationPageDefault'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import LottieAnimations from '@components/LottieAnimations'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -58,12 +58,11 @@ function DynamicWorkspaceOwnerChangeSuccessPage({route}: DynamicWorkspaceOwnerCh title={translate('workspace.changeOwner.changeOwnerPageTitle')} onBackButtonPress={closePage} /> - diff --git a/src/pages/workspace/members/WorkArrangementPage.tsx b/src/pages/workspace/members/WorkArrangementPage.tsx index 4d9ff8fdd9f8..acabcfe78a56 100644 --- a/src/pages/workspace/members/WorkArrangementPage.tsx +++ b/src/pages/workspace/members/WorkArrangementPage.tsx @@ -1,4 +1,4 @@ -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Header from '@components/Header'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; @@ -6,24 +6,29 @@ import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import useThemeStyles from '@hooks/useThemeStyles'; import {setEmployeeWorkArrangement} from '@libs/actions/Policy/DistanceRate'; +import {setWorkspaceInviteWorkArrangementDraft} from '@libs/actions/Policy/Member'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {canMemberWrite, isMemberInHomeAndOfficeWorkspace} from '@libs/PolicyUtils'; -import {getEffectiveWorkArrangement, getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; +import {getEffectiveWorkArrangement, getMemberLoginByAccountID, getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import type {PersonalDetailsList} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -43,12 +48,16 @@ type WorkArrangementOption = ListItem & { }; type WorkArrangementPageProps = Omit & - PlatformStackScreenProps & { + ( + | PlatformStackScreenProps + | PlatformStackScreenProps + ) & { personalDetails: OnyxEntry; }; function WorkArrangementPage({policy, personalDetails, route}: WorkArrangementPageProps) { - const accountID = Number(route.params.accountID); + const isInviteFlow = route.name === SCREENS.WORKSPACE.INVITE_WORK_ARRANGEMENT; + const inviteBackPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_INVITE_WORK_ARRANGEMENT.path, isInviteFlow); const policyID = route.params.policyID; const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -56,23 +65,39 @@ function WorkArrangementPage({policy, personalDetails, route}: WorkArrangementPa const {isBetaEnabled} = usePermissions(); const isWorkArrangementBetaEnabled = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS); - const memberLogin = personalDetails?.[accountID]?.login ?? ''; + const [inviteWorkArrangementDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_WORK_ARRANGEMENT_DRAFT}${policyID}`); + // Only an existing member has an account ID here. The invite flow reads its own arrangement draft and never looks up a member. + const accountID = route.name === SCREENS.WORKSPACE.MEMBER_WORK_ARRANGEMENT ? Number(route.params.accountID) : CONST.DEFAULT_NUMBER_ID; + const memberLogin = personalDetails?.[accountID]?.login ?? getMemberLoginByAccountID(policy, accountID); const member = policy?.employeeList?.[memberLogin]; + const memberPersonalDetails = usePersonalDetailByLogin(memberLogin); + const memberAccountID = memberPersonalDetails?.accountID ?? accountID; const canWriteMembers = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS); - const canAccessWorkArrangementPage = canWriteMembers && isWorkArrangementBetaEnabled && isMemberInHomeAndOfficeWorkspace(policy, memberLogin); + const isHomeAndOfficeWorkspace = policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; + const canAccessWorkArrangementPage = + canWriteMembers && isWorkArrangementBetaEnabled && isHomeAndOfficeWorkspace && (isInviteFlow || isMemberInHomeAndOfficeWorkspace(policy, memberLogin)); - // The member-level setting wins; otherwise fall back to the workspace default, then to no regular workspace. - const currentIsOffice = getEffectiveWorkArrangement(member?.hasOfficeWorkArrangement, policy?.commuterExclusions?.isOfficeWorkArrangement); + // The member-level setting wins; otherwise use the workspace default and the server's office-based fallback. + const currentIsOffice = getEffectiveWorkArrangement(isInviteFlow ? inviteWorkArrangementDraft : member?.hasOfficeWorkArrangement, policy?.commuterExclusions?.isOfficeWorkArrangement); const navigateBackToDetails = () => { - Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); + if (isInviteFlow) { + Navigation.goBack(inviteBackPath); + return; + } + Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, memberAccountID)); }; const changeWorkArrangement = ({value}: WorkArrangementOption) => { if (value === currentIsOffice || !canAccessWorkArrangementPage) { return; } - setEmployeeWorkArrangement(policy, [accountID], value, personalDetails, translate); + if (isInviteFlow) { + setWorkspaceInviteWorkArrangementDraft(policyID, value); + navigateBackToDetails(); + return; + } + setEmployeeWorkArrangement(policy, [memberAccountID], value, personalDetails); navigateBackToDetails(); }; @@ -89,7 +114,7 @@ function WorkArrangementPage({policy, personalDetails, route}: WorkArrangementPa text: getWorkArrangementLabel(translate, false), alternateText: translate('workspace.people.workArrangementPage.optionNoRegularWorkspaceHelp'), isSelected: !currentIsOffice, - keyForList: 'no-regular-workplace', + keyForList: 'no-regular-workspace', }, ]; @@ -104,10 +129,10 @@ function WorkArrangementPage({policy, personalDetails, route}: WorkArrangementPa testID="WorkArrangementPage" enableEdgeToEdgeBottomSafeAreaPadding > - +
+ + +
(); - const {inputCallbackRef, inputRef} = useAutoFocusInput(); const [invitedEmailsToAccountIDsDraft, invitedEmailsToAccountIDsDraftResult] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`); const [workspaceInviteMessageDraft, workspaceInviteMessageDraftResult] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT}${policyID}`); + const [workspaceInviteWorkArrangementDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_WORK_ARRANGEMENT_DRAFT}${policyID}`); const [workspaceInviteRoleDraftFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_ROLE_DRAFT}${policyID}`); const currentUserLogin = currentUserPersonalDetails.login ?? ''; const canManageUserRole = canMemberAssignRole(policy, currentUserLogin, CONST.POLICY.ROLE.USER); @@ -129,9 +130,17 @@ function WorkspaceInviteMessageComponent({ // `approvalMode` can drift from the actual workflow structure because it is updated optimistically. // Dynamic external workflows that hide people also have no selectable approver. const {isAdvanceApproval, rulesCollection} = useApprovalWorkflows(policy); + // Dynamic External Workflow also routes by each member's approver, even when the derived workflows look basic. const isDynamicExternalApproval = hasDynamicExternalWorkflow(policy) && isControlPolicy(policy); const shouldShowApproverRow = (isAdvanceApproval || isDynamicExternalApproval) && !!policy?.areWorkflowsEnabled && !shouldHideDynamicExternalWorkflowPeople(policy); + const shouldShowWorkArrangement = + isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; + const selectedHasOfficeWorkArrangement = workspaceInviteWorkArrangementDraft ?? policy?.commuterExclusions?.isOfficeWorkArrangement ?? true; + const officeLocations = getAssignableOfficeLocations(policy, translate, localeCompare); + const selectedWorkArrangementLabel = translate(selectedHasOfficeWorkArrangement ? 'workspace.people.officeBased' : 'workspace.people.noRegularWorkspace'); + // Invitees have no office of their own yet, so office-based ones commute to the primary office + const defaultOfficeLabel = selectedHasOfficeWorkArrangement && officeLocations.length > 1 ? getMemberOfficeLocation(officeLocations, undefined)?.name : undefined; const isApproverValid = !!workspaceInviteApproverDraft && workspaceInviteApproverDraft in (policy?.employeeList ?? {}); const validatedApprover = isApproverValid ? workspaceInviteApproverDraft : undefined; @@ -140,6 +149,10 @@ function WorkspaceInviteMessageComponent({ Navigation.navigate(ROUTES.WORKSPACE_INVITE_MESSAGE_APPROVER.getRoute(policyID, Navigation.getActiveRoute())); }; + const navigateToWorkArrangementPage = () => { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_INVITE_WORK_ARRANGEMENT.path)); + }; + const isOnyxLoading = isLoadingOnyxValue(workspaceInviteMessageDraftResult, invitedEmailsToAccountIDsDraftResult, formDataResult); const personalDetailsOfInvitedEmails = getPersonalDetailsForAccountIDs(Object.values(invitedEmailsToAccountIDsDraft ?? {}), allPersonalDetails ?? {}); @@ -215,8 +228,10 @@ function WorkspaceInviteMessageComponent({ }, filteredReportActions, shouldShowApproverRow ? validatedApprover : undefined, + shouldShowWorkArrangement ? selectedHasOfficeWorkArrangement : undefined, ); setWorkspaceInviteMessageDraft(policyID, welcomeNote ?? null); + clearWorkspaceInviteWorkArrangementDraft(policyID); clearDraftValues(ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM); if (goToNextStep) { @@ -292,6 +307,7 @@ function WorkspaceInviteMessageComponent({ return () => { clearWorkspaceInviteRoleDraft(policyID); clearWorkspaceInviteApproverDraft(policyID); + clearWorkspaceInviteWorkArrangementDraft(policyID); }; }, [policyID]); @@ -380,6 +396,13 @@ function WorkspaceInviteMessageComponent({ value={approverName} /> )} + {shouldShowWorkArrangement && ( + + )} {translate('workspace.inviteMessage.inviteMessagePrompt')} diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 8a88a2ef386e..1ea6bc57d14b 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -2,6 +2,8 @@ import UserAvatar from '@components/Avatar/UserAvatar'; import Button from '@components/Button'; import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; +import getBankIcon from '@components/Icon/BankIcons'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; @@ -37,15 +39,18 @@ import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; import {getAllCardsForWorkspace, getCardFeedIcon, getCardFeedWithDomainID, getPlaidInstitutionIconUrl, lastFourNumbersFromCardName, maskCardNumber} from '@libs/CardUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {getAssignableOfficeLocations, getMemberOfficeLocation, hasOfficeLocations} from '@libs/OfficeLocationUtils'; import {getPhoneNumber, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {isPolicyReimburser} from '@libs/PolicyMemberRoleUtils'; import { + arePaymentsEnabled, canMemberAssignRole, canMemberManageMemberWithRole, canMemberWrite, hasActiveExpensifyCard, isControlPolicy, + isPolicyAdmin, isPolicyApprover, PAYER_ROLES, tryNavigateToSubmitWorkspaceUpgrade, @@ -53,8 +58,7 @@ import { import {isApproverOfOutstandingPolicyReports} from '@libs/ReportUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; -import {generateAccountID} from '@libs/UserUtils'; -import {getEffectiveWorkArrangement, getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; +import {getEffectiveWorkArrangement, getMemberLoginByAccountID, getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; import {convertPolicyEmployeesToApprovalWorkflows, updateWorkflowDataOnApproverRemoval} from '@libs/WorkflowUtils'; import Navigation from '@navigation/Navigation'; @@ -73,7 +77,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {CompanyCardFeed, CompanyCardFeedWithDomainID, Card as MemberCard, PersonalDetails, PersonalDetailsList, Policy} from '@src/types/onyx'; +import type {CompanyCardFeed, CompanyCardFeedWithDomainID, Card as MemberCard, PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -95,23 +99,6 @@ function isNameValuePairsObject(value: unknown): value is Record, accountID: number) { - const matchesOptimisticAccountID = (login: string) => generateAccountID(login) === accountID || generateAccountID(Str.removeSMSDomain(login)) === accountID; - // A member invited by one of their secondary logins ends up listed under their primary login, so a route ID derived - // from the secondary matches no employee — primaryLoginsInvited records that mapping and recovers the member. - const primaryLoginOfInvitedSecondary = Object.entries(policy?.primaryLoginsInvited ?? {}).find(([secondaryLogin]) => matchesOptimisticAccountID(secondaryLogin))?.[1]; - if (primaryLoginOfInvitedSecondary) { - return primaryLoginOfInvitedSecondary; - } - return Object.keys(policy?.employeeList ?? {}).find(matchesOptimisticAccountID) ?? ''; -} - function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceMemberDetailsPageProps) { const policyID = route.params.policyID; const workspaceAccountID = policy?.policyAccountID ?? CONST.DEFAULT_NUMBER_ID; @@ -128,6 +115,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const {accountID: currentUserAccountID, login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const [cardFeeds] = useCardFeeds(policyID); const [cardList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const [memberBankAccounts] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_MEMBER_BANK_ACCOUNTS}${policyID}`); const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); const [outstandingReportsForPolicy] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {selector: createOutstandingReportsForPolicySelector(policyID)}); @@ -137,11 +125,12 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const showRuleBotGuardModal = useRuleBotGuardModal(); const routeAccountID = Number(route.params.accountID); - const memberLogin = personalDetails?.[routeAccountID]?.login ?? getMemberLoginByOptimisticAccountID(policy, routeAccountID); + const memberLogin = personalDetails?.[routeAccountID]?.login ?? getMemberLoginByAccountID(policy, routeAccountID); const memberPersonalDetails = usePersonalDetailByLogin(memberLogin); const accountID = memberPersonalDetails?.accountID ?? routeAccountID; const member = policy?.employeeList?.[memberLogin]; const memberWorkArrangement = getEffectiveWorkArrangement(member?.hasOfficeWorkArrangement, policy?.commuterExclusions?.isOfficeWorkArrangement); + const memberOfficeLocation = getMemberOfficeLocation(getAssignableOfficeLocations(policy, translate, localeCompare), member?.officeID); const prevMember = usePrevious(member); const details = memberPersonalDetails ?? ({} as PersonalDetails); const fallbackIcon = details.fallbackIcon ?? ''; @@ -151,7 +140,9 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const isSelectedMemberCurrentUser = accountID === currentUserAccountID; const isCurrentUserAdmin = policy?.employeeList?.[currentUserLogin]?.role === CONST.POLICY.ROLE.ADMIN; const isCurrentUserOwner = policy?.owner === currentUserLogin; + const shouldShowOfficeLocation = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && hasOfficeLocations(policy); const canWriteMembers = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS); + const canChangeOfficeLocation = shouldShowOfficeLocation && canWriteMembers; const canManageSelectedMemberRole = canMemberAssignRole(policy, currentUserLogin, member?.role); const canRemoveSelectedMember = canWriteMembers && !isSelectedMemberOwner && !isSelectedMemberCurrentUser && canMemberManageMemberWithRole(policy, currentUserLogin, member?.role); const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]; @@ -186,6 +177,12 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM ? Object.values(workspaceCards).filter((card) => card.accountID === accountID && card.nameValuePairs?.feedCountry !== CONST.TRAVEL.PROGRAM_TRAVEL_US) : []; + const memberBankAccount = isPolicyAdmin(policy) && arePaymentsEnabled(policy) ? memberBankAccounts?.[accountID] : undefined; + const memberBankIcon = getBankIcon({bankName: memberBankAccount?.bankName, styles}); + const memberBankNameKey = Object.values(CONST.BANK_NAMES).find((bankName) => bankName === memberBankAccount?.bankName?.toLowerCase()); + // the bank name can be an empty string + const memberBankName = memberBankNameKey ? CONST.BANK_NAMES_USER_FRIENDLY[memberBankNameKey] : memberBankAccount?.bankName || translate('common.bankAccount'); + const isApprover = isPolicyApprover(policy, memberLogin) || isApproverOfOutstandingPolicyReports(accountID, outstandingReportsForPolicy, privateIsArchivedMap); const isTechnicalContact = policy?.technicalContact === details?.login; const exporters = [ @@ -423,6 +420,18 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.getRoute(policyID, accountID)); }} /> + {shouldShowOfficeLocation && ( + Navigation.navigate(ROUTES.WORKSPACE_MEMBER_OFFICE_LOCATION.getRoute(policyID, accountID)) : undefined} + testID="member-office-location-menu-item" + value={ + memberOfficeLocation + ? translate('workspace.people.officeLocationWithAddress', {name: memberOfficeLocation.name, address: memberOfficeLocation.address}) + : '' + } + /> + )} {policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && isWorkArrangementBetaEnabled && ( )} + {!!memberBankAccount?.bankAccountLastFour && ( + <> + + + {translate('common.bankAccount')} + + + + + + + + + {memberBankName} + {memberBankAccount.bankAccountLastFour} + + + + + )}
diff --git a/src/pages/workspace/members/WorkspaceMemberDisplayNamePage.tsx b/src/pages/workspace/members/WorkspaceMemberDisplayNamePage.tsx index 0f01ca56d076..32e2f6c3cb9f 100644 --- a/src/pages/workspace/members/WorkspaceMemberDisplayNamePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDisplayNamePage.tsx @@ -7,6 +7,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; @@ -23,11 +24,12 @@ import {updatePolicyMemberDisplayName} from '@userActions/Policy/Member'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/DisplayNameForm'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type WorkspaceMemberDisplayNamePageProps = PlatformStackScreenProps; @@ -43,6 +45,10 @@ function WorkspaceMemberDisplayNamePage({route}: WorkspaceMemberDisplayNamePageP const isLoadingMemberPersonalDetails = isLoadingOnyxValue(memberPersonalDetailsResult); const hasDisplayNameSet = !!memberPersonalDetails?.firstName || !!memberPersonalDetails?.lastName; const isPolicyMember = !!memberLogin && !!policy?.employeeList?.[memberLogin]; + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.PROFILE_DISPLAY_NAME.path); + + // Saving sets the name straight away, so without this the page would block itself while it closes. + const [hasSubmitted, setHasSubmitted] = useState(false); const validate = (values: FormOnyxValues) => getDisplayNameErrors(values.firstName, values.lastName, translate); @@ -52,7 +58,8 @@ function WorkspaceMemberDisplayNamePage({route}: WorkspaceMemberDisplayNamePageP accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} policyFeature={CONST.POLICY.POLICY_FEATURE.MEMBERS} policyFeatureAccess={CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE} - shouldBeBlocked={!isLoadingMemberPersonalDetails && (hasDisplayNameSet || !isPolicyMember)} + shouldBeBlocked={!isLoadingMemberPersonalDetails && !hasSubmitted && (hasDisplayNameSet || !isPolicyMember)} + fullPageNotFoundViewProps={{onBackButtonPress: () => Navigation.goBack(backPath)}} > & + PlatformStackScreenProps & { + personalDetails: OnyxEntry; + }; + +function WorkspaceMemberOfficeLocationPage({policy, personalDetails, route}: WorkspaceMemberOfficeLocationPageProps) { + const accountID = Number(route.params.accountID); + const policyID = route.params.policyID; + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); + + const memberLogin = personalDetails?.[accountID]?.login ?? ''; + const member = policy?.employeeList?.[memberLogin]; + const canChangeOfficeLocation = + isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && + canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS) && + hasOfficeLocations(policy) && + !!member; + const officeLocations = getAssignableOfficeLocations(policy, translate, localeCompare); + const currentOfficeID = getMemberOfficeLocation(officeLocations, member?.officeID)?.officeID; + const [chosenOfficeID, setChosenOfficeID] = useState(); + const officeID = chosenOfficeID ?? currentOfficeID; + + const navigateBackToDetails = () => { + Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); + }; + + const changeOfficeLocation = () => { + if (!officeID || !canChangeOfficeLocation) { + return; + } + + // Confirming the member's current office leaves them as they are, so a member following the primary office keeps following it + if (officeID !== currentOfficeID) { + updateEmployeeOfficeLocation(policy, [accountID], officeID, personalDetails); + } + navigateBackToDetails(); + }; + + const options: ListItem[] = officeLocations.map((officeLocation) => ({ + text: officeLocation.name, + alternateText: officeLocation.address, + keyForList: officeLocation.officeID, + isSelected: officeLocation.officeID === officeID, + })); + + return ( + + + + + setChosenOfficeID(keyForList)} + initiallyFocusedItemKey={officeID} + /> + + {translate('workspace.people.workArrangementPage.futureOnlyNote')} + + + + + + + + ); +} + +export default withPolicyAndFullscreenLoading(WorkspaceMemberOfficeLocationPage); diff --git a/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx b/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx new file mode 100644 index 000000000000..37083fbb5f84 --- /dev/null +++ b/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx @@ -0,0 +1,139 @@ +import Button from '@components/Button'; +import FixedFooter from '@components/FixedFooter'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import Text from '@components/Text'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {updateEmployeeOfficeLocation} from '@libs/actions/Policy/DistanceRate'; +import {clearWorkspaceMembersSelectedForBulkEdit} from '@libs/actions/Policy/Member'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getAssignableOfficeLocations, getMemberOfficeLocation, hasOfficeLocations} from '@libs/OfficeLocationUtils'; +import {canMemberWrite} from '@libs/PolicyUtils'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {PersonalDetailsList} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React, {useState} from 'react'; +import {View} from 'react-native'; + +type WorkspaceMembersChangeOfficeLocationPageProps = Omit & + PlatformStackScreenProps & { + personalDetails: OnyxEntry; + }; + +function WorkspaceMembersChangeOfficeLocationPage({policy, personalDetails, route}: WorkspaceMembersChangeOfficeLocationPageProps) { + const policyID = route.params.policyID; + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); + const [selection, selectionMetadata] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT); + const [chosenOfficeID, setChosenOfficeID] = useState(); + + // Members picked on another workspace's member list are left out, even when they also belong to this one + const selectedAccountIDs = selection?.policyID === policyID ? selection.accountIDs : undefined; + + // The selection only lives in memory, so it is gone when the page is opened from a link or after a refresh + const hasSelectedMembers = isLoadingOnyxValue(selectionMetadata) || !!selectedAccountIDs?.length; + const canChangeOfficeLocation = + isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS) && hasOfficeLocations(policy); + const officeLocations = getAssignableOfficeLocations(policy, translate, localeCompare); + + const selectedMembers = (selectedAccountIDs ?? []).map((accountID) => ({ + accountID, + officeID: getMemberOfficeLocation(officeLocations, policy?.employeeList?.[personalDetails?.[accountID]?.login ?? '']?.officeID)?.officeID, + })); + + // The office the selected members share starts out selected, and none does while their offices differ + const selectedMembersOfficeIDs = new Set(selectedMembers.map((member) => member.officeID)); + const officeID = chosenOfficeID ?? (selectedMembersOfficeIDs.size === 1 ? [...selectedMembersOfficeIDs].at(0) : undefined); + + const navigateBackToMembers = () => { + Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)); + }; + + const changeOfficeLocation = () => { + if (!officeID || !selectedAccountIDs?.length) { + return; + } + + // Members already in the chosen office are left out, so a member following the primary office keeps following it + const accountIDsToUpdate = selectedMembers.filter((member) => member.officeID !== officeID).map((member) => member.accountID); + if (accountIDsToUpdate.length) { + updateEmployeeOfficeLocation(policy, accountIDsToUpdate, officeID, personalDetails); + } + Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); + }; + + const options: ListItem[] = officeLocations.map((officeLocation) => ({ + text: officeLocation.name, + alternateText: officeLocation.address, + keyForList: officeLocation.officeID, + isSelected: officeLocation.officeID === officeID, + })); + + return ( + + + + + setChosenOfficeID(keyForList)} + initiallyFocusedItemKey={officeID} + /> + + {translate('workspace.people.workArrangementPage.futureOnlyNote')} + + + + + + + + ); +} + +export default withPolicyAndFullscreenLoading(WorkspaceMembersChangeOfficeLocationPage); diff --git a/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx b/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx new file mode 100644 index 000000000000..55d71de80073 --- /dev/null +++ b/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx @@ -0,0 +1,133 @@ +import Button from '@components/Button'; +import FixedFooter from '@components/FixedFooter'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import WorkArrangementSelector from '@components/WorkArrangementSelector'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {setEmployeeWorkArrangement} from '@libs/actions/Policy/DistanceRate'; +import {clearWorkspaceMembersSelectedForBulkEdit} from '@libs/actions/Policy/Member'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {canMemberWrite} from '@libs/PolicyUtils'; +import {getEffectiveWorkArrangement} from '@libs/WorkArrangementUtils'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {PersonalDetailsList} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React, {useState} from 'react'; +import {View} from 'react-native'; + +type WorkspaceMembersChangeWorkArrangementPageProps = Omit & + PlatformStackScreenProps & { + personalDetails: OnyxEntry; + }; + +function WorkspaceMembersChangeWorkArrangementPage({policy, personalDetails, route}: WorkspaceMembersChangeWorkArrangementPageProps) { + const policyID = route.params.policyID; + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); + const [selection, selectionMetadata] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT); + const [chosenIsOffice, setChosenIsOffice] = useState(); + + // Members picked on another workspace's member list are left out, even when they also belong to this one + const selectedAccountIDs = selection?.policyID === policyID ? selection.accountIDs : undefined; + + // The selection only lives in memory, so it is gone when the page is opened from a link or after a refresh + const hasSelectedMembers = isLoadingOnyxValue(selectionMetadata) || !!selectedAccountIDs?.length; + const canChangeWorkArrangement = + isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && + canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS) && + policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; + + const selectedMembers = (selectedAccountIDs ?? []).map((accountID) => ({ + accountID, + isOffice: getEffectiveWorkArrangement( + policy?.employeeList?.[personalDetails?.[accountID]?.login ?? '']?.hasOfficeWorkArrangement, + policy?.commuterExclusions?.isOfficeWorkArrangement, + ), + })); + + // The arrangement the selected members share starts out selected, and none does while their arrangements differ + const selectedMembersIsOffice = new Set(selectedMembers.map((member) => member.isOffice)); + const isOffice = chosenIsOffice ?? (selectedMembersIsOffice.size === 1 ? [...selectedMembersIsOffice].at(0) : undefined); + + const navigateBackToMembers = () => { + Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)); + }; + + const changeWorkArrangement = () => { + if (isOffice === undefined || !selectedAccountIDs?.length) { + return; + } + + // Members already on the chosen arrangement are left out, so a member following the workspace default keeps following it + const accountIDsToUpdate = selectedMembers.filter((member) => member.isOffice !== isOffice).map((member) => member.accountID); + if (accountIDsToUpdate.length) { + setEmployeeWorkArrangement(policy, accountIDsToUpdate, isOffice, personalDetails); + } + Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); + }; + + return ( + + + + + + + + {translate('workspace.people.workArrangementPage.futureOnlyNote')} + + + + + + + ); +} + +export default withPolicyAndFullscreenLoading(WorkspaceMembersChangeWorkArrangementPage); diff --git a/src/pages/workspace/members/WorkspaceMembersRolePage.tsx b/src/pages/workspace/members/WorkspaceMembersRolePage.tsx new file mode 100644 index 000000000000..ecf82dc5a5b4 --- /dev/null +++ b/src/pages/workspace/members/WorkspaceMembersRolePage.tsx @@ -0,0 +1,147 @@ +import ScreenWrapper from '@components/ScreenWrapper'; +import WorkspaceMemberRoleList from '@components/WorkspaceMemberRoleList'; +import type {ListItemType} from '@components/WorkspaceMemberRoleList'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; +import useRedirectSubmitWorkspaceFeatureUpgrade from '@hooks/useRedirectSubmitWorkspaceFeatureUpgrade'; +import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; + +import {clearMembersSelectedForRoleChange, discardMembersSelectedForRoleChange, updateWorkspaceMembersRole} from '@libs/actions/Policy/Member'; +import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {canMemberManageMemberWithRole, getMemberAccountIDsForWorkspace, getReimburserEmail, PAYER_ROLES} from '@libs/PolicyUtils'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import type {ValueOf} from 'type-fest'; + +import React, {useEffect, useRef, useState} from 'react'; + +type WorkspaceMembersRolePageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps; + +function WorkspaceMembersRolePage({policy, route}: WorkspaceMembersRolePageProps) { + const policyID = route.params.policyID; + const {translate} = useLocalize(); + const showRuleBotGuardModal = useRuleBotGuardModal(); + const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); + const [selectedLogins, selectedLoginsMetadata] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE); + const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); + const memberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails, true); + + // A selection left behind by an earlier visit belongs to the workspace it was made on, so it is ignored here. + const memberLogins = selectedLogins?.policyID === policyID ? selectedLogins.logins : []; + const memberRoles = memberLogins.map((login) => policy?.employeeList?.[login]?.role); + const canManageSelectedMemberRoles = memberRoles.every((role) => canMemberManageMemberWithRole(policy, currentUserLogin, role)); + + // Only offer a role as already picked when the whole selection shares it, so a mixed selection starts with nothing + // selected and the member has to make a deliberate choice. + const sharedRole = memberRoles.length > 0 ? Object.values(CONST.POLICY.ROLE).find((role) => memberRoles.every((memberRole) => memberRole === role)) : undefined; + const [draftRole, setDraftRole] = useState>(); + // Counted rather than flagged, so a Save pressed again with still nothing picked reads the error out again. + const [rejectedSaveCount, setRejectedSaveCount] = useState(0); + const selectedRole = draftRole ?? sharedRole; + + // The Authorized Payer (reimburser) must stay a valid payer, so restrict the whole selection to the roles that can pay once it includes them. + const reimburserEmail = getReimburserEmail(policy); + const hasReimburser = !!reimburserEmail && memberLogins.includes(reimburserEmail); + const allowedRoles = hasReimburser ? [...PAYER_ROLES] : undefined; + + useRedirectSubmitWorkspaceFeatureUpgrade({ + policy, + backTo: ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), + upgradeFeatureAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.roles.alias, + }); + + // The selection only lives in memory, so a reload or a deep link landing here directly has nothing to act on. + const isSelectionLoading = isLoadingOnyxValue(selectedLoginsMetadata); + const memberCount = memberLogins.length; + useEffect(() => { + if (isSelectionLoading || memberCount > 0) { + return; + } + Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)); + }, [isSelectionLoading, memberCount, policyID]); + + // Leaving without saving drops the selection, so coming back to this screen through history cannot apply a role to + // members the table no longer has selected. + const hasSaved = useRef(false); + useEffect( + () => () => { + if (hasSaved.current) { + return; + } + discardMembersSelectedForRoleChange(); + }, + [], + ); + + const saveAndGoBack = () => { + if (!selectedRole) { + setRejectedSaveCount((count) => count + 1); + return; + } + + const loginsToUpdate = memberLogins.filter((login) => policy?.employeeList?.[login]?.role !== selectedRole); + const accountIDsToUpdate = loginsToUpdate.map((login) => memberEmailsToAccountIDs[login]).filter((accountID) => accountID !== undefined); + + if (loginsToUpdate.length > 0) { + if (selectedRole !== CONST.POLICY.ROLE.ADMIN && loginsToUpdate.some((login) => isRuleBotEnforcingRules(memberEmailsToAccountIDs[login], policy))) { + showRuleBotGuardModal('changeRole', policyID); + return; + } + + updateWorkspaceMembersRole(policy, loginsToUpdate, accountIDsToUpdate, selectedRole); + } + + hasSaved.current = true; + clearMembersSelectedForRoleChange(policyID); + Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)); + }; + + return ( + 0 && !canManageSelectedMemberRoles} + > + + { + setDraftRole(value); + setRejectedSaveCount(0); + }} + allowedRoles={allowedRoles} + navigateBackTo={ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)} + confirmButtonOptions={{ + showButton: true, + text: translate('common.save'), + onConfirm: saveAndGoBack, + }} + errorMessage={rejectedSaveCount > 0 ? translate('common.error.pleaseSelectOne') : ''} + errorAnnouncementKey={rejectedSaveCount} + /> + + + ); +} + +export default withPolicyAndFullscreenLoading(WorkspaceMembersRolePage); diff --git a/src/pages/workspace/perDiem/ImportedPerDiemPage.tsx b/src/pages/workspace/perDiem/ImportedPerDiemPage.tsx index 5866cdffd35f..f21a24049840 100644 --- a/src/pages/workspace/perDiem/ImportedPerDiemPage.tsx +++ b/src/pages/workspace/perDiem/ImportedPerDiemPage.tsx @@ -107,6 +107,9 @@ function ImportedPerDiemPage({route}: ImportedPerDiemPageProps) { errors = {}; } } + if (!perDiemCustomUnit?.customUnitID) { + errors.missingPerDiemUnit = translate('workspace.perDiem.missingPerDiemUnit'); + } return errors; }; diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx index 5ce30dcea9e6..b4a328413991 100644 --- a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx +++ b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx @@ -1,6 +1,5 @@ import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; -import DecisionModal from '@components/DecisionModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import RenderHTML from '@components/RenderHTML'; @@ -9,6 +8,7 @@ import type {TableEmptyStateProps} from '@components/Table/TableEmptyStates/Tabl import WorkspacePerDiemTable from '@components/Tables/WorkspacePerDiemTable'; import type {PerDiemTableRowData} from '@components/Tables/WorkspacePerDiemTable'; +import useAlertModals from '@hooks/useAlertModals'; import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -34,6 +34,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import {canMemberWrite, getPerDiemCustomUnit} from '@libs/PolicyUtils'; +import showInlineEditError from '@libs/showInlineEditError'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -49,7 +50,7 @@ import type {Rate} from '@src/types/onyx/Policy'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo} from 'react'; import {View} from 'react-native'; type SubRateData = { @@ -90,14 +91,12 @@ function getSubRatesData(customUnitRates: Rate[]) { type WorkspacePerDiemPageProps = PlatformStackScreenProps; function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { - // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {shouldUseNarrowLayout, isSmallScreenWidth, isInLandscapeMode} = useResponsiveLayout(); + const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); const styles = useThemeStyles(); const {pageGutter} = useLayoutSpacing(); const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); - const [isDownloadFailureModalVisible, setIsDownloadFailureModalVisible] = useState(false); + const {showDownloadErrorModal} = useAlertModals(); const policyID = route.params.policyID; const backTo = route.params?.backTo; const policy = usePolicy(policyID); @@ -204,12 +203,15 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { canEditSubrate: canEdit, canEditAmount: canEdit, action: () => openSubRateDetails(subRate.rateID, subRate.subRateID), - onRenameDestination: (newName: string) => renamePerDiemDestinationInline(policyID, subRate.rateID, customUnit, subRate.destination, newName), - onRenameSubrate: (newName: string) => renamePerDiemSubrateInline(policyID, subRate.rateID, subRate.subRateID, customUnit, subRate.subRateName, newName), - onChangeAmount: (newAmount: string) => updatePerDiemAmountInline(policyID, subRate.rateID, subRate.subRateID, customUnit, subRate.rate, newAmount), + onRenameDestination: (newName: string) => + showInlineEditError(renamePerDiemDestinationInline(translate, policyID, subRate.rateID, customUnit, subRate.destination, newName)), + onRenameSubrate: (newName: string) => + showInlineEditError(renamePerDiemSubrateInline(translate, policyID, subRate.rateID, subRate.subRateID, customUnit, subRate.subRateName, newName)), + onChangeAmount: (newAmount: string) => + showInlineEditError(updatePerDiemAmountInline(translate, policyID, subRate.rateID, subRate.subRateID, customUnit, subRate.rate, newAmount)), }; }), - [allSubRates, isOffline, openSubRateDetails, canWritePerDiem, isSelectionModeActive, policyID, customUnit], + [allSubRates, isOffline, openSubRateDetails, canWritePerDiem, isSelectionModeActive, policyID, customUnit, translate], ); const secondaryActions = useMemo(() => { @@ -236,7 +238,8 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { value: CONST.POLICY.SECONDARY_ACTIONS.IMPORT_SPREADSHEET, }); } - if (hasVisibleSubRates) { + // Admins can download the CSV before adding rates, to use it as an import template + if (hasVisibleSubRates || canWritePerDiem) { menuItems.push({ icon: expensifyIcons.Download, text: translate('spreadsheet.downloadCSV'), @@ -245,15 +248,7 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { showOfflineModal(); return; } - close(() => - downloadPerDiemCSV( - policyID, - () => { - setIsDownloadFailureModalVisible(true); - }, - translate, - ), - ); + close(() => downloadPerDiemCSV(policyID, showDownloadErrorModal, translate)); }, value: CONST.POLICY.SECONDARY_ACTIONS.DOWNLOAD_CSV, }); @@ -262,6 +257,7 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { return menuItems; }, [ showOfflineModal, + showDownloadErrorModal, policy?.areCategoriesEnabled, policyCategories, canWritePerDiem, @@ -424,15 +420,6 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { headerComponent={hasVisibleSubRates ? subtitleContent : undefined} emptyState={emptyState} /> - setIsDownloadFailureModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isDownloadFailureModalVisible} - onClose={() => setIsDownloadFailureModalVisible(false)} - /> ); diff --git a/src/pages/workspace/receiptPartners/DynamicInviteReceiptPartnerPolicyPage.tsx b/src/pages/workspace/receiptPartners/DynamicInviteReceiptPartnerPolicyPage.tsx index b141ad55543f..04eb1be88d8e 100644 --- a/src/pages/workspace/receiptPartners/DynamicInviteReceiptPartnerPolicyPage.tsx +++ b/src/pages/workspace/receiptPartners/DynamicInviteReceiptPartnerPolicyPage.tsx @@ -1,4 +1,4 @@ -import ConfirmationPage from '@components/ConfirmationPage'; +import ConfirmationPageDefault from '@components/ConfirmationPage/composed/ConfirmationPageDefault'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; @@ -192,12 +192,11 @@ function DynamicInviteReceiptPartnerPolicyPage({route}: DynamicInviteReceiptPart title={translate('workspace.receiptPartners.uber.allSet')} onBackButtonPress={() => Navigation.goBack(backPath)} /> - Navigation.goBack(backPath)} descriptionStyle={styles.colorMuted} @@ -212,7 +211,10 @@ function DynamicInviteReceiptPartnerPolicyPage({route}: DynamicInviteReceiptPart policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED} > - + Navigation.goBack(backPath)} diff --git a/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx b/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx index 93b42c05f1c8..cf10b7f71f45 100644 --- a/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx +++ b/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx @@ -1,4 +1,3 @@ -import Button from '@components/Button'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; @@ -8,7 +7,6 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; -import ThreeDotsMenu from '@components/ThreeDotsMenu'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -23,22 +21,26 @@ import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import type {MenuItemData} from '@pages/workspace/accounting/types'; import withUnifiedConnectionsBeta from '@pages/workspace/connections/withUnifiedConnectionsBeta'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {AnchorPosition} from '@src/styles'; -import type {ComponentRef} from 'react'; - -import React, {useEffect, useRef} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; +import type {ReceiptPartnerName, ReceiptPartnerRowComponent} from './connectionRows/types'; + +import UberConnectionRow from './connectionRows/UberConnectionRow'; import useReceiptPartnersSettings from './useReceiptPartnersSettings'; -import getSynchronizationErrorMessage from './utils'; + +const CONNECTION_ROWS: Record = { + [CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER]: UberConnectionRow, +}; + +const receiptPartnerNames = Object.values(CONST.POLICY.RECEIPT_PARTNERS.NAME); type WorkspaceReceiptPartnersPageProps = PlatformStackScreenProps; @@ -49,25 +51,8 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps const buildDynamicRoute = useScreenBoundDynamicRoute(); const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const receiptPartnerIntegrations = Object.values(CONST.POLICY.RECEIPT_PARTNERS.NAME); - const threeDotsMenuContainerRef = useRef>(null); - const { - policy, - integrations, - isAutoInvite, - isAutoRemove, - isUberConnected, - shouldShowEnterCredentialsError, - getReceiptPartnersIntegrationData, - isOffline, - canWriteMoreFeatures, - showReadOnlyModal, - withReadOnlyFallback, - startIntegrationFlow, - toggleUberAutoInvite, - toggleUberAutoRemove, - getOverflowMenu, - } = useReceiptPartnersSettings(policyID); + const {policy, integrations, isAutoInvite, isAutoRemove, isUberConnected, canWriteMoreFeatures, withReadOnlyFallback, toggleUberAutoInvite, toggleUberAutoRemove} = + useReceiptPartnersSettings(policyID); useWorkspaceDocumentTitle(policy?.name, 'workspace.common.receiptPartners'); const isLoading = policy?.isLoading; const centralBillingAccountEmail = !!integrations?.uber?.centralBillingAccountEmail; @@ -82,106 +67,6 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER))); }, [prevIsUberConnected, isUberConnected, policyID, canWriteMoreFeatures, buildDynamicRoute]); - const calculateAndSetThreeDotsMenuPosition = () => { - if (shouldUseNarrowLayout) { - return Promise.resolve({horizontal: 0, vertical: 0}); - } - return new Promise((resolve) => { - threeDotsMenuContainerRef.current?.measureInWindow((x, y, width, height) => { - resolve({ - horizontal: x + width, - vertical: y + height, - }); - }); - }); - }; - - const getConnectionsMenuItems = (): MenuItemData[] => { - if (policyID) { - return receiptPartnerIntegrations - .map((integration) => { - const integrationData = getReceiptPartnersIntegrationData(integration); - if (!integrationData) { - return undefined; - } - const overflowMenu = canWriteMoreFeatures ? getOverflowMenu(integration) : []; - - const iconProps = integrationData?.icon - ? { - icon: integrationData.icon, - iconType: CONST.ICON_TYPE_AVATAR, - } - : {}; - - const isUber = integration === CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER; - let rightComponent: React.ReactNode; - if (canWriteMoreFeatures && (isUberConnected || shouldShowEnterCredentialsError)) { - rightComponent = ( - - - - ); - } else { - rightComponent = ( - - ); - } - - return { - ...iconProps, - ...integrationData, - interactive: false, - errorText: shouldShowEnterCredentialsError ? getSynchronizationErrorMessage(integrationData.title, translate, styles) : undefined, - wrapperStyle: [styles.sectionMenuItemTopDescription], - shouldShowRightComponent: !!rightComponent, - title: integrationData?.title, - badgeText: isUber ? translate('workspace.accounting.claimOffer.badgeText') : undefined, - onBadgePress: - isUber && canWriteMoreFeatures - ? () => { - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER)); - } - : undefined, - badgeStyle: styles.mr3, - isBadgeSuccess: isUber, - shouldShowBadgeInSeparateRow: shouldUseNarrowLayout, - numberOfLinesDescription: 5, - titleContainerStyle: [styles.pr2], - description: integrationData?.description, - brickRoadIndicator: !!integrationData?.errorFields || shouldShowEnterCredentialsError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - rightComponent, - }; - }) - .filter(Boolean) as MenuItemData[]; - } - - return []; - }; - const connectionsMenuItems = getConnectionsMenuItems(); - return ( - {connectionsMenuItems.map((menuItem) => ( - - { + const ConnectionRow = CONNECTION_ROWS[name]; + return ( + - - ))} + ); + })} {isUberConnected && ( <> diff --git a/src/pages/workspace/receiptPartners/connectionRows/UberConnectionRow.tsx b/src/pages/workspace/receiptPartners/connectionRows/UberConnectionRow.tsx new file mode 100644 index 000000000000..ca2bbc2654de --- /dev/null +++ b/src/pages/workspace/receiptPartners/connectionRows/UberConnectionRow.tsx @@ -0,0 +1,182 @@ +/** + * Uber for Business row on the receipt partners page. Shows the partner, a claim-offer badge and either a setup + * button or, once connected, a three-dots menu to re-enter credentials or disconnect. + */ +import UserAvatar from '@components/Avatar/UserAvatar'; +import Badge from '@components/Badge'; +import Button from '@components/Button'; +import FormHelpMessage from '@components/FormHelpMessage'; +import MenuItem from '@components/MenuItem'; +import MenuItemSectionRoot from '@components/MenuItem/presets/MenuItemSectionRoot'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; +import ThreeDotsMenu from '@components/ThreeDotsMenu'; + +import useConfirmModal from '@hooks/useConfirmModal'; +import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePolicy from '@hooks/usePolicy'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import Navigation from '@navigation/Navigation'; + +import getSynchronizationErrorMessage from '@pages/workspace/receiptPartners/utils'; + +import {openExternalLink} from '@userActions/Link'; +import {openPolicyReceiptPartnersPage, removePolicyReceiptPartnersConnection} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {AnchorPosition} from '@src/styles'; + +import type {ComponentRef} from 'react'; + +import React, {useRef} from 'react'; +import {View} from 'react-native'; + +import type {ReceiptPartnerRowProps} from './types'; + +const UBER = CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER; + +function UberConnectionRow({policyID}: ReceiptPartnerRowProps) { + const icons = useMemoizedLazyExpensifyIcons(['Key', 'NewWindow', 'Trashcan']); + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isOffline} = useNetwork(); + const {showConfirmModal} = useConfirmModal(); + const overflowMenuAnchorRef = useRef>(null); + const policy = usePolicy(policyID); + const uber = policy?.receiptPartners?.uber; + const {getReceiptPartnersIntegrationData, shouldShowEnterCredentialsError, isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); + const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); + const integrationData = getReceiptPartnersIntegrationData(UBER); + + if (!integrationData) { + return null; + } + + const hasError = !!integrationData.errorFields || shouldShowEnterCredentialsError; + + const openUberConnect = () => openExternalLink(`${CONST.UBER_CONNECT_URL}?${uber?.connectFormData}`); + + const disconnect = () => { + showConfirmModal({ + title: translate('workspace.moreFeatures.receiptPartnersWarningModal.featureEnabledTitle'), + prompt: translate('workspace.moreFeatures.receiptPartnersWarningModal.description'), + confirmText: translate('workspace.accounting.disconnect'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + removePolicyReceiptPartnersConnection(policyID, UBER, uber); + openPolicyReceiptPartnersPage(policyID); + }); + }; + + const getOverflowMenuAnchorPosition = () => { + if (shouldUseNarrowLayout) { + return Promise.resolve({horizontal: 0, vertical: 0}); + } + return new Promise((resolve) => { + overflowMenuAnchorRef.current?.measureInWindow((x, y, width, height) => { + resolve({horizontal: x + width, vertical: y + height}); + }); + }); + }; + + let overflowMenuItems: PopoverMenuItem[] = []; + if (canWrite && shouldShowEnterCredentialsError) { + overflowMenuItems = [ + { + icon: icons.Key, + text: translate('workspace.accounting.enterCredentials'), + onSelected: openUberConnect, + shouldCallAfterModalHide: true, + disabled: isOffline, + iconRight: icons.NewWindow, + }, + ]; + } else if (canWrite && isUberConnected) { + overflowMenuItems = [ + { + icon: icons.Trashcan, + text: translate('workspace.accounting.disconnect'), + onSelected: disconnect, + shouldCallAfterModalHide: true, + }, + ]; + } + + const badge = ( + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, UBER)) : undefined} + pressable={canWrite} + badgeStyles={shouldUseNarrowLayout ? [styles.alignSelfStart, styles.ml13, styles.mt2] : [styles.ml0, hasError && styles.mr1]} + /> + ); + + return ( + + + + + + + {integrationData.title} + {integrationData.description} + + + {!shouldUseNarrowLayout && badge} + {hasError && } + {overflowMenuItems.length > 0 ? ( + + + + ) : ( + + )} + + + {shouldUseNarrowLayout && badge} + {shouldShowEnterCredentialsError && ( + + )} + + ); +} + +export default UberConnectionRow; diff --git a/src/pages/workspace/receiptPartners/connectionRows/types.ts b/src/pages/workspace/receiptPartners/connectionRows/types.ts new file mode 100644 index 000000000000..8d63b4da0344 --- /dev/null +++ b/src/pages/workspace/receiptPartners/connectionRows/types.ts @@ -0,0 +1,16 @@ +/** Contract between the receipt partners page and the per-partner connection rows it renders */ +import type CONST from '@src/CONST'; + +import type {ComponentType} from 'react'; +import type {ValueOf} from 'type-fest'; + +type ReceiptPartnerName = ValueOf; + +/** Props every partner's connection row receives from the receipt partners page */ +type ReceiptPartnerRowProps = { + policyID: string; +}; + +type ReceiptPartnerRowComponent = ComponentType; + +export type {ReceiptPartnerName, ReceiptPartnerRowProps, ReceiptPartnerRowComponent}; diff --git a/src/pages/workspace/recruiting/approver/MergeATSApproverFieldPage.tsx b/src/pages/workspace/recruiting/approver/MergeATSApproverFieldPage.tsx index dc4ce3fc0893..9d40c65fa9bd 100644 --- a/src/pages/workspace/recruiting/approver/MergeATSApproverFieldPage.tsx +++ b/src/pages/workspace/recruiting/approver/MergeATSApproverFieldPage.tsx @@ -11,7 +11,7 @@ import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {isMergeConnected} from '@libs/merge/MergeUtils'; -import {getConnectedATSProvider} from '@libs/merge/RecruitingUtils'; +import {getConnectedATSProvider, getMergeATSApproverFields} from '@libs/merge/RecruitingUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {MergeATSApprovalNavigatorParamList} from '@libs/Navigation/types'; @@ -22,7 +22,6 @@ import {getApproverFieldName} from '@pages/workspace/recruiting/utils'; import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; import type {MergeATSApproverField} from '@src/types/onyx/Policy'; -import ObjectUtils from '@src/types/utils/ObjectUtils'; import React from 'react'; import {View} from 'react-native'; @@ -49,16 +48,14 @@ function MergeATSApproverFieldPage({ const {approverField: currentApproverField} = useMergeATSApprovalDraftState(policyID); const {setDraftApproverField} = useMergeATSApprovalDraftActions(); - const approverFieldOptions: ApproverFieldListItem[] = ObjectUtils.typedKeys(CONST.MERGE.ATS_APPROVER_FIELD).map((key) => { - const value = CONST.MERGE.ATS_APPROVER_FIELD[key]; + const approverFieldOptions: ApproverFieldListItem[] = getMergeATSApproverFields(policy).map((value) => ({ + text: getApproverFieldName(value, translate), + keyForList: value, + value, + isSelected: currentApproverField === value, + })); - return { - text: getApproverFieldName(value, translate), - keyForList: value, - value, - isSelected: currentApproverField === value, - }; - }); + const approverFieldNames = approverFieldOptions.map((option) => option.text).filter((text): text is string => !!text); const selectApproverField = (approverField: MergeATSApproverField) => { setDraftApproverField(approverField); @@ -78,7 +75,7 @@ function MergeATSApproverFieldPage({ > - {translate('workspace.recruiting.approverFieldDescription', providerName)} + {translate('workspace.recruiting.approverFieldDescription', providerName, approverFieldNames)} >>({}); + const providerName = getConnectedATSProvider(policy)?.displayName ?? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.merge_ats; const filterRows: Array<{filterType: MergeATSFilterType; toggleTitle: string; rowDescription: string}> = [ @@ -74,10 +77,8 @@ function MergeATSFiltersPage({ const hasRequiredFilter = !!filters.tags?.length || !!filters.stages?.length; const toggleFilter = (filterType: MergeATSFilterType, isEnabled: boolean) => { - if (errorKey && isEnabled && filterType !== CONST.MERGE.ATS_FILTER_TYPE.OFFICES) { - setErrorKey(undefined); - } - setFilter(filterType, isEnabled ? getMergeATSFilterValues(filterType, mergeATS?.data) : []); + setSwitchedOnFilters((previous) => ({...previous, [filterType]: isEnabled})); + setFilter(filterType, []); }; const handleSave = () => { @@ -114,7 +115,7 @@ function MergeATSFiltersPage({ title={toggleTitle} switchAccessibilityLabel={toggleTitle} toggleContainerStyles={[styles.pv3, styles.mh5]} - isActive={!!filters[filterType]?.length} + isActive={!!filters[filterType]?.length || !!switchedOnFilters[filterType]} onToggle={(isEnabled) => toggleFilter(filterType, isEnabled)} subMenuItems={ <> @@ -134,7 +135,7 @@ function MergeATSFiltersPage({ - {!!errorKey && ( + {!!errorKey && !hasRequiredFilter && ( ${approver}`; + return `${translate('workspace.merge.approvalModes.advanced')} • ${getApproverFieldLabel(policy, translate)}${approver ? ` -> ${approver}` : ''}`; } if (approvalMode === CONST.MERGE.APPROVAL_MODE.CUSTOM) { diff --git a/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx b/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx index 86e924334f20..5fda55a5b54d 100644 --- a/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx +++ b/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx @@ -113,7 +113,7 @@ function WorkspaceRoomsPage({route}: WorkspaceRoomsPageProps) { keyForList: report.reportID, reportID: report.reportID, name: getReportName(report, derivedNames?.[report.reportID]), - memberCount: getParticipantsAccountIDsForDisplay(report, true, false, false, undefined, personalDetails).length, + memberCount: getParticipantsAccountIDsForDisplay(report, undefined, true, false, false, personalDetails).length, action: () => { if (isAdmin) { // Admins open the details RHP directly instead of the room report, so the report is never fetched via ReportScreen. diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index 7e8109e39e21..e146e2c0f1eb 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -1,3 +1,4 @@ +import Banner from '@components/Banner'; import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; @@ -20,6 +21,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearDraftValues, setDraftValues} from '@libs/actions/FormActions'; import Tab from '@libs/actions/Tab'; +import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -60,7 +62,7 @@ function isAgentRuleTab(key: string): key is AgentRuleTab { function AddAgentRulePage({ route: { - params: {policyID}, + params: {policyID, generationID}, }, }: AddAgentRulePageProps) { const {translate} = useLocalize(); @@ -72,9 +74,21 @@ function AddAgentRulePage({ const [activeTab, setActiveTab] = useState(CONST.TAB.AGENT_RULE.SUGGESTIONS); const [activeTabPolicyID, setActiveTabPolicyID] = useState(policyID); const [draftValues] = useOnyx(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT); - const tabIcons = useMemoizedLazyExpensifyIcons(['Feed', 'Pencil']); + const [generatedRule] = useOnyx(ONYXKEYS.GENERATED_RULE); + const icons = useMemoizedLazyExpensifyIcons(['Feed', 'Pencil', 'Info']); const hasDraftPrompt = !!draftValues?.[INPUT_IDS.PROMPT]?.trim(); + const [didSelectSuggestion, setDidSelectSuggestion] = useState(false); + const isDraftFromFallback = !!generationID && !didSelectSuggestion; + const fallbackExplanation = generatedRule?.generationID === generationID ? generatedRule?.explanation : undefined; + + // The draft can load after the first render. Open Write one time only, because the admin must still be able to go + // to Suggestions. + const [didOpenTabForDraft, setDidOpenTabForDraft] = useState(false); + if (!didOpenTabForDraft && hasDraftPrompt) { + setDidOpenTabForDraft(true); + setActiveTab(CONST.TAB.AGENT_RULE.WRITE); + } // Reset the active tab to Suggestions when the workspace changes. if (activeTabPolicyID !== policyID) { @@ -103,12 +117,12 @@ function AddAgentRulePage({ { key: CONST.TAB.AGENT_RULE.SUGGESTIONS, title: translate('workspace.rules.agentRules.suggestionsTab'), - icon: tabIcons.Feed, + icon: icons.Feed, }, { key: CONST.TAB.AGENT_RULE.WRITE, title: translate('workspace.rules.agentRules.writeTab'), - icon: tabIcons.Pencil, + icon: icons.Pencil, }, ]; @@ -117,6 +131,7 @@ function AddAgentRulePage({ return; } setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[INPUT_IDS.PROMPT]: suggestion.prompt}); + setDidSelectSuggestion(true); setActiveTab(CONST.TAB.AGENT_RULE.WRITE); }; @@ -128,7 +143,7 @@ function AddAgentRulePage({ }; const handleBackButtonPress = () => { - if (activeTab === CONST.TAB.AGENT_RULE.WRITE) { + if (!isDraftFromFallback && activeTab === CONST.TAB.AGENT_RULE.WRITE) { setActiveTab(CONST.TAB.AGENT_RULE.SUGGESTIONS); return; } @@ -144,8 +159,12 @@ function AddAgentRulePage({ // When the workspace has no agent rules yet, the backend creates the "RuleBot" agent and adds it as // an admin. Surface a one-time modal explaining this side effect before navigating back. const isFirstRule = isEmptyObject(policy?.rules?.agentRules); + const agentRuleID = rand64(); suppressDiscardPrompt(); - addPolicyAgentRule(policyID, rand64(), values[INPUT_IDS.PROMPT]); + addPolicyAgentRule(policyID, agentRuleID, values[INPUT_IDS.PROMPT]); + if (isDraftFromFallback) { + Log.info('[GenerateRule] Saved agent rule from fallback', false, {policyID, generationID, agentRuleID}); + } clearDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM); if (!isFirstRule) { navigateBackToAgentsTab(); @@ -233,7 +252,24 @@ function AddAgentRulePage({ onSelectSuggestion={selectSuggestion} />
- {activeTab === CONST.TAB.AGENT_RULE.WRITE && } + {activeTab === CONST.TAB.AGENT_RULE.WRITE && ( + <> + {isDraftFromFallback && ( + + )} + + + )}
diff --git a/src/pages/workspace/rules/DescribeNewRuleForm.tsx b/src/pages/workspace/rules/DescribeNewRuleForm.tsx new file mode 100644 index 000000000000..77ca88639147 --- /dev/null +++ b/src/pages/workspace/rules/DescribeNewRuleForm.tsx @@ -0,0 +1,176 @@ +/** + * The section of New rule where an admin describes a rule in plain English. Concierge builds a standard rule when it + * can, and the section drafts an agent rule when it can't. + */ +import Icon from '@components/Icon'; +import Text from '@components/Text'; + +import useDefaultFundID from '@hooks/useDefaultFundID'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {setDraftValues} from '@libs/actions/FormActions'; +import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; +import {setDraftFlagForReviewRule, setDraftMerchantRule, setDraftRequireFieldsRule, setDraftSpendRule} from '@libs/actions/User'; +import {filterInactiveCards} from '@libs/CardUtils'; +import {categoryHasTaxRule, getCategoryTaxRuleDraftFromGeneratedValues} from '@libs/CategoryTaxRulesUtils'; +import {getMerchantRuleDraftFromGeneratedValues} from '@libs/ExpenseDefaultRuleUtils'; +import {hasExplicitFlagAmount} from '@libs/FlagForReviewRulesUtils'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import {categoryHasAnyRequireFieldsRule, getRequireFieldsDraftFromGeneratedValues} from '@libs/RequireFieldsRulesUtils'; +import {getEligibleSpendRuleCards, getSpendRuleDraftFromGeneratedValues} from '@libs/SpendRulesUtils'; + +import variables from '@styles/variables'; + +import {clearNewRulePrompt} from '@userActions/Policy/Rules'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import AGENT_RULE_INPUT_IDS from '@src/types/form/AddAgentRuleForm'; +import type {Policy, PolicyCategory} from '@src/types/onyx'; +import type {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; + +import type {ReactNode} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; + +import {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import React from 'react'; +import {View} from 'react-native'; + +import DescribeRuleForm from './DescribeRuleForm'; + +type DescribeNewRuleFormProps = { + policyID: string; + + /** Content above the describe section */ + children: ReactNode; +}; + +type SeedDraftContext = { + policyID: string; + policy: OnyxEntry; + + /** The cards that a new spend rule can cover, or undefined while the card settings load */ + eligibleCardIDs: string[] | undefined; +}; + +/** Seeds the draft of the editor for the generated rule type and opens that editor */ +function seedDraftAndNavigate(values: GeneratedRuleValues, ruleType: GeneratedRuleType, {policyID, policy, eligibleCardIDs}: SeedDraftContext) { + switch (ruleType) { + case CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS: + setDraftRequireFieldsRule(getRequireFieldsDraftFromGeneratedValues(values).draft); + Navigation.navigate(ROUTES.RULES_REQUIRE_FIELDS_RULE_NEW.getRoute(policyID)); + break; + case CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW: + setDraftFlagForReviewRule(values); + Navigation.navigate(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID)); + break; + case CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND: + setDraftSpendRule(getSpendRuleDraftFromGeneratedValues(values, eligibleCardIDs)); + Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); + break; + case CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS: + setDraftMerchantRule(getMerchantRuleDraftFromGeneratedValues(values)); + Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + break; + // The expense defaults editor takes its rule type from the draft. Therefore a category draft opens the category tax rate editor. + case CONST.GENERATED_RULE.RULE_TYPE.CATEGORY_TAX_RATE: + setDraftMerchantRule(getCategoryTaxRuleDraftFromGeneratedValues(values, policy)); + Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + break; + default: + break; + } +} + +function DescribeNewRuleForm({policyID, children}: DescribeNewRuleFormProps) { + const {translate} = useLocalize(); + const theme = useTheme(); + const styles = useThemeStyles(); + const isFocused = useIsFocused(); + const policy = usePolicy(policyID); + const icons = useMemoizedLazyExpensifyIcons(['Sparkles']); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const domainAccountID = useDefaultFundID(policyID); + const [expensifyCardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${domainAccountID}`); + const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards}); + const eligibleCardIDs = expensifyCardSettings ? getEligibleSpendRuleCards(cardsList, expensifyCardSettings).map((card) => String(card.cardID)) : undefined; + + // A generated category stays in the draft only when the policy categories contain it. Therefore this form loads + // the categories before the answer arrives. + const fetchPolicyCategories = () => { + if (!policy?.areCategoriesEnabled || policyCategories) { + return; + } + openPolicyCategoriesPage(policyID); + }; + + useNetwork({onReconnect: fetchPolicyCategories}); + + useFocusEffect(() => { + fetchPolicyCategories(); + // The Describe modal of the rule editors uses the same form. Clear it when New rule loses focus, so the modal + // does not open with the description or the error of New rule. + return () => clearNewRulePrompt(); + }); + + /** Returns whether a new rule of the generated rule type can use the category */ + const isCategoryEligible = (category: PolicyCategory, generatedRuleType: GeneratedRuleType) => { + switch (generatedRuleType) { + case CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS: + return !categoryHasAnyRequireFieldsRule(category); + case CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW: + return !hasExplicitFlagAmount(category.maxExpenseAmount); + case CONST.GENERATED_RULE.RULE_TYPE.CATEGORY_TAX_RATE: + return !categoryHasTaxRule(policy?.rules?.expenseRules, category.name); + default: + return true; + } + }; + + const offerAgentRule = (prompt: string, generationID: string, state: GeneratedRuleState) => { + Log.info('[GenerateRule] Offered agent rule fallback', false, {policyID, generationID, state}); + setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[AGENT_RULE_INPUT_IDS.PROMPT]: prompt}); + Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID, generationID)); + }; + + return ( + seedDraftAndNavigate(values, generatedRuleType, {policyID, policy, eligibleCardIDs})} + isCategoryEligible={isCategoryEligible} + onAgentRuleFallback={offerAgentRule} + > + {children} + + {translate('workspace.rules.newRule.describeItBelow')} + + + + ); +} + +DescribeNewRuleForm.displayName = 'DescribeNewRuleForm'; + +export default DescribeNewRuleForm; diff --git a/src/pages/workspace/rules/DescribeRuleButton.tsx b/src/pages/workspace/rules/DescribeRuleButton.tsx index 3cc111931343..4aee47c5d369 100644 --- a/src/pages/workspace/rules/DescribeRuleButton.tsx +++ b/src/pages/workspace/rules/DescribeRuleButton.tsx @@ -7,7 +7,10 @@ import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; +import {clearNewRulePrompt} from '@userActions/Policy/Rules'; + import CONST from '@src/CONST'; +import type {PolicyCategory} from '@src/types/onyx'; import type {GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import React, {useState} from 'react'; @@ -27,6 +30,9 @@ type DescribeRuleButtonProps = { /** Called with the generated form values once Concierge has built the rule */ onRuleGenerated: (values: GeneratedRuleValues) => void; + /** Whether a new rule can use the category, so a generated category that already has this kind of rule shows an error */ + isCategoryEligible?: (category: PolicyCategory) => boolean; + onPress?: never; } | { @@ -46,12 +52,21 @@ function DescribeRuleButton(props: DescribeRuleButtonProps) { return null; } + const openModal = () => { + clearNewRulePrompt(); + if (props.onPress) { + props.onPress(); + return; + } + setIsModalVisible(true); + }; + return ( <> + + )} +
+ )} + + ); +} + +DescribeRuleForm.displayName = 'DescribeRuleForm'; + +export default DescribeRuleForm; diff --git a/src/pages/workspace/rules/DescribeRuleModal.tsx b/src/pages/workspace/rules/DescribeRuleModal.tsx index 89762fb17839..920b625a2c71 100644 --- a/src/pages/workspace/rules/DescribeRuleModal.tsx +++ b/src/pages/workspace/rules/DescribeRuleModal.tsx @@ -1,37 +1,25 @@ /** * Modal where an admin describes a rule in plain English and Concierge fills in the open rule editor's form. */ -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues, FormRef} from '@components/Form/types'; import Modal from '@components/Modal'; -import type {AnimatedTextInputRef} from '@components/RNTextInput'; import Text from '@components/Text'; -import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; +import useWindowDimensions from '@hooks/useWindowDimensions'; -import {getDecodedCategoryName} from '@libs/CategoryUtils'; - -import {clearGeneratedRule, clearNewRulePrompt, generateRule, setNewRulePromptError} from '@userActions/Policy/Rules'; +import {clearNewRulePrompt} from '@userActions/Policy/Rules'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import INPUT_IDS from '@src/types/form/NewRulePromptForm'; -import type {PolicyCategories} from '@src/types/onyx'; +import type {PolicyCategory} from '@src/types/onyx'; import type {GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; -import type {TextInputKeyPressEvent} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; - -import {useFocusEffect} from '@react-navigation/native'; -import React, {useEffect, useRef} from 'react'; -import {View} from 'react-native'; +import React, {useState} from 'react'; -type NewRulePromptFormID = typeof ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM; +import DescribeRuleForm from './DescribeRuleForm'; type DescribeRuleModalProps = { isVisible: boolean; @@ -40,140 +28,69 @@ type DescribeRuleModalProps = { policyID: string; - /** The rule type of the editor the modal opens from, so Concierge fills in that form */ + /** The rule type of the editor that opens the modal */ ruleType: GeneratedRuleType; - /** Called with the generated form values once Concierge has built the rule */ - onRuleGenerated: (values: GeneratedRuleValues) => void; -}; + /** Called with the generated form values and their rule type after Concierge builds the rule */ + onRuleGenerated: (values: GeneratedRuleValues, generatedRuleType: GeneratedRuleType) => void; -/** Replaces the generated category with the workspace's own spelling of it, or drops it when the workspace has no such category */ -function withPolicyCategory(values: GeneratedRuleValues, policyCategories: OnyxEntry): GeneratedRuleValues { - const {category, ...rest} = values; - if (!category) { - return values; - } - const policyCategory = Object.values(policyCategories ?? {}).find((candidate) => candidate.enabled && getDecodedCategoryName(candidate.name) === getDecodedCategoryName(category)); - return policyCategory ? {...rest, category: policyCategory.name} : rest; -} + /** Whether a new rule can use the category, so a generated category that already has this kind of rule shows an error */ + isCategoryEligible?: (category: PolicyCategory) => boolean; +}; -function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenerated}: DescribeRuleModalProps) { +function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenerated, isCategoryEligible}: DescribeRuleModalProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {windowHeight} = useWindowDimensions(); + const viewportOffsetTop = useViewportOffsetTop(); // We need to use isSmallScreenWidth here because the Modal breaks in RHP with shouldUseNarrowLayout. // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); - const formRef = useRef(null); - const inputRef = useRef(null); - const pendingGenerationIDRef = useRef(undefined); - const [generatedRule] = useOnyx(ONYXKEYS.GENERATED_RULE); - const [newRulePromptForm] = useOnyx(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); - const generatedRuleID = generatedRule?.generationID; - const generatedRuleState = generatedRule?.state; - const generatedRuleValues = generatedRule?.rule; - const inputLabel = translate('workspace.rules.newRule.describeRuleInputLabel'); - - const close = () => { - pendingGenerationIDRef.current = undefined; - clearNewRulePrompt(); - onClose(); - }; - useEffect(() => { - if (!generatedRuleID || !generatedRuleState || generatedRuleID !== pendingGenerationIDRef.current) { - return; + // Each opening gets a new form, so a description that is still building when the modal closes cannot fill in the + // editor after the modal opens again. + const [formKey, setFormKey] = useState(0); + const [wasVisible, setWasVisible] = useState(isVisible); + if (isVisible !== wasVisible) { + setWasVisible(isVisible); + if (isVisible) { + setFormKey((key) => key + 1); } - if (generatedRuleState !== CONST.GENERATED_RULE.STATE.RULE) { - setNewRulePromptError(generatedRuleState); - clearGeneratedRule(); - return; - } - onRuleGenerated(withPolicyCategory(generatedRuleValues ?? {}, policyCategories)); - close(); - }, [close, generatedRuleID, generatedRuleState, generatedRuleValues, onRuleGenerated, policyCategories]); + } - useEffect(() => { - if (!isVisible || !newRulePromptForm?.isLoading || pendingGenerationIDRef.current) { - return; - } + const dismiss = () => { clearNewRulePrompt(); - }, [isVisible, newRulePromptForm?.isLoading]); - - // Focus only after the open animation, because focusing mid-animation blurs the input on mWeb and native - useFocusEffect(() => { - if (!isVisible) { - return; - } - const focusTimeout = setTimeout(() => inputRef.current?.focus(), CONST.ANIMATED_TRANSITION); - return () => clearTimeout(focusTimeout); - }); - - const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { - if (!('key' in event)) { - return; - } - if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - formRef.current?.submit(); - } - }; - - const validate = (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - if (!values[INPUT_IDS.PROMPT]?.trim()) { - errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired'); - } - return errors; - }; - - const submit = (values: FormOnyxValues) => { - pendingGenerationIDRef.current = generateRule(policyID, ruleType, values[INPUT_IDS.PROMPT].trim()); + onClose(); }; return ( - {translate('workspace.rules.newRule.describeRule')} - - - - + ); } diff --git a/src/pages/workspace/rules/ExpenseReportRulesSection.tsx b/src/pages/workspace/rules/ExpenseReportRulesSection.tsx index 3e53c5beae1b..d613e960db4f 100644 --- a/src/pages/workspace/rules/ExpenseReportRulesSection.tsx +++ b/src/pages/workspace/rules/ExpenseReportRulesSection.tsx @@ -10,11 +10,11 @@ import useReviewWorkspaceSettingsTaskCompletion from '@hooks/useReviewWorkspaceS import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import {getWorkflowApprovalsUnavailable, isAutoPayApprovedReportsAvailable, isControlPolicy} from '@libs/PolicyUtils'; +import {arePaymentsEnabled, getWorkflowApprovalsUnavailable, isAutoPayApprovedReportsAvailable, isControlPolicy} from '@libs/PolicyUtils'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; -import {enableAutoApprovalOptions, enablePolicyAutoReimbursementLimit, setPolicyPreventSelfApproval} from '@userActions/Policy/Policy'; +import {enableAutoApprovalOptions, enablePolicyAutoReimbursementLimit, setPolicyPreventPayoutNonReimbursableReports, setPolicyPreventSelfApproval} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -38,6 +38,7 @@ function ExpenseReportRulesSection({policyID, canWriteApprovals, canWritePayment const {environmentURL} = useEnvironment(); const workflowApprovalsUnavailable = getWorkflowApprovalsUnavailable(policy); const autoPayApprovedReportsUnavailable = !isAutoPayApprovedReportsAvailable(policy); + const paymentsUnavailable = !arePaymentsEnabled(policy); // Auto-pay that is already on (e.g. kept after a downgrade) stays usable so admins can still turn it off. // Users who can't write payments (e.g. on an archived workspace) can't upgrade either, so they don't get the upgrade link. const autoPayApprovedReportsRequiresUpgrade = canWritePayments && !isControlPolicy(policy) && !policy?.shouldShowAutoReimbursementLimitOption; @@ -89,6 +90,18 @@ function ExpenseReportRulesSection({policyID, canWriteApprovals, canWritePayment setPolicyPreventSelfApproval(policyID, isEnabled, policy?.preventSelfApproval, getReviewWorkspaceSettingsTaskCompletion()); }, }, + { + title: translate('workspace.rules.expenseReportRules.preventPayoutNonReimbursableReportsTitle'), + subtitle: translate('workspace.rules.expenseReportRules.preventPayoutNonReimbursableReportsSubtitle'), + shouldParseSubtitle: false, + switchAccessibilityLabel: translate('workspace.rules.expenseReportRules.preventPayoutNonReimbursableReportsTitle'), + isActive: policy?.preventPayoutNonReimbursableReports && !paymentsUnavailable, + disabled: paymentsUnavailable || !canWritePayments, + disabledAction: withPaymentsReadOnlyFallback(), + showLockIcon: paymentsUnavailable || !canWritePayments, + pendingAction: policy?.pendingFields?.preventPayoutNonReimbursableReports ?? policy?.pendingAction, + onToggle: (isEnabled: boolean) => setPolicyPreventPayoutNonReimbursableReports(policyID, isEnabled, policy?.preventPayoutNonReimbursableReports), + }, { title: translate('workspace.rules.expenseReportRules.autoApproveCompliantReportsTitle'), subtitle: workflowApprovalsUnavailable diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index 2ecce17a3397..bf10f9b8c5f7 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx @@ -39,6 +39,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES, getFlagForReviewRuleAmountRoute, getFlagForReviewRuleCategoryRoute, getWorkspaceCategorySettingsRoute} from '@src/ROUTES'; import type {FlagForReviewRuleForm} from '@src/types/form/FlagForReviewRuleForm'; import INPUT_IDS from '@src/types/form/FlagForReviewRuleForm'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useRef, useState} from 'react'; @@ -90,6 +91,7 @@ function FlagForReviewRulePageBase({ const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [shouldShowError, setShouldShowError] = useState(false); const initializedDraftForRuleKeyRef = useRef(null); + const hasDraft = !isEmptyObject(form); const category = categoryName ? policyCategories?.[categoryName] : undefined; const selectedCategoryName = form?.[INPUT_IDS.CATEGORY]; @@ -105,7 +107,9 @@ function FlagForReviewRulePageBase({ if (!isEditing) { if (initializedDraftForRuleKeyRef.current !== ROUTES.NEW) { initializedDraftForRuleKeyRef.current = ROUTES.NEW; - setDraftFlagForReviewRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); + if (!hasDraft) { + setDraftFlagForReviewRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); + } } return; } @@ -129,7 +133,7 @@ function FlagForReviewRulePageBase({ initializedDraftForRuleKeyRef.current = categoryName; setDraftFlagForReviewRule(getFlagForReviewFormFromCategory(category, getCurrencyDecimals, policyCurrency)); - }, [category, categoryName, draftMaxExpenseAmount, getCurrencyDecimals, initialCategoryName, isEditing, policyCurrency, selectedCategoryName]); + }, [category, categoryName, draftMaxExpenseAmount, getCurrencyDecimals, hasDraft, initialCategoryName, isEditing, policyCurrency, selectedCategoryName]); const fetchPolicyData = useCallback(() => { if (!policy?.areCategoriesEnabled || policyCategories) { return; @@ -227,6 +231,7 @@ function FlagForReviewRulePageBase({ ruleType={CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.FLAG_FOR_REVIEW_RULE_DESCRIBE} onRuleGenerated={setDraftFlagForReviewRule} + isCategoryEligible={(generatedCategory) => !hasExplicitFlagAmount(generatedCategory.maxExpenseAmount)} /> ) } diff --git a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx index 30389ea9af7d..93c879c22684 100644 --- a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx @@ -12,7 +12,7 @@ import {updateDraftMerchantRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {getMatchingVendors, getVendorRuleDisplayValue, hasVendorFeature, isXeroActiveMatchingSource, sortVendors} from '@libs/PolicyUtils'; +import {getMatchingVendors, getVendorRuleDisplayValue, hasVendorFeature, sortVendors} from '@libs/PolicyUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -63,8 +63,7 @@ function AddVendorPage({route}: AddVendorPageProps) { const {isFetchNeeded, isLoadingFetchedFlag} = usePolicyConnectionsPrefetch(policy, true); const isVendorMatchingBetaEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING); - const isOnXero = isXeroActiveMatchingSource(policy); - const unavailableLabel = translate(isOnXero ? 'workspace.rules.merchantRules.supplierUnavailable' : 'workspace.rules.merchantRules.vendorUnavailable'); + const unavailableLabel = translate('workspace.rules.merchantRules.vendorUnavailable'); const selectedVendorItem = getSelectedVendorItem(policy, form?.vendorID, unavailableLabel); const vendorItems = getVendorSelectionItems(policy, localeCompare); @@ -88,7 +87,7 @@ function AddVendorPage({route}: AddVendorPageProps) { return ( Navigation.goBack(backToRoute)} > diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 10cfd1c1a20e..4d33aedc476e 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -27,17 +27,24 @@ import useRulesPrefetch from '@hooks/useRulesPrefetch'; import useThemeStyles from '@hooks/useThemeStyles'; import {deletePolicyCategoryTax, movePolicyCategoryTax, openPolicyCategoriesPage, setPolicyCategoryTaxes} from '@libs/actions/Policy/Category'; -import {deleteMerchantRule, setMerchantRule} from '@libs/actions/Policy/Rules'; +import {deleteRule, setRule} from '@libs/actions/Policy/Rules'; import {openPolicyTagsPage} from '@libs/actions/Policy/Tag'; import Tab from '@libs/actions/Tab'; import {clearDraftMerchantRule, setDraftMerchantRule} from '@libs/actions/User'; -import {getCategoryTaxRuleTaxID, getTaxRateDisplayName, hasUsableTaxRates, isCategoryRuleDraft} from '@libs/CategoryTaxRulesUtils'; +import { + categoryHasTaxRule, + getCategoryTaxRuleDraftFromGeneratedValues, + getCategoryTaxRuleTaxID, + getTaxRateDisplayName, + hasUsableTaxRates, + isCategoryRuleDraft, +} from '@libs/CategoryTaxRulesUtils'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; -import {canEditMerchantRule, getMerchantRuleFormValues, getPolicyExpenseDefaultRules} from '@libs/ExpenseDefaultRuleUtils'; +import {buildMerchantRule, canEditMerchantRule, getMerchantRuleDraftFromGeneratedValues, getMerchantRuleFormValues, getPolicyExpenseDefaultRules} from '@libs/ExpenseDefaultRuleUtils'; import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; -import {findPolicyTagAtLevel, getCleanedTagName, getTagLists, getTaxByID, getVendorRuleDisplayValue, hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; +import {findPolicyTagAtLevel, getCleanedTagName, getTagLists, getTaxByID, getVendorRuleDisplayValue, hasVendorFeature} from '@libs/PolicyUtils'; import {getEnabledTags} from '@libs/TagsOptionsListUtils'; import {getTagArrayFromName} from '@libs/TransactionUtils'; @@ -295,9 +302,7 @@ function MerchantRulePageBase({ const isVendorMatchingBetaEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING); const isVendorFeatureEnabled = hasVendorFeature(policy, isVendorMatchingBetaEnabled); - const isOnXero = isXeroActiveMatchingSource(policy); - const vendorFieldLabel = translate(isOnXero ? 'common.supplier' : 'common.vendor'); - const unavailableLabel = translate(isOnXero ? 'workspace.rules.merchantRules.supplierUnavailable' : 'workspace.rules.merchantRules.vendorUnavailable'); + const unavailableLabel = translate('workspace.rules.merchantRules.vendorUnavailable'); const vendorDisplayName = form?.vendorID ? getVendorRuleDisplayValue(policy, form.vendorID, unavailableLabel) : undefined; // `Expense defaults` has not been migrated to the new rules system, so a rule can only carry one condition. The @@ -436,7 +441,7 @@ function MerchantRulePageBase({ return; } - setMerchantRule(policyID, form, policy, ruleID, existingRule, shouldUpdateMatchingTransactions); + setRule(policyID, buildMerchantRule(form, policy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, existingRule, shouldUpdateMatchingTransactions); if (isCreatedFromExpense) { // Opened from the callout, so this page is a suffix on the expense's path. Dropping it returns to the // expense instead of the workspace Rules page. @@ -487,7 +492,7 @@ function MerchantRulePageBase({ startWithLoading(() => saveRule()); }; - const deleteRule = () => { + const handleDeleteRule = () => { if (!policy) { return false; } @@ -495,7 +500,7 @@ function MerchantRulePageBase({ if (editCategoryTaxRuleFor) { deletePolicyCategoryTax(policy, editCategoryTaxRuleFor); } else if (ruleID) { - deleteMerchantRule(policy.id, ruleID, existingRule); + deleteRule(ruleID, existingRule); } return true; }; @@ -507,7 +512,7 @@ function MerchantRulePageBase({ const {deleteIconButtonProps} = useRuleDeleteHeaderProps({ canDelete: canDeleteRule, - onDelete: deleteRule, + onDelete: handleDeleteRule, sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_DELETE, backTo: categorySettingsBackPath, }); @@ -602,7 +607,7 @@ function MerchantRulePageBase({ isVendorFeatureEnabled ? { key: 'vendorID', - description: vendorFieldLabel, + description: translate('common.vendor'), title: vendorDisplayName, onPress: () => Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_VENDOR_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_VENDOR.getRoute(policyID, ruleID))), icon: icons.Basket, @@ -679,7 +684,7 @@ function MerchantRulePageBase({ const shouldShowDescribeRule = !isEditingSavedRule && !isCategoryLocked; const applyGeneratedRule = (values: GeneratedRuleValues) => { - setDraftMerchantRule({...values, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}); + setDraftMerchantRule(isScopedToCategory ? getCategoryTaxRuleDraftFromGeneratedValues(values, policy) : getMerchantRuleDraftFromGeneratedValues(values)); }; const footer = canWriteRules ? ( @@ -816,8 +821,9 @@ function MerchantRulePageBase({ isVisible={isDescribeRuleModalVisible} onClose={() => setIsDescribeRuleModalVisible(false)} policyID={policyID} - ruleType={CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS} + ruleType={isScopedToCategory ? CONST.GENERATED_RULE.RULE_TYPE.CATEGORY_TAX_RATE : CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS} onRuleGenerated={applyGeneratedRule} + isCategoryEligible={isScopedToCategory ? (generatedCategory) => !categoryHasTaxRule(policy?.rules?.expenseRules, generatedCategory.name) : undefined} /> )} diff --git a/src/pages/workspace/rules/PolicyRulesPageRevamp.tsx b/src/pages/workspace/rules/PolicyRulesPageRevamp.tsx index 3934298d59aa..7ae3d178d1b9 100644 --- a/src/pages/workspace/rules/PolicyRulesPageRevamp.tsx +++ b/src/pages/workspace/rules/PolicyRulesPageRevamp.tsx @@ -99,7 +99,7 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) { const {showConfirmModal} = useConfirmModal(); useEffect(() => { - // Fetch once on mount and whenever policyID changes. setMerchantRule already updates Onyx, so refetching + // Fetch once on mount and whenever policyID changes. setRule already updates Onyx, so refetching // after a save can overwrite a newly added rule with stale data. openPolicyRulesPage(policyID); // The mount fetch is this page's own, rather than the shared hook's once per session, because this page diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index b4dbf4882a3d..36ab9ccda092 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -31,7 +31,9 @@ import { getEffectiveRequireFieldsRuleForm, getRequireFieldsDisplayedSetting, getRequireFieldsFieldClearKeys, + getRequireFieldsDraftFromGeneratedValues, getRequireFieldsFieldSettingUpdate, + REQUIRE_FIELDS_SETTING_FIELD_KEYS, getRequireFieldsFormFromCategory, getRequireFieldsPendingActionForCategory, getRequireFieldsRuleKey, @@ -54,12 +56,16 @@ import ROUTES, {DYNAMIC_ROUTES, getRequireFieldsRuleCategoryRoute, getWorkspaceC import type {RequireFieldsRuleForm, RequireFieldsRuleSettingFieldKey} from '@src/types/form/RequireFieldsRuleForm'; import INPUT_IDS from '@src/types/form/RequireFieldsRuleForm'; import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; -const SETTING_FIELD_KEYS = [INPUT_IDS.DESCRIPTION_SETTING, INPUT_IDS.ATTENDEES_SETTING, INPUT_IDS.RECEIPT_SETTING, INPUT_IDS.ITEMIZED_RECEIPT_SETTING] as const; +/** Returns the setting fields that have a value in the given form values */ +function getDefinedSettingFieldKeys(values: Partial | undefined) { + return REQUIRE_FIELDS_SETTING_FIELD_KEYS.filter((fieldKey) => values?.[fieldKey] !== undefined); +} type RequireFieldsRulePageBaseProps = { policyID: string; @@ -129,7 +135,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, // Preserve whatever is currently shown (edit often displays category overrides // without those fields being in touchedFields yet). - for (const fieldKey of SETTING_FIELD_KEYS) { + for (const fieldKey of REQUIRE_FIELDS_SETTING_FIELD_KEYS) { const displayedSetting = getRequireFieldsDisplayedSetting({ fieldKey, category: previousCategory, @@ -166,7 +172,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, // Remount after a category change loses local touched state — rebuild it from the draft. if (isEditing && categoryName && selectedCategoryName && selectedCategoryName !== categoryName && form) { - const draftSettingKeys = SETTING_FIELD_KEYS.filter((fieldKey) => form[fieldKey] !== undefined); + const draftSettingKeys = getDefinedSettingFieldKeys(form); if (draftSettingKeys.some((fieldKey) => !touchedFields.has(fieldKey))) { setTouchedFields(new Set([...touchedFields, ...draftSettingKeys])); } @@ -179,7 +185,12 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, if (!isEditing) { if (initializedDraftForRuleKeyRef.current !== ROUTES.NEW) { initializedDraftForRuleKeyRef.current = ROUTES.NEW; - setDraftRequireFieldsRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); + if (!isEmptyObject(form)) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Seed local selection state from the seeded draft. + setTouchedFields(new Set(getDefinedSettingFieldKeys(form))); + } else { + setDraftRequireFieldsRule(initialCategoryName ? {[INPUT_IDS.CATEGORY]: initialCategoryName} : {}); + } } return; } @@ -202,7 +213,6 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, // Always reseed from the category so a leftover new-rule draft cannot leave a stale Require/Don't require. initializedDraftForRuleKeyRef.current = ruleKey; - // eslint-disable-next-line react-hooks/set-state-in-effect -- Seed local selection state when opening an edit rule. setTouchedFields(new Set()); setClearedFields(new Set()); setCouplingInteractionFields(new Set()); @@ -312,7 +322,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, const nextDraft: Partial = { [INPUT_IDS.CATEGORY]: form[INPUT_IDS.CATEGORY], }; - for (const settingFieldKey of SETTING_FIELD_KEYS) { + for (const settingFieldKey of REQUIRE_FIELDS_SETTING_FIELD_KEYS) { if (keysToClear.includes(settingFieldKey) || form[settingFieldKey] === undefined) { continue; } @@ -432,9 +442,10 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, } const applyGeneratedRule = (values: GeneratedRuleValues) => { - setDraftRequireFieldsRule(values); + const {draft, touchedFieldKeys} = getRequireFieldsDraftFromGeneratedValues(values); + setDraftRequireFieldsRule(draft); setGeneratedCategoryName(values.category); - setTouchedFields(new Set(SETTING_FIELD_KEYS.filter((fieldKey) => values[fieldKey] !== undefined))); + setTouchedFields(touchedFieldKeys); }; const footer = canWriteRules ? ( @@ -457,6 +468,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, ruleType={CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.REQUIRE_FIELDS_RULE_DESCRIBE} onRuleGenerated={applyGeneratedRule} + isCategoryEligible={(generatedCategory) => !categoryHasAnyRequireFieldsRule(generatedCategory)} /> ) } diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 5cc21f7f416b..0123104667b0 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -7,6 +7,7 @@ import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -28,6 +29,8 @@ import type IconAsset from '@src/types/utils/IconAsset'; import React from 'react'; import {View} from 'react-native'; +import DescribeNewRuleForm from './DescribeNewRuleForm'; + type RulesNewPageProps = | PlatformStackScreenProps | PlatformStackScreenProps; @@ -52,6 +55,8 @@ function RulesNewPage({route}: RulesNewPageProps) { const isCategoryScopedCreate = isCategorySettingsFlow || !!categoryName; const policy = usePolicy(policyID); const {showConfirmModal} = useConfirmModal(); + const {isBetaEnabled} = usePermissions(); + const isDescribeRuleBetaEnabled = isBetaEnabled(CONST.BETAS.DESCRIBE_RULE); // From category settings the only expense default is the category's tax rate, so the type chooser is skipped. It // can't be set without a rate to pick, so that case gets the chooser's explainer instead of an empty picker. @@ -96,7 +101,7 @@ function RulesNewPage({route}: RulesNewPageProps) { { key: 'requireFields', icon: illustrations.CheckboxText, - title: translate('workspace.rules.newRule.requireFields'), + title: translate(isDescribeRuleBetaEnabled ? 'workspace.rules.newRule.requireFieldsShort' : 'workspace.rules.newRule.requireFields'), description: translate('workspace.rules.newRule.requireFieldsDescription'), onPress: () => Navigation.navigate( @@ -116,18 +121,41 @@ function RulesNewPage({route}: RulesNewPageProps) { // Only category settings can scope the editor to its category, so a category passed any other way drops it. isWorkspaceOnly: !isCategorySettingsFlow, }, - { - key: 'createAgentRule', - icon: illustrations.AiBot, - title: translate('workspace.rules.newRule.createAgentRule'), - description: translate('workspace.rules.newRule.createAgentRuleDescription'), - onPress: () => Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID)), - sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.NEW_RULE_MENU_ITEM_CREATE_AGENT_RULE, - isWorkspaceOnly: true, - }, + // The describe section drafts an agent rule when it cannot build a standard rule. Therefore it replaces this option. + ...(isDescribeRuleBetaEnabled + ? [] + : [ + { + key: 'createAgentRule', + icon: illustrations.AiBot, + title: translate('workspace.rules.newRule.createAgentRule'), + description: translate('workspace.rules.newRule.createAgentRuleDescription'), + onPress: () => Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID)), + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.NEW_RULE_MENU_ITEM_CREATE_AGENT_RULE, + isWorkspaceOnly: true, + }, + ]), ]; const visibleNewRuleOptions = isCategoryScopedCreate ? newRuleOptions.filter((option) => !option.isWorkspaceOnly) : newRuleOptions; + // The describe section cannot scope a rule to a category. Therefore it shows only when New rule opens from the workspace Rules page. + const shouldShowDescribeSection = isDescribeRuleBetaEnabled && !isCategoryScopedCreate; + + const newRuleMenuItems = visibleNewRuleOptions.map((option) => ( + + )); return ( - - {translate('workspace.rules.newRule.subtitle')} - - {visibleNewRuleOptions.map((option) => ( - - ))} - - + {shouldShowDescribeSection ? ( + + {translate('workspace.rules.newRule.startWithTemplate')} + {newRuleMenuItems} + + ) : ( + + {translate('workspace.rules.newRule.subtitle')} + {newRuleMenuItems} + + )} ); diff --git a/src/pages/workspace/rules/SpendRules/AddSpendRulePage.tsx b/src/pages/workspace/rules/SpendRules/AddSpendRulePage.tsx index 10cfd1f42bc0..6ebce6caf864 100644 --- a/src/pages/workspace/rules/SpendRules/AddSpendRulePage.tsx +++ b/src/pages/workspace/rules/SpendRules/AddSpendRulePage.tsx @@ -1,6 +1,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React from 'react'; @@ -10,10 +11,14 @@ import SpendRulePageBase from './SpendRulePageBase'; type AddSpendRulePageProps = PlatformStackScreenProps; function AddSpendRulePage({route}: AddSpendRulePageProps) { + const {policyID} = route.params; + return ( ); } diff --git a/src/pages/workspace/rules/SpendRules/EditSpendRulePage.tsx b/src/pages/workspace/rules/SpendRules/EditSpendRulePage.tsx index 99f566a27181..0381fabf5962 100644 --- a/src/pages/workspace/rules/SpendRules/EditSpendRulePage.tsx +++ b/src/pages/workspace/rules/SpendRules/EditSpendRulePage.tsx @@ -1,6 +1,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React from 'react'; @@ -10,11 +11,15 @@ import SpendRulePageBase from './SpendRulePageBase'; type EditSpendRulePageProps = PlatformStackScreenProps; function EditSpendRulePage({route}: EditSpendRulePageProps) { + const {policyID, ruleID} = route.params; + return ( ); } diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx index b75b5182e22a..956b83acd709 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx @@ -33,7 +33,7 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getHeaderMessage} from '@libs/OptionsListUtils'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; -import {getSpendRuleFormValuesFromCardRule} from '@libs/SpendRulesUtils'; +import {getEligibleSpendRuleCards} from '@libs/SpendRulesUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -45,10 +45,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {Card, ExpensifyCardSettings, WorkspaceCardsList} from '@src/types/onyx'; -import type {ExpensifyCardRule} from '@src/types/onyx/ExpensifyCardSettings'; - -import type {OnyxEntry} from 'react-native-onyx'; +import type {Card} from '@src/types/onyx'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useState} from 'react'; @@ -60,34 +57,6 @@ type ExpensifyCardListItem = ListItem & type SpendRuleCardPageProps = PlatformStackScreenProps; -function getCardIDsWithSpendRules(cardRules: Record | undefined, currentRuleID?: string): Set { - const cardIDs = new Set(); - if (!cardRules) { - return cardIDs; - } - - for (const [ruleID, rule] of Object.entries(cardRules)) { - if (ruleID === currentRuleID) { - continue; - } - const formValues = getSpendRuleFormValuesFromCardRule(rule); - for (const cardID of formValues?.cardIDs ?? []) { - const numericCardID = Number(cardID); - if (Number.isFinite(numericCardID)) { - cardIDs.add(numericCardID); - } - } - } - - return cardIDs; -} - -function getEligibleCards(cardsList: OnyxEntry, expensifyCardSettings: ExpensifyCardSettings, currentRuleID?: string) { - const {cardList, ...cards} = cardsList ?? {}; - const cardIDsWithSpendRules = getCardIDsWithSpendRules(expensifyCardSettings?.cardRules, currentRuleID); - return Object.values(cards).filter((card: Card) => !cardIDsWithSpendRules.has(card.cardID)); -} - function SpendRuleCardPage({route}: SpendRuleCardPageProps) { const {policyID, ruleID} = route.params; const styles = useThemeStyles(); @@ -130,7 +99,7 @@ function SpendRuleCardPage({route}: SpendRuleCardPageProps) { }); const isCardSettingsLoading = !isOffline && (!expensifyCardSettings || expensifyCardSettings.isLoading) && !expensifyCardSettings?.hasOnceLoaded; - const eligibleCards = expensifyCardSettings ? getEligibleCards(cardsList, expensifyCardSettings, ruleID === ROUTES.NEW ? undefined : ruleID) : []; + const eligibleCards = expensifyCardSettings ? getEligibleSpendRuleCards(cardsList, expensifyCardSettings, ruleID === ROUTES.NEW ? undefined : ruleID) : []; const sortCards = (cards: Card[]) => sortCardsByCardholderName(cards, personalDetails, localeCompare, translate, formatPhoneNumber); diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index 4fce96f34e6c..1611f954b9ab 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -29,7 +29,13 @@ import {convertToBackendAmount} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import {rand64} from '@libs/NumberUtils'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; -import {getSpendRuleFormValuesFromCardRule, getTruncatedSpendRuleSummary} from '@libs/SpendRulesUtils'; +import { + getEligibleSpendRuleCards, + getSpendRuleDraftFromGeneratedValues, + getSpendRuleFormValuesFromCardRule, + getTruncatedSpendRuleSummary, + hasSpendRuleMerchantRestrictions, +} from '@libs/SpendRulesUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -44,7 +50,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {SpendRuleCategory} from '@src/types/form/SpendRuleForm'; -import {isSpendRuleCategory} from '@src/types/form/SpendRuleForm'; import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -100,17 +105,15 @@ function SpendRulePageBase({policyID, ruleID, testID, upgradeBackTo}: SpendRuleP const existingFormValues = useMemo(() => getSpendRuleFormValuesFromCardRule(existingRule), [existingRule]); const [isErrorVisible, setIsErrorVisible] = useState(false); - const [isRestrictMerchantsOff, setIsRestrictMerchantsOff] = useState(() => { - const hasNoMerchantRestrictions = !existingFormValues?.merchantNames.length && !existingFormValues?.categories?.length; - return isNewRule || hasNoMerchantRestrictions; - }); + const [isRestrictMerchantsOff, setIsRestrictMerchantsOff] = useState(() => !hasSpendRuleMerchantRestrictions(isNewRule ? spendRuleForm : existingFormValues)); useEffect(() => () => clearDraftSpendRule(), []); const applyGeneratedRule = (values: GeneratedRuleValues) => { - const generatedCategories = values.categories?.filter(isSpendRuleCategory); - setDraftSpendRule(generatedCategories ? {...values, categories: generatedCategories} : values); - setIsRestrictMerchantsOff(!generatedCategories?.length && !values.merchantNames?.length); + const eligibleCardIDs = expensifyCardSettings ? getEligibleSpendRuleCards(cardsList, expensifyCardSettings).map((card) => String(card.cardID)) : undefined; + const draft = getSpendRuleDraftFromGeneratedValues(values, eligibleCardIDs, spendRuleForm?.cardIDs); + setDraftSpendRule(draft); + setIsRestrictMerchantsOff(!hasSpendRuleMerchantRestrictions(draft)); }; useEffect(() => { diff --git a/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx b/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx index f626758620bd..41d20ec2212a 100644 --- a/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx +++ b/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx @@ -1,6 +1,8 @@ import AgentRulesList from '@components/AgentRules/AgentRulesList'; import useAgentRulesSectionHeader from '@components/AgentRules/useAgentRulesSectionHeader'; import GenericEmptyStateComponent from '@components/EmptyStateComponent/GenericEmptyStateComponent'; +import MenuItem from '@components/MenuItem'; +import MenuItemSectionRoot from '@components/MenuItem/presets/MenuItemSectionRoot'; import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; @@ -111,6 +113,22 @@ function RulesAgentsTab({policyID, canWriteRules, showReadOnlyModal, headerCompo listContainerStyle={[styles.mt6, styles.gap2]} menuItemWrapperStyle={styles.justifyContentCenter} /> + {/* The row stays pressable when the user cannot write rules. Pressing it explains why, so it only looks disabled. */} + + + + + + + + {translate('workspace.rules.agentRules.addRule')} + + + +
diff --git a/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts b/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts index 5c1bc32c4bc9..c2c5ae0ba5c7 100644 --- a/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts +++ b/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts @@ -19,7 +19,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {deleteExpensifyCardRule} from '@libs/actions/Card'; import {deletePolicyCategoryTaxes, openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; import {openPolicyExpensifyCardsPage} from '@libs/actions/Policy/Policy'; -import {deleteMerchantRule} from '@libs/actions/Policy/Rules'; +import {deleteRule} from '@libs/actions/Policy/Rules'; import {getCategoryNameFromTaxRuleKey, isCategoryTaxRuleKey} from '@libs/CategoryTaxRulesUtils'; import {deleteFlagForReviewRule, getFlagForReviewTableData} from '@libs/FlagForReviewRulesUtils'; import {getExpenseDefaultsTableData, isMerchantTypeRuleKey} from '@libs/MerchantTypeRulesUtils'; @@ -268,7 +268,7 @@ function useRulesTableBulkActions({policyID, activeTab, selectedRuleKeysByTab, c continue; } - deleteMerchantRule(policyID, ruleID, rules?.[`${ONYXKEYS.COLLECTION.RULE}${ruleID}`]); + deleteRule(ruleID, rules?.[`${ONYXKEYS.COLLECTION.RULE}${ruleID}`]); } if (selectedCategoryNames.length > 0) { diff --git a/src/pages/workspace/tags/ImportMultiLevelTagsSettingsPage.tsx b/src/pages/workspace/tags/ImportMultiLevelTagsSettingsPage.tsx index 4c7dbb8bc129..acc7ec24e696 100644 --- a/src/pages/workspace/tags/ImportMultiLevelTagsSettingsPage.tsx +++ b/src/pages/workspace/tags/ImportMultiLevelTagsSettingsPage.tsx @@ -10,6 +10,7 @@ import useCloseImportPage from '@hooks/useCloseImportPage'; import useImportSpreadsheetConfirmModal from '@hooks/useImportSpreadsheetConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -55,6 +56,8 @@ function ImportMultiLevelTagsSettingsPage({route}: ImportMultiLevelTagsSettingsP const {setIsClosing} = useCloseImportPage(); const [spreadsheet, spreadsheetMetadata] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET); const showImportSpreadsheetConfirmModal = useImportSpreadsheetConfirmModal(); + const {isBetaEnabled} = usePermissions(); + const isAppendingToExistingLists = hasAccountingConnections && isBetaEnabled(CONST.BETAS.INDIRECT_TAG_UPLOADS) && !!spreadsheet?.isAppendingToExistingLists; useEffect(() => { setImportedSpreadsheetIsFirstLineHeader(true); @@ -79,13 +82,13 @@ function ImportMultiLevelTagsSettingsPage({route}: ImportMultiLevelTagsSettingsP closeImportPageAndModal(); }; - if (hasAccountingConnections) { - return ; - } - if (!spreadsheet && isLoadingOnyxValue(spreadsheetMetadata)) { return; } + + if (hasAccountingConnections && !isAppendingToExistingLists) { + return ; + } return (
- - - {translate('workspace.tags.importMultiLevelTags.independentTags')} - - { - setImportedSpreadsheetIsImportingIndependentMultiLevelTags(value); - }} - /> - + {/* Appended tag lists are always independent, so the dependent option isn't offered */} + {!isAppendingToExistingLists && ( + + + {translate('workspace.tags.importMultiLevelTags.independentTags')} + + { + setImportedSpreadsheetIsImportingIndependentMultiLevelTags(value); + }} + /> + + )} { + close(() => showDownloadErrorModal()); + }; + const [shouldRunPostUpgradeFlow, setShouldRunPostUpgradeFlow] = useState(false); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); const workspaceTagsImportPath = createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_TAGS_IMPORT.path, ROUTES.WORKSPACE_TAGS.getRoute(policyID)); @@ -88,26 +89,9 @@ function ImportTagsOptionsPage({route}: ImportTagsOptionsPageProps) { { if (isMultiLevelTags) { - downloadMultiLevelTagsCSV( - policyID, - () => { - close(() => { - setIsDownloadFailureModalVisible(true); - }); - }, - hasDependentTags, - translate, - ); + downloadMultiLevelTagsCSV(policyID, showDownloadErrorModalAfterClose, hasDependentTags, translate); } else { - downloadTagsCSV( - policyID, - () => { - close(() => { - setIsDownloadFailureModalVisible(true); - }); - }, - translate, - ); + downloadTagsCSV(policyID, showDownloadErrorModalAfterClose, translate); } }} > @@ -117,7 +101,7 @@ function ImportTagsOptionsPage({route}: ImportTagsOptionsPageProps) { ), - [translate, isMultiLevelTags, policyID, hasDependentTags], + [translate, isMultiLevelTags, policyID, hasDependentTags, showDownloadErrorModalAfterClose], ); const switchSingleToMultiLevelTagPrompt = useMemo( @@ -128,26 +112,9 @@ function ImportTagsOptionsPage({route}: ImportTagsOptionsPageProps) { { if (isMultiLevelTags) { - downloadMultiLevelTagsCSV( - policyID, - () => { - close(() => { - setIsDownloadFailureModalVisible(true); - }); - }, - hasDependentTags, - translate, - ); + downloadMultiLevelTagsCSV(policyID, showDownloadErrorModalAfterClose, hasDependentTags, translate); } else { - downloadTagsCSV( - policyID, - () => { - close(() => { - setIsDownloadFailureModalVisible(true); - }); - }, - translate, - ); + downloadTagsCSV(policyID, showDownloadErrorModalAfterClose, translate); } }} > @@ -158,7 +125,7 @@ function ImportTagsOptionsPage({route}: ImportTagsOptionsPageProps) { {translate('workspace.tags.switchSingleToMultiLevelTagWarning.prompt6')} ), - [translate, policyID, hasDependentTags, isMultiLevelTags], + [translate, policyID, hasDependentTags, isMultiLevelTags, showDownloadErrorModalAfterClose], ); const startMultiLevelTagImportFlow = useCallback(async () => { @@ -295,15 +262,6 @@ function ImportTagsOptionsPage({route}: ImportTagsOptionsPageProps) { /> - setIsDownloadFailureModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isDownloadFailureModalVisible} - onClose={() => setIsDownloadFailureModalVisible(false)} - />
); } diff --git a/src/pages/workspace/tags/ImportTagsPage.tsx b/src/pages/workspace/tags/ImportTagsPage.tsx index 9dfcc7a6cf80..f146c20a1ff1 100644 --- a/src/pages/workspace/tags/ImportTagsPage.tsx +++ b/src/pages/workspace/tags/ImportTagsPage.tsx @@ -2,6 +2,7 @@ import ImportSpreadsheet from '@components/ImportSpreadsheet'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; @@ -32,6 +33,13 @@ function ImportTagsPage({route}: ImportTagsPageProps) { const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_IMPORT; const backTo = isQuickSettingsFlow && 'backTo' in route.params ? route.params.backTo : undefined; const [spreadsheet, spreadsheetMetadata] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET); + const {isBetaEnabled} = usePermissions(); + const isAppendingToExistingLists = + !isQuickSettingsFlow && + hasAccountingConnections && + isBetaEnabled(CONST.BETAS.INDIRECT_TAG_UPLOADS) && + !!spreadsheet?.isImportingMultiLevelTags && + !!spreadsheet?.isAppendingToExistingLists; const workspaceTagsListBackPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_TAGS_IMPORT.path); const workspaceGoToImportedPath = createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_TAGS_IMPORTED.path, ROUTES.WORKSPACE_TAGS.getRoute(policyID)); @@ -39,7 +47,7 @@ function ImportTagsPage({route}: ImportTagsPageProps) { if (!spreadsheet && isLoadingOnyxValue(spreadsheetMetadata)) { return; } - if (hasAccountingConnections) { + if (hasAccountingConnections && !isAppendingToExistingLists) { return ; } @@ -65,6 +73,7 @@ function ImportTagsPage({route}: ImportTagsPageProps) { return workspaceGoToImportedPath; })()} isImportingMultiLevelTags={spreadsheet?.isImportingMultiLevelTags} + isAppendingToExistingLists={isAppendingToExistingLists} shouldForceReplaceNavigation={!isQuickSettingsFlow && !spreadsheet?.isImportingMultiLevelTags} /> diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index ede238267301..d228033b0574 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -2,7 +2,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; -import DecisionModal from '@components/DecisionModal'; import EmployeesSeeTagsAsText from '@components/EmployeesSeeTagsAsText'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ImportedFromAccountingSoftware from '@components/ImportedFromAccountingSoftware'; @@ -13,6 +12,7 @@ import type {WorkspaceTagTableRowData} from '@components/Tables/WorkspaceTagsTab import WorkspaceTagsTable from '@components/Tables/WorkspaceTagsTable'; import Text from '@components/Text'; +import useAlertModals from '@hooks/useAlertModals'; import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; import useConfirmModal from '@hooks/useConfirmModal'; import useEnvironment from '@hooks/useEnvironment'; @@ -38,10 +38,14 @@ import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {renameTagInline} from '@libs/actions/Policy/InlineEdit'; import { clearPolicyTagErrors, + clearPolicyTagListErrors, deletePolicyTags, downloadMultiLevelTagsCSV, downloadTagsCSV, openPolicyTagsPage, + setImportedSpreadsheetIsAppendingToExistingLists, + setImportedSpreadsheetIsImportingMultiLevelTags, + setPolicyShowTagGLCodes, setPolicyTagsRequired, setWorkspaceTagEnabled, } from '@libs/actions/Policy/Tag'; @@ -63,11 +67,13 @@ import { isMultiLevelTags as isMultiLevelTagsPolicyUtils, shouldShowSyncError, } from '@libs/PolicyUtils'; +import showInlineEditError from '@libs/showInlineEditError'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import {getCurrentAccountingIntegrationName} from '@pages/workspace/accounting/utils'; import {close} from '@userActions/Modal'; +import {clearPolicyErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -100,16 +106,15 @@ function getPendingAction(policyTagList: PolicyTagList): PendingAction | undefin } function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { - // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use the correct modal type for the decision modal - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {shouldUseNarrowLayout, isSmallScreenWidth, isInLandscapeMode} = useResponsiveLayout(); + const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); const {pageGutter} = useLayoutSpacing(); const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); - const {isBetaEnabledOrUnknown} = usePermissions(); + const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); + const isIndirectTagUploadsBetaEnabled = isBetaEnabled(CONST.BETAS.INDIRECT_TAG_UPLOADS); const {showConfirmModal} = useConfirmModal(); - const [isDownloadFailureModalVisible, setIsDownloadFailureModalVisible] = useState(false); + const {showDownloadErrorModal} = useAlertModals(); const {backTo, policyID} = route.params; const policyData = usePolicyData(policyID); const {policy, tags: policyTags} = policyData; @@ -134,8 +139,6 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { ); const {canWrite: canWriteTags, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.TAGS); - // The multi-level tag settings live in Rules, but the GL codes toggle stays here and needs a way in. - const shouldShowTagsSettings = canWriteTags && (!isMultiLevelTags || !!policy?.glCodes); // Multi-level tag rows only ever offered the Required bulk actions, and those moved to Rules, so selecting them // would open a dropdown with nothing in it. const isSelectionEnabled = canWriteTags && !hasDependentTags && !isMultiLevelTags; @@ -400,7 +403,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { canEditName: canWriteTags && !isSelectionModeActive, action: () => navigateToTagSettings(tag.name), onToggleEnabled: (enabled: boolean) => handleTagEnabledToggle(enabled, tag), - onRenameName: (newName: string) => renameTagInline(policyData, tag.name, newName, isVendorMatchingBetaEnabled), + onRenameName: (newName: string) => showInlineEditError(renameTagInline(translate, policyData, tag.name, newName, isVendorMatchingBetaEnabled)), onClose: () => clearPolicyTagErrors({policyID, tagName: tag.name, tagListIndex: 0, policyTags}), }); @@ -417,6 +420,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { policy, policyID, policyTagLists, + translate, policyTags, shouldShowApproverColumn, tagApproverEmails, @@ -434,9 +438,10 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { [tagRows], ); - const navigateToTagsSettings = useCallback(() => { - Navigation.navigate(buildDynamicRoute(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_TAGS_SETTINGS.path : DYNAMIC_ROUTES.WORKSPACE_TAGS_SETTINGS.path)); - }, [buildDynamicRoute, isQuickSettingsFlow]); + const navigateToCustomTagName = useCallback(() => { + const orderWeight = policyTagLists.at(0)?.orderWeight ?? 0; + Navigation.navigate(buildDynamicRoute(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_TAGS_EDIT.getRoute(orderWeight) : DYNAMIC_ROUTES.WORKSPACE_EDIT_TAGS.getRoute(orderWeight))); + }, [buildDynamicRoute, isQuickSettingsFlow, policyTagLists]); const navigateToCreateTagPage = () => { Navigation.navigate(isQuickSettingsFlow ? buildDynamicRoute(DYNAMIC_ROUTES.SETTINGS_TAG_CREATE.path) : buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_TAG_CREATE.path)); @@ -472,15 +477,66 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { ); }, [backTo, isOffline, isQuickSettingsFlow, policyID, showConfirmModal, translate]); + const navigateToAppendCustomTagList = useCallback(() => { + if (isOffline) { + showConfirmModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.thisFeatureRequiresInternet'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + shouldHandleNavigationBack: true, + }); + return; + } + // Appending keeps the accounting connection tag lists, so we skip the import options page and its override warnings + setImportedSpreadsheetIsImportingMultiLevelTags(true); + setImportedSpreadsheetIsAppendingToExistingLists(true); + Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_TAGS_IMPORT.path)); + }, [buildDynamicRoute, isOffline, showConfirmModal, translate]); + const hasAccountingConnections = hasAccountingConnectionsPolicyUtils(policy); + // Same as Classic, a custom tag list can only be appended to single-level or independent multi-level tags + const shouldShowAppendCustomTagList = canWriteTags && hasAccountingConnections && isIndirectTagUploadsBetaEnabled && !isQuickSettingsFlow && (!isMultiLevelTags || hasIndependentTags); const secondaryActions = useMemo(() => { const menuItems = []; - if (shouldShowTagsSettings) { + // The former Settings page's rows are surfaced directly in this menu (the dedicated Settings page was removed). + if (canWriteTags && !isMultiLevelTags) { + menuItems.push({ + // Match the design: the "Custom tag name" label is the small gray description on top, the tag list name is the regular-weight title below. + text: policyTagLists.at(0)?.name ?? '', + description: translate('workspace.tags.customTagName'), + shouldShowDescriptionOnTop: true, + titleStyle: styles.fontWeightNormal, + onSelected: navigateToCustomTagName, + shouldShowRightIcon: true, + shouldIgnoreCompactStyle: true, + value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + pendingAction: policyTags?.[policyTagLists.at(0)?.name ?? '']?.pendingAction, + errors: policyTags?.[policyTagLists.at(0)?.name ?? '']?.errors, + onCloseError: () => clearPolicyTagListErrors({policyID, tagListIndex: policyTagLists.at(0)?.orderWeight ?? 0, policyTags}), + }); + } + if (canWriteTags && !!policy?.glCodes) { menuItems.push({ - icon: expensifyIcons.Gear, - text: translate('common.settings'), - onSelected: navigateToTagsSettings, + text: translate('workspace.tags.showTagGLCodes'), value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + // Selecting the row (click or Enter) toggles it; the Switch is a display-only indicator. Keep the menu open on select. + shouldCloseModalOnSelect: false, + onSelected: () => setPolicyShowTagGLCodes(policyID, !(policy?.showTagGLCodes ?? false), policy?.showTagGLCodes), + shouldIgnoreCompactStyle: true, + // Let the label wrap fully and keep the Switch centered against it on narrow screens. + numberOfLinesTitle: 0, + innerContainerStyle: styles.alignItemsCenter, + titleStyle: [styles.textLabel, styles.fontWeightNormal], + pendingAction: policy?.pendingFields?.showTagGLCodes, + errors: policy?.errorFields?.showTagGLCodes, + onCloseError: () => clearPolicyErrorField(policyID, 'showTagGLCodes'), + switchProps: { + isOn: policy?.showTagGLCodes ?? false, + accessibilityLabel: translate('workspace.tags.showTagGLCodes'), + onToggle: (value: boolean) => setPolicyShowTagGLCodes(policyID, value, policy?.showTagGLCodes), + disabled: !policy?.areTagsEnabled, + }, }); } @@ -490,6 +546,17 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { text: translate('spreadsheet.importSpreadsheet'), onSelected: navigateToImportSpreadsheet, value: CONST.POLICY.SECONDARY_ACTIONS.IMPORT_SPREADSHEET, + // Group the settings rows apart from the spreadsheet actions. + addSeparatorBefore: true, + }); + } + + if (shouldShowAppendCustomTagList) { + menuItems.push({ + icon: expensifyIcons.Table, + text: translate('workspace.tags.appendCustomTagList'), + onSelected: navigateToAppendCustomTagList, + value: CONST.POLICY.SECONDARY_ACTIONS.APPEND_CUSTOM_TAG_LIST, }); } @@ -510,22 +577,9 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { } close(() => { if (isMultiLevelTags) { - downloadMultiLevelTagsCSV( - policyID, - () => { - setIsDownloadFailureModalVisible(true); - }, - hasDependentTags, - translate, - ); + downloadMultiLevelTagsCSV(policyID, showDownloadErrorModal, hasDependentTags, translate); } else { - downloadTagsCSV( - policyID, - () => { - setIsDownloadFailureModalVisible(true); - }, - translate, - ); + downloadTagsCSV(policyID, showDownloadErrorModal, translate); } }); }, @@ -536,9 +590,9 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { return menuItems; }, [ translate, - shouldShowTagsSettings, - navigateToTagsSettings, hasAccountingConnections, + shouldShowAppendCustomTagList, + navigateToAppendCustomTagList, hasVisibleTags, navigateToImportSpreadsheet, isOffline, @@ -547,7 +601,19 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { hasDependentTags, expensifyIcons, showConfirmModal, + showDownloadErrorModal, canWriteTags, + policyTagLists, + policyTags, + navigateToCustomTagName, + policy?.glCodes, + policy?.showTagGLCodes, + policy?.areTagsEnabled, + policy?.pendingFields?.showTagGLCodes, + policy?.errorFields?.showTagGLCodes, + styles.alignItemsCenter, + styles.textLabel, + styles.fontWeightNormal, ]); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); @@ -771,77 +837,66 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { }; return ( - <> - + - { + if (isMobileSelectionModeEnabled) { + clearTableSelection(); + turnOffMobileSelectionMode(); + return; + } + + if (backTo) { + Navigation.goBack(backTo); + return; + } + + Navigation.goBack(); + }} > - { - if (isMobileSelectionModeEnabled) { - clearTableSelection(); - turnOffMobileSelectionMode(); - return; - } - - if (backTo) { - Navigation.goBack(backTo); - return; - } - - Navigation.goBack(); - }} - > - {!shouldDisplayButtonsInSeparateLine && getHeaderButtons()} - - {shouldDisplayButtonsInSeparateLine && !!getHeaderButtons() && {getHeaderButtons()}} - {(!hasVisibleTags || isLoading) && headerContent} - {isLoading && ( - - )} - {!isLoading && ( - - )} - - - setIsDownloadFailureModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isDownloadFailureModalVisible} - onClose={() => setIsDownloadFailureModalVisible(false)} - /> - + {!shouldDisplayButtonsInSeparateLine && getHeaderButtons()} + + {shouldDisplayButtonsInSeparateLine && !!getHeaderButtons() && {getHeaderButtons()}} + {(!hasVisibleTags || isLoading) && headerContent} + {isLoading && ( + + )} + {!isLoading && ( + + )} + + ); } diff --git a/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx deleted file mode 100644 index f1308b83fa04..000000000000 --- a/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScreenWrapper from '@components/ScreenWrapper'; - -import useDynamicBackPath from '@hooks/useDynamicBackPath'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import usePolicyData from '@hooks/usePolicyData'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {clearPolicyErrorField} from '@libs/actions/Policy/Policy'; -import {clearPolicyTagListErrors, setPolicyShowTagGLCodes} from '@libs/actions/Policy/Tag'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; -import Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {getTagLists as getTagListsUtil, isMultiLevelTags as isMultiLevelTagsUtil} from '@libs/PolicyUtils'; - -import type {SettingsNavigatorParamList} from '@navigation/types'; - -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; - -import CONST from '@src/CONST'; -import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; -import type {Policy} from '@src/types/onyx'; - -import type {OnyxEntry} from 'react-native-onyx'; - -import React, {useMemo} from 'react'; -import {View} from 'react-native'; - -type WorkspaceTagsSettingsPageProps = - | PlatformStackScreenProps - | PlatformStackScreenProps; - -function WorkspaceTagsSettingsPage({route}: WorkspaceTagsSettingsPageProps) { - const policyID = route.params.policyID; - const styles = useThemeStyles(); - const policyData = usePolicyData(policyID); - const {tags: policyTags} = policyData; - const {translate} = useLocalize(); - const [policyTagLists, isMultiLevelTags] = useMemo(() => [getTagListsUtil(policyTags), isMultiLevelTagsUtil(policyTags)], [policyTags]); - const customTagName = policyTagLists.at(0)?.name; - const isLoading = !getTagListsUtil(policyTags)?.at(0) || Object.keys(policyTags ?? {}).at(0) === 'undefined'; - const {isOffline} = useNetwork(); - const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAGS_SETTINGS; - const backPath = useDynamicBackPath(DYNAMIC_ROUTES.SETTINGS_TAGS_SETTINGS.path); - // Multi-level tag settings moved to Rules, so the GL codes toggle is the only thing that can keep this page alive. - const shouldBlockEmptySettings = isMultiLevelTags && !isLoading && !policyData.policy?.glCodes; - - const getTagsSettings = (policy: OnyxEntry) => { - const updateShowTagGLCodes = (value: boolean) => { - setPolicyShowTagGLCodes(policyID, value, policy?.showTagGLCodes); - }; - return ( - - {!isMultiLevelTags && ( - - clearPolicyTagListErrors({ - policyID, - tagListIndex: policyTagLists.at(0)?.orderWeight ?? 0, - policyTags, - }) - } - pendingAction={policyTags?.[policyTagLists.at(0)?.name ?? '']?.pendingAction} - errorRowStyles={styles.mh5} - > - { - Navigation.navigate( - isQuickSettingsFlow - ? createDynamicRoute(DYNAMIC_ROUTES.SETTINGS_TAGS_EDIT.getRoute(policyTagLists.at(0)?.orderWeight ?? 0)) - : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EDIT_TAGS.getRoute(policyTagLists.at(0)?.orderWeight ?? 0)), - ); - }} - value={customTagName} - /> - - )} - {!!policy?.glCodes && ( - clearPolicyErrorField(policyID, 'showTagGLCodes')} - /> - )} - - ); - }; - return ( - - {({policy}) => ( - - Navigation.goBack(isQuickSettingsFlow ? backPath : undefined)} - /> - {isOffline && isLoading ? {getTagsSettings(policy)} : getTagsSettings(policy)} - - )} - - ); -} - -export default WorkspaceTagsSettingsPage; diff --git a/src/pages/workspace/taxes/WorkspaceCreateTaxValuePage.tsx b/src/pages/workspace/taxes/WorkspaceCreateTaxValuePage.tsx index fec0dc11f096..0282b918d0e6 100644 --- a/src/pages/workspace/taxes/WorkspaceCreateTaxValuePage.tsx +++ b/src/pages/workspace/taxes/WorkspaceCreateTaxValuePage.tsx @@ -1,9 +1,7 @@ import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import NumberWithSymbolForm from '@components/NumberWithSymbolForm'; import NumericInput from '@components/NumericInput'; import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useLocalize from '@hooks/useLocalize'; @@ -11,14 +9,11 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {setDraftValues} from '@libs/actions/FormActions'; -import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import variables from '@styles/variables'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -27,12 +22,9 @@ import INPUT_IDS from '@src/types/form/WorkspaceNewTaxForm'; import {useFocusEffect} from '@react-navigation/native'; import React, {useRef, useState} from 'react'; -import {View} from 'react-native'; type WorkspaceCreateTaxValuePageProps = PlatformStackScreenProps; -const shouldUseLegacyInput = canUseTouchScreenUtil(); - function WorkspaceCreateTaxValuePage({ route: { params: {policyID}, @@ -71,58 +63,31 @@ function WorkspaceCreateTaxValuePage({ title={translate('workspace.taxes.value')} onBackButtonPress={goBack} /> - - - {shouldUseLegacyInput ? ( - - ) : ( - - - - % - - - )} + {translate('common.save')} - - + } + > + + % + ); } diff --git a/src/pages/workspace/travel/WorkspaceTravelBillingMonthlyLimitPage.tsx b/src/pages/workspace/travel/WorkspaceTravelBillingMonthlyLimitPage.tsx index 147c28ab17f7..5c4146de715d 100644 --- a/src/pages/workspace/travel/WorkspaceTravelBillingMonthlyLimitPage.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelBillingMonthlyLimitPage.tsx @@ -9,10 +9,10 @@ import Text from '@components/Text'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useConfirmModal from '@hooks/useConfirmModal'; -import useDefaultFundID from '@hooks/useDefaultFundID'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import useTravelBillingFundID from '@hooks/useTravelBillingFundID'; import {updateTravelBillingMonthlyLimit} from '@libs/actions/TravelBilling'; import {getCardSettings} from '@libs/CardUtils'; @@ -36,8 +36,8 @@ function WorkspaceTravelBillingMonthlyLimitPage({route}: WorkspaceTravelBillingM const styles = useThemeStyles(); const {translate} = useLocalize(); const policyID = route.params?.policyID; - const defaultFundID = useDefaultFundID(policyID); - const [cardSettings] = useOnyx(getTravelBillingCardSettingsKey(defaultFundID)); + const travelBillingFundID = useTravelBillingFundID(policyID); + const [cardSettings] = useOnyx(getTravelBillingCardSettingsKey(travelBillingFundID)); const travelSettings = getCardSettings(cardSettings, CONST.TRAVEL.PROGRAM_TRAVEL_US); const currentLimit = travelSettings?.monthlySpendLimitPerUser ?? 0; const defaultValue = convertToFrontendAmountAsString(currentLimit, CONST.DEFAULT_CURRENCY_DECIMALS); @@ -45,7 +45,7 @@ function WorkspaceTravelBillingMonthlyLimitPage({route}: WorkspaceTravelBillingM const {showConfirmModal} = useConfirmModal(); const submitLimit = (newLimitInCents: number) => { - updateTravelBillingMonthlyLimit(defaultFundID, newLimitInCents, currentLimit); + updateTravelBillingMonthlyLimit(travelBillingFundID, newLimitInCents, currentLimit); Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); }; diff --git a/src/pages/workspace/travel/WorkspaceTravelBillingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelBillingSection.tsx index bd4871f17efc..19d2e2979b16 100644 --- a/src/pages/workspace/travel/WorkspaceTravelBillingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelBillingSection.tsx @@ -12,13 +12,13 @@ import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useDefaultFundID from '@hooks/useDefaultFundID'; import useExpensifyCardFeedsForFeedSelector from '@hooks/useExpensifyCardFeedsForFeedSelector'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import useTravelBillingFundID from '@hooks/useTravelBillingFundID'; import { clearTravelBillingErrors, @@ -82,7 +82,7 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection const {isLargeScreenWidth} = useResponsiveLayout(); const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); - const defaultFundID = useDefaultFundID(policyID); + const travelBillingFundID = useTravelBillingFundID(policyID); const {allFeeds: accessibleTravelFeeds} = useExpensifyCardFeedsForFeedSelector(policyID, [CONST.TRAVEL.PROGRAM_TRAVEL_US]); const {showConfirmModal, closeModal} = useConfirmModal(); @@ -93,7 +93,7 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection const shouldResumeToggleRef = useRef(false); // Read the travel feed from the resolved fund so a shared domain feed shows feed-wide spend and limit. - const [cardSettings] = useOnyx(getTravelBillingCardSettingsKey(defaultFundID)); + const [cardSettings] = useOnyx(getTravelBillingCardSettingsKey(travelBillingFundID)); const [cardOnWaitlist] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST}${policyID}`); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); @@ -101,7 +101,7 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); - const [domainMemberData] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${defaultFundID}`); + const [domainMemberData] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${travelBillingFundID}`); // Resolve travel-specific settings from the shared card settings key const travelSettings = getCardSettings(cardSettings, CONST.TRAVEL.PROGRAM_TRAVEL_US); @@ -210,7 +210,7 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection if (result.action !== ModalActions.CONFIRM) { return; } - payTravelBillingSpend(policyID, defaultFundID, travelSpendRef.current); + payTravelBillingSpend(policyID, travelBillingFundID, travelSpendRef.current); }; /** @@ -218,7 +218,7 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection * can reconcile their travel spend. */ const handleViewOnSpend = () => { - const travelFeedID = getTravelBillingFeedID(defaultFundID); + const travelFeedID = getTravelBillingFeedID(travelBillingFundID); const query = buildQueryStringFromFilterFormValues({ type: CONST.SEARCH.DATA_TYPES.EXPENSE, feed: [travelFeedID], @@ -260,7 +260,7 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection // Has settlement account - enable Travel Billing and navigate to settlement page to show verification state if (settlementAccount?.bankAccountID) { - configureTravelBillingForPolicy(policyID, defaultFundID, settlementAccount.bankAccountID); + configureTravelBillingForPolicy(policyID, travelBillingFundID, settlementAccount.bankAccountID); } Navigation.navigate(ROUTES.WORKSPACE_TRAVEL_SETTINGS_ACCOUNT.getRoute(policyID)); }; @@ -293,7 +293,7 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection if (result.action !== ModalActions.CONFIRM) { return; } - deactivateTravelBilling(policyID, defaultFundID); + deactivateTravelBilling(policyID, travelBillingFundID); }; /** @@ -388,7 +388,7 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection retryTravelCardsProvisioning(policyID, defaultFundID, travelProvisioningErrors ?? {})} + onRetry={() => retryTravelCardsProvisioning(policyID, travelBillingFundID, travelProvisioningErrors ?? {})} variant={CONST.BUTTON_VARIANT.DANGER} shouldAlignButtonToMessage /> @@ -444,7 +444,7 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection clearTravelBillingSettlementAccountErrors(defaultFundID, travelSettings?.previousPaymentBankAccountID ?? null)} + onClose={() => clearTravelBillingSettlementAccountErrors(travelBillingFundID, travelSettings?.previousPaymentBankAccountID ?? null)} errorRowStyles={styles.mh2half} errorRowTextStyles={styles.mr3} > @@ -468,7 +468,7 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection clearTravelBillingSettlementFrequencyErrors(defaultFundID, travelSettings?.previousMonthlySettlementDate)} + onClose={() => clearTravelBillingSettlementFrequencyErrors(travelBillingFundID, travelSettings?.previousMonthlySettlementDate)} errorRowStyles={styles.mh2half} errorRowTextStyles={styles.mr3} > @@ -487,7 +487,7 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection clearTravelBillingMonthlyLimitErrors(defaultFundID)} + onClose={() => clearTravelBillingMonthlyLimitErrors(travelBillingFundID)} errorRowStyles={styles.mh2half} errorRowTextStyles={styles.mr3} > @@ -520,7 +520,7 @@ function WorkspaceTravelBillingSection({policyID}: WorkspaceTravelBillingSection showLockIcon={!canWriteMoreFeatures || isOnWaitlist || hasOutstandingBalance} pendingAction={togglePendingAction} errors={toggleErrors} - onCloseError={() => clearTravelBillingErrors(defaultFundID)} + onCloseError={() => clearTravelBillingErrors(travelBillingFundID)} subMenuItems={travelBillingSubMenuItems} /> diff --git a/src/pages/workspace/travel/WorkspaceTravelBillingSettlementAccountPage.tsx b/src/pages/workspace/travel/WorkspaceTravelBillingSettlementAccountPage.tsx index 90da40a46336..1f25eec5786b 100644 --- a/src/pages/workspace/travel/WorkspaceTravelBillingSettlementAccountPage.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelBillingSettlementAccountPage.tsx @@ -7,10 +7,10 @@ import SettlementAccountSelector, {BankAccountListItemLeftElement} from '@compon import type {BankAccountListItem} from '@components/SettlementAccountSelector'; import Text from '@components/Text'; -import useDefaultFundID from '@hooks/useDefaultFundID'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import useTravelBillingFundID from '@hooks/useTravelBillingFundID'; import {configureTravelBillingForPolicy, setTravelBillingSettlementAccount} from '@libs/actions/TravelBilling'; import {getLastFourDigits} from '@libs/BankAccountUtils'; @@ -37,10 +37,10 @@ function WorkspaceTravelBillingSettlementAccountPage({route}: WorkspaceTravelBil const styles = useThemeStyles(); const {translate} = useLocalize(); const policyID = route.params?.policyID; - const defaultFundID = useDefaultFundID(policyID); + const travelBillingFundID = useTravelBillingFundID(policyID); const [bankAccountsList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - const [cardSettings] = useOnyx(getTravelBillingCardSettingsKey(defaultFundID)); + const [cardSettings] = useOnyx(getTravelBillingCardSettingsKey(travelBillingFundID)); const [cardOnWaitlist] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST}${policyID}`); const travelSettings = getCardSettings(cardSettings, CONST.TRAVEL.PROGRAM_TRAVEL_US); @@ -89,12 +89,12 @@ function WorkspaceTravelBillingSettlementAccountPage({route}: WorkspaceTravelBil } if (!isTravelBillingEnabled) { - configureTravelBillingForPolicy(policyID, defaultFundID, value); + configureTravelBillingForPolicy(policyID, travelBillingFundID, value); return; } const previousPaymentBankAccountID = travelSettings?.previousPaymentBankAccountID ?? travelSettings?.paymentBankAccountID; - setTravelBillingSettlementAccount(policyID, defaultFundID, value, previousPaymentBankAccountID); + setTravelBillingSettlementAccount(policyID, travelBillingFundID, value, previousPaymentBankAccountID); Navigation.goBack(); }; diff --git a/src/pages/workspace/travel/WorkspaceTravelBillingSettlementFrequencyPage.tsx b/src/pages/workspace/travel/WorkspaceTravelBillingSettlementFrequencyPage.tsx index dd6a2bb56e50..056b40af225d 100644 --- a/src/pages/workspace/travel/WorkspaceTravelBillingSettlementFrequencyPage.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelBillingSettlementFrequencyPage.tsx @@ -5,10 +5,10 @@ import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelec import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; -import useDefaultFundID from '@hooks/useDefaultFundID'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import useTravelBillingFundID from '@hooks/useTravelBillingFundID'; import {updateTravelBillingSettlementFrequency} from '@libs/actions/TravelBilling'; import {getCardSettings} from '@libs/CardUtils'; @@ -36,8 +36,8 @@ function WorkspaceTravelBillingSettlementFrequencyPage({route}: WorkspaceTravelB const styles = useThemeStyles(); const {translate} = useLocalize(); const policyID = route.params?.policyID; - const defaultFundID = useDefaultFundID(policyID); - const [cardSettings] = useOnyx(getTravelBillingCardSettingsKey(defaultFundID)); + const travelBillingFundID = useTravelBillingFundID(policyID); + const [cardSettings] = useOnyx(getTravelBillingCardSettingsKey(travelBillingFundID)); const travelSettings = getCardSettings(cardSettings, CONST.TRAVEL.PROGRAM_TRAVEL_US); const currentFrequency = getTravelSettlementFrequency(travelSettings); @@ -68,7 +68,7 @@ function WorkspaceTravelBillingSettlementFrequencyPage({route}: WorkspaceTravelB const monthlySettlementDate = travelSettings?.monthlySettlementDate; const saveAndGoBack = () => { if (selectedFrequency && (selectedFrequency !== currentFrequency || hasFrequencyError)) { - updateTravelBillingSettlementFrequency(defaultFundID, selectedFrequency, monthlySettlementDate); + updateTravelBillingSettlementFrequency(travelBillingFundID, selectedFrequency, monthlySettlementDate); } Navigation.goBack(); }; diff --git a/src/pages/workspace/upgrade/UpgradeConfirmation.tsx b/src/pages/workspace/upgrade/UpgradeConfirmation.tsx index 9733dfb85fc9..48235ec02faf 100644 --- a/src/pages/workspace/upgrade/UpgradeConfirmation.tsx +++ b/src/pages/workspace/upgrade/UpgradeConfirmation.tsx @@ -1,4 +1,5 @@ import ConfirmationPage from '@components/ConfirmationPage'; +import FixedFooter from '@components/FixedFooter'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; @@ -71,14 +72,19 @@ function UpgradeConfirmation({policyName, planName, afterUpgradeAcknowledged, is }, [isCategorizing, isReporting, translate]); return ( - + + + + {heading} + {description} + + + + + ); } diff --git a/src/pages/workspace/upgrade/UpgradeIntro.tsx b/src/pages/workspace/upgrade/UpgradeIntro.tsx index 66d7c007967a..937be15f44be 100644 --- a/src/pages/workspace/upgrade/UpgradeIntro.tsx +++ b/src/pages/workspace/upgrade/UpgradeIntro.tsx @@ -47,7 +47,6 @@ function UpgradeIntro({feature, onUpgrade, buttonDisabled, loading, isCategorizi const formattedPrice = useMemo(() => { const upgradeCurrency = Object.hasOwn(CONST.SUBSCRIPTION_PRICES, preferredCurrency) ? preferredCurrency : CONST.PAYMENT_CARD_CURRENCY.USD; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const matchesTeamPricingHeuristics = isCategorizing || isDistanceRateUpgrade || isReporting || isSubmitFeature || upgradePlanType === CONST.POLICY.TYPE.TEAM; // An explicit upgradePlanType (chosen in the Plan RHP) is authoritative over the feature-based heuristics, so pricing matches the plan title shown in GenericFeaturesView. const shouldUseTeamPricing = upgradePlanType === CONST.POLICY.TYPE.CORPORATE ? false : matchesTeamPricingHeuristics; diff --git a/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx b/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx index 921a25fca47a..66df1fc37dfd 100644 --- a/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx +++ b/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx @@ -1,30 +1,123 @@ +import ActivityIndicator from '@components/ActivityIndicator'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ImportedFromAccountingSoftware from '@components/ImportedFromAccountingSoftware'; import ScreenWrapper from '@components/ScreenWrapper'; import type {WorkspaceVendorTableRowData} from '@components/Tables/WorkspaceVendorsTable'; import WorkspaceVendorsTable from '@components/Tables/WorkspaceVendorsTable'; +import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; +import useFilteredSelection from '@hooks/useFilteredSelection'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSearchBackPress from '@hooks/useSearchBackPress'; +import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import {clearVendorErrors, openPolicyVendorsPage, setPolicyVendorsEnabled} from '@libs/actions/Policy/Vendor'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {getActiveVendorMatchingIntegration, getMatchingVendors, hasVendorFeature, sortVendors} from '@libs/PolicyUtils'; +import {getActiveVendorMatchingIntegration, getDefaultVendorID, hasVendorFeature, sortVendors} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; +import type {PolicyVendors} from '@src/types/onyx'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; -import React, {useMemo} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; + +import React, {useEffect} from 'react'; import {View} from 'react-native'; +type VendorBulkActionsProps = { + policyID: string; + selectedVendorKeys: string[]; + policyVendors?: OnyxEntry; + defaultVendorID?: string; + onClearSelection: () => void; +}; + +function VendorBulkActions({policyID, selectedVendorKeys, policyVendors, defaultVendorID, onClearSelection}: VendorBulkActionsProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); + const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Close']); + + const options: Array>> = []; + const disabledVendors = selectedVendorKeys.filter((id) => !policyVendors?.[id]?.enabled); + if (disabledVendors.length > 0) { + options.push({ + icon: icons.Checkmark, + text: translate(disabledVendors.length === 1 ? 'workspace.vendors.enableVendor' : 'workspace.vendors.enableVendors'), + value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE, + onSelected: () => { + onClearSelection(); + setPolicyVendorsEnabled({ + policyID, + vendorIDs: disabledVendors, + enabled: true, + policyVendors, + }); + }, + }); + } + + const vendorsToDisable = selectedVendorKeys.filter((id) => policyVendors?.[id]?.enabled && id !== defaultVendorID); + if (vendorsToDisable.length > 0) { + options.push({ + icon: icons.Close, + text: translate(vendorsToDisable.length === 1 ? 'workspace.vendors.disableVendor' : 'workspace.vendors.disableVendors'), + value: CONST.POLICY.BULK_ACTION_TYPES.DISABLE, + onSelected: () => { + onClearSelection(); + setPolicyVendorsEnabled({ + policyID, + vendorIDs: vendorsToDisable, + enabled: false, + policyVendors, + }); + }, + }); + } + + if (options.length === 0) { + return null; + } + + return ( + null} + shouldAlwaysShowDropdownMenu + size={CONST.BUTTON_SIZE.MEDIUM} + customText={translate('workspace.common.selected', {count: selectedVendorKeys.length})} + options={options} + isSplitButton={false} + style={[shouldDisplayButtonsInSeparateLine && styles.flexGrow1, shouldDisplayButtonsInSeparateLine && styles.mb3]} + isDisabled={!selectedVendorKeys.length} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.INITIAL.VENDORS} + testID="WorkspaceVendorsPage-header-dropdown-menu-button" + /> + ); +} + +VendorBulkActions.displayName = 'VendorBulkActions'; + type WorkspaceVendorsPageProps = WithPolicyConnectionsProps & PlatformStackScreenProps; function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { @@ -33,24 +126,84 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isBetaEnabled} = usePermissions(); + const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); + const isMobileSelectionModeEnabled = useMobileSelectionMode(); + const {canWrite: canWriteVendors, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.VENDORS); + + const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`); + const [selectedVendorKeys, setSelectedVendorKeys] = useFilteredSelection(policyVendors, (vendor) => !!vendor); useWorkspaceDocumentTitle(policy?.name, 'workspace.common.vendors'); const isVendorMatchingBetaEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING); const isFeatureAvailable = hasVendorFeature(policy, isVendorMatchingBetaEnabled); - const vendors = getMatchingVendors(policy); - const sortedVendors = sortVendors(vendors, localeCompare); + + // Stale sessions may never have received the policyVendors_ collection, so fetch it if missing. + const fetchVendors = () => { + if (!isFeatureAvailable) { + return; + } + openPolicyVendorsPage(policyID); + }; + + const {isOffline} = useNetwork({onReconnect: fetchVendors}); + + useEffect(() => { + fetchVendors(); + // Run only on mount because reconnect re-fetches are handled by useNetwork above. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const connectedIntegration = getActiveVendorMatchingIntegration(policy); const currentConnectionName = connectedIntegration ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectedIntegration] : undefined; + const defaultVendorID = getDefaultVendorID(policy, connectedIntegration); - const vendorRows: WorkspaceVendorTableRowData[] = useMemo( - () => - sortedVendors.map((vendor) => ({ - keyForList: vendor.id, - name: vendor.name, - })), - [sortedVendors], - ); + const clearTableSelection = () => { + setSelectedVendorKeys((prev) => (prev.length > 0 ? [] : prev)); + }; + + useCleanupSelectedOptions(clearTableSelection); + + useSearchBackPress({ + onClearSelection: clearTableSelection, + onNavigationCallBack: () => Navigation.goBack(), + }); + + const handleVendorToggle = (enabled: boolean, vendorID: string) => { + if (!canWriteVendors) { + showReadOnlyModal(); + return; + } + + if (!enabled && vendorID === defaultVendorID) { + return; + } + + setPolicyVendorsEnabled({ + policyID, + vendorIDs: [vendorID], + enabled, + policyVendors, + }); + }; + + const sortedVendors = sortVendors(Object.values(policyVendors ?? {}), localeCompare); + + const vendorRows: WorkspaceVendorTableRowData[] = sortedVendors.map((vendor) => { + const isDefaultVendor = vendor.externalID === defaultVendorID; + const isLocked = (vendor.enabled && isDefaultVendor) || !canWriteVendors; + + return { + keyForList: vendor.externalID, + name: vendor.name, + enabled: vendor.enabled, + isLocked, + errors: vendor.errors ?? undefined, + pendingAction: vendor.pendingAction, + onToggleEnabled: (enabled: boolean) => handleVendorToggle(enabled, vendor.externalID), + dismissError: () => clearVendorErrors(policyID, vendor.externalID), + }; + }); const headerContent = currentConnectionName ? ( @@ -63,6 +216,10 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { ) : undefined; + const canShowBulkActions = canWriteVendors && selectedVendorKeys.length > 0; + const isLoading = !isOffline && policyVendors === undefined; + const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; + return ( Navigation.goBack()} - /> - + title={selectionModeHeader ? translate('common.selectMultiple') : translate('workspace.common.vendors')} + onBackButtonPress={() => { + if (isMobileSelectionModeEnabled) { + clearTableSelection(); + turnOffMobileSelectionMode(); + return; + } + Navigation.goBack(); + }} + > + {!shouldDisplayButtonsInSeparateLine && canShowBulkActions && ( + + )} + + {shouldDisplayButtonsInSeparateLine && canShowBulkActions && ( + + + + )} + + {isLoading && ( + + )} + + {!isLoading && ( + + )} ); diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx index b82d3e72f03e..b9c8f7aa6dd2 100644 --- a/src/pages/workspace/withPolicy.tsx +++ b/src/pages/workspace/withPolicy.tsx @@ -24,6 +24,8 @@ type PolicyRouteName = | typeof SCREENS.WORKSPACE.PROFILE | typeof SCREENS.WORKSPACE.MORE_FEATURES | typeof SCREENS.WORKSPACE.MEMBERS + | typeof SCREENS.WORKSPACE.MEMBERS_ROLE + | typeof SCREENS.WORKSPACE.AGENTS | typeof SCREENS.WORKSPACE.EXPENSIFY_CARD | typeof SCREENS.WORKSPACE.COMPANY_CARDS | typeof SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_INVITE @@ -45,13 +47,15 @@ type PolicyRouteName = | typeof SCREENS.WORKSPACE.MEMBER_DETAILS | typeof SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE | typeof SCREENS.WORKSPACE.MEMBER_WORK_ARRANGEMENT + | typeof SCREENS.WORKSPACE.MEMBER_OFFICE_LOCATION + | typeof SCREENS.WORKSPACE.MEMBERS_CHANGE_OFFICE_LOCATION + | typeof SCREENS.WORKSPACE.MEMBERS_CHANGE_WORK_ARRANGEMENT + | typeof SCREENS.WORKSPACE.INVITE_WORK_ARRANGEMENT | typeof SCREENS.WORKSPACE.MEMBER_CUSTOM_FIELD | typeof SCREENS.WORKSPACE.INVOICES | typeof SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_CHECK | typeof SCREENS.WORKSPACE.TAX_EDIT | typeof SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_OVERVIEW_ADDRESS - | typeof SCREENS.WORKSPACE.DYNAMIC_CATEGORIES_SETTINGS - | typeof SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORIES_SETTINGS | typeof SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RATE_EDIT | typeof SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT | typeof SCREENS.WORKSPACE.REPORT_FIELDS_CREATE @@ -83,7 +87,8 @@ type PolicyRouteName = | typeof SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_CARD_PROGRAM_ACCOUNT_SELECTOR | typeof SCREENS.WORKSPACE.ACCOUNTING.DUALENTRY_CARD_ACCOUNT_CARD_LIST | typeof SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_CARD_PROGRAM_ACCOUNT_SELECTOR - | typeof SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_CARD_ACCOUNT_CARD_LIST; + | typeof SCREENS.WORKSPACE.ACCOUNTING.CAMPFIRE_CARD_ACCOUNT_CARD_LIST + | typeof SCREENS.WORKSPACE.ACCOUNTING.BUSINESS_CENTRAL_BANK_ACCOUNT_SELECTOR; type PolicyRoute = PlatformStackRouteProp; diff --git a/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx b/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx index bcf631da20fc..d89b64d90c6b 100644 --- a/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx +++ b/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx @@ -279,7 +279,7 @@ function ToggleSettingOptionRow({ styles.flexRow, shouldAnchorSwitchToTop ? styles.alignItemsStart : styles.alignItemsCenter, styles.justifyContentBetween, - shouldPlaceSubtitleBelowSwitch && !shouldUseCompactSubtitleSpacing && styles.h10, + shouldPlaceSubtitleBelowSwitch && !shouldUseCompactSubtitleSpacing && styles.mnh40, toggleContainerStyles, ]} > diff --git a/src/pages/workspace/workflows/WorkspaceWiseGlobalReimbursementsBanner.tsx b/src/pages/workspace/workflows/WorkspaceWiseGlobalReimbursementsBanner.tsx new file mode 100644 index 000000000000..2bce00f4dbbd --- /dev/null +++ b/src/pages/workspace/workflows/WorkspaceWiseGlobalReimbursementsBanner.tsx @@ -0,0 +1,60 @@ +import WiseGlobalReimbursementsBanner from '@components/WiseGlobalReimbursementsBanner'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import usePolicy from '@hooks/usePolicy'; +import useShouldSuppressPromotionalUI from '@hooks/useShouldSuppressPromotionalUI'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {dismissProductTraining} from '@libs/actions/Welcome'; +import {getWorkspaceWiseGlobalReimbursementsBannerBankAccountID} from '@libs/BankAccountUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import {isPolicyAdmin} from '@libs/PolicyUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {DismissedProductTraining} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; +import {View} from 'react-native'; + +type WorkspaceWiseGlobalReimbursementsBannerProps = { + /** The workspace whose connected bank account the banner promotes */ + policyID: string; +}; + +const isBannerDismissedSelector = (value: OnyxEntry): boolean => !!value?.[CONST.WISE_GLOBAL_REIMBURSEMENTS_WORKSPACE_BANNER]; + +/** Promotes Wise global reimbursements in Workflows > Payments for the workspace's connected bank account, until dismissed or onboarding starts */ +function WorkspaceWiseGlobalReimbursementsBanner({policyID}: WorkspaceWiseGlobalReimbursementsBannerProps) { + const styles = useThemeStyles(); + const policy = usePolicy(policyID); + const {login: currentUserLogin} = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); + const shouldSuppressPromotionalUI = useShouldSuppressPromotionalUI(); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [isDismissed = true] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {selector: isBannerDismissedSelector}); + + const bankAccountID = getWorkspaceWiseGlobalReimbursementsBannerBankAccountID(policy, bankAccountList); + + if (!isBetaEnabled(CONST.BETAS.WISE_REIMBURSEMENTS) || !isPolicyAdmin(policy, currentUserLogin) || shouldSuppressPromotionalUI || isDismissed || !bankAccountID) { + return null; + } + + return ( + + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WISE_GLOBAL_REIMBURSEMENTS_INTRO.getRoute(bankAccountID)))} + onDismissPress={() => dismissProductTraining(CONST.WISE_GLOBAL_REIMBURSEMENTS_WORKSPACE_BANNER, true)} + sentryLabels={CONST.SENTRY_LABEL.WISE_GLOBAL_REIMBURSEMENTS_WORKSPACE_BANNER} + /> + + ); +} + +export default WorkspaceWiseGlobalReimbursementsBanner; diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerSuccessPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerSuccessPage.tsx index 557f8d109bdc..126e209c3488 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerSuccessPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerSuccessPage.tsx @@ -1,4 +1,4 @@ -import ConfirmationPage from '@components/ConfirmationPage'; +import ConfirmationPageDefault from '@components/ConfirmationPage/composed/ConfirmationPageDefault'; import ScrollView from '@components/ScrollView'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; @@ -37,16 +37,14 @@ function WorkspaceWorkflowsPayerSuccessPage() { addBottomSafeAreaPadding contentContainerStyle={styles.flexGrow1} > - ); diff --git a/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx b/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx index 10a88fbb18fb..b716b0ea20bd 100644 --- a/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -121,7 +121,7 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe isApprovalWorkflowLockedByIntegration(policy) || shouldHideDynamicExternalWorkflowPeople(policy); const isInitialCreationFlow = approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE && approvalWorkflow?.isInitialFlow; - const hasAnyEligibleMember = Object.values(policy?.employeeList ?? {}).some((employee) => !!employee.email && employee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const hasAnyEligibleMember = Object.values(policy?.employeeList ?? {}).some((employee) => !!employee?.email && employee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const shouldShowListEmptyContent = !isLoadingApprovalWorkflow && !hasAnyEligibleMember; const isCreateAction = approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE; const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList, employeeAndApprovalMembersPersonalDetails); @@ -141,7 +141,7 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe const map = new Map(); for (const employee of Object.values(employees)) { - if (!employee.email || !employee.submitsTo || employee.submitsTo === defaultApprover) { + if (!employee?.email || !employee.submitsTo || employee.submitsTo === defaultApprover) { continue; } // Only track members who submit to a non-default approver (i.e., they're in a custom workflow) @@ -231,7 +231,7 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe const liveAvailableMembers = useMemo(() => { const employees = policy?.employeeList ?? {}; return Object.values(employees) - .filter((employee) => !!employee.email && employee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .filter((employee) => !!employee?.email && employee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) .map((employee) => { const personalDetail = employeeAndApprovalMembersPersonalDetails[employee.email ?? '']; return { @@ -339,7 +339,7 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe // Drop any selected members who never made it into the workspace. They were staged for invite but // never confirmed, so leaving them in approvalWorkflow.members would carry an un-invited user into // the form and fail backend validation with "Approvals can only be set for members of the policy". - // This must run on every back path, including when an explicit backTo is honored below. + // This must run on every back path so unconfirmed invitees are removed from the workflow draft. const dropUnconfirmedStagedMembers = useCallback(() => { // Going back means we're done with this expenses-from session, so any // hand-off to the invite-message page is no longer in flight. @@ -438,9 +438,8 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe // effect must skip its clear if this page unmounts during the hand-off. isHandingOffToInviteRef.current = true; - // The dynamic invite-message route is appended to the current expenses-from URL, - // so the back navigation parent (with any nested backTo query param) is preserved - // automatically without needing to construct a backToRoute manually. + // Append the dynamic invite-message route to the current expenses-from URL so the + // confirmation flow returns to this workflow context without a URL backTo parameter. Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_INVITE_MESSAGE.path)); return; } diff --git a/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx b/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx index a74f63595c5e..c32498d52131 100644 --- a/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx +++ b/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx @@ -36,6 +36,8 @@ import {getReimbursementChoice, isPolicyAdmin, isSubmitPolicy} from '@libs/Polic import {hasInProgressVBBA} from '@libs/ReimbursementAccountUtils'; import {getEligibleExistingBusinessBankAccounts} from '@libs/WorkflowUtils'; +import WorkspaceWiseGlobalReimbursementsBanner from '@pages/workspace/workflows/WorkspaceWiseGlobalReimbursementsBanner'; + import {pressLockedBankAccount} from '@userActions/BankAccounts'; import {navigateToBankAccountRoute} from '@userActions/ReimbursementAccount'; import {navigateToConciergeChat} from '@userActions/Report'; @@ -47,7 +49,7 @@ import ROUTES from '@src/ROUTES'; import type {TupleToUnion} from 'type-fest'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useCallback} from 'react'; import {View} from 'react-native'; @@ -74,9 +76,11 @@ function WorkflowsPaymentsTab({policyID}: WorkflowsPaymentsTabProps) { const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { - selector: hasSeenTourSelector, + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { + selector: guidedSetupAndTourStatusSelector, }); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const delegateAccountID = useDelegateAccountID(); const {accountID: currentUserAccountID, login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); @@ -141,7 +145,7 @@ function WorkflowsPaymentsTab({policyID}: WorkflowsPaymentsTabProps) { const hasOtherEligibleExistingAccounts = getEligibleExistingBusinessBankAccounts(bankAccountList, policy?.outputCurrency, true, bankAccountID).length > 0; const shouldShowBankAccount = (!!isBankAccountFullySetup || !!bankAccountConnectedToWorkspace) && getReimbursementChoice(policy) !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; - const shouldShowPayer = shouldShowBankAccount || getReimbursementChoice(policy) === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; + const shouldShowPayer = shouldShowBankAccount || getReimbursementChoice(policy) !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; const bankAccountPendingAction = bankAccountConnectedToWorkspace?.pendingAction; const isBankAccountPendingDelete = bankAccountPendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; @@ -153,7 +157,6 @@ function WorkflowsPaymentsTab({policyID}: WorkflowsPaymentsTabProps) { const hasReimburserError = !!policy?.errorFields?.reimburser; // `||` not `??`: bankCurrency can be an empty string, which should fall through to additionalData. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const bankAccountCurrency = bankAccountConnectedToWorkspace?.bankCurrency || bankAccountConnectedToWorkspace?.accountData?.additionalData?.currency; const bankConnectionStatus = getBankAccountConnectionStatus(state, bankAccountCurrency); const bankConnectionBrickRoadIndicator = bankConnectionStatus?.brickRoadIndicator ?? (hasReimburserError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined); @@ -228,7 +231,14 @@ function WorkflowsPaymentsTab({policyID}: WorkflowsPaymentsTabProps) { return; } pressLockedBankAccount(bankAccountID, translate, conciergeReportID ?? undefined, delegateAccountID, initiatingBankAccountUnlock); - navigateToConciergeChat({conciergeReportID: conciergeReportID ?? undefined, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails}); + navigateToConciergeChat({ + conciergeReportID: conciergeReportID ?? undefined, + introSelected, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + }); return; } @@ -409,6 +419,7 @@ function WorkflowsPaymentsTab({policyID}: WorkflowsPaymentsTabProps) { )} + } isActive={getReimbursementChoice(policy) !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO} diff --git a/src/selectors/AgentZeroChat.ts b/src/selectors/AgentZeroChat.ts index 473b07849d8b..5d5b9b25bdb0 100644 --- a/src/selectors/AgentZeroChat.ts +++ b/src/selectors/AgentZeroChat.ts @@ -24,9 +24,18 @@ const getCustomAgentParticipantAccountID = return participantAccountIDs.find((accountID) => !!personalDetails[accountID]?.isCustomAgent); }; +/** + * Returns the accountIDs flagged as custom agents. The IDs are sorted, so the result compares equal when only the order of the input changes. + */ +const getCustomAgentAccountIDs = (personalDetails: PersonalDetailsList): number[] => + Object.keys(personalDetails) + .filter((accountID) => !!personalDetails[accountID]?.isCustomAgent) + .map(Number) + .sort((first, second) => first - second); + const hasPendingFollowupListSkeletonSelector = (reportActionID: string) => (pending: OnyxEntry): boolean => !pending?.hidden && pending?.reportActionID === reportActionID; -export {getReportParticipantAccountIDs, getCustomAgentParticipantAccountID, hasPendingFollowupListSkeletonSelector}; +export {getReportParticipantAccountIDs, getCustomAgentParticipantAccountID, getCustomAgentAccountIDs, hasPendingFollowupListSkeletonSelector}; diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index 8983819d4fbe..e93e811d21d7 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -4,6 +4,7 @@ import {hasSynchronizationErrorMessage, isConnectionInProgress, isConnectionUnve import {getDisplayNameForWorkspace} from '@libs/actions/Policy/Policy'; import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; import {getConnectedHRProvider} from '@libs/merge/HRUtils'; +import type {PolicyPaymentAttribution} from '@libs/PolicyPaymentUtils'; import { canSendInvoice, getActiveAdminWorkspaces, @@ -447,7 +448,8 @@ const hasOnlyPersonalPoliciesSelector = (policies: OnyxCollection): bool */ const homeAndOfficeCommuterExclusionPolicyNameSelector = (policies: OnyxCollection): string | undefined => Object.values(policies ?? {}).find( - (policy) => policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + (policy): policy is Policy => + !!policy && policy.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, )?.name; function isAdminPolicyConnectedTo(policy: OnyxEntry, connectionName: ReusablePolicyConnectionName): policy is Policy { @@ -542,7 +544,9 @@ const policyRoleSelector = (policy: OnyxEntry) => policy?.role; const areInvoicesEnabledSelector = (policy: OnyxEntry) => policy?.areInvoicesEnabled; -const policyACHAccountNumberSelector = (policy: OnyxEntry) => policy?.achAccount?.accountNumber; +/** The policy fields that attribute a payment to a bank account (see `getBankAccountLastFourDigits`). */ +const policyPaymentAttributionSelector = (policy: OnyxEntry): PolicyPaymentAttribution | undefined => + policy ? {achAccount: policy.achAccount, reimburser: policy.reimburser} : undefined; function isAdminForPolicyByIDSelector(policyID?: string) { return (policies: OnyxCollection | null): boolean => { @@ -575,6 +579,14 @@ const createAdminPoliciesSelector = }, {}); }; +const groupByPolicyAccountIDPoliciesSelector = (policies: OnyxCollection) => + Object.values(policies ?? {}).reduce((m, policy) => { + if (policy?.policyAccountID) { + m.set(policy.policyAccountID, policy); + } + return m; + }, new Map()); + export type {PolicySelector, TimeSensitiveAdminPolicy}; export { createAllPolicyReportFieldsSelector, @@ -605,10 +617,11 @@ export { policyRoleSelector, policyTypeSelector, areInvoicesEnabledSelector, - policyACHAccountNumberSelector, + policyPaymentAttributionSelector, createAdminPoliciesSelector, isAdminForPolicyByIDSelector, isCollectingDepositAccountsSelector, createIsInternationalCountrySelector, + groupByPolicyAccountIDPoliciesSelector, }; export type {ReusablePolicyConnectionName}; diff --git a/src/selectors/Report.ts b/src/selectors/Report.ts index c5fb7a5005b9..2d07aa327584 100644 --- a/src/selectors/Report.ts +++ b/src/selectors/Report.ts @@ -14,7 +14,6 @@ import { import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OutstandingReportsByPolicyIDDerivedValue, PersonalDetailsList, Report, ReportActions, ReportNameValuePairs, Transaction} from '@src/types/onyx'; -import mapOnyxCollectionItems from '@src/utils/mapOnyxCollectionItems'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {TupleToUnion, ValueOf} from 'type-fest'; @@ -49,6 +48,10 @@ function getReportParentReportID(report: OnyxEntry) { return report?.parentReportID; } +function getReportHasParentAccess(report: OnyxEntry) { + return report?.hasParentAccess; +} + /** Which report-type avatar wrapper the `ReportAvatar` dispatcher should render for this report. */ function reportAvatarKindSelector(report: OnyxEntry): ValueOf | undefined { if (!report) { @@ -349,7 +352,6 @@ function getStableReportSelector(report: OnyxEntry) { // Coerce placeholder `0` to `undefined`. The backend ships `managerID: 0` on chat reports // without a manager, and a later push removes the key entirely; treating both as // `undefined` keeps the projection stable through that reconciliation. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing managerID: report.managerID || undefined, supportTicketCalendarLink: report.supportTicketCalendarLink, ownerAccountID: report.ownerAccountID, @@ -364,6 +366,7 @@ function getStableReportSelector(report: OnyxEntry) { errors: report.errors, isWaitingOnBankAccount: report.isWaitingOnBankAccount, canCancelReimbursement: report.canCancelReimbursement, + isWaitingForCancelledReimbursement: report.isWaitingForCancelledReimbursement, isCancelledIOU: report.isCancelledIOU, hasReportBeenRetracted: report.hasReportBeenRetracted, hasReportBeenReopened: report.hasReportBeenReopened, @@ -386,18 +389,28 @@ function isDraftReportSelector(draft: OnyxEntry): boolean { return !!draft; } -function reportHierarchySelector(report: OnyxEntry): OnyxEntry { - return ( - report && { - parentReportID: report.parentReportID, - parentReportActionID: report.parentReportActionID, +/** + * Creates a selector returning the parent links of the given report and each of its ancestors, so a consumer only + * looks up the reports on that chain instead of iterating the whole collection. + */ +function reportParentChainSelector(reportID: string | undefined) { + return function selectReportParentChain(reports: OnyxCollection): OnyxCollection { + const result: OnyxCollection = {}; + let currentReportID = reportID; + while (currentReportID) { + const key = `${ONYXKEYS.COLLECTION.REPORT}${currentReportID}` as const; + const report = reports?.[key]; + // Stop at the top of the chain, and at an already visited report in case the links form a cycle + if (!report || result[key]) { + break; + } + result[key] = {parentReportID: report.parentReportID, parentReportActionID: report.parentReportActionID}; + currentReportID = report.parentReportID; } - ); + return result; + }; } -function reportsParentHierarchySelector(reports: OnyxCollection): OnyxCollection { - return mapOnyxCollectionItems(reports, reportHierarchySelector); -} /** * Creates a selector returning only the reports for the given IDs, so a consumer that knows the exact * reports it needs doesn't re-render when any other report in the account changes. @@ -419,6 +432,7 @@ export { groupChatAvatarReportSelector, getReportOwnerAccountID, getReportParentReportID, + getReportHasParentAccess, getReportPolicyID, policyIDsWithEmptyReportsSelector, canShowReportRecipientLocalTimeSelector, @@ -432,7 +446,7 @@ export { openExpenseReportIDsSelector, getStableReportSelector, isDraftReportSelector, - reportsParentHierarchySelector, + reportParentChainSelector, reportsByIDsSelector, }; diff --git a/src/selectors/ReportAction.ts b/src/selectors/ReportAction.ts index 468dd9bfd28c..c27d0c5824a4 100644 --- a/src/selectors/ReportAction.ts +++ b/src/selectors/ReportAction.ts @@ -1,10 +1,20 @@ -import {filterOutDeprecatedReportActions, getIOUActionForTransactionID, getLinkedTransactionID, getOriginalMessage, getSortedReportActions, isActionOfType} from '@libs/ReportActionsUtils'; +import { + filterOutDeprecatedReportActions, + getIOUActionForTransactionID, + getLatestConciergeFeedbackActionIDFromReportActions, + getLinkedTransactionID, + getOriginalMessage, + getSortedReportActions, + isActionOfType, +} from '@libs/ReportActionsUtils'; +import {hasReportBeenForwardedSinceLastSubmit} from '@libs/ReportUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction, ReportActions} from '@src/types/onyx'; import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import lodashFindLast from 'lodash/findLast'; @@ -142,7 +152,42 @@ function getReceiptScanFailedIOUActionDataSelector( const transactionThreadReportIDSelector = (transactionID: string | undefined) => (reportActions: OnyxEntry) => transactionID ? getIOUActionForTransactionID(Object.values(reportActions ?? {}), transactionID)?.childReportID : undefined; +/** Returns the ID of the author's newest comment in the report that can show the feedback prompt. */ +const getLatestFeedbackActionIDByAuthorSelector = (authorAccountID: number | undefined) => (reportActions: OnyxEntry) => + authorAccountID ? getLatestConciergeFeedbackActionIDFromReportActions(reportActions, authorAccountID) : undefined; + +// IDs of reports forwarded since their last submit, so re-renders only happen on a yes/no flip per report. +function forwardedSinceLastSubmitReportIDsSelector(allReportActions: OnyxCollection): Record | undefined { + if (!allReportActions) { + return undefined; + } + + const result: Record = {}; + for (const key of Object.keys(allReportActions)) { + const reportID = key.slice(ONYXKEYS.COLLECTION.REPORT_ACTIONS.length); + if (hasReportBeenForwardedSinceLastSubmit({reportID}, allReportActions[key] ?? {})) { + result[reportID] = true; + } + } + return result; +} + +type ThreadReplyCounterFields = 'childVisibleActionCount' | 'childCommenterCount' | 'childLastVisibleActionCreated' | 'childOldestFourAccountIDs'; + +type ReportActionWithoutThreadReplyCounters = Omit; + +/** Strips the reply counters, which change on every reply in the thread, so components that only show the parent message don't re-render for each reply. */ +function getParentReportActionWithoutThreadRepliesSelector(parentReportActions: OnyxEntry, parentReportActionID?: string): ReportActionWithoutThreadReplyCounters | undefined { + const parentReportAction = getParentReportActionSelector(parentReportActions, parentReportActionID); + if (!parentReportAction) { + return undefined; + } + const {childVisibleActionCount, childCommenterCount, childLastVisibleActionCreated, childOldestFourAccountIDs, ...parentReportActionWithoutThreadReplies} = parentReportAction; + return parentReportActionWithoutThreadReplies; +} + export { + getLatestFeedbackActionIDByAuthorSelector, getParentReportActionSelector, getLastClosedReportAction, getNewestReportActionSelector, @@ -150,6 +195,8 @@ export { getReportActionByIDSelector, getReceiptScanFailedIOUActionDataSelector, reportVisibleActionsSelector, + forwardedSinceLastSubmitReportIDsSelector, transactionThreadReportIDSelector, + getParentReportActionWithoutThreadRepliesSelector, }; -export type {NewestReportAction}; +export type {NewestReportAction, ReportActionWithoutThreadReplyCounters}; diff --git a/src/selectors/ReportAttributes.ts b/src/selectors/ReportAttributes.ts index 3c6fb8789673..ac784f1e0271 100644 --- a/src/selectors/ReportAttributes.ts +++ b/src/selectors/ReportAttributes.ts @@ -1,5 +1,5 @@ import type {ReportAttributesDerivedValue} from '@src/types/onyx'; -import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; +import type {ReportAttributes, ReportNamesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {OnyxEntry} from 'react-native-onyx'; @@ -19,5 +19,16 @@ const reportNameSelector = (attributes: OnyxEntry, const reportIsEmptySelector = (attributes: OnyxEntry, reportID: string | undefined) => (reportID ? attributes?.reports?.[reportID]?.isEmpty : undefined); -export {reportNameSelector, reportIsEmptySelector}; +const reportNamesByIDsSelector = (attributes: OnyxEntry, reportIDs: Array): ReportNamesDerivedValue => { + const reportNames: ReportNamesDerivedValue = {}; + for (const reportID of reportIDs) { + const reportName = reportNameSelector(attributes, reportID); + if (reportID && reportName !== undefined) { + reportNames[reportID] = {reportName}; + } + } + return reportNames; +}; + +export {reportNameSelector, reportIsEmptySelector, reportNamesByIDsSelector}; export default reportByIDsSelector; diff --git a/src/selectors/ReportMetaData.ts b/src/selectors/ReportMetaData.ts index ca37d3b6a964..898ef91ad76a 100644 --- a/src/selectors/ReportMetaData.ts +++ b/src/selectors/ReportMetaData.ts @@ -53,6 +53,9 @@ const pendingDeleteMemberAccountIDsByReportIDSelector = (reportMetadata: OnyxCol return result; }; +const pendingExpenseActionSelector = (reportMetadata: OnyxEntry): OnyxEntry => + reportMetadata ? {pendingExpenseAction: reportMetadata.pendingExpenseAction} : undefined; + const pendingNewTransactionIDsSelector = (reportMetadata: OnyxEntry) => reportMetadata?.pendingNewTransactionIDs; const isOptimisticReportSelector = (reportMetadata: OnyxEntry) => reportMetadata?.isOptimisticReport; @@ -66,6 +69,7 @@ export { isOptimisticReportSelector, pendingNewTransactionIDsSelector, pendingChatMembersSelector, + pendingExpenseActionSelector, pendingDeleteMemberAccountIDsSelector, pendingDeleteMemberAccountIDsByReportIDSelector, }; diff --git a/src/selectors/ReportNameValuePairs.ts b/src/selectors/ReportNameValuePairs.ts index 01660b09e8ac..5f628a2bb45c 100644 --- a/src/selectors/ReportNameValuePairs.ts +++ b/src/selectors/ReportNameValuePairs.ts @@ -70,9 +70,9 @@ function agentZeroProcessingAgentIDsSelector(reportNameValuePairs: OnyxEntry a - b); } -function getConciergeFeedbackForReportActionID(reportNameValuePairs: OnyxEntry): string | undefined { - return reportNameValuePairs?.conciergeFeedbackForReportActionID; +function latestCalendlyCallSelector(reportNameValuePairs: OnyxEntry) { + return reportNameValuePairs?.calendlyCalls?.at(-1); } -export {getAgentZeroProcessingLabel, agentZeroProcessingAgentIDsSelector, getConciergeFeedbackForReportActionID, reportNameValuePairsArchivedSelector}; +export {getAgentZeroProcessingLabel, agentZeroProcessingAgentIDsSelector, latestCalendlyCallSelector, reportNameValuePairsArchivedSelector}; export type {ReportNameValuePairsArchivedState}; diff --git a/src/selectors/Session.ts b/src/selectors/Session.ts index 95ffe111e926..e222de6f92a2 100644 --- a/src/selectors/Session.ts +++ b/src/selectors/Session.ts @@ -7,6 +7,8 @@ const isSupportalSessionSelector = (session: OnyxEntry) => session?.aut const isDelegateSessionSelector = (session: OnyxEntry) => session?.authTokenType === CONST.AUTH_TOKEN_TYPES.DELEGATE; +const isAnonymousSessionSelector = (session: OnyxEntry) => session?.authTokenType === CONST.AUTH_TOKEN_TYPES.ANONYMOUS; + const emailSelector = (session: OnyxEntry) => session?.email; const accountIDSelector = (session: OnyxEntry) => session?.accountID; @@ -18,4 +20,13 @@ const authTokenSelector = (session: OnyxEntry) => session?.authToken; /** Whether the user is signed in to a real account, which excludes the anonymous sessions used to view public rooms */ const hasNonAnonymousSessionSelector = (session: OnyxEntry) => !!session?.authToken && session.authTokenType !== CONST.AUTH_TOKEN_TYPES.ANONYMOUS; -export {emailSelector, accountIDSelector, sessionEmailAndAccountIDSelector, authTokenSelector, hasNonAnonymousSessionSelector, isSupportalSessionSelector, isDelegateSessionSelector}; +export { + emailSelector, + accountIDSelector, + sessionEmailAndAccountIDSelector, + authTokenSelector, + hasNonAnonymousSessionSelector, + isSupportalSessionSelector, + isDelegateSessionSelector, + isAnonymousSessionSelector, +}; diff --git a/src/selectors/TableColumnWidths.ts b/src/selectors/TableColumnWidths.ts index 3ca8486ffff3..a17ffe4bb0be 100644 --- a/src/selectors/TableColumnWidths.ts +++ b/src/selectors/TableColumnWidths.ts @@ -2,7 +2,7 @@ import type {TableColumnWidths} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -/** One table's stored widths. Narrowing keeps a drag in one table from re-rendering every other table. */ +/** Narrowed to one table, so a drag doesn't re-render every other table. */ const tableColumnWidthsSelector = (columnResizingID: string) => (tableColumnWidths: OnyxEntry) => tableColumnWidths?.[columnResizingID]; // eslint-disable-next-line import/prefer-default-export -- additional selectors may be added here diff --git a/src/setup/index.ts b/src/setup/index.ts index c6967ca7bffc..027219d927df 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -102,10 +102,14 @@ export default function () { ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_TAGS_LOADING_STATE, ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN, ONYXKEYS.RAM_ONLY_MERGE_HR_LINK_TOKEN, + ONYXKEYS.RAM_ONLY_WISE_KYC_REVIEW_EMBEDDED_LINK, ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD, ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE, + ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, + ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER, ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL, + ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS, ], }); diff --git a/src/stories/DynamicForm.stories.tsx b/src/stories/DynamicForm.stories.tsx new file mode 100644 index 000000000000..30b0ec1534a4 --- /dev/null +++ b/src/stories/DynamicForm.stories.tsx @@ -0,0 +1,593 @@ +import DynamicFormFlow from '@components/DynamicForm'; +import DynamicFormFields from '@components/DynamicForm/components/DynamicFormFields'; +import type {DynamicFormLayout} from '@components/DynamicForm/types'; +import getDynamicFieldErrors from '@components/DynamicForm/utils/getDynamicFieldErrors'; +import getPageTitle from '@components/DynamicForm/utils/getPageTitle'; +import {getAllFields, getPages} from '@components/DynamicForm/utils/paginate'; +import FormProvider from '@components/Form/FormProvider'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; + +import {clearDraftValues, setDraftValues} from '@userActions/FormActions'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import styles from '@src/styles'; +import {defaultTheme} from '@src/styles/theme'; +import type {DynamicFormListItem, DynamicFormPage, DynamicFormSchema, DynamicFormSchemaField} from '@src/types/onyx'; + +import type {Meta, StoryFn} from 'storybook-react-rsbuild'; + +import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; +import {useArgs} from 'storybook/preview-api'; + +import {StoryRouterProvider} from '../../.storybook/mocks/storyRouter'; + +const defaultStyles = styles(defaultTheme); + +const STORYBOOK_FORM_ID = ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM; + +const FLOW_ROUTE_PREFIX = 'settings/wallet/add-bank-account'; + +/** A fixture field with the title of the page it is asked on */ +type GroupedField = DynamicFormSchemaField & {group: string}; + +/** Each run of fields sharing a group becomes one page titled by it, as the server sends pages. A flat schema drops the groups and lets the form build untitled pages. */ +function toSchema(fields: GroupedField[], isFlat = false): DynamicFormSchema { + if (isFlat) { + return {fields: fields.map(({group, ...field}) => field)}; + } + const pages: DynamicFormPage[] = []; + for (const {group, ...field} of fields) { + const lastPage = pages.at(-1); + if (lastPage?.title === group) { + lastPage.content.push(field); + continue; + } + pages.push({key: group, title: group, content: [field]}); + } + return {pages}; +} + +type Preset = { + /** Fields in page order, each naming its page in `group` */ + fields: GroupedField[]; + + /** Answers seeded into the form draft before the first render */ + draftValues: Record; + + /** Sends the fields as a flat `{fields}` schema, ignoring `group`, so the form paginates them itself */ + isFlat?: boolean; +}; + +/** A bank account form spread over several pages */ +const originalBankAccountFields: GroupedField[] = [ + { + key: 'accountNumber', + label: 'Account number', + labelKey: 'bankAccount.accountNumber', + group: 'Account details', + type: 'text', + required: true, + regex: '^\\d{8}$', + minLength: 8, + maxLength: 8, + example: '12345678', + }, + { + key: 'legalType', + label: 'Recipient type', + group: 'Account details', + type: 'radio', + required: true, + values: [ + {key: 'PRIVATE', label: 'Person'}, + {key: 'BUSINESS', label: 'Business'}, + ], + refreshOnChange: true, + }, + { + key: 'accountType', + label: 'Account type', + group: 'Account details', + type: 'select', + required: true, + dependsOn: { + key: 'legalType', + valuesBy: { + PRIVATE: [ + {key: 'CHECKING', label: 'Checking'}, + {key: 'SAVINGS', label: 'Savings'}, + ], + BUSINESS: [ + {key: 'CHECKING', label: 'Checking'}, + {key: 'SAVINGS', label: 'Savings'}, + {key: 'BUSINESS_CHECKING', label: 'Business checking'}, + ], + }, + }, + }, + { + key: 'businessRegistrationDocument', + label: 'Business registration document', + group: 'Account details', + type: 'file', + required: true, + maxFiles: 2, + showWhen: {key: 'legalType', equals: ['BUSINESS']}, + }, + {key: 'annualVolume', label: 'Annual volume', group: 'Account details', type: 'amount', required: true, currencyKey: 'annualVolumeCurrency'}, + {key: 'numberOfEmployees', label: 'Number of employees', group: 'Account details', type: 'number', required: true, example: '25'}, + {key: 'settlementCurrency', label: 'Settlement currency', group: 'Account details', type: 'currency', required: true}, + {key: 'operatingCountries', label: 'Countries you operate in', group: 'Account holder details', type: 'countryMultiselect', required: true}, + {key: 'dateOfBirth', label: 'Date of birth', labelKey: 'common.dob', group: 'Account holder details', type: 'date', rule: 'dateOfBirth', required: true}, + {key: 'country', label: 'Country', labelKey: 'common.country', group: 'Account holder details', type: 'country', required: true, refreshOnChange: true}, + {key: 'address', label: 'Address', labelKey: 'common.address', group: 'Account holder details', type: 'address', required: true}, + { + key: 'useCases', + label: 'How will you use this account?', + group: 'Account holder details', + type: 'multiselect', + required: true, + values: [ + {key: 'PAYING_SUPPLIERS_CONTRACTORS_EMPLOYEES', label: 'Paying suppliers, contractors or employees'}, + {key: 'RECEIVING_MONEY_FOR_SERVICES', label: 'Receiving money for services'}, + {key: 'TRANSFER_WITHIN_COMPANY_OR_GROUP', label: 'Transfers within the company or group'}, + ], + }, + {key: 'isSourceOfFund', label: 'This source funds the account', group: 'Account holder details', type: 'boolean', required: false, presentation: 'checkbox'}, +]; + +const accountDetails: GroupedField[] = [ + {key: 'accountNumber', label: 'Account number', group: 'Account details', type: 'text', required: true, regex: '^\\d{8}$', minLength: 8, maxLength: 8, example: '12345678'}, + { + key: 'legalType', + label: 'Recipient type', + group: 'Account details', + type: 'radio', + required: true, + presentation: 'tabs', + values: [ + {key: 'PRIVATE', label: 'Person'}, + {key: 'BUSINESS', label: 'Business'}, + ], + }, + { + key: 'accountType', + label: 'Account type', + group: 'Account details', + type: 'select', + required: true, + dependsOn: { + key: 'legalType', + valuesBy: { + PRIVATE: [ + {key: 'CHECKING', label: 'Checking'}, + {key: 'SAVINGS', label: 'Savings'}, + ], + BUSINESS: [ + {key: 'CHECKING', label: 'Checking'}, + {key: 'BUSINESS_CHECKING', label: 'Business checking'}, + ], + }, + }, + }, + { + key: 'registrationDocument', + label: 'Business registration document', + group: 'Account details', + type: 'file', + required: true, + maxFiles: 2, + showWhen: {key: 'legalType', equals: ['BUSINESS']}, + }, + {key: 'annualVolume', label: 'Annual volume', group: 'Account details', type: 'amount', required: true, currencyKey: 'annualVolumeCurrency'}, + {key: 'numberOfEmployees', label: 'Number of employees', group: 'Account details', type: 'number', required: true, example: '25'}, + {key: 'settlementCurrency', label: 'Settlement currency', group: 'Account details', type: 'currency', required: true}, +]; + +const accountHolder: GroupedField[] = [ + {key: 'legalName', label: 'Legal name', group: 'Account holder', type: 'text', required: true, rule: 'legalName'}, + {key: 'phone', label: 'Phone number', group: 'Account holder', type: 'text', required: true, rule: 'phone'}, + {key: 'dateOfBirth', label: 'Date of birth', group: 'Account holder', type: 'date', required: true, rule: 'dateOfBirth'}, + { + key: 'ssn', + label: 'Social security number or ITIN', + group: 'Account holder', + type: 'text', + required: true, + sensitive: true, + keyboard: 'numeric', + regex: '^\\d{9}$', + example: '123456789', + }, + {key: 'address', label: 'Address', group: 'Account holder', type: 'address', required: true, rule: 'zipCode'}, + {key: 'operatingCountries', label: 'Countries you operate in', group: 'Account holder', type: 'countryMultiselect', required: true}, + {key: 'ownershipPercentage', label: 'Your ownership percentage', group: 'Account holder', type: 'percent', required: true}, + {key: 'acceptTerms', label: 'I confirm the details above are correct', group: 'Account holder', type: 'boolean', required: true, presentation: 'checkbox'}, +]; + +const businessInfo: GroupedField[] = [ + {key: 'legalBusinessName', label: 'Legal business name', group: 'Business info', type: 'text', required: true, readonly: true}, + { + key: 'companyRole', + label: 'Your role at the company', + group: 'Business info', + type: 'select', + required: true, + values: [ + {key: 'OWNER', label: 'Owner'}, + {key: 'DIRECTOR', label: 'Director'}, + {key: 'OTHER', label: 'Other'}, + ], + }, + { + key: 'industries', + label: 'Industry category', + group: 'Business info', + type: 'multiselect', + required: true, + values: [ + {key: 'SOFTWARE', label: 'Software'}, + {key: 'RETAIL', label: 'Retail'}, + {key: 'CRYPTO', label: 'Crypto (high risk)'}, + {key: 'GAMBLING', label: 'Gambling (high risk)'}, + ], + }, + {key: 'businessDescription', label: 'Business description', group: 'Business info', type: 'text', required: true, multiline: true, maxLength: 500}, +]; + +/** The design doc's enhanced due diligence: a source of funds asked only for high-risk industries, then a proof for it, each on its own page */ +const dueDiligence: GroupedField[] = [ + { + key: 'sourceOfFunds', + label: 'Source of funds', + group: 'Source of funds', + type: 'select', + required: true, + showWhen: {key: 'industries', equals: ['CRYPTO', 'GAMBLING']}, + values: [ + {key: 'SALES', label: 'Sales revenue'}, + {key: 'INVESTMENT', label: 'Investment'}, + {key: 'LOAN', label: 'Loan'}, + ], + }, + {key: 'sourceOfFundsProof', label: 'Source of funds document', group: 'Proof', type: 'file', required: true, showWhen: {key: 'sourceOfFunds', equals: ['SALES', 'INVESTMENT', 'LOAN']}}, +]; + +const owners: GroupedField = { + key: 'owners', + label: 'Owners', + itemLabel: 'owner', + addItemDescription: 'Only applies if 25% ownership or more', + group: 'Owners', + type: 'list', + required: true, + minItems: 1, + maxItems: 4, + itemFields: [ + { + key: 'ownerType', + label: 'Owner type', + type: 'radio', + presentation: 'tabs', + required: true, + values: [ + {key: 'INDIVIDUAL', label: 'Individual'}, + {key: 'COMPANY', label: 'Company/Fund'}, + ], + }, + {key: 'firstName', label: 'Legal first name', type: 'text', required: true, rule: 'legalName', showWhen: {key: 'ownerType', equals: ['INDIVIDUAL']}}, + {key: 'lastName', label: 'Legal last name', type: 'text', required: true, rule: 'legalName', showWhen: {key: 'ownerType', equals: ['INDIVIDUAL']}}, + {key: 'ssn', label: 'SSN', type: 'text', required: false, sensitive: true, showWhen: {key: 'ownerType', equals: ['INDIVIDUAL']}}, + {key: 'companyName', label: 'Legal business name', type: 'text', required: true, showWhen: {key: 'ownerType', equals: ['COMPANY']}}, + {key: 'countryOfResidence', label: 'Country of residence', type: 'country', required: true}, + {key: 'ownership', label: 'Ownership percentage', type: 'percent', required: true}, + ], +}; + +/** Every case Storybook shows, each usable in the Flow, the Playground and as its own sidebar entry */ +const PRESETS = { + originalBankAccount: { + fields: originalBankAccountFields, + draftValues: { + numberOfEmployees: '25', + settlementCurrency: 'USD', + operatingCountries: ['GB'], + legalType: 'PRIVATE', + accountNumber: '12345678', + accountType: 'CHECKING', + annualVolume: '1000', + annualVolumeCurrency: 'USD', + dateOfBirth: '1990-01-31', + country: 'GB', + address: '1 High Street', + }, + }, + bankAccount: { + fields: [...accountDetails, ...accountHolder], + draftValues: {legalType: 'PRIVATE', numberOfEmployees: '25'}, + }, + flatFields: { + // The address is alone on its page, so its question heads that page + fields: [ + ...accountDetails, + ...accountHolder.map((field) => (field.key === 'address' ? {...field, question: "What's your home address?"} : field)), + {key: 'ownsQuarter', label: 'Do you own 25% or more?', group: 'Account holder', type: 'boolean', required: true}, + ], + draftValues: {legalType: 'PRIVATE', numberOfEmployees: '25'}, + isFlat: true, + }, + businessOnboarding: { + fields: [...businessInfo, ...dueDiligence, owners], + draftValues: {legalBusinessName: 'Boulder Development Inc.'}, + }, + owners: { + fields: [owners], + draftValues: { + owners: [ + {id: 'alice', ownerType: 'INDIVIDUAL', firstName: 'Alice', lastName: 'Nguyen', countryOfResidence: 'US', ownership: '25'}, + {id: 'acme', ownerType: 'COMPANY', companyName: 'Acme Holdings', countryOfResidence: 'GB', ownership: '40'}, + ], + }, + }, + loneMultiselect: { + fields: businessInfo.filter((field) => field.key === 'industries'), + draftValues: {}, + }, + yesNoQuestion: { + fields: [{key: 'hasOtherOwners', label: 'Does anyone else own 25% or more?', group: 'Ownership', type: 'boolean', required: true}], + draftValues: {}, + }, + loneCountry: { + fields: [{key: 'country', label: 'Where is your bank account?', group: 'Country', type: 'country', required: true}], + draftValues: {}, + }, + loneCurrency: { + fields: [{key: 'currency', label: 'Which currency?', group: 'Currency', type: 'currency', required: true}], + draftValues: {}, + }, + amountWithCurrency: { + fields: accountDetails.filter((field) => field.key === 'annualVolume' || field.key === 'accountNumber'), + draftValues: {annualVolume: '25000'}, + }, + unknownType: { + fields: [{key: 'signature', label: 'Signature', group: 'Sign', type: 'signature', required: true}, ...accountDetails.slice(0, 1).map((field) => ({...field, group: 'Sign'}))], + draftValues: {}, + }, +} satisfies Record; + +type PresetName = keyof typeof PRESETS; + +const PRESET_NAMES = Object.keys(PRESETS).filter((name): name is PresetName => name in PRESETS); + +/** Seeds the draft after mount and reports once the write of these values has landed, so the form mounts with the draft as a real page would, also after the preset changes */ +function useSeededDraft(draftValues: Preset['draftValues']): boolean { + const [seededDraftValues, setSeededDraftValues] = useState(); + useEffect(() => { + let isCancelled = false; + clearDraftValues(STORYBOOK_FORM_ID); + setDraftValues(STORYBOOK_FORM_ID, draftValues).then(() => { + if (isCancelled) { + return; + } + setSeededDraftValues(draftValues); + }); + return () => { + isCancelled = true; + }; + }, [draftValues]); + return seededDraftValues === draftValues; +} + +/** Every page on one screen, for looking at the renderer rather than the flow */ +function SinglePage({fields, draftValues, isFlat}: Preset) { + const {translate} = useLocalize(); + const isDraftReady = useSeededDraft(draftValues); + // List entries are edited on flow pages, so this screen leaves lists out + const pageFields = fields.filter((field) => field.type !== 'list'); + const pages = getPages(toSchema(pageFields, isFlat)); + const allFields = getAllFields(pages); + + if (!isDraftReady) { + return ; + } + + return ( + getDynamicFieldErrors(allFields, values, translate)} + onSubmit={(values) => alert(JSON.stringify(values, null, 4))} + style={defaultStyles.ph5} + > + {({inputValues}) => + pages.map((page) => { + const title = getPageTitle(page, inputValues, translate, allFields); + return ( + + {!!title && {title}} + + + ); + }) + } + + ); +} + +type FlowProps = Preset & { + layout: DynamicFormLayout; + confirmation: 'auto' | 'yes' | 'no'; + + /** Remounts the route stack when it changes, so a new schema starts from its first page */ + resetKey: string; +}; + +/** The whole flow on a story-owned route stack: pages, resume, the entry editor pages and the confirmation are all clickable */ +function Flow({fields, draftValues, isFlat, layout, confirmation, resetKey}: FlowProps) { + const isDraftReady = useSeededDraft(draftValues); + if (!isDraftReady) { + return ; + } + let hasConfirmation: boolean | undefined; + if (confirmation !== 'auto') { + hasConfirmation = confirmation === 'yes'; + } + return ( + + ROUTES.SETTINGS_ADD_BANK_ACCOUNT.getRoute(undefined, pageName, action)} + onSubmit={(values) => alert(JSON.stringify(values, null, 4))} + onBack={() => alert('Left the flow')} + onRefreshRequirements={(inputID, value) => console.debug('Refresh requirements', inputID, value)} + layout={layout} + hasConfirmation={hasConfirmation} + /> + + ); +} + +type FlowStoryProps = Pick & { + preset: PresetName; +}; + +function FlowFromPreset({preset, layout, confirmation}: FlowStoryProps) { + const presetValues: Preset = PRESETS[preset]; + return ( + + ); +} + +const flowArgTypes = { + preset: {options: PRESET_NAMES, control: {type: 'select'}}, + layout: {options: ['auto', 'stepper', 'pages'], control: {type: 'radio'}}, + confirmation: {options: ['auto', 'yes', 'no'], control: {type: 'radio'}}, +} as const; + +function presetFlowStory(preset: PresetName): StoryFn { + const flowStory: StoryFn = FlowFromPreset.bind({}); + flowStory.args = {preset, layout: 'auto', confirmation: 'auto'}; + flowStory.argTypes = flowArgTypes; + return flowStory; +} + +const STORY_FRAME_INSET = '2rem'; + +const story: Meta = { + title: 'Components/DynamicForm', + component: FlowFromPreset, + decorators: [ + (Story) => ( + // Screens size to their window, so these stories get the viewport as their frame +
+ +
+ ), + ], +}; + +type PlaygroundProps = Preset & { + /** Loads a starting schema and draft into the editable `fields` and `draftValues` controls */ + preset: PresetName; + + /** The real flow, or every group on one screen */ + layout: 'flow' | 'single'; +}; + +/** Editable schema: pick a preset, then change the field JSON in the Controls panel and watch the form follow */ +function Playground({preset, fields, draftValues, isFlat, layout}: PlaygroundProps) { + const [, updateArgs] = useArgs(); + useEffect(() => { + updateArgs(PRESETS[preset]); + }, [preset, updateArgs]); + + const schema = Array.isArray(fields) ? fields : []; + const resetKey = `${preset}-${JSON.stringify(draftValues)}-${JSON.stringify(schema)}`; + if (layout === 'single') { + return ( + + ); + } + return ( + + ); +} + +const FlowStory = presetFlowStory('originalBankAccount'); +FlowStory.storyName = 'Flow'; + +const PlaygroundStory: StoryFn = Playground.bind({}); +PlaygroundStory.storyName = 'Playground'; +PlaygroundStory.args = {preset: 'originalBankAccount', ...PRESETS.originalBankAccount, layout: 'flow'}; +PlaygroundStory.argTypes = { + preset: {options: PRESET_NAMES, control: {type: 'select'}}, + fields: {control: {type: 'object'}}, + draftValues: {control: {type: 'object'}}, + isFlat: {control: {type: 'boolean'}}, + layout: {options: ['flow', 'single'], control: {type: 'radio'}}, +}; + +const AllFieldTypes: StoryFn = SinglePage.bind({}); +AllFieldTypes.args = {fields: [...accountDetails, ...accountHolder, ...businessInfo], draftValues: {legalType: 'BUSINESS', legalBusinessName: 'Boulder Development Inc.'}}; + +const BankAccount = presetFlowStory('bankAccount'); +const FlatFields = presetFlowStory('flatFields'); +const BusinessOnboarding = presetFlowStory('businessOnboarding'); +const OwnersList = presetFlowStory('owners'); +const LoneMultiselect = presetFlowStory('loneMultiselect'); +const YesNoQuestion = presetFlowStory('yesNoQuestion'); +const LoneCountry = presetFlowStory('loneCountry'); +const LoneCurrency = presetFlowStory('loneCurrency'); +const AmountWithCurrency = presetFlowStory('amountWithCurrency'); +const UnknownType = presetFlowStory('unknownType'); + +export default story; +export { + FlowStory, + PlaygroundStory, + AllFieldTypes, + BankAccount, + FlatFields, + BusinessOnboarding, + OwnersList, + LoneMultiselect, + YesNoQuestion, + LoneCountry, + LoneCurrency, + AmountWithCurrency, + UnknownType, +}; diff --git a/src/stories/Tooltip.stories.tsx b/src/stories/Tooltip.stories.tsx index 97e842f149fd..c4e630750d26 100644 --- a/src/stories/Tooltip.stories.tsx +++ b/src/stories/Tooltip.stories.tsx @@ -23,7 +23,6 @@ function Template(props: TooltipExtendedProps) {
overflow: 'visible', }, + // Only the mobile web bar uses this; iOS and Android draw native tab bars. navigationTabBarContainer: { - flexDirection: 'row', - height: variables.bottomTabHeight, - borderTopWidth: 1, - borderTopColor: theme.border, + // Grows every tab by the same amount until its label fits, then shares what is left between them, so a long + // label gets the room it needs and, once the labels no longer all fit, the long ones truncate to the same + // width while the short ones keep theirs. + display: 'grid', + gridTemplateColumns: `repeat(${variables.floatingTabBarTabCount}, minmax(0, auto))`, + // react-native-web defaults alignContent to flex-start, which would leave the row at its content's height. + alignContent: 'stretch', + height: variables.floatingTabBarHeight, + marginHorizontal: variables.floatingTabBarHorizontalInset, + borderRadius: variables.componentBorderRadiusCircle, backgroundColor: theme.appBG, + borderWidth: variables.hairlineBorderWidth, + borderColor: theme.border, + // Insets the row so the selected item's capsule keeps the same margin inside the bar's rounded edge on + // every side, past the overhang it reaches beyond the first and last tabs. + ...spacing.pv1, + paddingHorizontal: spacing.p1.padding + variables.floatingTabBarSelectedOverhang, + // Clips each item's hover and press background to the capsule's rounded ends. + overflow: 'hidden', }, + // A grid item is otherwise never narrower than its label, so the label could not truncate. + navigationTabBarSlot: { + minWidth: 0, + }, + + // Stretches every tab to its slot, so a long label truncates inside the slot instead of spilling into its neighbors. + navigationTabBarItemNarrow: { + alignSelf: 'stretch', + paddingHorizontal: variables.floatingTabBarLabelInset, + }, + + navigationTabBarItemSelected: { + backgroundColor: theme.floatingTabBarSelectedBG, + borderRadius: variables.componentBorderRadiusCircle, + // The extra padding matches the negative margin, so the label keeps the same width selected or not. + paddingHorizontal: variables.floatingTabBarLabelInset + variables.floatingTabBarSelectedOverhang, + marginHorizontal: -variables.floatingTabBarSelectedOverhang, + }, + + androidNativeTabBarSpacer: { + height: variables.androidNativeTabBarHeight, + }, + + // The gap between the floating bar and the bottom edge of the screen. + floatingTabBarBottomInset: { + paddingBottom: variables.floatingTabBarBottomInset, + }, + + // Used both for a tab's pressable and for the wrapper around its icon and label. navigationTabBarItem: { height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', - paddingHorizontal: 4, + }, + + // Sits the avatar's bottom edge where the glyphs next to it end, inside their box, and lets its extra height + // reach above the box, so its label lines up with theirs and is the same distance from the avatar. + navigationTabBarAvatar: { + marginTop: variables.iconFloatingTabBar - variables.avatarFloatingTabBar - variables.floatingTabBarGlyphBottomInset, + marginBottom: variables.floatingTabBarGlyphBottomInset, }, navigationTabBarFABItem: { @@ -2227,6 +2277,26 @@ const staticStyles = (theme: ThemeColors) => paddingBottom: 12, }, + optionRowCompactMinHeight: { + minHeight: variables.optionRowHeightCompact, + }, + + // Sits flush against the right edge of a report field whose input has `noRightBorderRadius`, so the input's + // right border reads as a divider and the two look like one segmented field. + reportFieldDeleteButton: { + width: variables.componentSizeLarge, + // Matches the input beside it, whose height follows the device font scale + height: variables.inputHeight, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.appBG, + borderWidth: 1, + borderLeftWidth: 0, + borderTopRightRadius: variables.componentBorderRadiusNormal, + borderBottomRightRadius: variables.componentBorderRadiusNormal, + borderColor: theme.bordersBold, + }, + optionsListSectionHeader: { marginTop: 8, marginBottom: 4, @@ -3355,6 +3425,14 @@ const staticStyles = (theme: ThemeColors) => transform: [{rotate: '180deg'}], }, + // Use this instead of `flipUpsideDown` when the element being flipped has asymmetric horizontal padding. + // `rotate(180deg)` turns the whole box about its centre, so it mirrors that padding too and the content + // visibly slides sideways. Mirroring on the vertical axis leaves the box where it is, and looks identical + // for content that is already symmetric left-to-right (a caret, a chevron, a tooltip pointer). + flipUpsideDownInPlace: { + transform: [{scaleY: -1}], + }, + navigationScreenCardStyle: { height: '100%', }, @@ -5742,6 +5820,13 @@ const staticStyles = (theme: ThemeColors) => paddingBottom: bulkActionBarHeight + CONST.BULK_ACTION_BAR.BOTTOM_OFFSET + CONST.BULK_ACTION_BAR.LIST_GAP, }, + // Keeps a row the browser scrolls into view, such as one reached with Tab, from settling underneath the bar + // (WCAG 2.4.11). The content spacing above only lifts the end of the list clear of it. Applied to the scrolling + // container, which is what the browser measures that scroll against. + bulkActionBarScrollPadding: { + scrollPaddingBottom: bulkActionBarHeight + CONST.BULK_ACTION_BAR.BOTTOM_OFFSET + CONST.BULK_ACTION_BAR.LIST_GAP, + }, + // Wide enough for a three-digit count, so the bar does not resize as the selection grows past 9 or 99. A // selection can cover far more rows than are on screen. Also keeps the width steady while the count loads. bulkActionBarCount: { @@ -6248,6 +6333,14 @@ const staticStyles = (theme: ThemeColors) => height: variables.describeRulePromptInputHeight, }, + describeNewRulePromptInput: { + height: variables.describeNewRulePromptInputHeight, + }, + + describeRulePromptInputNarrow: { + height: variables.textInputAutoGrowMaxHeight, + }, + emptyStateSamlIllustration: { width: 183, height: 160, @@ -6969,6 +7062,11 @@ const staticStyles = (theme: ThemeColors) => ...textVariants.label, color: theme.text, }, + chartTooltipDot: { + borderRadius: '50%', + width: 8, + height: 8, + }, chartContainer: { borderRadius: variables.componentBorderRadiusLarge, }, @@ -7006,6 +7104,35 @@ const staticStyles = (theme: ThemeColors) => width: 12, height: 12, }, + pieChartResponsiveRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + columnGap: VictoryTheme.pie.legendGap, + }, + pieChartResponsiveColumn: { + flexDirection: 'column', + alignItems: 'center', + }, + pieChartLegendSide: { + width: VictoryTheme.pie.legendMinWidth, + justifyContent: 'center', + rowGap: 12, + }, + pieChartLegendBelow: { + alignSelf: 'stretch', + marginTop: VictoryTheme.pie.legendStackGap, + rowGap: 12, + }, + pieChartLegendRow: { + flexDirection: 'row', + alignItems: 'flex-start', + columnGap: 8, + }, + pieChartLegendDotColumn: { + height: variables.lineHeightXLarge, + justifyContent: 'center', + }, chartInlineTable: { marginTop: 32, rowGap: 24, @@ -7031,6 +7158,61 @@ const staticStyles = (theme: ThemeColors) => const dynamicStyles = (theme: ThemeColors) => ({ + // Floats the offline indicator above a footer laid over the bottom of a tab root screen, without shrinking the + // content that runs under the footer. + offlineIndicatorAboveFooter: (footerHeight: number) => + ({ + position: 'absolute', + right: 0, + bottom: footerHeight, + left: 0, + zIndex: variables.searchFooterZIndex, + backgroundColor: theme.appBG, + }) satisfies ViewStyle, + + // Fills the screen from the top of the tab bar down to the bottom edge, so no content shows between the + // offline indicator and the bar. + tabBarOfflineIndicator: (tabBarCoveredHeight: number) => + ({ + position: 'absolute', + right: 0, + bottom: 0, + left: 0, + height: CONST.OFFLINE_INDICATOR_HEIGHT + tabBarCoveredHeight, + paddingBottom: tabBarCoveredHeight, + backgroundColor: theme.appBG, + }) satisfies ViewStyle, + + // Runs a footer's background down under the tab bar that covers the bottom of a tab root screen, so the + // footer's content sits above the bar. + tabRootFooter: (tabBarCoveredHeight: number) => + ({ + paddingBottom: tabBarCoveredHeight, + backgroundColor: theme.appBG, + }) satisfies ViewStyle, + + // Zero-height strip anchored above the native tab bar, so the floating buttons can be positioned + // against it without taking part in the tab screen's layout. + nativeTabBarFloatingButtons: (bottom: number) => + ({ + position: 'absolute', + right: 0, + bottom, + left: 0, + height: 0, + }) satisfies ViewStyle, + + // Covers Material's bar and the gesture inset under it, and casts the shadow up over the screen content. + androidNativeTabBarShadow: (bottomInset: number) => + ({ + position: 'absolute', + right: 0, + bottom: 0, + left: 0, + height: variables.androidNativeTabBarHeight + bottomInset, + boxShadow: theme.androidTabBarShadow, + }) satisfies ViewStyle, + getSplitListItemAmountStyle: (inputMarginLeft: number, amountWidth: number | string) => ({ marginLeft: inputMarginLeft, width: amountWidth, @@ -7632,6 +7814,10 @@ const plainStyles = (theme: ThemeColors) => zIndex: variables.searchTopBarZIndex, }, + searchFooterZIndexStyle: { + zIndex: variables.searchFooterZIndex, + }, + getWidgetContainerTitleStyle: (color: string) => ({ ...textVariants.textStrong, diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index c108dd30b0fa..50ff2a213588 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -58,6 +58,10 @@ const darkTheme = { signInSidebar: colors.green800, sidebar: colors.productDark100, sidebarHover: colors.productDark300, + floatingTabBarSelectedBG: 'rgba(26,61,50,0.50)', + // selectedOptionBG (green700) at 40% + androidTabBarActiveIndicatorBG: 'rgba(8,82,57,0.40)', + androidTabBarShadow: '0px -4px 12px 0px rgba(2,18,4,0.24)', heading: colors.productDark900, textLight: colors.productDark900, textDark: colors.productDark100, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 3a71dfe79c3d..87f9b53d95a3 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -58,6 +58,10 @@ const lightTheme = { signInSidebar: colors.green800, sidebar: colors.productLight100, sidebarHover: colors.productLight300, + floatingTabBarSelectedBG: 'rgba(230,225,218,0.50)', + // selectedOptionBG (green100) at 40% + androidTabBarActiveIndicatorBG: 'rgba(177,242,214,0.40)', + androidTabBarShadow: '0px -4px 12px 0px rgba(2,18,4,0.06)', heading: colors.productLight900, textLight: colors.white, textDark: colors.productLight900, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index 415292a2bbe2..acaffebc8360 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -2,6 +2,7 @@ import type {NavBarButtonStyle} from '@libs/NavBarManager/types'; import type CONST from '@src/CONST'; +import type {ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {ColorScheme, StatusBarStyle} from '..'; @@ -65,6 +66,10 @@ type ThemeColors = { signInSidebar: Color; sidebar: Color; sidebarHover: Color; + floatingTabBarSelectedBG: Color; + androidTabBarActiveIndicatorBG: Color; + /** A CSS box-shadow, not a color. */ + androidTabBarShadow: NonNullable; heading: Color; textLight: Color; textDark: Color; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index de4cdb4259a5..82bf9682d8f7 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -380,7 +380,6 @@ function getBackgroundColorAndFill(backgroundColor: string, fill: string): SVGAv * Helper method to return eReceipt color code */ function getEReceiptColorCode(transaction: OnyxEntry): EReceiptColorName { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const transactionID = transaction?.parentTransactionID || transaction?.transactionID; if (!transactionID) { return CONST.ERECEIPT_COLORS.YELLOW; @@ -570,7 +569,7 @@ function getCameraViewfinderStyle(aspectRatio: number | undefined, isInLandscape /** * Returns a style for text color */ -function getTextColorStyle(color: string): TextColorStyle { +function getTextColorStyle(color: string) { return { color, }; @@ -1231,7 +1230,7 @@ function getMenuItemIconStyle(compactMode: boolean): ViewStyle { /** * Returns color style */ -function getColorStyle(color: string): TextColorStyle { +function getColorStyle(color: ColorValue): TextColorStyle { return {color}; } @@ -1890,6 +1889,9 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ */ getMentionTextColor: (isOurMention: boolean): string => (isOurMention ? theme.ourMentionText : theme.mentionText), + /** Returns the color of a change in spend, red when it went up and green when it went down */ + getSpendChangeColor: (isIncrease: boolean): string => (isIncrease ? theme.danger : theme.successHover), + /** * Generate the wrapper styles for the mini ReportActionContextMenu. */ @@ -2119,6 +2121,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ break; case CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE: case CONST.SEARCH.TABLE_COLUMNS.BILLABLE: + case CONST.SEARCH.TABLE_COLUMNS.HELD: columnWidth = {...getWidthStyle(variables.w80)}; break; case CONST.SEARCH.TABLE_COLUMNS.MCC: @@ -2527,9 +2530,8 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ getTabBarNarrowStyle: (safeAreaPaddingBottom: number): ViewStyle => ({ overflow: 'visible', - marginTop: -(variables.bottomTabHeight + safeAreaPaddingBottom), - paddingBottom: safeAreaPaddingBottom, - backgroundColor: theme.appBG, + marginTop: -(variables.floatingTabBarHeight + variables.floatingTabBarBottomInset + safeAreaPaddingBottom), + paddingBottom: variables.floatingTabBarBottomInset + safeAreaPaddingBottom, }), getStyleWithEnvSafeAreaPadding: (style: ViewStyle): ViewStyle => ({ diff --git a/src/styles/utils/types.ts b/src/styles/utils/types.ts index 99a39b301317..b6ad8807c88a 100644 --- a/src/styles/utils/types.ts +++ b/src/styles/utils/types.ts @@ -3,7 +3,7 @@ import type colors from '@styles/theme/colors'; import type CONST from '@src/CONST'; import type {Dimensions} from '@src/types/utils/Layout'; -import type {ImageStyle, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {ColorValue as RNColorValue, ImageStyle, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; type AllStyles = ViewStyle | TextStyle | ImageStyle; @@ -44,7 +44,7 @@ type EreceiptColorStyle = { color: ColorValue; titleColor: ColorValue; }; -type TextColorStyle = {color: string}; +type TextColorStyle = {color: RNColorValue}; export type { AllStyles, diff --git a/src/styles/utils/userSelect/index.native.ts b/src/styles/utils/userSelect/index.native.ts index 05e3c8996f4a..d0905f8b5f25 100644 --- a/src/styles/utils/userSelect/index.native.ts +++ b/src/styles/utils/userSelect/index.native.ts @@ -1,6 +1,9 @@ import type UserSelectStyles from './types'; const userSelect: UserSelectStyles = { + userSelectAuto: { + userSelect: 'auto', + }, userSelectText: { userSelect: 'text', }, diff --git a/src/styles/utils/userSelect/index.ts b/src/styles/utils/userSelect/index.ts index 79eec6d3b139..15d2910ac1fe 100644 --- a/src/styles/utils/userSelect/index.ts +++ b/src/styles/utils/userSelect/index.ts @@ -1,6 +1,10 @@ import type UserSelectStyles from './types'; const userSelect: UserSelectStyles = { + userSelectAuto: { + userSelect: 'auto', + WebkitUserSelect: 'auto', + }, userSelectText: { userSelect: 'text', WebkitUserSelect: 'text', diff --git a/src/styles/utils/userSelect/types.ts b/src/styles/utils/userSelect/types.ts index 74a4a7bbde9a..06c0d5bbf2b1 100644 --- a/src/styles/utils/userSelect/types.ts +++ b/src/styles/utils/userSelect/types.ts @@ -1,5 +1,6 @@ import type {TextStyle} from 'react-native'; -type UserSelectStyles = Record<'userSelectText' | 'userSelectNone', Pick>; +type UserSelectStyle = Pick & Partial>; +type UserSelectStyles = Record<'userSelectAuto' | 'userSelectText' | 'userSelectNone', UserSelectStyle>; export default UserSelectStyles; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 72376ce47d4f..c93926165e36 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -31,6 +31,15 @@ const avatarSizes = { export default { bottomTabHeight: 72, + floatingTabBarHeight: 60, + floatingTabBarHorizontalInset: 16, + // How far the selected pill reaches past its tab on each side, whatever the label's length. + floatingTabBarSelectedOverhang: 4, + // Space between a label's truncation edge and its tab's edge, so a neighbor's selected pill never reaches the label. + floatingTabBarLabelInset: 8, + floatingTabBarBottomInset: 8, + // Home, Inbox, Spend and Workspaces, with Insights or Account as the fifth. + floatingTabBarTabCount: 5, // styles.p3 (12) on each side of the DebugTabView row plus the View button (componentSizeNormal). debugTabViewHeight: 64, contentHeaderHeight: getValueUsingPixelRatio(72, 100), @@ -45,6 +54,7 @@ export default { spacing2: 8, rulesDocumentThumbnailMaxWidth: 368, rulesDocumentThumbnailHeight: 200, + hairlineBorderWidth: 0.5, componentBorderRadius: 8, componentBorderRadiusSmall: 4, componentBorderRadiusMedium: 6, @@ -98,6 +108,37 @@ export default { iconSizeMegaLarge: 105, iconSizeMenuItem: 32, iconBottomBar: 24, + /** Glyph size in the floating tab bar. */ + iconFloatingTabBar: 20, + /** Account avatar size in the floating tab bar. */ + avatarFloatingTabBar: avatarSizes.avatarSizeXSmall, + // The tab glyphs are drawn about this far above the bottom of their box. + floatingTabBarGlyphBottomInset: 1, + /** Glyph size in the iOS native tab bar. */ + iconNativeTabBarIOS: 22, + /** Account avatar size in the iOS native tab bar. */ + avatarNativeTabBarIOS: 26, + /** Glyph size in the iOS native tab bar in landscape, where UIKit lays the label beside a smaller icon. */ + iconNativeTabBarIOSLandscape: 18, + /** Account avatar size in the iOS native tab bar in landscape. */ + avatarNativeTabBarIOSLandscape: 21, + /** Radius of the status dot drawn into a native tab bar icon. */ + nativeTabIconDotRadius: 4, + /** Width of the ring cut out of a native tab bar icon around its status dot, as the JS tab bar's dot border does. */ + nativeTabIconDotCutout: 2, + /** Gap between the glyph and the label drawn under it, or beside it in landscape, in an iOS native tab bar icon. */ + nativeTabIconLabelGap: 6, + /** Space between the screen edge and the first tab of the iOS 26 floating tab bar. */ + iosNativeTabBarHorizontalInset: 25, + // UITabBar sizes its items to their icons and gives the selected pill a minimum width, so a label as wide as an even + // share of the bar lets that pill reach over the label next to it. + iosNativeTabBarLabelInset: 8, + /** Material's BottomNavigationView row, without the gesture inset under it. */ + androidNativeTabBarHeight: 80, + /** Material's bar row (androidNativeTabBarHeight) plus the 16 dp Material keeps between a FAB and the bar. */ + androidNativeTabBarFloatingButtonsBottom: 96, + /** UITabBar's row above the home indicator inset, plus the gap to the floating buttons. */ + iosNativeTabBarFloatingButtonsBottom: 50, iconHeader: 48, iconSection: 68, iouAmountTextSize: 40, @@ -330,7 +371,6 @@ export default { photoUploadPopoverWidth: 335, featureTrainingModalWidth: 500, onboardingModalWidth: 640, - aiFeaturesPromoModalWidth: 440, productMarketingWindowWidth: 320, productMarketingWindowMaxWidthNarrow: 450, productMarketingWindowVisualAspectRatio: 7 / 4, @@ -338,6 +378,7 @@ export default { productMarketingWindowOffset: 20, productMarketingWindowOffsetNarrow: 12, productMarketingWindowHorizontalOffsetNarrow: 16, + wiseGlobalReimbursementsIntroIllustrationSize: 250, holdEducationModalWidth: 400, changePolicyEducationModalWidth: 400, wideConfirmModalWidth: 400, @@ -385,6 +426,7 @@ export default { rulesNewMenuItemMinHeight: 84, agentRulePromptInputHeight: 340, describeRulePromptInputHeight: 312, + describeNewRulePromptInputHeight: 96, cardPreviewWidth: 235, cardDetailsActionButtonMinWidth: 140, cardScarfOverlayWidth: 264, @@ -451,6 +493,8 @@ export default { searchListContentMarginTop: 120, searchListContentWithFiltersMarginTop: 164, searchTopBarZIndex: 9, + // Above the search page header (zIndex 10), whose top bar floats over the list and reaches the footer when the screen is short. + searchFooterZIndex: 11, searchAutocompleteInputSkeletonHeight: 8, searchAutocompleteInputSkeletonWidth: 145, diff --git a/src/types/form/AddAgentForm.ts b/src/types/form/AddAgentForm.ts index db9a02105c92..2be221400a23 100644 --- a/src/types/form/AddAgentForm.ts +++ b/src/types/form/AddAgentForm.ts @@ -1,3 +1,5 @@ +import type CONST from '@src/CONST'; + import type {ValueOf} from 'type-fest'; import type Form from './Form'; @@ -5,6 +7,7 @@ import type Form from './Form'; const INPUT_IDS = { FIRST_NAME: 'firstName', PROMPT: 'prompt', + ROLE: 'role', } as const; type InputID = ValueOf; @@ -14,6 +17,7 @@ type AddAgentForm = Form< { [INPUT_IDS.FIRST_NAME]: string; [INPUT_IDS.PROMPT]: string; + [INPUT_IDS.ROLE]: ValueOf; } >; diff --git a/src/types/form/DynamicFormListItemForm.ts b/src/types/form/DynamicFormListItemForm.ts new file mode 100644 index 000000000000..26fb0bcc45e0 --- /dev/null +++ b/src/types/form/DynamicFormListItemForm.ts @@ -0,0 +1,9 @@ +import type {FormValue} from '@components/Form/types'; + +import type {BaseForm} from './Form'; + +/** The list item being added or edited. Its keys are the item schema's field keys. */ +type DynamicFormListItemForm = BaseForm & Record; + +// eslint-disable-next-line import/prefer-default-export +export type {DynamicFormListItemForm}; diff --git a/src/types/form/SearchAdvancedFiltersForm.ts b/src/types/form/SearchAdvancedFiltersForm.ts index f860ec0faaaf..b4675ca215ee 100644 --- a/src/types/form/SearchAdvancedFiltersForm.ts +++ b/src/types/form/SearchAdvancedFiltersForm.ts @@ -40,6 +40,7 @@ const DATE_FILTER_KEYS: SearchDateFilterKeys[] = [ CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED, + CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID, CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED, @@ -101,6 +102,12 @@ const FILTER_KEYS = { APPROVED_BEFORE: 'approvedBefore', APPROVED_RANGE: 'approvedRange', + ANY_APPROVAL_NOT: 'anyApprovalNot', + ANY_APPROVAL_ON: 'anyApprovalOn', + ANY_APPROVAL_AFTER: 'anyApprovalAfter', + ANY_APPROVAL_BEFORE: 'anyApprovalBefore', + ANY_APPROVAL_RANGE: 'anyApprovalRange', + PAID_NOT: 'paidNot', PAID_ON: 'paidOn', PAID_AFTER: 'paidAfter', @@ -152,6 +159,7 @@ const FILTER_KEYS = { MERCHANT_NOT: 'merchantNot', MERCHANT: 'merchant', + MERCHANT_OPERATOR: 'merchantOperator', DESCRIPTION_NOT: 'descriptionNot', DESCRIPTION: 'description', @@ -239,6 +247,11 @@ const FILTER_KEYS = { BILLABLE_NOT: 'billableNot', BILLABLE: 'billable', + DECLINED_NOT: 'declinedNot', + DECLINED: 'declined', + HELD_NOT: 'heldNot', + HELD: 'held', + ACTION_NOT: 'actionNot', ACTION: 'action', @@ -293,6 +306,7 @@ const ALLOWED_TYPE_FILTERS: Record> = { FILTER_KEYS.TRANSACTION_STATUS_NOT, FILTER_KEYS.MERCHANT, FILTER_KEYS.MERCHANT_NOT, + FILTER_KEYS.MERCHANT_OPERATOR, FILTER_KEYS.DATE_ON, FILTER_KEYS.DATE_NOT, FILTER_KEYS.DATE_AFTER, @@ -345,6 +359,10 @@ const ALLOWED_TYPE_FILTERS: Record> = { FILTER_KEYS.REIMBURSABLE_NOT, FILTER_KEYS.BILLABLE, FILTER_KEYS.BILLABLE_NOT, + FILTER_KEYS.DECLINED, + FILTER_KEYS.DECLINED_NOT, + FILTER_KEYS.HELD, + FILTER_KEYS.HELD_NOT, FILTER_KEYS.REPORT_ID, FILTER_KEYS.REPORT_ID_NOT, FILTER_KEYS.SUBMITTED_ON, @@ -357,6 +375,11 @@ const ALLOWED_TYPE_FILTERS: Record> = { FILTER_KEYS.APPROVED_AFTER, FILTER_KEYS.APPROVED_BEFORE, FILTER_KEYS.APPROVED_RANGE, + FILTER_KEYS.ANY_APPROVAL_ON, + FILTER_KEYS.ANY_APPROVAL_NOT, + FILTER_KEYS.ANY_APPROVAL_AFTER, + FILTER_KEYS.ANY_APPROVAL_BEFORE, + FILTER_KEYS.ANY_APPROVAL_RANGE, FILTER_KEYS.PAID_ON, FILTER_KEYS.PAID_NOT, FILTER_KEYS.PAID_AFTER, @@ -443,6 +466,8 @@ const ALLOWED_TYPE_FILTERS: Record> = { FILTER_KEYS.WITHDRAWAL_STATUS_NOT, FILTER_KEYS.PAID_STATUS, FILTER_KEYS.PAID_STATUS_NOT, + FILTER_KEYS.DECLINED, + FILTER_KEYS.DECLINED_NOT, FILTER_KEYS.WITHDRAWN_AFTER, FILTER_KEYS.WITHDRAWN_BEFORE, FILTER_KEYS.WITHDRAWN_ON, @@ -504,6 +529,7 @@ const ALLOWED_TYPE_FILTERS: Record> = { FILTER_KEYS.POLICY_ID_NOT, FILTER_KEYS.MERCHANT, FILTER_KEYS.MERCHANT_NOT, + FILTER_KEYS.MERCHANT_OPERATOR, FILTER_KEYS.DATE_ON, FILTER_KEYS.DATE_NOT, FILTER_KEYS.DATE_AFTER, @@ -602,6 +628,7 @@ const ALLOWED_TYPE_FILTERS: Record> = { FILTER_KEYS.POLICY_ID_NOT, FILTER_KEYS.MERCHANT, FILTER_KEYS.MERCHANT_NOT, + FILTER_KEYS.MERCHANT_OPERATOR, FILTER_KEYS.DATE_ON, FILTER_KEYS.DATE_NOT, FILTER_KEYS.DATE_AFTER, @@ -732,6 +759,7 @@ type IsFilterValues = IsFilterValue[]; type BooleanValue = ValueOf; type ExpenseTypeValue = ValueOf; type ExpenseTypeValues = ExpenseTypeValue[]; +type MerchantMatchType = typeof CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO | typeof CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS; type ReceiptTypeValue = ValueOf; type ReceiptTypeValues = ReceiptTypeValue[]; type TransactionStatusValue = ValueOf; @@ -765,6 +793,12 @@ type SearchAdvancedFiltersForm = Form< [FILTER_KEYS.APPROVED_BEFORE]: string; [FILTER_KEYS.APPROVED_RANGE]: string; + [FILTER_KEYS.ANY_APPROVAL_ON]: string; + [FILTER_KEYS.ANY_APPROVAL_NOT]: string; + [FILTER_KEYS.ANY_APPROVAL_AFTER]: string; + [FILTER_KEYS.ANY_APPROVAL_BEFORE]: string; + [FILTER_KEYS.ANY_APPROVAL_RANGE]: string; + [FILTER_KEYS.PAID_ON]: string; [FILTER_KEYS.PAID_NOT]: string; [FILTER_KEYS.PAID_AFTER]: string; @@ -819,6 +853,7 @@ type SearchAdvancedFiltersForm = Form< [FILTER_KEYS.MERCHANT]: string; [FILTER_KEYS.MERCHANT_NOT]: string; + [FILTER_KEYS.MERCHANT_OPERATOR]: MerchantMatchType; [FILTER_KEYS.DESCRIPTION]: string; [FILTER_KEYS.DESCRIPTION_NOT]: string; @@ -906,6 +941,11 @@ type SearchAdvancedFiltersForm = Form< [FILTER_KEYS.BILLABLE]: BooleanValue; [FILTER_KEYS.BILLABLE_NOT]: BooleanValue; + [FILTER_KEYS.DECLINED]: BooleanValue; + [FILTER_KEYS.DECLINED_NOT]: BooleanValue; + [FILTER_KEYS.HELD]: BooleanValue; + [FILTER_KEYS.HELD_NOT]: BooleanValue; + [FILTER_KEYS.ACTION]: string; [FILTER_KEYS.ACTION_NOT]: string; @@ -950,6 +990,7 @@ export type { IsFilterValues, ExpenseTypeValue, ExpenseTypeValues, + MerchantMatchType, ReceiptTypeValue, TransactionStatusValue, SearchNegatableFilterKeys, diff --git a/src/types/form/VacationDelegateForm.ts b/src/types/form/VacationDelegateForm.ts new file mode 100644 index 000000000000..bd75e0e74307 --- /dev/null +++ b/src/types/form/VacationDelegateForm.ts @@ -0,0 +1,28 @@ +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', + + /** Time the vacation delegate clears at on that day, in the timezone of the person setting it. Stored as yyyy-MM-dd HH:mm:ss by the time picker, but only the time is used. */ + CLEAR_AFTER_TIME: 'clearAfterTime', +} as const; + +type InputID = ValueOf; + +type VacationDelegateForm = Form< + InputID, + { + [INPUT_IDS.DELEGATE]: string; + [INPUT_IDS.CLEAR_AFTER_DATE]: string; + [INPUT_IDS.CLEAR_AFTER_TIME]: string; + } +>; + +export type {VacationDelegateForm}; +export default INPUT_IDS; diff --git a/src/types/form/WiseBusinessOnboardingForm.ts b/src/types/form/WiseBusinessOnboardingForm.ts new file mode 100644 index 000000000000..fc7121bb7b72 --- /dev/null +++ b/src/types/form/WiseBusinessOnboardingForm.ts @@ -0,0 +1,9 @@ +import type {FormValue} from '@components/Form/types'; + +import type {BaseForm} from './Form'; + +/** Wise business onboarding form answers, keyed by field */ +type WiseBusinessOnboardingForm = BaseForm & Record; + +// eslint-disable-next-line import/prefer-default-export +export type {WiseBusinessOnboardingForm}; diff --git a/src/types/form/WorkspaceOfficeLocationForm.ts b/src/types/form/WorkspaceOfficeLocationForm.ts new file mode 100644 index 000000000000..1e2c9f0eb2ae --- /dev/null +++ b/src/types/form/WorkspaceOfficeLocationForm.ts @@ -0,0 +1,35 @@ +import type {Country} from '@src/CONST'; + +import type {ValueOf} from 'type-fest'; + +import type Form from './Form'; + +const INPUT_IDS = { + LOCATION_NAME: 'locationName', + ADDRESS_LINE_1: 'addressLine1', + ADDRESS_LINE_2: 'addressLine2', + COUNTRY: 'country', + STATE: 'state', + CITY: 'city', + ZIP_POST_CODE: 'zipPostCode', + IS_PRIMARY: 'isPrimary', +} as const; + +type InputID = ValueOf; + +type WorkspaceOfficeLocationForm = Form< + InputID, + { + [INPUT_IDS.LOCATION_NAME]: string; + [INPUT_IDS.ADDRESS_LINE_1]: string; + [INPUT_IDS.ADDRESS_LINE_2]: string; + [INPUT_IDS.COUNTRY]: Country | ''; + [INPUT_IDS.STATE]: string; + [INPUT_IDS.CITY]: string; + [INPUT_IDS.ZIP_POST_CODE]: string; + [INPUT_IDS.IS_PRIMARY]: boolean; + } +>; + +export type {WorkspaceOfficeLocationForm}; +export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index dd91169079fe..6f18c42c90fc 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -38,6 +38,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'; @@ -100,6 +101,8 @@ export type {MoneyRequestTimeForm} from './MoneyRequestTimeForm'; export type {MoneyRequestSubrateForm} from './MoneyRequestSubrateForm'; export type {CollectDepositAccountForm} from './CollectDepositAccountForm'; export type {InternationalBankAccountForm} from './InternationalBankAccountForm'; +export type {DynamicFormListItemForm} from './DynamicFormListItemForm'; +export type {WiseBusinessOnboardingForm} from './WiseBusinessOnboardingForm'; export type {WorkspacePerDiemForm} from './WorkspacePerDiemForm'; export type {MoneyRequestRejectReasonForm} from './MoneyRequestRejectReasonForm'; export type {ReportRejectForm} from './ReportRejectForm'; @@ -132,6 +135,7 @@ export type {EditAgentPromptForm} from './EditAgentPromptForm'; export type {AddAgentRuleForm} from './AddAgentRuleForm'; export type {NewRulePromptForm} from './NewRulePromptForm'; export type {EditAgentRuleForm} from './EditAgentRuleForm'; +export type {WorkspaceOfficeLocationForm} from './WorkspaceOfficeLocationForm'; export type {RilletCredentialsForm} from './RilletCredentialsForm'; export type {DualEntryCredentialsForm} from './DualEntryCredentialsForm'; export type {CampfireCredentialsForm} from './CampfireCredentialsForm'; diff --git a/src/types/modules/react-native.d.ts b/src/types/modules/react-native.d.ts index e43d30a8631e..50a6535f707d 100644 --- a/src/types/modules/react-native.d.ts +++ b/src/types/modules/react-native.d.ts @@ -9,6 +9,8 @@ import type {TargetedEvent} from 'react-native'; type AppStateTrackerModule = { getWasAppRelaunchedFromIcon: () => Promise; + // Missing on binaries built before this method was added. + reportFullyDrawn?: () => void; }; type RNTextInputResetModule = { diff --git a/src/types/onyx/Account.ts b/src/types/onyx/Account.ts index 8f8683689812..189416870ce3 100644 --- a/src/types/onyx/Account.ts +++ b/src/types/onyx/Account.ts @@ -136,6 +136,9 @@ type Account = { /** Errors related to specific account fields */ errorFields?: OnyxCommon.ErrorFields; + /** Pending account changes while requests are queued. */ + pendingFields?: OnyxCommon.PendingFields<'exactOnlineWaitlist'>; + /** Authentication success message */ success?: string; diff --git a/src/types/onyx/AgentPrompt.ts b/src/types/onyx/Agent.ts similarity index 51% rename from src/types/onyx/AgentPrompt.ts rename to src/types/onyx/Agent.ts index ea2e4cb2af2e..8e7b97608031 100644 --- a/src/types/onyx/AgentPrompt.ts +++ b/src/types/onyx/Agent.ts @@ -1,10 +1,23 @@ +import type CONST from '@src/CONST'; + +import type {ValueOf} from 'type-fest'; + import type {Errors, OnyxValueWithOfflineFeedback} from './OnyxCommon'; -/** Model of an agent's prompt data stored as a shared NVP */ -type AgentPrompt = OnyxValueWithOfflineFeedback<{ +/** The agent owner account type */ +type AgentOwnerType = ValueOf; + +/** Agent model */ +type Agent = OnyxValueWithOfflineFeedback<{ /** The system prompt defining the agent's behavior */ prompt: string; + /** The agent owner account ID */ + ownerAccountID?: number; + + /** The agent owner account type (person, workspace or domain) */ + ownerType?: AgentOwnerType; + /** Errors from the last failed action */ errors?: Errors | null; @@ -18,4 +31,5 @@ type AgentPrompt = OnyxValueWithOfflineFeedback<{ avatarErrors?: Errors | null; }>; -export default AgentPrompt; +export type {AgentOwnerType}; +export default Agent; diff --git a/src/types/onyx/AppReview.ts b/src/types/onyx/AppReview.ts index 8d4f6ff86d80..8246ea77309c 100644 --- a/src/types/onyx/AppReview.ts +++ b/src/types/onyx/AppReview.ts @@ -19,5 +19,14 @@ type AppReview = { conciergeReportActionID?: string; }; +/** Device-local record of when this client last prompted for an app review */ +type AppReviewLastPrompt = { + /** The account this record belongs to, so it is ignored after a different user signs in on this device */ + accountID: number; + + /** When this client last prompted (UTC timestamp) */ + lastPrompt: string; +}; + export default AppReview; -export type {AppReviewResponse}; +export type {AppReviewResponse, AppReviewLastPrompt}; diff --git a/src/types/onyx/BankAccount.ts b/src/types/onyx/BankAccount.ts index ed901b726d23..94436dce38a2 100644 --- a/src/types/onyx/BankAccount.ts +++ b/src/types/onyx/BankAccount.ts @@ -1,9 +1,12 @@ import type CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; +import type {ValueOf} from 'type-fest'; + import type AccountData from './AccountData'; import type {BankName} from './Bank'; import type * as OnyxCommon from './OnyxCommon'; +import type {WiseBusinessOnboarding} from './ReimbursementAccount'; /** Model of additional bank account data */ type BankAccountAdditionalData = { @@ -28,6 +31,12 @@ type BankAccountAdditionalData = { achAuthorizationForm?: FileObject[]; }; + /** Global reimbursement provider, derived by the server */ + globalReimbursementProvider?: ValueOf; + + /** The business's Wise onboarding as every holder of the account sees it: the status only, the answers are the admin's */ + wise?: Pick; + /** Verification statuses returned by the backend, mirroring how Classic determines status */ verifications?: { /** Present once the account is set up with Corpay for Global Reimbursement */ diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index 3f5e2a136157..0d71120505c0 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -48,6 +48,9 @@ type ReportAttributes = { needsParentChatErrorPropagation?: boolean; }; +/** Report names keyed by reportID, the only part of the report attributes that option rows read for reports other than their own */ +type ReportNamesDerivedValue = Record>; + /** * The derived value for report attributes. */ @@ -247,6 +250,7 @@ type OneOnOneChatReportIDsDerivedValue = { export type { ReportAttributes, ReportAttributesDerivedValue, + ReportNamesDerivedValue, ReportTransactionsAndViolationsDerivedValue, ReportTransactionsAndViolations, OutstandingReportsByPolicyIDDerivedValue, diff --git a/src/types/onyx/DismissedProductTraining.ts b/src/types/onyx/DismissedProductTraining.ts index d034afa5776b..b9cb49fda92a 100644 --- a/src/types/onyx/DismissedProductTraining.ts +++ b/src/types/onyx/DismissedProductTraining.ts @@ -35,11 +35,6 @@ type DismissedProductTraining = { */ [CONST.MIGRATED_USER_WELCOME_MODAL]: DismissedProductTrainingElement; - /** - * When user dismisses the AI features promo modal, we store the timestamp here. - */ - [CONST.AI_FEATURES_PROMO_MODAL]: DismissedProductTrainingElement; - // TODO: CONCIERGE_LHN_GBR tooltip will be replaced by a tooltip in the #admins room // https://github.com/Expensify/App/issues/57045#issuecomment-2701455668 /** @@ -77,6 +72,16 @@ type DismissedProductTraining = { */ [CONST.AGENTS_RULES_BANNER]: DismissedProductTrainingElement; + /** + * When user dismisses the Wallet global reimbursements banner, or starts global reimbursements onboarding, we store the timestamp here. + */ + [CONST.WISE_GLOBAL_REIMBURSEMENTS_WALLET_BANNER]: DismissedProductTrainingElement; + + /** + * When user dismisses the Workspace Workflows global reimbursements banner, or starts global reimbursements onboarding, we store the timestamp here. + */ + [CONST.WISE_GLOBAL_REIMBURSEMENTS_WORKSPACE_BANNER]: DismissedProductTrainingElement; + /** * When user dismisses the GPS tooltip, we store the timestamp here. */ diff --git a/src/types/onyx/DynamicFormField.ts b/src/types/onyx/DynamicFormField.ts index 036b7b4f43b4..c79faf501381 100644 --- a/src/types/onyx/DynamicFormField.ts +++ b/src/types/onyx/DynamicFormField.ts @@ -1,10 +1,16 @@ +import type {FormValue} from '@components/Form/types'; + import type {TranslationPaths} from '@src/languages/types'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; import type {InputModeOptions} from 'react-native'; /** Met while the answer to `key` is one of `equals` */ type DynamicFormCondition = { + /** Key of the field whose answer is checked */ key: string; + + /** Answers that meet the condition. A boolean answer matches as `'true'` or `'false'`, and a list answer matches when any chosen value is listed. */ equals: string[]; }; @@ -16,11 +22,13 @@ type DynamicFormHeading = { /** The schema author's wording, shown when there is no titleKey */ title?: string; + /** Translation of `title`, used instead of it when set */ titleKey?: TranslationPaths; /** The schema author's wording, shown when there is no descriptionKey */ description?: string; + /** Translation of `description`, used instead of it when set */ descriptionKey?: TranslationPaths; /** Hides the title only. The fields after it still show, since a heading marks a place rather than containing fields. */ @@ -41,13 +49,22 @@ type DynamicFormFieldBase = { /** The schema author's wording, shown when there is no labelKey */ label?: string; + /** Translation of `label`, used instead of it when set */ labelKey?: TranslationPaths; /** Supporting text, such as which format is accepted */ description?: string; + /** Translation of `description`, used instead of it when set */ descriptionKey?: TranslationPaths; + /** The headline of a page with no title of its own where this is the only question, such as "What's your account number?". It also names that page's section on the confirmation page. */ + question?: string; + + /** Translation of `question`, used instead of it when set */ + questionKey?: TranslationPaths; + + /** The form cannot move on until the field is answered */ required: boolean; /** Never written to the form draft, for SSNs and account numbers */ @@ -65,19 +82,28 @@ type DynamicFormFieldBase = { /** Free text, checked by regex, length and an optional named rule */ type DynamicFormTextField = DynamicFormFieldBase & { + /** Tells the field types apart */ type: 'text'; + + /** Pattern the answer must match, anchored by the schema when the whole answer must fit. An invalid pattern is reported and skipped, so it never blocks the user. */ regex?: string; + + /** Fewest characters the answer can have */ minLength?: number; + + /** Most characters the answer can have. The input stops taking more. */ maxLength?: number; /** Sample value, shown as a hint and in the format error */ example?: string; + /** Keyboard the input opens. Without it, `rule: 'phone'` opens the phone keyboard and a digits-only `regex` the numeric one. */ keyboard?: Extract; /** Grows with its content, for descriptions */ multiline?: boolean; + /** A named check on top of the others: `legalName` for a person's legal name, `phone` for an international phone number */ rule?: 'legalName' | 'phone'; }; @@ -86,7 +112,10 @@ type DynamicFormNumberField = DynamicFormFieldBase & Pick; }; }; @@ -104,12 +136,16 @@ type DynamicFormMultiChoiceField = DynamicFormFieldBase & Pick.`. */ type DynamicFormAddressField = DynamicFormFieldBase & { + /** Tells the field types apart */ type: 'address'; /** Checks the zip against the format of the chosen country */ @@ -118,6 +154,7 @@ type DynamicFormAddressField = DynamicFormFieldBase & { /** Uploaded documents */ type DynamicFormFileField = DynamicFormFieldBase & { + /** Tells the field types apart */ type: 'file'; /** One file when not set */ @@ -129,6 +166,7 @@ type DynamicFormFileField = DynamicFormFieldBase & { /** A money amount */ type DynamicFormAmountField = DynamicFormFieldBase & { + /** Tells the field types apart */ type: 'amount'; /** Draft key of the currency the user picks next to the amount. Without it the currency is fixed by the screen. */ @@ -137,16 +175,49 @@ type DynamicFormAmountField = DynamicFormFieldBase & { /** One country, or any number of countries, picked from every country the App knows */ type DynamicFormCountryField = DynamicFormFieldBase & { + /** `country` picks one country, `countryMultiselect` any number of them */ type: 'country' | 'countryMultiselect'; }; -/** A Yes/No question when it is the only question on its page, where No is an answer too. Among other fields, a checkbox that must be ticked when required. */ +/** A Yes/No question, where No is an answer too, or a checkbox that must be ticked when required */ type DynamicFormBooleanField = DynamicFormFieldBase & { + /** Tells the field types apart */ type: 'boolean'; + + /** `yesNo`, the default, asks a question that No answers. `checkbox` asks for consent, such as accepting terms, which only a tick gives. */ + presentation?: 'yesNo' | 'checkbox'; +}; + +/** Repeated entries, such as a company's owners, each answered with its own fields on an editor page */ +type DynamicFormListField = DynamicFormFieldBase & { + /** Tells the field types apart */ + type: 'list'; + + /** The fields of one entry */ + itemFields: DynamicFormField[]; + + /** The noun for one entry, such as "owner", for the add row and the editor title */ + itemLabel?: string; + + /** Translation of `itemLabel`, used instead of it when set */ + itemLabelKey?: TranslationPaths; + + /** Hint shown under the add row */ + addItemDescription?: string; + + /** Translation of `addItemDescription`, used instead of it when set */ + addItemDescriptionKey?: TranslationPaths; + + /** Fewest entries the list must hold to pass validation */ + minItems?: number; + + /** Most entries the list can hold. The add row is hidden once it is reached. */ + maxItems?: number; }; /** Types that need nothing beyond the common properties */ type DynamicFormPlainField = DynamicFormFieldBase & { + /** `currency` picks a currency code, `percent` takes a percentage from 1 to 100 */ type: 'currency' | 'percent'; }; @@ -162,14 +233,36 @@ type DynamicFormField = | DynamicFormAmountField | DynamicFormCountryField | DynamicFormBooleanField + | DynamicFormListField | DynamicFormPlainField; +/** One entry of a list field: its answers keyed by item field key, and an `id` made on the device to key the entry's row and editor page */ +type DynamicFormListItem = { + /** Made on the device when the entry is added, and keys the entry's row, editor page and sensitive answers */ + id: string; + + /** The entry's answers, keyed by item field key */ + [fieldKey: string]: FormValue | undefined; +}; + /** A field as the schema sends it, which can be of a type this App version does not know */ type DynamicFormSchemaField = DynamicFormField | (DynamicFormFieldBase & {type: string}); /** A heading or a field of a page */ type DynamicFormContentItem = DynamicFormHeading | DynamicFormSchemaField; +/** A page of a multi-page form. It is a heading with its own content, so it can carry a title, a description and a `showWhen`. */ +type DynamicFormPage = DynamicFormHeading & { + /** The headings and fields shown on this page */ + content: DynamicFormContentItem[]; +}; + +/** What a multi-page form is built from: pages as the server groups them, or a flat list of fields the App paginates */ +type DynamicFormSchema = {pages: DynamicFormPage[]} | {fields: DynamicFormSchemaField[]}; + +/** Answers to sensitive fields, keyed by form ID, then by field key. Kept in memory only, never in a draft. */ +type DynamicFormSensitiveAnswers = Partial>>>; + /** Every type the renderer and validator handle */ type DynamicFormFieldType = DynamicFormField['type']; @@ -182,8 +275,13 @@ export type { DynamicFormFieldOption, DynamicFormFieldType, DynamicFormHeading, + DynamicFormListField, + DynamicFormListItem, DynamicFormMultiChoiceField, DynamicFormNumberField, + DynamicFormPage, + DynamicFormSchema, DynamicFormSchemaField, + DynamicFormSensitiveAnswers, DynamicFormTextField, }; diff --git a/src/types/onyx/GeneratedRule.ts b/src/types/onyx/GeneratedRule.ts index f7543798f047..34196d1cd7f6 100644 --- a/src/types/onyx/GeneratedRule.ts +++ b/src/types/onyx/GeneratedRule.ts @@ -9,8 +9,17 @@ type GeneratedRuleState = ValueOf; /** The rule type a description was mapped to */ type GeneratedRuleType = ValueOf; +/** Why neither a rule nor an agent rule can do what the description asks */ +type GeneratedRuleImpossibleReason = ValueOf; + /** The generated form values, seeding whichever draft ruleType names */ -type GeneratedRuleValues = Partial & Partial & Partial & Partial; +type GeneratedRuleValues = Partial & + Partial & + Partial & + Partial & { + /** Whether a card spend rule covers every card a new rule can cover */ + isAllCards?: boolean; + }; /** Model of a rule generated from an admin's description */ type GeneratedRule = { @@ -20,9 +29,27 @@ type GeneratedRule = { /** The outcome of the generation attempt */ state: GeneratedRuleState; + /** The rule type the description was mapped to */ + ruleType?: GeneratedRuleType; + /** The form values to seed the rule draft with */ rule?: GeneratedRuleValues; + + /** The instruction an agent rule follows, written as what the agent will do. Only when state is agentRule. */ + agentRulePrompt?: string; + + /** + * One sentence in the language of the description. When state is impossible, it says what cannot be done. When state + * is agentRule, it says how the agent rule differs from the description, and it is absent when they match. + */ + explanation?: string; + + /** Why the description cannot be done. Only when state is impossible. */ + reason?: GeneratedRuleImpossibleReason; + + /** The closest description that a rule or an agent rule can do. Only when state is impossible, and it can be empty. */ + suggestedPrompt?: string; }; export default GeneratedRule; -export type {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues}; +export type {GeneratedRuleImpossibleReason, GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues}; diff --git a/src/types/onyx/ImportedSpreadsheet.ts b/src/types/onyx/ImportedSpreadsheet.ts index ccc231b0d1c0..5e9251ade717 100644 --- a/src/types/onyx/ImportedSpreadsheet.ts +++ b/src/types/onyx/ImportedSpreadsheet.ts @@ -81,6 +81,9 @@ type ImportedSpreadsheet = { isImportingMultiLevelTags: boolean; isImportingIndependentMultiLevelTags: boolean; + /** Whether the imported tag lists are appended to the existing (accounting connection) tag lists instead of replacing them */ + isAppendingToExistingLists?: boolean; + /** Whether the GL code is in the adjacent column */ isGLAdjacent: boolean; diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index aa0a2aba838d..5eafc50f3336 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -7,7 +7,7 @@ import type {CardID} from './Card'; import type {PolicyRuleTaxRate} from './ExpenseRule'; import type {Attendee} from './IOU'; import type {OldDotOriginalMessageMap} from './OldDotAction'; -import type {AllConnectionName} from './Policy'; +import type {AllConnectionName, CompanyAddress} from './Policy'; import type {PolicyChangeLogCopyReportActionNames} from './ReportAction'; import type ReportActionName from './ReportActionName'; import type {Reservation, TransactionCommentVendor} from './Transaction'; @@ -582,12 +582,21 @@ type OriginalMessagePolicyChangeLog = { */ unit?: string; + /** Previous distance unit when a commuter-exclusion distance changes units */ + oldUnit?: string; + /** Start date of the custom unit rate (yyyy-MM-dd), used in ADD actions */ startDate?: string; /** End date of the custom unit rate (yyyy-MM-dd), used in ADD actions */ endDate?: string; + /** Share of the custom unit rate that is tax reclaimable, used in ADD actions */ + taxClaimablePercentage?: number; + + /** Name of the tax rate the custom unit rate's reclaimable tax falls under, used in ADD actions */ + taxRateName?: string; + /** New start date of the custom unit rate (yyyy-MM-dd), used in UPDATE actions */ newStartDate?: string; @@ -862,6 +871,60 @@ type OriginalMessageAgentRuleChangeLog = { prompt?: string; }; +/** Model of a workspace office location change log action (add, update, or delete) */ +type OriginalMessageOfficeLocationChangeLog = { + /** ID of the office that changed */ + officeID?: string; + + /** Nickname of the office */ + name?: string; + + /** Address of the added office */ + address?: CompanyAddress; + + /** Whether the added office is the workspace default */ + isDefault?: boolean; + + /** Field of the office that was updated */ + updatedField?: ValueOf; + + /** Name or default flag of the office after the change */ + newValue?: string | boolean; + + /** Name or default flag of the office before the change */ + oldValue?: string | boolean; + + /** Address of the office after an address change */ + newAddress?: CompanyAddress; + + /** Address of the office before an address change */ + oldAddress?: CompanyAddress; +}; + +/** Model of a member office location change log action */ +type OriginalMessageEmployeeOfficeLocationChangeLog = { + /** Account ID of the member */ + accountID?: number; + + /** Login of the member */ + email?: string; + + /** Display name of the member */ + name?: string; + + /** Office the member was assigned to, or empty when the assignment was cleared */ + officeID?: string; + + /** Nickname of the office the member was assigned to */ + officeName?: string; + + /** Office the member was assigned to before the change, or empty when they had none */ + oldOfficeID?: string; + + /** Nickname of the office the member was assigned to before the change */ + oldOfficeName?: string; +}; + /** Model of a policy copy change log action */ type OriginalMessagePolicyChangeCopyLog = { /** The ID of the source policy from which the user copied settings */ @@ -1245,6 +1308,9 @@ type OriginalMessageReimbursed = { /** Currency the creditedAmount is denominated in (the employee's deposit currency) */ creditedCurrency?: string; + + /** The Concierge reasoning for the action */ + reasoning?: string; }; /** Model of `trip room preview` report action */ @@ -1589,18 +1655,6 @@ type OriginalMessageSettlementAccountLocked = { policyID: string; }; -/** - * Original message for Expensify Card issue/replacement actions - */ -type IssueNewCardOriginalMessage = OriginalMessage< - | typeof CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS - | typeof CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED - | typeof CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED_VIRTUAL - | typeof CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED - | typeof CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED_VIRTUAL - | typeof CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED ->; - /** * Model of a HOME_ADDRESS_REQUIRED Concierge report action. */ @@ -1697,6 +1751,7 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.MARK_REIMBURSED_FROM_INTEGRATION]: never; [CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED]: OriginalMessageMarkedReimbursed; [CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION]: never; + [CONST.REPORT.ACTIONS.TYPE.MERGED_EXPENSE_DELETED]: never; [CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE]: OriginalMessageModifiedExpense; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_MATCH_VENDOR]: OriginalMessageConciergeAutoMatchVendor; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_SELECT_DISTANCE_RATE]: OriginalMessageConciergeAutoSelectDistanceRate; @@ -1782,6 +1837,10 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_AGENT_RULE]: OriginalMessageAgentRuleChangeLog; [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AGENT_RULE]: OriginalMessageAgentRuleChangeLog; [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_AGENT_RULE]: OriginalMessageAgentRuleChangeLog; + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION]: OriginalMessageOfficeLocationChangeLog; + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION]: OriginalMessageOfficeLocationChangeLog; + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION]: OriginalMessageOfficeLocationChangeLog; + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION]: OriginalMessageEmployeeOfficeLocationChangeLog; } & Record, OriginalMessageChangeLog>; type OriginalMessage = T extends keyof OriginalMessageMap ? OriginalMessageMap[T] : never; @@ -1801,7 +1860,6 @@ export type { JoinWorkspaceResolution, OriginalMessageModifiedExpense, OriginalMessageExportIntegration, - IssueNewCardOriginalMessage, OriginalMessageChangePolicy, OriginalMessageMovedTransaction, PolicyBudgetFrequency, diff --git a/src/types/onyx/PersonalDetails.ts b/src/types/onyx/PersonalDetails.ts index 116903875937..74b1f5fb6fcb 100644 --- a/src/types/onyx/PersonalDetails.ts +++ b/src/types/onyx/PersonalDetails.ts @@ -90,7 +90,7 @@ type PersonalDetails = OnyxCommon.OnyxValueWithOfflineFeedback<{ * NVP set). Stamped by Auth in `Account::formatNewDotPersonalDetails`, so it travels with * every personalDetails payload returned to the client (OpenReport, OpenApp, ReconnectApp, * GetPersonalDetailsForEmails, etc.). Lets chat surfaces detect custom-agent participants - * without subscribing to the wider `SHARED_NVP_AGENT_PROMPT` collection. + * without subscribing to the wider `AGENT` collection. */ isCustomAgent?: boolean; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index f00609403cf8..1e20d55c58a9 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1241,7 +1241,7 @@ type NetSuiteConnection = { lastSync?: ConnectionLastSync; - /** Config object used solely to store autosync settings */ + /** Config object used to store autosync settings and authentication type */ config: OnyxCommon.OnyxValueWithOfflineFeedback<{ /** NetSuite auto synchronization configs */ autoSync: { @@ -1256,6 +1256,9 @@ type NetSuiteConnection = { /** Collection of form field errors */ errorFields?: OnyxCommon.ErrorFields; + + /** Authentication method used for this connection */ + authType?: 'tba' | 'oauth2'; }>; /** Encrypted token secret for authenticating to NetSuite */ @@ -3034,6 +3037,9 @@ type BusinessCentralExport = { /** Accounting method used during export */ accountingMethod: ValueOf; + + /** Whether reports dated in a closed accounting period are exported to the next open period */ + exportToNextOpenPeriod: boolean; }; /** @@ -3042,8 +3048,31 @@ type BusinessCentralExport = { type BusinessCentralAutoSync = { /** Whether automatic synchronization is enabled */ enabled: boolean; + + /** ID of the daily job that runs the automatic synchronization, null while it is disabled */ + jobID?: string | null; }; +/** + * Settings for syncing payments made in Expensify back to Business Central. + */ +type BusinessCentralSync = { + /** Whether reimbursed expense reports are marked as paid in Business Central */ + syncReimbursedReports: boolean; + + /** ID of the Business Central bank account employee reimbursements are paid from */ + reimbursementBankAccountID: string; + + /** Whether Expensify Card settlements are synced to Business Central */ + syncExpensifyCardSettlements: boolean; + + /** ID of the Business Central bank account Expensify Card settlements are paid from */ + settlementsBankAccountID: string; +}; + +/** Payment sync setting that holds a Business Central bank account ID */ +type BusinessCentralBankAccountSettingName = keyof Pick; + /** * Connection config for Business Central. */ @@ -3073,13 +3102,16 @@ type BusinessCentralConnectionsConfig = OnyxCommon.OnyxValueWithOfflineFeedback< /** Auto-sync settings */ autoSync?: BusinessCentralAutoSync; + /** Payment sync settings */ + sync?: BusinessCentralSync; + /** Collection of errors coming from BE */ errors?: OnyxCommon.Errors; /** Collection of form field errors */ errorFields?: OnyxCommon.ErrorFields; }, - 'companyID' | 'enableNewCategories' | BusinessCentralCodingOfflineFeedbackKeys | keyof BusinessCentralExport | keyof BusinessCentralAutoSync + 'companyID' | 'enableNewCategories' | 'autoSync' | BusinessCentralCodingOfflineFeedbackKeys | keyof BusinessCentralExport | keyof BusinessCentralSync >; /** Gusto connection data */ @@ -3438,6 +3470,21 @@ type CommuterExclusions = OnyxCommon.OnyxValueWithOfflineFeedback<{ isOfficeWorkArrangement?: boolean; }>; +/** An office location of a workspace */ +type OfficeLocation = OnyxCommon.OnyxValueWithOfflineFeedback<{ + /** Nickname of the office */ + name: string; + + /** Address of the office */ + address: CompanyAddress; + + /** Whether this is the workspace's default office. At most one office is the default, and while none is, the company address is. */ + isDefault?: boolean; + + /** Errors from the last change to the office */ + errors?: OnyxCommon.Errors; +}>; + /** Prohibited expense types */ type ProhibitedExpenses = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Whether the policy prohibits alcohol expenses */ @@ -3765,6 +3812,9 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< preventSelfApproval?: boolean; + /** When enabled, non-reimbursable reports cannot be marked as paid */ + preventPayoutNonReimbursableReports?: boolean; + /** When the monthly scheduled submit should happen */ autoReportingOffset?: AutoReportingOffset; @@ -4010,6 +4060,9 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** Commuter exclusion configuration applied to distance expenses on this workspace */ commuterExclusions?: CommuterExclusions; + /** Offices of the workspace, keyed by officeID */ + officeLocations?: Record; + /** Indicates if the Policy is in loading state */ isLoading?: boolean; @@ -4190,6 +4243,7 @@ export type { Subrate, ProhibitedExpenses, CommuterExclusions, + OfficeLocation, NetSuiteConnectionData, MergeApprovalMode, MergeHRConnectionConfig, @@ -4238,6 +4292,8 @@ export type { BusinessCentralCoding, BusinessCentralCustomerMappings, BusinessCentralExport, + BusinessCentralSync, + BusinessCentralBankAccountSettingName, BusinessCentralCodingOfflineFeedbackKeys, }; diff --git a/src/types/onyx/PolicyEmployee.ts b/src/types/onyx/PolicyEmployee.ts index dd43c199a4ad..e87eeb14f194 100644 --- a/src/types/onyx/PolicyEmployee.ts +++ b/src/types/onyx/PolicyEmployee.ts @@ -36,6 +36,12 @@ type PolicyEmployee = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Whether the member is based in an office. When absent, the workspace default applies. */ hasOfficeWorkArrangement?: boolean; + /** + * The office the member is assigned to, keyed in the workspace's officeLocations, or CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID + * for the company address. When absent, or when its office was deleted, the workspace's primary office applies. + */ + officeID?: string; + /** * Errors from api calls on the specific user * {: 'error message', : 'error message 2'} diff --git a/src/types/onyx/PolicyMemberBankAccounts.ts b/src/types/onyx/PolicyMemberBankAccounts.ts new file mode 100644 index 000000000000..1e1c298b96b0 --- /dev/null +++ b/src/types/onyx/PolicyMemberBankAccounts.ts @@ -0,0 +1,15 @@ +import type {BankName} from './Bank'; + +/** Default deposit bank account of each workspace member that has one, indexed by accountID. Only sent to workspace admins. */ +type PolicyMemberBankAccounts = Record< + string, + { + /** Name of the bank, used to pick its icon */ + bankName?: BankName; + + /** Last four digits of the account number */ + bankAccountLastFour?: string; + } +>; + +export default PolicyMemberBankAccounts; diff --git a/src/types/onyx/PolicyVendor.ts b/src/types/onyx/PolicyVendor.ts index 913978785477..65cbfbcd2238 100644 --- a/src/types/onyx/PolicyVendor.ts +++ b/src/types/onyx/PolicyVendor.ts @@ -1,21 +1,29 @@ +import type {ErrorFields, Errors, OnyxValueWithOfflineFeedback} from './OnyxCommon'; import type {PolicyConnectionName} from './Policy'; -/** Record of normalized policy vendors, indexed by externalID. */ -type PolicyVendors = Record< - string, - { - /** The vendor identifier scoped to its active accounting connection. */ - externalID: string; +/** Model of policy vendor */ +type PolicyVendor = OnyxValueWithOfflineFeedback<{ + /** The vendor identifier scoped to its active accounting connection. */ + externalID: string; + + /** The accounting-system display name. */ + name: string; + + /** Whether the policy preference permits this vendor to be selected. */ + enabled: boolean; - /** The accounting-system display name. */ - name: string; + /** The active accounting connection that supplied this vendor. */ + origin?: PolicyConnectionName; - /** Whether the policy preference permits this vendor to be selected. */ - enabled: boolean; + /** A list of errors keyed by microtime */ + errors?: Errors | null; - /** The active accounting connection that supplied this vendor. */ - origin?: PolicyConnectionName; - } ->; + /** Error objects keyed by field name containing errors keyed by microtime */ + errorFields?: ErrorFields | null; +}>; + +/** Record of normalized policy vendors, indexed by externalID. */ +type PolicyVendors = Record; export default PolicyVendors; +export type {PolicyVendor}; diff --git a/src/types/onyx/PurchaseList.ts b/src/types/onyx/PurchaseList.ts index f2b2366f69c3..ef680cba4ab3 100644 --- a/src/types/onyx/PurchaseList.ts +++ b/src/types/onyx/PurchaseList.ts @@ -118,6 +118,12 @@ type Message = { yearlySubscriptionUserCountCost?: number; }; +/** Receipt resolved by the detail API for a purchase */ +type PurchaseReceipt = { + receiptID: number; + source: string | null; +}; + /** Purchase type */ type Purchase = { amount: number; @@ -131,6 +137,18 @@ type Purchase = { message: Message; purchaseID: number; + + /** Resolved receipt/invoice, populated by OpenPaymentDetailsPage */ + receipt?: PurchaseReceipt; + + /** Active-user account IDs, populated by OpenPaymentDetailsPage */ + activeUsers?: number[]; + + /** Account that was billed, populated by OpenPaymentDetailsPage */ + billedToAccountID?: number; + + /** Whether detail data has been loaded from the server */ + isLoaded?: boolean; }; /** Array of purchases */ diff --git a/src/types/onyx/ReimbursementAccount.ts b/src/types/onyx/ReimbursementAccount.ts index 75a0ce61f4a6..ba6296953748 100644 --- a/src/types/onyx/ReimbursementAccount.ts +++ b/src/types/onyx/ReimbursementAccount.ts @@ -4,10 +4,11 @@ import type {ACHContractStepProps, BeneficialOwnersStepProps, CompanyStepProps, import type INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import type {FileObject} from '@src/types/utils/Attachment'; -import type {ValueOf} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; import type {BankName} from './Bank'; import type * as OnyxCommon from './OnyxCommon'; +import type {WiseKYCRequirement} from './WiseKYCReview'; /** Steps to setup a reimbursement bank account */ type BankAccountStep = ValueOf; @@ -216,6 +217,12 @@ type ACHData = Partial; + + /** The business's Wise onboarding, the Wise counterpart of `corpay` above. The admin doing the setup sees the whole object here */ + wise?: WiseBusinessOnboarding; + /** Date the corpay bank account was created */ created?: string; @@ -255,6 +262,55 @@ type ACHData = Partial; + + /** The bank account's currency */ + currency?: string; + + /** Last saved step */ + step?: string; + + /** Business page answers */ + business?: { + /** The legal form, prefilled from the account's incorporation type where it maps cleanly */ + companyType: ValueOf; + + companyRole: ValueOf; + industryCategories: Array>; + description: string; + }; + + /** Representative page answers. The full SSN stays on the server */ + representative?: { + /** Last four digits of the SSN */ + ssnLast4?: string; + + /** The representative has no SSN or ITIN */ + hasNoSSN?: boolean; + }; + + /** KYC requirement answers, by requirement then by Wise's field name */ + requirements?: { + ID_DOCUMENT?: { + issuingCountry: string; + documentType: string; + }; + } & Record>; + + /** Uploaded file keys, by requirement then by field */ + documents?: Record>; + + /** + * What the application still needs, one entry per Wise requirement type. + * Built by the server on every send and never stored. Before Submit it lists what the + * intake pre-collects; after Submit it is Wise's live review list. + */ + kycRequirements?: WiseKYCRequirement[]; +}; + /** The step in an reimbursement account's ach data */ type ReimbursementAccountStep = BankAccountStep | ''; @@ -332,4 +388,4 @@ type ReimbursementAccount = OnyxCommon.OnyxValueWithOfflineFeedback<{ }>; export default ReimbursementAccount; -export type {Corpay, BankAccountStep, BankAccountSubStep, ACHData, ReimbursementAccountStep, ReimbursementAccountSubStep, ACHDataReimbursementAccount}; +export type {Corpay, WiseBusinessOnboarding, BankAccountStep, BankAccountSubStep, ACHData, ReimbursementAccountStep, ReimbursementAccountSubStep, ACHDataReimbursementAccount}; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 18055b381c89..f20475611aed 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -225,6 +225,9 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether Auth still allows the bank reimbursement to be cancelled, returned by OpenReport */ canCancelReimbursement?: boolean; + /** Whether the report can't be paid until the cancelled reimbursement finishes processing */ + isWaitingForCancelledReimbursement?: boolean; + isCancelledIOU?: boolean; hasReportBeenRetracted?: boolean; hasReportBeenReopened?: boolean; diff --git a/src/types/onyx/ReportLoadingState.ts b/src/types/onyx/ReportLoadingState.ts index dea1e5985bc9..bf033bb8ca71 100644 --- a/src/types/onyx/ReportLoadingState.ts +++ b/src/types/onyx/ReportLoadingState.ts @@ -18,6 +18,9 @@ type ReportLoadingState = { /** Flag to check if the initial report actions fetch is in flight */ isLoadingInitialReportActions?: boolean; + /** Whether a support-ticket creation request is in flight in this session */ + isCreatingSupportTicket?: boolean; + /** Whether participants private notes are being currently loaded */ isLoadingPrivateNotes?: boolean; diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index 58f469b68d82..289a2b48e40b 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -126,6 +126,15 @@ type Response = { /** Cache key returned from GetExpensifyCardStatementPDF */ statementKey?: string; + + /** Whether the ParseExpenseFilters command successfully parsed the natural-language query */ + success?: boolean; + + /** Search URL returned by ParseExpenseFilters on success */ + searchURL?: string; + + /** Human-readable summary of the parsed filters returned by ParseExpenseFilters on success */ + humanReadableSummary?: string; }; export default Response; diff --git a/src/types/onyx/Rule.ts b/src/types/onyx/Rule.ts index 861e1831d070..ba3e4aa7c2b8 100644 --- a/src/types/onyx/Rule.ts +++ b/src/types/onyx/Rule.ts @@ -38,3 +38,4 @@ type Rule = OnyxValueWithOfflineFeedback< >; export default Rule; +export type {RuleBody}; diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index c01d07fba869..f92f71490bc6 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -90,6 +90,9 @@ type SearchResultsInfo = { * Distinct from `count`, which is the number of expenses; used to label "Select all matching" on the Reports tab. */ reportCount?: number; + /** Expense reports a to-do search's responses held since its count. Those this device listed and no longer does come off `reportCount`. */ + countedReportIDs?: Record; + /** The total spend */ total?: number; diff --git a/src/types/onyx/TableColumnWidths.ts b/src/types/onyx/TableColumnWidths.ts index 20a8be4812df..7f5f3d92081a 100644 --- a/src/types/onyx/TableColumnWidths.ts +++ b/src/types/onyx/TableColumnWidths.ts @@ -1,8 +1,17 @@ -/** User-dragged widths by column key. Absent columns are sized from content, so only touched columns get stored. */ -type ColumnWidthOverrides = Record; +/** One table's layout, saved when a drag ends. Painted as saved, and only columns the user never dragged absorb room changes. */ +type StoredTableColumnWidths = { + /** Table width minus selection column, gaps, margin and padding, when saved. */ + availableColumnsWidth: number; -/** Dragged table column widths, keyed by `columnResizingID`. */ -type TableColumnWidths = Record; + /** Painted width of every column rendered when saved. */ + columnWidths: Record; + + /** Columns the user dragged. The rest absorb room changes. */ + resizedColumnKeys: string[]; +}; + +/** Saved table layouts, keyed by `columnResizingID`. */ +type TableColumnWidths = Record; export default TableColumnWidths; -export type {ColumnWidthOverrides}; +export type {StoredTableColumnWidths}; diff --git a/src/types/onyx/TransactionViolation.ts b/src/types/onyx/TransactionViolation.ts index b116547d050b..cf22f84078f5 100644 --- a/src/types/onyx/TransactionViolation.ts +++ b/src/types/onyx/TransactionViolation.ts @@ -117,9 +117,6 @@ type TransactionViolationData = { /** List of fields that failed to scan (e.g., ["merchant", "date", "amount"]) */ missingFields?: string[]; - /** True when the violation should render Xero "Supplier" wording instead of the default "Vendor" copy */ - isSupplierViolation?: boolean; - /** Start date of the rate's valid range (for customUnitRateOutOfDateRange) */ startDate?: string; 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/src/types/onyx/WiseKYCReview.ts b/src/types/onyx/WiseKYCReview.ts new file mode 100644 index 000000000000..4dd3a6924429 --- /dev/null +++ b/src/types/onyx/WiseKYCReview.ts @@ -0,0 +1,38 @@ +import type CONST from '@src/CONST'; + +import type {ValueOf} from 'type-fest'; + +import type {Errors} from './OnyxCommon'; + +/** One KYC requirement the Wise onboarding still needs, on `achData.wise.kycRequirements` */ +type WiseKYCRequirement = { + /** Wise's requirement type */ + key: string; + + /** Wise's state for the requirement */ + state: ValueOf; + + /** True when only Wise's embedded page can collect it */ + isHostedOnly: boolean; + + /** Whether we asked for it before Submit or Wise asked after */ + requestedBy: ValueOf; + + /** Wise's attributes for the requirement */ + attributes?: Record; +}; + +/** A link to Wise's embedded KYC page. It carries a token, so the key is RAM only */ +type WiseKYCReviewEmbeddedLink = { + url?: string; + + /** When the link expires */ + expiresAt?: string; + + /** Whether the link is being fetched */ + isLoading?: boolean; + + errors?: Errors; +}; + +export type {WiseKYCRequirement, WiseKYCReviewEmbeddedLink}; diff --git a/src/types/onyx/WorkspaceMembersSelectedForBulkEdit.ts b/src/types/onyx/WorkspaceMembersSelectedForBulkEdit.ts new file mode 100644 index 000000000000..5675f987fbfa --- /dev/null +++ b/src/types/onyx/WorkspaceMembersSelectedForBulkEdit.ts @@ -0,0 +1,10 @@ +/** Members picked on a workspace member list for a bulk office location or work arrangement change */ +type WorkspaceMembersSelectedForBulkEdit = { + /** The workspace whose member list the members were picked from */ + policyID: string; + + /** The account IDs of the picked members */ + accountIDs: number[]; +}; + +export default WorkspaceMembersSelectedForBulkEdit; diff --git a/src/types/onyx/WorkspaceMembersSelectedForRoleChange.ts b/src/types/onyx/WorkspaceMembersSelectedForRoleChange.ts new file mode 100644 index 000000000000..b46024cfb427 --- /dev/null +++ b/src/types/onyx/WorkspaceMembersSelectedForRoleChange.ts @@ -0,0 +1,15 @@ +/** + * The workspace members picked for a bulk role change, handed to the role screen that applies it. + * + * RAM-only, so the role screen has nothing to act on when it is reached by a reload or a deep link rather than from + * the members table. + */ +type WorkspaceMembersSelectedForRoleChange = { + /** The workspace the members were picked on, so a stash left behind cannot be applied to another one */ + policyID: string; + + /** Logins of the picked members. Empty once the change has been saved, which is what clears the rows behind it. */ + logins: string[]; +}; + +export default WorkspaceMembersSelectedForRoleChange; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 373c346a3087..df5d873d115c 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -4,9 +4,10 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type Account from './Account'; import type AccountData from './AccountData'; +import type Agent from './Agent'; import type AgentNewAvatarDraft from './AgentNewAvatarDraft'; -import type AgentPrompt from './AgentPrompt'; import type AppReview from './AppReview'; +import type {AppReviewLastPrompt} from './AppReview'; import type {ApprovalWorkflowOnyx} from './ApprovalWorkflow'; import type {AssignCard, AssignCardData} from './AssignCard'; import type Attachment from './Attachment'; @@ -91,9 +92,14 @@ import type { DynamicFormFieldOption, DynamicFormFieldType, DynamicFormHeading, + DynamicFormListField, + DynamicFormListItem, DynamicFormMultiChoiceField, DynamicFormNumberField, + DynamicFormPage, + DynamicFormSchema, DynamicFormSchemaField, + DynamicFormSensitiveAnswers, DynamicFormTextField, } from './DynamicFormField'; import type EarlyRenewalOfferEligibility from './EarlyRenewalOfferEligibility'; @@ -162,10 +168,12 @@ import type PolicyDataLoadingState from './PolicyDataLoadingState'; import type {PolicyEmployeeList} from './PolicyEmployee'; import type PolicyEmployee from './PolicyEmployee'; import type PolicyJoinMember from './PolicyJoinMember'; +import type PolicyMemberBankAccounts from './PolicyMemberBankAccounts'; import type PolicyOwnershipChangeChecks from './PolicyOwnershipChangeChecks'; import type PolicyRoomsMetadata from './PolicyRoomsMetadata'; import type {ParticipantsPolicyTags, PolicyTag, PolicyTagLists, PolicyTags, SearchTagFilterItem} from './PolicyTag'; import type PolicyVendors from './PolicyVendor'; +import type {PolicyVendor} from './PolicyVendor'; import type PrivatePersonalDetails from './PrivatePersonalDetails'; import type PrivatePromoDiscount from './PrivatePromoDiscount'; import type PrivateSubscription from './PrivateSubscription'; @@ -247,6 +255,9 @@ import type WalletOnfido from './WalletOnfido'; import type WalletStatement from './WalletStatement'; import type WalletTerms from './WalletTerms'; import type WalletTransfer from './WalletTransfer'; +import type {WiseKYCReviewEmbeddedLink} from './WiseKYCReview'; +import type WorkspaceMembersSelectedForBulkEdit from './WorkspaceMembersSelectedForBulkEdit'; +import type WorkspaceMembersSelectedForRoleChange from './WorkspaceMembersSelectedForRoleChange'; export type { FileObject, @@ -348,9 +359,11 @@ export type { PolicyCategories, PolicyCategory, PolicyDataLoadingState, + PolicyVendor, PolicyVendors, PolicyEmployee, PolicyEmployeeList, + PolicyMemberBankAccounts, PolicyConnectionName, PolicyConnectionSyncProgress, PolicyOwnershipChangeChecks, @@ -407,6 +420,7 @@ export type { TransactionViolations, TravelSettings, WorkspaceTravelSettings, + WorkspaceMembersSelectedForBulkEdit, UserLocation, UserMetadata, UserWallet, @@ -419,6 +433,7 @@ export type { WalletStatement, WalletTerms, WalletTransfer, + WorkspaceMembersSelectedForRoleChange, SupportalPermissionDenied, PurchaseList, Purchases, @@ -466,6 +481,7 @@ export type { VerifyValidateCodeAction, ShareTempFile, CorpayFields, + WiseKYCReviewEmbeddedLink, CorpayFormField, DynamicFormAddressField, DynamicFormChoiceField, @@ -475,9 +491,14 @@ export type { DynamicFormFieldOption, DynamicFormFieldType, DynamicFormHeading, + DynamicFormListField, + DynamicFormListItem, DynamicFormMultiChoiceField, DynamicFormNumberField, + DynamicFormPage, + DynamicFormSchema, DynamicFormSchemaField, + DynamicFormSensitiveAnswers, DynamicFormTextField, JoinablePolicies, DismissedProductTraining, @@ -507,6 +528,7 @@ export type { ExportTemplate, HybridApp, AppReview, + AppReviewLastPrompt, SamlMetadata, DomainErrors, DomainHighlightItems, @@ -520,7 +542,7 @@ export type { LocallyProcessed3DSChallengeReviews, InitiatingBankAccountUnlock, AgentNewAvatarDraft, - AgentPrompt, + Agent, SuggestedAgentRule, SuggestedAgent, }; diff --git a/src/types/utils/whitelistedReportKeys.ts b/src/types/utils/whitelistedReportKeys.ts index 1a4c786da824..95758837816c 100644 --- a/src/types/utils/whitelistedReportKeys.ts +++ b/src/types/utils/whitelistedReportKeys.ts @@ -64,6 +64,7 @@ type WhitelistedReport = OnyxCommon.OnyxValueWithOfflineFeedback< errors: unknown; isWaitingOnBankAccount: unknown; canCancelReimbursement: unknown; + isWaitingForCancelledReimbursement: unknown; isCancelledIOU: unknown; hasReportBeenRetracted: unknown; hasReportBeenReopened: unknown; diff --git a/tests/actions/AttachmentTest.ts b/tests/actions/AttachmentTest.ts index e7ee663ea8f4..5fcb057ba780 100644 --- a/tests/actions/AttachmentTest.ts +++ b/tests/actions/AttachmentTest.ts @@ -323,7 +323,18 @@ describe('AttachmentStorage', () => { } // Delete attachment - deleteReportComment({reportID}, attachmentAction, undefined, undefined, [], false, false, 'test@user.com', false); + deleteReportComment({ + report: {reportID}, + reportAction: attachmentAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: [], + isReportArchived: false, + isOriginalReportArchived: false, + currentEmail: 'test@user.com', + isOffline: false, + currentUserAccountID: 1, + }); await waitForBatchedUpdates(); // Then the attachment should be removed @@ -386,7 +397,18 @@ describe('AttachmentStorage', () => { } // Delete attachment - deleteReportComment({reportID}, attachmentAction, undefined, undefined, [], false, false, 'test@user.com', false); + deleteReportComment({ + report: {reportID}, + reportAction: attachmentAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: [], + isReportArchived: false, + isOriginalReportArchived: false, + currentEmail: 'test@user.com', + isOffline: false, + currentUserAccountID: 1, + }); await waitForBatchedUpdates(); const removedAttachment = attachments?.[`${ONYXKEYS.COLLECTION.ATTACHMENT}${attachmentID}`]; diff --git a/tests/actions/BankAccountsTest.ts b/tests/actions/BankAccountsTest.ts index f8b9d5a43dc8..37cb3529d727 100644 --- a/tests/actions/BankAccountsTest.ts +++ b/tests/actions/BankAccountsTest.ts @@ -1,4 +1,11 @@ -import {clearPersonalBankAccount, connectBankAccountWithPlaid, initiateBankAccountUnlock, openPersonalBankAccountSetupView, updateBankAccountName} from '@libs/actions/BankAccounts'; +import { + clearPersonalBankAccount, + connectBankAccountWithPlaid, + enableGlobalReimbursementsForUSDBankAccount, + initiateBankAccountUnlock, + openPersonalBankAccountSetupView, + updateBankAccountName, +} from '@libs/actions/BankAccounts'; import {WRITE_COMMANDS} from '@libs/API/types'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -328,6 +335,42 @@ describe('actions/BankAccounts', () => { }); }); + describe('enableGlobalReimbursementsForUSDBankAccount', () => { + test('clears the form draft after success so the next bank account does not reuse its values', async () => { + // Given a draft filled in while enabling Global Reimbursement for the first bank account + await Onyx.set(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT, { + businessRegistrationIncorporationNumber: '123456789', + natureOfBusiness: 'retail', + }); + + // When the request succeeds + enableGlobalReimbursementsForUSDBankAccount({inputs: '{}', bankAccountID: 1234}); + await waitForBatchedUpdates(); + + // Then the draft is cleared and the form reports success + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ENABLE_GLOBAL_REIMBURSEMENTS_FOR_USD_BANK_ACCOUNT, 1); + expect(await getOnyxValue(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT)).toBeUndefined(); + const form = await getOnyxValue(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS); + expect(form?.isSuccess).toBe(true); + }); + + test('keeps the form draft when the request fails so the user can retry', async () => { + // Given a draft filled in for a bank account + await Onyx.set(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT, { + businessRegistrationIncorporationNumber: '123456789', + }); + mockFetch.fail?.(); + + // When the request fails + enableGlobalReimbursementsForUSDBankAccount({inputs: '{}', bankAccountID: 1234}); + await waitForBatchedUpdates(); + + // Then the draft is kept + const draft = await getOnyxValue(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT); + expect(draft?.businessRegistrationIncorporationNumber).toBe('123456789'); + }); + }); + describe('initiateBankAccountUnlock', () => { test('clears both pendingAction and isOptimisticAction on the Concierge unlock message after success', async () => { // Given an optimistic Concierge unlock message, as written when the user presses a locked bank account diff --git a/tests/actions/CopyPolicySettingsTest.ts b/tests/actions/CopyPolicySettingsTest.ts index 221bfdf6f57a..1cdc44a08fcd 100644 --- a/tests/actions/CopyPolicySettingsTest.ts +++ b/tests/actions/CopyPolicySettingsTest.ts @@ -65,6 +65,7 @@ function makeSourcePolicy(overrides: Partial = {}): Policy { defaultBillable: true, eReceipts: true, preventSelfApproval: true, + preventPayoutNonReimbursableReports: true, ...overrides, }; } @@ -106,6 +107,7 @@ function makeTargetPolicy(overrides: Partial = {}): Policy { defaultBillable: false, eReceipts: false, preventSelfApproval: false, + preventPayoutNonReimbursableReports: false, customUnits: {}, ...overrides, }; @@ -176,6 +178,7 @@ describe('actions/Policy/CopyPolicySettings', () => { 'eReceipts', 'isAttendeeTrackingEnabled', 'preventSelfApproval', + 'preventPayoutNonReimbursableReports', 'shouldShowAutoApprovalOptions', 'shouldShowAutoReimbursementLimitOption', ], diff --git a/tests/actions/DelegateTest.ts b/tests/actions/DelegateTest.ts index 9311bc06435b..ce4480619759 100644 --- a/tests/actions/DelegateTest.ts +++ b/tests/actions/DelegateTest.ts @@ -6,8 +6,12 @@ import { connect, isConnectedAsDelegate, removeDelegate, + removeSelfAsDelegate, + resumeSelfRemovalAsDelegate, updateDelegateRole, } from '@libs/actions/Delegate'; +import * as API from '@libs/API'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import {pause, resetQueue} from '@libs/Network/SequentialQueue'; @@ -298,7 +302,6 @@ describe('actions/Delegate', () => { credentials: {}, session: {authToken: 'restrictedToken', authTokenType: CONST.AUTH_TOKEN_TYPES.DELEGATE}, activePolicyID: undefined, - isFromOldDot: true, }); await waitForBatchedUpdates(); @@ -308,6 +311,115 @@ describe('actions/Delegate', () => { }); }); + // Stubbing the flow's API methods counts as chained API calls for this rule + /* eslint-disable rulesdir/no-multiple-api-calls */ + describe('removeSelfAsDelegate', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it.each([ + [ + 'removes the copilot access once the server restores the original session', + {authToken: 'copilotRefreshedToken', encryptedAuthToken: 'encrypted', requesterID: 2, requesterEmail: 'copilot@test.com'}, + false, + true, + {email: 'copilot@test.com', authToken: 'copilotRefreshedToken'}, + ], + [ + 'removes the copilot access when restoring the original session is interrupted after SESSION was written', + {authToken: 'copilotRefreshedToken', encryptedAuthToken: 'encrypted', requesterID: 2, requesterEmail: 'copilot@test.com'}, + true, + true, + {email: 'copilot@test.com', authToken: 'copilotRefreshedToken'}, + ], + [ + 'removes the copilot access once the original session is restored from the stash', + {authToken: undefined, encryptedAuthToken: undefined, requesterID: undefined, requesterEmail: undefined}, + false, + true, + {email: 'copilot@test.com', authToken: 'copilotAuthToken'}, + ], + [ + 'keeps the copilot access and shows an error when the original session could not be restored', + new Error('Network error'), + false, + false, + {email: 'delegator@test.com', authToken: 'restrictedToken'}, + ], + ])('%s', async (_description, disconnectResponse, shouldOpenAppFail, shouldRemoveDelegator, expectedSession) => { + // Given a copilot acting as the delegator, with their own session and login stashed + const stashedSession = {authToken: 'copilotAuthToken', email: 'copilot@test.com', accountID: 2}; + const stashedCredentials = {autoGeneratedLogin: 'copilotLogin', autoGeneratedPassword: 'copilotPassword'}; + await Onyx.set(ONYXKEYS.SESSION, {authToken: 'restrictedToken', email: 'delegator@test.com', accountID: 1, authTokenType: CONST.AUTH_TOKEN_TYPES.DELEGATE}); + let pendingRemovalAtDisconnect: string | undefined; + jest.spyOn(API, 'makeRequestWithSideEffects').mockImplementation(async () => { + pendingRemovalAtDisconnect = await getOnyxValue(ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL); + if (disconnectResponse instanceof Error) { + throw disconnectResponse; + } + return {jsonCode: CONST.JSON_CODE.SUCCESS, ...disconnectResponse}; + }); + jest.spyOn(API, 'writeWithNoDuplicatesOpenAppConflictAction').mockImplementation(() => (shouldOpenAppFail ? Promise.reject(new Error('OpenApp failed')) : Promise.resolve())); + const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); + const readSpy = jest.spyOn(API, 'read').mockImplementation(() => {}); + + // When the copilot removes themselves from the delegator's copilots + await removeSelfAsDelegate({delegatorEmail: 'delegator@test.com', delegateEmail: 'copilot@test.com', stashedCredentials, stashedSession}); + await waitForBatchedUpdates(); + + // Then the removal is saved as pending before the session is ended, so a kill mid-flow can't lose it + expect(pendingRemovalAtDisconnect).toBe('delegator@test.com'); + + // And the access is revoked only once the copilot is back on their own account, so the app never runs on a revoked token + const removeDelegatorCalls = writeSpy.mock.calls.filter(([command]) => command === WRITE_COMMANDS.REMOVE_DELEGATOR); + expect(removeDelegatorCalls).toHaveLength(shouldRemoveDelegator ? 1 : 0); + await expect(getOnyxValue(ONYXKEYS.SESSION)).resolves.toEqual(expect.objectContaining(expectedSession)); + if (shouldRemoveDelegator) { + expect(writeSpy).toHaveBeenCalledWith( + WRITE_COMMANDS.REMOVE_DELEGATOR, + {delegatorEmail: 'delegator@test.com'}, + expect.objectContaining({optimisticData: expect.arrayContaining([{onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL, value: null}])}), + ); + expect(readSpy).toHaveBeenCalledWith(READ_COMMANDS.OPEN_SECURITY_SETTINGS_PAGE, null); + return; + } + + // And a failed restore leaves the copilot on the delegator's account with an error on their own row and nothing pending + const account = await getOnyxValue(ONYXKEYS.ACCOUNT); + expect(account?.delegatedAccess?.errorFields?.removeDelegate?.['copilot@test.com']).toBeDefined(); + await expect(getOnyxValue(ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL)).resolves.toBeUndefined(); + }); + }); + + describe('resumeSelfRemovalAsDelegate', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it.each([ + ['queues the removal once the copilot is back on their own account', false, 1], + ['cancels the removal when the copilot is still acting as the delegator, as the restore never happened', true, 0], + ])('%s', async (_description, isDelegateSession, expectedRemoveDelegatorCalls) => { + // Given a self-removal that a kill interrupted after it was saved as pending + await Onyx.set(ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL, 'delegator@test.com'); + const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); + jest.spyOn(API, 'read').mockImplementation(() => {}); + + // When the app starts again + resumeSelfRemovalAsDelegate({delegatorEmail: 'delegator@test.com', isDelegateSession}); + await waitForBatchedUpdates(); + + // Then the removal is only completed when the copilot's own session was restored + const removeDelegatorCalls = writeSpy.mock.calls.filter(([command]) => command === WRITE_COMMANDS.REMOVE_DELEGATOR); + expect(removeDelegatorCalls).toHaveLength(expectedRemoveDelegatorCalls); + if (isDelegateSession) { + await expect(getOnyxValue(ONYXKEYS.PENDING_SELF_COPILOT_REMOVAL)).resolves.toBeUndefined(); + } + }); + }); + /* eslint-enable rulesdir/no-multiple-api-calls */ + describe('clearOnyxForDelegateTransition', () => { it('keeps IS_LOADING_APP=true after the clear so DelegateAccessHandler does not see HAS_LOADED_APP=true && IS_LOADING_APP=undefined and fire a duplicate openApp', async () => { // Simulate the pre-switch state: app is fully loaded with the previous account. diff --git a/tests/actions/DomainTest.ts b/tests/actions/DomainTest.ts index fdb1d6937351..0c290a7a4419 100644 --- a/tests/actions/DomainTest.ts +++ b/tests/actions/DomainTest.ts @@ -18,6 +18,7 @@ import { declineDomainAdminshipRequest, deleteDomainSecurityGroup, deleteDomainVacationDelegate, + getScimToken, requestDomainAdminship, resetCreateDomainForm, resetDomain, @@ -29,7 +30,9 @@ import { setTwoFactorAuthExemptEmailForDomain, updateDomainSecurityGroup, } from '@libs/actions/Domain'; -import {WRITE_COMMANDS} from '@libs/API/types'; +import {ScimTokenState} from '@libs/actions/ScimToken/ScimTokenUtils'; +import type {makeRequestWithSideEffects, write} from '@libs/API'; +import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {generateAccountID} from '@libs/UserUtils'; import CONST from '@src/CONST'; @@ -40,10 +43,13 @@ import type {Domain, DomainSecurityGroup, UserSecurityGroupData} from '@src/type import type {SecurityGroupKey} from '@src/types/onyx/Domain'; import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; +import type {OnyxSetInput} from 'react-native-onyx'; + import {Num} from 'expensify-common'; import Onyx from 'react-native-onyx'; import createMock from '../utils/createMock'; +import getOnyxValue from '../utils/getOnyxValue'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -220,6 +226,31 @@ describe('actions/Domain', () => { }); }); + it('applies and clears added domain membership on success through real Onyx', async () => { + // Given an existing security group with an unrelated member, as stored in the domain collection. + const domainKey = `${ONYXKEYS.COLLECTION.DOMAIN}123` as const; + const groupKey: SecurityGroupKey = `${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}10`; + const domain: NonNullable> = {}; + domain[groupKey] = {shared: {[String(99)]: 'read'}}; + await Onyx.set(domainKey, domain); + const request = jest.spyOn(jest.requireActual<{write: typeof write}>('@libs/API'), 'write').mockResolvedValue(undefined); + // When the actual action creates membership for its generated account ID. + addMemberToDomain(123, 'new@example.com', '10'); + const data = request.mock.calls.at(0)?.[2]; + expect(data).toBeDefined(); + await Onyx.update(data?.optimisticData ?? []); + const accountID = generateAccountID('new@example.com'); + // Then real nested merges retain the unrelated member and success removes only the optimistic membership. + const optimisticDomain = await getOnyxValue(domainKey); + expect(optimisticDomain?.[groupKey]?.shared?.[String(accountID)]).toBe('read'); + expect(optimisticDomain?.[groupKey]?.shared?.['99']).toBe('read'); + await Onyx.update(data?.successData ?? []); + const settledDomain = await getOnyxValue(domainKey); + expect(settledDomain?.[groupKey]?.shared).not.toHaveProperty(String(accountID)); + expect(settledDomain?.[groupKey]?.shared?.['99']).toBe('read'); + request.mockRestore(); + }); + it('addMemberToDomain', () => { const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); const domainAccountID = 123; @@ -761,7 +792,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 +806,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 +865,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 +924,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}`, @@ -1254,6 +1327,69 @@ describe('actions/Domain', () => { }); }); + describe('getScimToken', () => { + test.each(['token-value', ''])('keeps successful string token %p', async (token) => { + // Given a successful network response, including the valid empty string. + const response = {jsonCode: CONST.JSON_CODE.SUCCESS, SCIMToken: token}; + const request = jest + .spyOn(jest.requireActual<{makeRequestWithSideEffects: typeof makeRequestWithSideEffects}>('@libs/API'), 'makeRequestWithSideEffects') + .mockResolvedValue(response); + // When the production action consumes the response. + const result = await getScimToken('example.com'); + // Then token display retains the exact string and the original request. + expect(result).toEqual({state: ScimTokenState.VALUE, value: token}); + expect(request).toHaveBeenCalledWith(SIDE_EFFECT_REQUEST_COMMANDS.GET_SCIM_TOKEN, {domain: 'example.com'}); + request.mockRestore(); + }); + + test.each([ + {description: 'missing', response: {jsonCode: CONST.JSON_CODE.SUCCESS}}, + {description: 'undefined', response: {jsonCode: CONST.JSON_CODE.SUCCESS, SCIMToken: undefined}}, + {description: 'null', response: {jsonCode: CONST.JSON_CODE.SUCCESS, SCIMToken: null}}, + {description: 'number', response: {jsonCode: CONST.JSON_CODE.SUCCESS, SCIMToken: 123}}, + {description: 'object', response: {jsonCode: CONST.JSON_CODE.SUCCESS, SCIMToken: {token: 'value'}}}, + ])('returns the existing error for a successful $description token', async ({response}) => { + // Given malformed token data at the network boundary, without changing the typed action call. + const request = jest + .spyOn(jest.requireActual<{makeRequestWithSideEffects: typeof makeRequestWithSideEffects}>('@libs/API'), 'makeRequestWithSideEffects') + .mockResolvedValue(response); + // When the production action handles an otherwise successful response. + const result = await getScimToken('example.com'); + // Then the approved correction selects the existing error/retry state. + expect(result).toEqual({state: ScimTokenState.ERROR, error: "Couldn't fetch SCIM token"}); + request.mockRestore(); + }); + + test.each([ + {response: {jsonCode: 400, message: 'Server error', SCIMToken: 123}, error: 'Server error'}, + {response: {jsonCode: 400, message: ''}, error: "Couldn't fetch SCIM token"}, + {response: {jsonCode: 400}, error: "Couldn't fetch SCIM token"}, + {response: undefined, error: "Couldn't fetch SCIM token"}, + ])('preserves non-success response $response', async ({response, error}) => { + // Given an unsuccessful or absent response, whose message must take precedence over token validation. + const request = jest + .spyOn(jest.requireActual<{makeRequestWithSideEffects: typeof makeRequestWithSideEffects}>('@libs/API'), 'makeRequestWithSideEffects') + .mockResolvedValue(response); + // When the action follows its existing non-success branch. + const result = await getScimToken('example.com'); + // Then message selection and the generic fallback stay unchanged. + expect(result).toEqual({state: ScimTokenState.ERROR, error}); + request.mockRestore(); + }); + + it('preserves the generic error when the request rejects', async () => { + // Given a rejected network request rather than a response object. + const request = jest + .spyOn(jest.requireActual<{makeRequestWithSideEffects: typeof makeRequestWithSideEffects}>('@libs/API'), 'makeRequestWithSideEffects') + .mockRejectedValue(new Error('Network failure')); + // When the production catch branch runs. + const result = await getScimToken('example.com'); + // Then the existing retry state remains available. + expect(result).toEqual({state: ScimTokenState.ERROR, error: "Couldn't fetch SCIM token"}); + request.mockRestore(); + }); + }); + describe('changeDomainSecurityGroup', () => { const DOMAIN_ACCOUNT_ID = 1001; const DOMAIN_NAME = 'example.com'; @@ -1353,6 +1489,58 @@ describe('actions/Domain', () => { apiWriteSpy.mockRestore(); }); + const membershipCases: Array<{description: string; shared: DomainSecurityGroup['shared']; collision: boolean}> = [ + {description: 'read membership', shared: {[String(ACCOUNT_ID)]: 'read'}, collision: false}, + {description: 'omitted membership', shared: {}, collision: false}, + // @ts-expect-error This stored undefined membership is malformed at this one field. Omitted valid membership is a separate case. + {description: 'undefined membership', shared: {[String(ACCOUNT_ID)]: undefined}, collision: false}, + {description: 'colliding keys', shared: {[String(ACCOUNT_ID)]: 'read'}, collision: true}, + ]; + test.each(membershipCases)('applies and rolls back real Onyx merges for $description', async ({shared, collision}) => { + // Given a domain snapshot with an unrelated member that must survive nested deletion. + const domainKey = `${ONYXKEYS.COLLECTION.DOMAIN}${DOMAIN_ACCOUNT_ID}` as const; + const targetKey = collision ? CURRENT_SECURITY_GROUP_KEY : TARGET_SECURITY_GROUP_KEY; + const currentGroup: Partial = {shared}; + const seededDomain: NonNullable> = {}; + seededDomain[CURRENT_SECURITY_GROUP_KEY] = {shared: {...shared, [String(99)]: 'read'}}; + if (!collision) { + seededDomain[TARGET_SECURITY_GROUP_KEY] = {shared: {[String(98)]: 'read'}}; + } + await Onyx.set(domainKey, seededDomain); + const request = jest.spyOn(jest.requireActual<{write: typeof write}>('@libs/API'), 'write').mockResolvedValue(undefined); + // When the actual action produces its ordered optimistic and failure updates. + changeDomainSecurityGroup(DOMAIN_ACCOUNT_ID, DOMAIN_NAME, EMPLOYEE_EMAIL, ACCOUNT_ID, CURRENT_SECURITY_GROUP_KEY, currentGroup, targetKey); + const data = request.mock.calls.at(0)?.[2]; + expect(data).toBeDefined(); + const [, , inspectedData] = TestHelper.getRequiredWriteCall(request.mock.calls, 0); + const rollback = TestHelper.getRequiredOnyxUpdate(inspectedData, 'failureData', domainKey, Onyx.METHOD.MERGE); + expect(rollback.value).toEqual( + collision + ? {[targetKey]: {shared: {[ACCOUNT_ID]: null}}} + : { + [CURRENT_SECURITY_GROUP_KEY]: {shared: {[ACCOUNT_ID]: shared[String(ACCOUNT_ID)]}}, + [targetKey]: {shared: {[ACCOUNT_ID]: null}}, + }, + ); + await Onyx.update(data?.optimisticData ?? []); + const optimisticDomain = await getOnyxValue(domainKey); + expect(optimisticDomain?.[targetKey]?.shared?.[String(ACCOUNT_ID)]).toBe('read'); + expect(optimisticDomain?.[CURRENT_SECURITY_GROUP_KEY]?.shared?.['99']).toBe('read'); + if (!collision) { + expect(optimisticDomain?.[CURRENT_SECURITY_GROUP_KEY]?.shared).not.toHaveProperty(String(ACCOUNT_ID)); + } + await Onyx.update(data?.failureData ?? []); + // Then explicit undefined remains in the payload, null deletes the target, and the last assignment wins collisions. + const rollbackDomain = await getOnyxValue(domainKey); + expect(rollbackDomain?.[targetKey]?.shared).not.toHaveProperty(String(ACCOUNT_ID)); + expect(rollbackDomain?.[CURRENT_SECURITY_GROUP_KEY]?.shared?.['99']).toBe('read'); + if (!collision) { + expect(rollbackDomain?.[CURRENT_SECURITY_GROUP_KEY]?.shared?.[String(ACCOUNT_ID)]).toBe(shared[String(ACCOUNT_ID)]); + expect(rollbackDomain?.[targetKey]?.shared?.['98']).toBe('read'); + } + request.mockRestore(); + }); + it('extracts newID correctly from targetSecurityGroupKey', () => { const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => {}); const customTargetKey: SecurityGroupKey = `${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}999`; diff --git a/tests/actions/ExportExpensifyCardListToCSVTest.ts b/tests/actions/ExportExpensifyCardListToCSVTest.ts new file mode 100644 index 000000000000..09932bfd34ba --- /dev/null +++ b/tests/actions/ExportExpensifyCardListToCSVTest.ts @@ -0,0 +1,38 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; + +import {exportExpensifyCardListToCSV} from '@libs/actions/Card'; +import localFileDownload from '@libs/localFileDownload'; +import {translate as translatePhrase} from '@libs/Localize'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; + +import {createRandomExpensifyCard} from '../utils/collections/card'; + +jest.mock('@libs/localFileDownload', () => jest.fn()); + +const translate: LocalizedTranslate = (key, ...parameters) => translatePhrase(CONST.LOCALES.EN, key, ...parameters); + +describe('exportExpensifyCardListToCSV', () => { + beforeAll(() => IntlStore.load(CONST.LOCALES.EN)); + + it('escapes cardholder names with the shared formula protection', () => { + // Given a cardholder name that could otherwise become a spreadsheet formula + const accountID = 1; + const card = {...createRandomExpensifyCard(1, {accountID}), lastFourPAN: '1234'}; + + // When the selected card is downloaded as CSV + exportExpensifyCardListToCSV({ + policyID: 'policy123', + cards: [card], + personalDetailsList: {[accountID]: {accountID, login: 'alex@example.com', displayName: '=SUM(1,2)'}}, + settlementCurrency: CONST.CURRENCY.USD, + defaultLimitType: CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY, + translate, + formatPhoneNumber: (login) => login, + }); + + // Then the name stays in one text cell instead of executing as a formula + expect(localFileDownload).toHaveBeenCalledWith('ExpensifyCards_policy123.csv', expect.stringContaining('\r\nalex@example.com,"\'=SUM(1,2)",1234,'), translate); + }); +}); diff --git a/tests/actions/IOU/CreateDraftTransactionTest.ts b/tests/actions/IOU/CreateDraftTransactionTest.ts index 4459c006b259..cff2f1987eca 100644 --- a/tests/actions/IOU/CreateDraftTransactionTest.ts +++ b/tests/actions/IOU/CreateDraftTransactionTest.ts @@ -39,7 +39,7 @@ jest.mock('@src/libs/Navigation/Navigation', () => ({ dismissModalWithReport: jest.fn(), goBack: jest.fn(), getTopmostReportId: jest.fn(() => topMostReportID), - setNavigationActionToMicrotaskQueue: jest.fn(), + setNavigationActionToMicrotaskQueue: jest.fn((callback: () => void) => callback()), removeScreenByKey: jest.fn(), isNavigationReady: jest.fn(() => Promise.resolve()), getReportRouteByID: jest.fn(), @@ -87,6 +87,9 @@ jest.mock('@src/libs/SearchQueryUtils', () => { jest.mock('@libs/PolicyUtils', () => ({ ...jest.requireActual('@libs/PolicyUtils'), + // This factory runs while PolicyUtils is still loading inside an import cycle, so the barrel's `export *` helpers + // aren't on it yet. Spread them from their own modules, which load fully. + ...jest.requireActual>('@libs/PolicyUtils/members'), isPaidGroupPolicy: jest.fn().mockReturnValue(true), isGroupPolicy: jest.fn().mockReturnValue(true), isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), @@ -714,6 +717,44 @@ describe('actions/IOU', () => { expect(getConfirmationRouteBackTo()).toBe(ROUTES.REPORT_WITH_ID.getRoute(selfDMReport.reportID)); }); + it('should defer navigating to the confirmation page until the draft workspace is merged in as the recipient', async () => { + // Given the real navigation deferral rather than the synchronous mock used by the other tests + const {selfDMReport, trackedExpense} = await setUpSelfDMTrackedExpense(); + mockedIsReportTopmostSplitNavigator.mockReturnValue(false); + jest.mocked(Navigation.setNavigationActionToMicrotaskQueue).mockImplementationOnce( + jest.requireActual<{default: typeof Navigation.setNavigationActionToMicrotaskQueue}>('@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue').default, + ); + + // When the expense is submitted to the employer and there is no workspace to submit to + createDraftTransactionAndNavigateToParticipantSelector({ + reportID: selfDMReport.reportID, + reportActions: undefined, + actionName: CONST.IOU.ACTION.SUBMIT, + reportActionID: '1', + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + draftTransactionIDs: [], + activePolicy: undefined, + userBillingGracePeriodEnds: undefined, + amountOwed: 0, + transaction: trackedExpense, + currentUserAccountID: RORY_ACCOUNT_ID, + currentUserEmail: RORY_EMAIL, + currentUserLocalCurrency: '', + submitDestination: CONST.IOU.SUBMIT_DESTINATION.EMPLOYER, + defaultWorkspaceName: "Rory's Workspace", + filteredPoliciesCount: 0, + firstPolicy: undefined, + }); + + // Then navigation does not happen synchronously, because the participants merge has not landed yet and the + // confirmation page would render without a recipient and auto-open the Choose recipient picker + expect(Navigation.navigate).not.toHaveBeenCalled(); + + // And it happens once the merge has been applied + await waitForBatchedUpdates(); + expect(getConfirmationRouteBackTo()).toBe(ROUTES.REPORT_WITH_ID.getRoute(selfDMReport.reportID)); + }); + it('should leave the draft transaction unreported when the destination picker is shown', async () => { // Given a tracked self DM expense and more than one workspace the user can submit to const {selfDMReport, trackedExpense} = await setUpSelfDMTrackedExpense(); diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index b923487366cf..c6d7fd926494 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -764,6 +764,7 @@ describe('MoneyRequest', () => { isVendorMatchingBetaEnabled: false, iouType: CONST.IOU.TYPE.CREATE, report: fakeReport, + moneyRequestReportTransactions: [], policy: fakePolicy, transaction: fakeTransaction, reportID: '1', @@ -812,6 +813,7 @@ describe('MoneyRequest', () => { translate: translateLocal, convertToDisplayString, dateFnsLocale: undefined, + formatPhoneNumber, }), participantsPolicyTags: {}, rules: undefined, @@ -1268,6 +1270,38 @@ describe('MoneyRequest', () => { ); }); + it('should pass backToReport to navigateToConfirmationPage when provided', async () => { + // Given backToReport parameter for distance navigation + const backToReport = 'test-back-to-report'; + + // When distance navigation handles the request + handleMoneyRequestStepDistanceNavigation({ + getCurrencyDecimals: getCurrencyDecimalsLocal, + ...baseParams, + backToReport, + shouldSkipConfirmation: false, + iouType: CONST.IOU.TYPE.SUBMIT, + draftTransactionIDs: [baseParams.transactionID], + delegateAccountID: undefined, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + await waitForBatchedUpdates(); + + // Then it should pass backToReport to navigate to confirmation page + expect(Navigation.navigate).toHaveBeenCalledWith( + ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute( + CONST.IOU.ACTION.CREATE, + CONST.IOU.TYPE.SUBMIT, + baseParams.transactionID, + baseParams.reportID, + backToReport, + undefined, + undefined, + ), + ); + }); + it('should navigate to confirmation page for CREATE flow from global menu', async () => { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); @@ -1742,6 +1776,7 @@ describe('MoneyRequest', () => { translate: translateLocal, convertToDisplayString, dateFnsLocale: undefined, + formatPhoneNumber, }); expect(Array.isArray(participants)).toBe(true); }); @@ -1760,6 +1795,7 @@ describe('MoneyRequest', () => { translate: translateLocal, convertToDisplayString, dateFnsLocale: undefined, + formatPhoneNumber, }); expect(Array.isArray(participants)).toBe(true); }); @@ -1778,6 +1814,7 @@ describe('MoneyRequest', () => { translate: translateLocal, convertToDisplayString, dateFnsLocale: undefined, + formatPhoneNumber, }); // For policy expense chat, participants have accountID 0 and go through getReportOption // which uses conciergeReportID for identifying concierge chat @@ -1799,6 +1836,7 @@ describe('MoneyRequest', () => { translate: translateLocal, convertToDisplayString, dateFnsLocale: undefined, + formatPhoneNumber, }); expect(Array.isArray(participants)).toBe(true); }); @@ -1821,6 +1859,7 @@ describe('MoneyRequest', () => { translate: translateLocal, convertToDisplayString, dateFnsLocale: undefined, + formatPhoneNumber, }); expect(Array.isArray(participants)).toBe(true); }); @@ -1839,6 +1878,7 @@ describe('MoneyRequest', () => { translate: translateLocal, convertToDisplayString, dateFnsLocale: undefined, + formatPhoneNumber, }); expect(Array.isArray(participants)).toBe(true); expect(participants.length).toBeGreaterThan(0); @@ -1859,6 +1899,7 @@ describe('MoneyRequest', () => { translate: translateLocal, convertToDisplayString, dateFnsLocale: undefined, + formatPhoneNumber, }); expect(Array.isArray(participants)).toBe(true); expect(participants.length).toBeGreaterThan(0); @@ -1879,6 +1920,7 @@ describe('MoneyRequest', () => { translate: translateLocal, convertToDisplayString, dateFnsLocale: undefined, + formatPhoneNumber, }); expect(Array.isArray(participants)).toBe(true); expect(participants.length).toBeGreaterThan(0); diff --git a/tests/actions/IOU/PerDiemTest.ts b/tests/actions/IOU/PerDiemTest.ts index 670ca6297765..d317b87a0349 100644 --- a/tests/actions/IOU/PerDiemTest.ts +++ b/tests/actions/IOU/PerDiemTest.ts @@ -85,7 +85,7 @@ describe('PerDiem', () => { beforeEach(async () => { jest.clearAllTimers(); - global.fetch = getGlobalFetchMock(); + globalThis.fetch = getGlobalFetchMock(); await Onyx.clear(); await waitForBatchedUpdates(); allPolicyTags = await getOnyxValue(ONYXKEYS.COLLECTION.POLICY_TAGS); @@ -137,6 +137,88 @@ describe('PerDiem', () => { return transaction; } + test.each([undefined, null])('does not write a draft for an absent transaction %p', async (transaction) => { + // Given an absent transaction and an unrelated orphan key that must remain untouched. + const key = `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}undefined` as const; + await Onyx.set(key, {merchant: 'Stored only'}); + const set = jest.spyOn(Onyx, 'set'); + // When each editor receives the missing transaction, including otherwise reachable NaN indices. + if (transaction === null) { + // @ts-expect-error Null is intentionally malformed at this one runtime transaction argument. + removeSubrate(null, '0'); + // @ts-expect-error Keep the malformed null exception at the transaction argument. + updateSubrate(null, 'not-a-number', 1, 'meal', 'Meal', 500); + // @ts-expect-error Keep the malformed null exception at the transaction argument. + addSubrate(null, '0', 2, 'meal', 'Meal', 500); + } else { + removeSubrate(transaction, '0'); + updateSubrate(transaction, 'not-a-number', 1, 'meal', 'Meal', 500); + addSubrate(transaction, '0', 2, 'meal', 'Meal', 500); + } + await waitForBatchedUpdates(); + // Then no partial draft is written and existing data is preserved. + expect(set).not.toHaveBeenCalled(); + expect(await getOnyxValue(key)).toEqual({merchant: 'Stored only'}); + set.mockRestore(); + }); + + test.each(['-2', 'not-a-number'])('preserves Number and splice behavior for index %s', async (index) => { + // Given two rates and unrelated fields from the existing transaction producer. + const rates = [ + {id: 'first', quantity: 1, name: 'First', rate: 100}, + {id: 'last', quantity: 2, name: 'Last', rate: 200}, + ]; + const transaction = createTransactionWithSubrates(rates); + transaction.merchant = 'Retained merchant'; + const originalComment = transaction.comment; + const originalUnit = transaction.comment?.customUnit; + const set = jest.spyOn(Onyx, 'set'); + // When update uses the existing numeric conversion and copies only edited ancestors. + updateSubrate(transaction, index, 3, 'replacement', 'Replacement', 300); + await waitForBatchedUpdates(); + const payload = set.mock.calls.at(-1)?.[1]; + // Then both indices replace the first rate without changing the source object. + expect(payload).toBeDefined(); + expect(payload).not.toBe(transaction); + expect(payload).toMatchObject({ + merchant: 'Retained merchant', + comment: {customUnit: {subRates: [{id: 'replacement', quantity: 3, name: 'Replacement', rate: 300}, rates.at(1)], quantity: null}}, + }); + expect(transaction.comment).toBe(originalComment); + expect(transaction.comment?.customUnit).toBe(originalUnit); + expect(transaction.comment?.customUnit?.subRates).toBe(rates); + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.comment).not.toBe(originalComment); + expect(draft?.comment?.customUnit).not.toBe(originalUnit); + expect(draft?.comment?.customUnit).not.toHaveProperty('quantity'); + // When removal receives the same index, splice removes the first rate. + removeSubrate(transaction, index); + await waitForBatchedUpdates(); + // Then the unedited rate and unrelated fields survive actual Onyx application. + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`)).toMatchObject({ + merchant: 'Retained merchant', + comment: {customUnit: {subRates: [rates.at(1)]}}, + }); + set.mockRestore(); + }); + + it('creates missing nested fields without retaining a prior draft comment', async () => { + // Given a historical partial transaction with no comment or custom unit. + const transaction = createMock({transactionID: TRANSACTION_ID, merchant: 'Input merchant'}); + const key = `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}` as const; + await Onyx.set(key, {comment: {comment: 'Prior draft only'}}); + // When the action adds the first subrate through partial SET. + addSubrate(transaction, '0', 1, 'meal', 'Meal', 500); + await waitForBatchedUpdates(); + // Then absent input fields remain absent and nested rate data is created. + expect(await getOnyxValue(key)).toEqual({ + transactionID: TRANSACTION_ID, + merchant: 'Input merchant', + comment: {customUnit: {subRates: [{quantity: 1, id: 'meal', name: 'Meal', rate: 500}]}}, + }); + expect(transaction).not.toHaveProperty('comment'); + }); + describe('addSubrate', () => { it('should add a subrate at the correct index', async () => { const transaction = createTransactionWithSubrates([{id: '1', quantity: 1, name: 'Day 1', rate: 10000}]); diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index d7bbd958b9f3..81f26b8ef4f9 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -95,6 +95,9 @@ jest.mock('@libs/PolicyUtils', () => ({ // aren't on it yet. Spread them from their own modules, which load fully. ...jest.requireActual>('@libs/PolicyUtils/policyType'), ...jest.requireActual>('@libs/PolicyUtils/permissions'), + ...jest.requireActual>('@libs/PolicyUtils/workflows'), + ...jest.requireActual>('@libs/PolicyUtils/approvals'), + ...jest.requireActual>('@libs/PolicyUtils/payments'), isPaidGroupPolicy: jest.fn().mockReturnValue(true), isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), })); diff --git a/tests/actions/IOUTest/DeleteMoneyRequestTest.ts b/tests/actions/IOUTest/DeleteMoneyRequestTest.ts index 38a4430fe6f0..53b3eba88273 100644 --- a/tests/actions/IOUTest/DeleteMoneyRequestTest.ts +++ b/tests/actions/IOUTest/DeleteMoneyRequestTest.ts @@ -34,6 +34,7 @@ import createRandomTransaction from '../../utils/collections/transaction'; import getOnyxValue from '../../utils/getOnyxValue'; import PusherHelper from '../../utils/PusherHelper'; import {createGlobalFetchMock, formatPhoneNumber, getCurrencyDecimalsLocal, getCurrencySymbolLocal, getOnyxData, setPersonalDetails, signInWithTestUser} from '../../utils/TestHelper'; +import {isObject} from '../../utils/typeGuards'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; const topMostReportID = '23423423'; @@ -2372,5 +2373,92 @@ describe('actions/IOU/DeleteMoneyRequest', () => { expect(result.optimisticData.some((entry) => entry.key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS))).toBe(false); expect(result.failureData.some((entry) => entry.key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS))).toBe(false); }); + + it("resolves the report preview's last visible action from the acting user's own visibility", async () => { + const CHAT_REPORT_ID = '70001'; + const IOU_REPORT_ID = '70002'; + const WHISPER_TARGET_ACCOUNT_ID = 808; + + const olderIOUAction: ReportAction = { + ...createRandomReportAction(10), + reportActionID: 'deleteOlderAction', + reportID: IOU_REPORT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + created: '2026-10-01 10:00:00.000', + message: [{type: 'COMMENT', html: 'Older comment', text: 'Older comment'}], + originalMessage: {html: 'Older comment'}, + }; + /** Newer than `olderIOUAction`, and only the whispered-to account can see it. */ + const whisper: ReportAction = { + ...createRandomReportAction(11), + reportActionID: 'deleteWhisper', + reportID: IOU_REPORT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + created: '2026-10-01 12:00:00.000', + message: [{type: 'COMMENT', html: 'changed the amount', text: 'changed the amount', whisperedTo: [WHISPER_TARGET_ACCOUNT_ID]}], + originalMessage: {whisperedTo: [WHISPER_TARGET_ACCOUNT_ID]}, + }; + const previewAction: ReportAction = { + ...createRandomReportAction(12), + reportActionID: 'deletePreview', + reportID: CHAT_REPORT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + childVisibleActionCount: 2, + originalMessage: {linkedReportID: IOU_REPORT_ID}, + }; + // The money-request action being deleted. Its reportID is what resolves the IOU report below. + const deletedIOUAction: ReportAction = { + ...createRandomReportAction(13), + reportActionID: 'deletedIOU', + reportID: IOU_REPORT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + childVisibleActionCount: 1, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: '70003', amount: 100, currency: 'USD', IOUReportID: IOU_REPORT_ID}, + }; + + const chatReport: Report = {...createRandomReport(70001, undefined), reportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT}; + const iouReport: Report = { + ...createRandomReport(70002, undefined), + reportID: IOU_REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + chatReportID: CHAT_REPORT_ID, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`, chatReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${IOU_REPORT_ID}`, iouReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CHAT_REPORT_ID}`, {[previewAction.reportActionID]: previewAction}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`, { + [olderIOUAction.reportActionID]: olderIOUAction, + [whisper.reportActionID]: whisper, + [deletedIOUAction.reportActionID]: deletedIOUAction, + }); + await waitForBatchedUpdates(); + + const getPreviewCreated = (currentUserAccountID: number) => { + const result = getCleanUpTransactionThreadReportOnyxData({ + shouldDeleteTransactionThread: false, + reportAction: deletedIOUAction, + iouReport, + chatReport, + currentUserAccountID, + }); + const entry = result.optimisticData.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CHAT_REPORT_ID}`); + const value: unknown = entry && 'value' in entry ? entry.value : undefined; + if (!isObject(value)) { + return undefined; + } + const updatedPreview = value[previewAction.reportActionID]; + if (!isObject(updatedPreview) || typeof updatedPreview.childLastVisibleActionCreated !== 'string') { + return undefined; + } + return updatedPreview.childLastVisibleActionCreated; + }; + + // Given the whisper targets the deleting user, it is the newest action they can see + expect(getPreviewCreated(WHISPER_TARGET_ACCOUNT_ID)).toBe(whisper.created); + + // Given the whisper targets somebody else, it is invisible and the older comment wins + expect(getPreviewCreated(WHISPER_TARGET_ACCOUNT_ID + 1)).toBe(olderIOUAction.created); + }); }); }); diff --git a/tests/actions/IOUTest/HoldTest.ts b/tests/actions/IOUTest/HoldTest.ts index 847e80bc480e..12e67b7863bd 100644 --- a/tests/actions/IOUTest/HoldTest.ts +++ b/tests/actions/IOUTest/HoldTest.ts @@ -1,12 +1,13 @@ import {changeMoneyRequestHoldStatus, getReportFromHoldRequestsOnyxData, putOnHold, putTransactionsOnHold, unholdRequest} from '@libs/actions/IOU/Hold'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import type {write} from '@libs/API'; import {WRITE_COMMANDS} from '@libs/API/types'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; import type * as PolicyUtils from '@libs/PolicyUtils'; -import {getReportActionMessage, getSortedReportActions} from '@libs/ReportActionsUtils'; -import {buildOptimisticIOUReport, buildOptimisticIOUReportAction, buildTransactionThread} from '@libs/ReportUtils'; +import {getOriginalMessage, getReportActionMessage, getSortedReportActions} from '@libs/ReportActionsUtils'; +import {buildOptimisticCreatedReportAction, buildOptimisticIOUReport, buildOptimisticIOUReportAction, buildTransactionThread} from '@libs/ReportUtils'; import {buildOptimisticTransaction} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; @@ -86,7 +87,7 @@ describe('actions/IOU/Hold', () => { beforeEach(() => { jest.clearAllTimers(); mockFetch = createGlobalFetchMock(); - global.fetch = mockFetch; + globalThis.fetch = mockFetch; return Onyx.clear().then(waitForBatchedUpdates); }); @@ -95,6 +96,94 @@ describe('actions/IOU/Hold', () => { }); describe('putOnHold', () => { + test.each([undefined])('preserves an unloaded existing thread %p and its rollback', async (initialReport) => { + // Given an existing thread ID whose report is not loaded in Onyx. + const transaction = buildOptimisticTransaction({transactionParams: {amount: 100, currency: 'USD', reportID: 'parent'}}); + const request = jest.spyOn(jest.requireActual<{write: typeof write}>('@libs/API'), 'write').mockResolvedValue(undefined); + // When the real action puts the transaction on hold without creating another thread. + putOnHold({ + transactionID: transaction.transactionID, + transaction, + comment: 'Review', + initialReportID: 'existing-thread', + initialReport, + transactionReport: undefined, + isOffline: true, + currentUserLogin: RORY_EMAIL, + currentUserAccountID: RORY_ACCOUNT_ID, + transactionViolations: undefined, + isTrackIntentUser: undefined, + delegateAccountID: undefined, + rules: undefined, + }); + const data = request.mock.calls.at(0)?.[2]; + expect(data).toBeDefined(); + expect(request.mock.calls.at(0)?.[1]).not.toHaveProperty('transactionThreadReportID'); + expect(data?.optimisticData?.filter((update) => update.onyxMethod === Onyx.METHOD.SET)).toEqual([]); + const rollback = data?.failureData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}existing-thread`); + expect(rollback).toBeDefined(); + expect(rollback?.value).toEqual({lastVisibleActionCreated: undefined}); + await Onyx.update(data?.optimisticData ?? []); + // Then the original thread receives the comment and the rollback removes the held state. + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}existing-thread`)).toBeDefined(); + expect((await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`))?.comment?.hold).toBeTruthy(); + await Onyx.update(data?.failureData ?? []); + expect((await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`))?.comment).not.toHaveProperty('hold'); + request.mockRestore(); + }); + + it('creates and links a new thread and clears it on failure', async () => { + // Given an IOU action without a transaction thread, as produced during expense creation. + const report = buildOptimisticIOUReport(1, 2, 100, '99', 'USD', getCurrencyDecimalsLocal); + const transaction = buildOptimisticTransaction({transactionParams: {amount: 100, currency: 'USD', reportID: report.reportID}}); + const action = buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 100, + currency: 'USD', + comment: '', + participants: [], + transactionID: transaction.transactionID, + getCurrencyDecimals: getCurrencyDecimalsLocal, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {[action.reportActionID]: action}); + await waitForBatchedUpdates(); + const request = jest.spyOn(jest.requireActual<{write: typeof write}>('@libs/API'), 'write').mockResolvedValue(undefined); + // When the production action creates the complete thread before its SET and link updates. + putOnHold({ + transactionID: transaction.transactionID, + transaction, + comment: 'Review', + initialReportID: undefined, + initialReport: undefined, + transactionReport: undefined, + isOffline: true, + currentUserLogin: RORY_EMAIL, + currentUserAccountID: RORY_ACCOUNT_ID, + transactionViolations: undefined, + isTrackIntentUser: undefined, + delegateAccountID: undefined, + rules: undefined, + }); + const data = request.mock.calls.at(0)?.[2]; + const params = request.mock.calls.at(0)?.[1]; + expect(data).toBeDefined(); + expect(params).toHaveProperty('transactionThreadReportID'); + const threadID = params && 'transactionThreadReportID' in params ? params.transactionThreadReportID : undefined; + expect(typeof threadID).toBe('string'); + const threadUpdate = data?.optimisticData?.find((update) => update.onyxMethod === Onyx.METHOD.SET && update.key.startsWith(ONYXKEYS.COLLECTION.REPORT)); + expect(threadUpdate).toBeDefined(); + await Onyx.update(data?.optimisticData ?? []); + const parentActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`); + // Then real Onyx links the parent action to the created thread and rollback restores that link. + expect(parentActions?.[action.reportActionID]).toHaveProperty('childReportID', threadID); + await Onyx.update(data?.failureData ?? []); + const reverted = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`); + expect(reverted?.[action.reportActionID]).not.toHaveProperty('childReportID'); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${threadID}`)).toBeFalsy(); + request.mockRestore(); + }); + test("should update the transaction thread report's lastVisibleActionCreated to the optimistically added hold comment report action created timestamp", () => { const iouReport = buildOptimisticIOUReport(1, 2, 100, '1', 'USD', getCurrencyDecimalsLocal); const transaction = buildOptimisticTransaction({ @@ -975,7 +1064,7 @@ describe('actions/IOU/Hold', () => { // When the user goes offline and removes the hold await Onyx.set(ONYXKEYS.NETWORK, {shouldForceOffline: true}); - jest.mocked(global.fetch).mockClear(); + jest.mocked(globalThis.fetch).mockClear(); unholdRequest({ transactionID: transaction.transactionID, transaction: heldTransaction, @@ -1021,6 +1110,83 @@ describe('actions/IOU/Hold', () => { }; }; + test.each(['ordinary IOU', 'general action', 'missing original message', 'legacy original message'])( + 'selects only IOU actions and restores %s through real Onyx', + async (scenario) => { + // Given an ordinary action or a stored action associated with the allowed transaction_undefined key. + const report = buildOptimisticIOUReport(1, 2, 100, '99', 'USD', getCurrencyDecimalsLocal); + const transaction = buildHeldTransaction(report.reportID, 100); + if (scenario !== 'ordinary IOU') { + transaction.transactionID = 'undefined'; + } + const action: ReportAction = + scenario === 'general action' + ? buildOptimisticCreatedReportAction({emailCreatingAction: RORY_EMAIL}) + : buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 100, + currency: 'USD', + comment: '', + participants: [], + transactionID: transaction.transactionID, + getCurrencyDecimals: getCurrencyDecimalsLocal, + }); + if (scenario === 'missing original message') { + // eslint-disable-next-line @typescript-eslint/no-deprecated -- This case represents stored actions before original-message migration. + delete action.originalMessage; + action.message = []; + } + if (scenario === 'legacy original message') { + // @ts-expect-error Historical primitive message data is intentionally malformed at this one stored field. + // eslint-disable-next-line @typescript-eslint/no-deprecated -- Legacy actions can store a primitive in this field. + action.originalMessage = 'Legacy message'; + } + const chatReport = createMock({reportID: '99', iouReportID: report.reportID}); + const actionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}` as const; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.set(actionsKey, {[action.reportActionID]: action}); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + await waitForBatchedUpdates(); + // When the actual report-move seam selects actions linked to held transactions. + const result = getReportFromHoldRequestsOnyxData({ + chatReport, + iouReport: report, + recipient: {accountID: 1}, + policy: undefined, + delegateAccountID: undefined, + isASAPSubmitBetaEnabled: false, + getCurrencyDecimals: getCurrencyDecimalsLocal, + rules: undefined, + }); + if (scenario === 'general action') { + // Then a general action cannot be copied through an unrelated transaction_undefined entry. + expect(result.optimisticHoldReportExpenseActionIDs).toEqual([]); + await Onyx.update(result.optimisticData); + expect(await getOnyxValue(actionsKey)).toEqual({[action.reportActionID]: action}); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`)).toEqual(transaction); + return; + } + expect(result.optimisticHoldReportExpenseActionIDs).toHaveLength(1); + const copiedID = result.optimisticHoldReportExpenseActionIDs.at(0)?.optimisticReportActionID; + await Onyx.update(result.optimisticData); + // Then the actual store contains the copied IOU action. + const copied = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${result.optimisticHoldReportID}`); + expect(copiedID).toBeDefined(); + if (copiedID === undefined) { + throw new Error('Expected the copied hold action ID'); + } + expect(copied?.[copiedID]).toMatchObject({actionName: action.actionName, reportID: result.optimisticHoldReportID}); + if (scenario === 'missing original message' || scenario === 'legacy original message') { + expect(copied?.[copiedID]).not.toHaveProperty('originalMessage'); + } else { + expect(getOriginalMessage(copied?.[copiedID])).toEqual(getOriginalMessage(action)); + } + await Onyx.update(result.failureData); + const restored = await getOnyxValue(actionsKey); + expect(restored?.[action.reportActionID]).toEqual(action); + }, + ); + const buildScenario = (overrides: { total: number; nonReimbursableTotal: number; diff --git a/tests/actions/IOUTest/PayMoneyRequestTest.ts b/tests/actions/IOUTest/PayMoneyRequestTest.ts index fd8b2e7243ef..ae82c5e32495 100644 --- a/tests/actions/IOUTest/PayMoneyRequestTest.ts +++ b/tests/actions/IOUTest/PayMoneyRequestTest.ts @@ -25,7 +25,7 @@ import type Transaction from '@src/types/onyx/Transaction'; import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {OnyxEntry, OnyxInputValue} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry, OnyxInputValue} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -93,6 +93,18 @@ const RORY_EMAIL = 'rory@expensifail.com'; const RORY_ACCOUNT_ID = 3; OnyxUpdateManager(); +/** Reads the expenses currently in Onyx for a report, i.e. what a component would pass as the report's transactions. */ +async function getReportTransactionsFromOnyx(reportID: string | undefined): Promise { + let allTransactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + callback: (transactions) => { + allTransactions = transactions; + }, + }); + return Object.values(allTransactions ?? {}).filter((transaction): transaction is Transaction => !!transaction && transaction.reportID === reportID); +} + describe('actions/IOU/PayMoneyRequest', () => { beforeAll(() => { Onyx.init({ @@ -237,17 +249,20 @@ describe('actions/IOU/PayMoneyRequest', () => { }); }), ) - .then(() => { + .then(async () => { mockFetch?.pause?.(); if (!chatReport || !iouReport) { return waitForBatchedUpdates(); } + const iouReportTransactions = await getReportTransactionsFromOnyx(iouReport.reportID); payMoneyRequest({ + bankAccountList: undefined, isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions, introSelected: undefined, currentUserAccountID, currentUserLogin, @@ -463,16 +478,19 @@ describe('actions/IOU/PayMoneyRequest', () => { }); }), ) - .then(() => { + .then(async () => { if (!chatReport || !expenseReport) { return waitForBatchedUpdates(); } + const iouReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); payMoneyRequest({ + bankAccountList: undefined, isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.VBBA, chatReport, iouReport: expenseReport, + iouReportTransactions, introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -643,17 +661,20 @@ describe('actions/IOU/PayMoneyRequest', () => { }); }), ) - .then(() => { + .then(async () => { mockFetch?.fail?.(); if (!chatReport || !expenseReport) { return waitForBatchedUpdates(); } + const iouReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); payMoneyRequest({ + bankAccountList: undefined, isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: 'ACH', chatReport, iouReport: expenseReport, + iouReportTransactions, introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -707,11 +728,13 @@ describe('actions/IOU/PayMoneyRequest', () => { // When paying the IOU report const chatReportPolicy = chatReportPolicyFromChat(chatReport); payMoneyRequest({ + bankAccountList: undefined, isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -778,6 +801,66 @@ describe('actions/IOU/PayMoneyRequest', () => { mockFetch?.resume?.(); }); + it('puts the payment failure error on the chat report preview, not only inside the expense report', async () => { + // Given an outstanding IOU report that is previewed in the chat the payer is looking at + const chatReport = { + ...createRandomReport(20, undefined), + lastReadTime: DateUtils.getDBTime(), + lastVisibleActionCreated: DateUtils.getDBTime(), + }; + const iouReport = { + ...createRandomReport(21, undefined), + chatType: undefined, + type: CONST.REPORT.TYPE.IOU, + chatReportID: chatReport.reportID, + total: 10, + }; + const reportPreviewAction: ReportAction = { + ...createRandomReportAction(22), + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + originalMessage: {linkedReportID: iouReport.reportID}, + }; + const chatReportActions: ReportActions = {[reportPreviewAction.reportActionID]: reportPreviewAction}; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, chatReportActions); + await waitForBatchedUpdates(); + + // When the server rejects the payment, which is what happens to a payment queued offline against an + // expense the requestor deleted in the meantime + mockFetch?.fail?.(); + payMoneyRequest({ + bankAccountList: undefined, + isASAPSubmitBetaEnabled: false, + conciergeChat: undefined, + paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, + chatReport, + iouReport, + iouReportTransactions: [], + introSelected: undefined, + currentUserAccountID: CARLOS_ACCOUNT_ID, + currentUserLogin: CARLOS_EMAIL, + isSelfTourViewed: false, + userBillingGracePeriodEnds: undefined, + amountOwed: 0, + chatReportPolicy: chatReportPolicyFromChat(chatReport), + chatReportActions, + delegateAccountID: undefined, + isTrackIntentUser: false, + getCurrencyDecimals: getCurrencyDecimalsLocal, + rules: undefined, + }); + await waitForBatchedUpdates(); + + // Then the error is on the preview in the chat. The pay action also carries it, but that action lives in + // the expense report, which the payer can no longer reach once it has been deleted. + const updatedChatActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}` as const); + expect(updatedChatActions?.[reportPreviewAction.reportActionID]?.errors).toBeTruthy(); + + mockFetch?.succeed?.(); + }); + describe('delegateAccountID forwarding', () => { it('sets delegateAccountID on the pay IOU action when delegateAccountID is provided', async () => { const DELEGATE_ACCOUNT_ID = 999; @@ -795,11 +878,13 @@ describe('actions/IOU/PayMoneyRequest', () => { mockFetch?.pause?.(); payMoneyRequest({ + bankAccountList: undefined, isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -828,6 +913,66 @@ describe('actions/IOU/PayMoneyRequest', () => { expect(payReportAction?.delegateAccountID).toBe(DELEGATE_ACCOUNT_ID); + mockFetch?.resume?.(); + }); + it('records the caller’s bank account details on an expense payment instead of reading cached accounts', async () => { + // Given an expense report and an account whose cached number differs from the caller’s snapshot. + const bankAccountID = 999; + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, {[bankAccountID]: {bankCurrency: CONST.CURRENCY.USD, bankCountry: CONST.COUNTRY.US, accountData: {accountNumber: 'XXXX1111'}}}); + const chatReport = { + ...createRandomReport(0, undefined), + lastReadTime: DateUtils.getDBTime(), + lastVisibleActionCreated: DateUtils.getDBTime(), + }; + const iouReport = { + ...createRandomReport(1, undefined), + chatType: undefined, + type: CONST.REPORT.TYPE.EXPENSE, + total: 10, + }; + mockFetch?.pause?.(); + + // When paying with the account provided by the caller. + payMoneyRequest({ + bankAccountList: {[bankAccountID]: {bankCurrency: CONST.CURRENCY.USD, bankCountry: CONST.COUNTRY.US, accountData: {accountNumber: 'XXXX9876'}}}, + methodID: bankAccountID, + isASAPSubmitBetaEnabled: false, + conciergeChat: undefined, + paymentType: CONST.IOU.PAYMENT_TYPE.VBBA, + chatReport, + iouReport, + iouReportTransactions: [], + introSelected: undefined, + currentUserAccountID: CARLOS_ACCOUNT_ID, + currentUserLogin: CARLOS_EMAIL, + isSelfTourViewed: false, + userBillingGracePeriodEnds: undefined, + amountOwed: 0, + chatReportPolicy: chatReportPolicyFromChat(chatReport), + chatReportActions: undefined, + delegateAccountID: undefined, + isTrackIntentUser: false, + getCurrencyDecimals: getCurrencyDecimalsLocal, + rules: undefined, + }); + + await waitForBatchedUpdates(); + + const payReportAction = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + resolve(Object.values(reportActions ?? {}).pop()); + }, + }); + }); + + // Then the action records the selected account and its current number for every viewer. + expect(payReportAction && isMoneyRequestAction(payReportAction) ? getOriginalMessage(payReportAction) : undefined).toEqual( + expect.objectContaining({bankAccountID, accountNumber: 'XXXX9876'}), + ); + mockFetch?.resume?.(); }); }); @@ -908,11 +1053,13 @@ describe('actions/IOU/PayMoneyRequest', () => { policyID: CONST.POLICY.ID_FAKE, }; payMoneyRequest({ + bankAccountList: undefined, isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport: partialPayChatReport, iouReport, + iouReportTransactions: [transaction1, transaction2], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -1008,11 +1155,13 @@ describe('actions/IOU/PayMoneyRequest', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport); const newExpenseReportID = payMoneyRequest({ + bankAccountList: undefined, isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport: expenseReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -1115,11 +1264,13 @@ describe('actions/IOU/PayMoneyRequest', () => { return expenseReport; }; - const pay = (expenseReport: Report) => + const pay = (expenseReport: Report, iouReportTransactions: Transaction[]) => payMoneyRequest({ + bankAccountList: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport: expenseReport, + iouReportTransactions, introSelected: undefined, currentUserAccountID: RORY_ACCOUNT_ID, currentUserLogin: RORY_EMAIL, @@ -1143,7 +1294,7 @@ describe('actions/IOU/PayMoneyRequest', () => { const scanFailedTransaction = buildTransaction('scanFailed1', 0, true); const expenseReport = await setUpReport([validTransaction, scanFailedTransaction]); - const newReportID = pay(expenseReport); + const newReportID = pay(expenseReport, [validTransaction, scanFailedTransaction]); await waitForBatchedUpdates(); expect(newReportID).toBeDefined(); @@ -1161,7 +1312,7 @@ describe('actions/IOU/PayMoneyRequest', () => { const scanFailedTransaction = buildTransaction('scanFailed1', 0, true); const expenseReport = await setUpReport([scanFailedTransaction]); - const newReportID = pay(expenseReport); + const newReportID = pay(expenseReport, [scanFailedTransaction]); await waitForBatchedUpdates(); expect(newReportID).toBeUndefined(); @@ -1174,7 +1325,7 @@ describe('actions/IOU/PayMoneyRequest', () => { const scanFailedTransaction = buildTransaction('scanFailed1', -5000, true); const expenseReport = await setUpReport([validTransaction, scanFailedTransaction]); - const newReportID = pay(expenseReport); + const newReportID = pay(expenseReport, [validTransaction, scanFailedTransaction]); await waitForBatchedUpdates(); expect(newReportID).toBeUndefined(); @@ -1205,11 +1356,13 @@ describe('actions/IOU/PayMoneyRequest', () => { const chatReportPolicyTrueTour = chatReportPolicyFromChat(chatReport); payMoneyRequest({ + bankAccountList: undefined, isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -1260,11 +1413,13 @@ describe('actions/IOU/PayMoneyRequest', () => { const chatReportPolicyFalseTour = chatReportPolicyFromChat(chatReport); payMoneyRequest({ + bankAccountList: undefined, isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -1435,11 +1590,13 @@ describe('actions/IOU/PayMoneyRequest', () => { jest.mocked(Navigation.navigate).mockClear(); payMoneyRequest({ + bankAccountList: undefined, isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -1509,11 +1666,13 @@ describe('actions/IOU/PayMoneyRequest', () => { jest.mocked(Navigation.navigate).mockClear(); payMoneyRequest({ + bankAccountList: undefined, isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -1573,11 +1732,13 @@ describe('actions/IOU/PayMoneyRequest', () => { jest.mocked(Navigation.navigate).mockClear(); payMoneyRequest({ + bankAccountList: undefined, isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -1616,11 +1777,13 @@ describe('actions/IOU/PayMoneyRequest', () => { const chatReportPolicyAmountZero = chatReportPolicyFromChat(chatReport); payMoneyRequest({ + bankAccountList: undefined, isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -2049,17 +2212,20 @@ describe('actions/IOU/PayMoneyRequest', () => { }, }), ) - .then(() => { + .then(async () => { // When the expense report is paid elsewhere (but really, any payment option would work) if (!chatReport || !expenseReport) { return waitForBatchedUpdates(); } + const iouReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); payMoneyRequest({ + bankAccountList: undefined, isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport: expenseReport, + iouReportTransactions, introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -2565,12 +2731,15 @@ describe('actions/IOU/PayMoneyRequest', () => { // When the expense report is paid elsewhere (but really, any payment option would work) if (chatReport && expenseReport) { mockFetch?.pause?.(); + const iouReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); payMoneyRequest({ + bankAccountList: undefined, isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport: expenseReport, + iouReportTransactions, introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, diff --git a/tests/actions/IOUTest/ReportWorkflowTest.ts b/tests/actions/IOUTest/ReportWorkflowTest.ts index cbe361ea11d2..defbd9af7915 100644 --- a/tests/actions/IOUTest/ReportWorkflowTest.ts +++ b/tests/actions/IOUTest/ReportWorkflowTest.ts @@ -45,7 +45,7 @@ import type Transaction from '@src/types/onyx/Transaction'; import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction'; import type CollectionDataSet from '@src/types/utils/CollectionDataSet'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -120,6 +120,18 @@ function getRequiredReportAction(update: unknown): Record { OnyxUpdateManager(); +/** Reads the expenses currently in Onyx for a report, i.e. what a component would pass as the report's transactions. */ +async function getReportTransactionsFromOnyx(reportID: string | undefined): Promise { + let allTransactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + callback: (transactions) => { + allTransactions = transactions; + }, + }); + return Object.values(allTransactions ?? {}).filter((transaction): transaction is Transaction => !!transaction && transaction.reportID === reportID); +} + describe('actions/IOU/ReportWorkflow', () => { let mockFetch: MockFetch; @@ -267,12 +279,14 @@ describe('actions/IOU/ReportWorkflow', () => { ) .then(async () => { if (expenseReport) { + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: undefined, personalDetailsByLogins: {}, expenseReport, + expenseReportTransactions, policy: undefined, currentUserAccountIDParam: CARLOS_ACCOUNT_ID, currentUserEmailParam: CARLOS_EMAIL, @@ -397,12 +411,14 @@ describe('actions/IOU/ReportWorkflow', () => { // When submitting the report while offline (callers pass the live report, refreshed by the hold) mockFetch?.pause?.(); const freshExpenseReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`); + const expenseReportTransactions = await getReportTransactionsFromOnyx(freshExpenseReport?.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: CARLOS_EMAIL, personalDetailsByLogins: {}, expenseReport: freshExpenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: CARLOS_ACCOUNT_ID, currentUserEmailParam: CARLOS_EMAIL, @@ -535,12 +551,14 @@ describe('actions/IOU/ReportWorkflow', () => { // When submitting the report while offline mockFetch?.pause?.(); const freshExpenseReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`); + const expenseReportTransactions = await getReportTransactionsFromOnyx(freshExpenseReport?.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: CARLOS_EMAIL, personalDetailsByLogins: {}, expenseReport: freshExpenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: CARLOS_ACCOUNT_ID, currentUserEmailParam: CARLOS_EMAIL, @@ -658,12 +676,14 @@ describe('actions/IOU/ReportWorkflow', () => { // When submitting the report while offline (callers pass the live report, refreshed by the hold) mockFetch?.pause?.(); const freshExpenseReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`); + const expenseReportTransactions = await getReportTransactionsFromOnyx(freshExpenseReport?.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: CARLOS_EMAIL, personalDetailsByLogins: {}, expenseReport: freshExpenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: CARLOS_ACCOUNT_ID, currentUserEmailParam: CARLOS_EMAIL, @@ -782,12 +802,14 @@ describe('actions/IOU/ReportWorkflow', () => { // When submitting the report while offline (callers pass the live report, refreshed by the hold) mockFetch?.pause?.(); const freshExpenseReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`); + const expenseReportTransactions = await getReportTransactionsFromOnyx(freshExpenseReport?.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: CARLOS_EMAIL, personalDetailsByLogins: {}, expenseReport: freshExpenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: CARLOS_ACCOUNT_ID, currentUserEmailParam: CARLOS_EMAIL, @@ -1110,12 +1132,14 @@ describe('actions/IOU/ReportWorkflow', () => { ) .then(async () => { if (expenseReport) { + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: undefined, personalDetailsByLogins: {}, expenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: CARLOS_ACCOUNT_ID, currentUserEmailParam: CARLOS_EMAIL, @@ -1411,12 +1435,14 @@ describe('actions/IOU/ReportWorkflow', () => { .then(async () => { mockFetch?.fail?.(); if (expenseReport) { + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: undefined, personalDetailsByLogins: {}, expenseReport, + expenseReportTransactions, policy: undefined, currentUserAccountIDParam: CARLOS_ACCOUNT_ID, currentUserEmailParam: CARLOS_EMAIL, @@ -1590,14 +1616,16 @@ describe('actions/IOU/ReportWorkflow', () => { }); }), ) - .then(() => { + .then(async () => { if (expenseReport) { + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: undefined, personalDetailsByLogins: {}, expenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: CARLOS_ACCOUNT_ID, currentUserEmailParam: CARLOS_EMAIL, @@ -1668,12 +1696,14 @@ describe('actions/IOU/ReportWorkflow', () => { jest.mocked(Navigation.navigate).mockClear(); // Submit with amountOwed > 0 should trigger restriction + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: undefined, personalDetailsByLogins: {}, expenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: CARLOS_ACCOUNT_ID, currentUserEmailParam: CARLOS_EMAIL, @@ -1790,12 +1820,14 @@ describe('actions/IOU/ReportWorkflow', () => { jest.mocked(Navigation.navigate).mockClear(); const ownerBillingGracePeriodEnd = Math.floor(Date.now() / 1000) - 86400 * 30; + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: undefined, personalDetailsByLogins: {}, expenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: CARLOS_ACCOUNT_ID, currentUserEmailParam: CARLOS_EMAIL, @@ -1854,12 +1886,14 @@ describe('actions/IOU/ReportWorkflow', () => { currency: CONST.CURRENCY.USD, }; + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: submitterEmail, personalDetailsByLogins, expenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: submitterAccountID, currentUserEmailParam: submitterEmail, @@ -1933,12 +1967,14 @@ describe('actions/IOU/ReportWorkflow', () => { currency: CONST.CURRENCY.USD, }; + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: submitterEmail, personalDetailsByLogins, expenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: submitterAccountID, currentUserEmailParam: submitterEmail, @@ -2007,12 +2043,14 @@ describe('actions/IOU/ReportWorkflow', () => { currency: CONST.CURRENCY.USD, }; + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: submitterEmail, personalDetailsByLogins, expenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: submitterAccountID, currentUserEmailParam: submitterEmail, @@ -2061,10 +2099,12 @@ describe('actions/IOU/ReportWorkflow', () => { currency: CONST.CURRENCY.USD, }; + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, expenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: submitterAccountID, currentUserEmailParam: submitterEmail, @@ -2118,10 +2158,12 @@ describe('actions/IOU/ReportWorkflow', () => { currency: CONST.CURRENCY.USD, }; + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, expenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: submitterAccountID, currentUserEmailParam: submitterEmail, @@ -2190,12 +2232,14 @@ describe('actions/IOU/ReportWorkflow', () => { currency: CONST.CURRENCY.USD, }; + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: submitterEmail, personalDetailsByLogins, expenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: adminAccountID, currentUserEmailParam: adminEmail, @@ -2284,10 +2328,12 @@ describe('actions/IOU/ReportWorkflow', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); await waitForBatchedUpdates(); + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, expenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: submitterAccountID, currentUserEmailParam: submitterEmail, @@ -2378,12 +2424,14 @@ describe('actions/IOU/ReportWorkflow', () => { currency: CONST.CURRENCY.USD, }; + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: submitterEmail, personalDetailsByLogins, expenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: adminAccountID, currentUserEmailParam: adminEmail, @@ -2455,12 +2503,14 @@ describe('actions/IOU/ReportWorkflow', () => { // Go offline so only the optimistic update is applied mockFetch?.pause?.(); + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: submitterEmail, personalDetailsByLogins, expenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: submitterAccountID, currentUserEmailParam: submitterEmail, @@ -2527,12 +2577,14 @@ describe('actions/IOU/ReportWorkflow', () => { currency: CONST.CURRENCY.USD, }; + const expenseReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); submitReport({ rules: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, submitterLogin: submitterEmail, personalDetailsByLogins, expenseReport, + expenseReportTransactions, policy, currentUserAccountIDParam: submitterAccountID, currentUserEmailParam: submitterEmail, @@ -2605,6 +2657,7 @@ describe('actions/IOU/ReportWorkflow', () => { submitterLogin: undefined, personalDetailsByLogins: {}, expenseReport: report, + expenseReportTransactions: [transaction], policy, currentUserAccountIDParam: submitterAccountID, currentUserEmailParam: submitterEmail, @@ -2671,6 +2724,7 @@ describe('actions/IOU/ReportWorkflow', () => { submitterLogin: undefined, personalDetailsByLogins: {}, expenseReport: report, + expenseReportTransactions: [transaction], policy, currentUserAccountIDParam: submitterAccountID, currentUserEmailParam: submitterEmail, @@ -2712,6 +2766,7 @@ describe('actions/IOU/ReportWorkflow', () => { submitterLogin: undefined, personalDetailsByLogins: {}, expenseReport, + expenseReportTransactions: [], policy: { ...createRandomPolicy(Number(policyID)), id: policyID, @@ -3127,6 +3182,7 @@ describe('actions/IOU/ReportWorkflow', () => { submitterLogin: undefined, personalDetailsByLogins: {}, expenseReport, + expenseReportTransactions: [], policy: undefined, currentUserAccountIDParam: CARLOS_ACCOUNT_ID, currentUserEmailParam: CARLOS_EMAIL, @@ -3167,6 +3223,7 @@ describe('actions/IOU/ReportWorkflow', () => { submitterLogin: undefined, personalDetailsByLogins: {}, expenseReport, + expenseReportTransactions: [], policy: undefined, currentUserAccountIDParam: CARLOS_ACCOUNT_ID, currentUserEmailParam: CARLOS_EMAIL, @@ -3785,6 +3842,88 @@ describe('actions/IOU/ReportWorkflow', () => { expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, RORY_EMAIL, RORY_ACCOUNT_ID, [], true, false)).toBeTruthy(); }); + it('allows a non-payer admin to pay when a bank account is connected (reimburseYes)', async () => { + // Given an approved expense report on a workspace with bank reimbursement enabled. + // When an admin who is not the designated payer checks payment eligibility. + // Then Pay is available through admin permission without making that admin the payer. + const DESIGNATED_PAYER_EMAIL = 'designated-payer@mail.com'; + const policyChat = createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + const fakePolicy: Policy = { + ...createRandomPolicy(1), + id: 'AA', + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + role: CONST.POLICY.ROLE.ADMIN, + achAccount: { + reimburser: DESIGNATED_PAYER_EMAIL, + bankAccountID: 1, + accountNumber: '1234567890', + routingNumber: '987654321', + addressName: 'Test Address', + bankName: 'Test Bank', + }, + }; + + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: 'AA', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: CARLOS_ACCOUNT_ID, + managerID: CARLOS_ACCOUNT_ID, + isWaitingOnBankAccount: false, + total: -100, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + + // Rory is an admin but not the designated reimburser, so the Pay option comes from canAdminPayReport rather than isPayer. + expect(isPayer(RORY_ACCOUNT_ID, RORY_EMAIL, fakeReport, {}, fakePolicy, false)).toBeFalsy(); + expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, RORY_EMAIL, RORY_ACCOUNT_ID, [], false, false)).toBeTruthy(); + }); + + it('does not allow a non-payer admin to pay when reimbursementChoice is not configured', async () => { + // Given an approved expense report on a workspace without a configured reimbursement choice. + // When a non-payer admin checks payment eligibility. + // Then admin permission alone cannot enable payment before reimbursement is configured. + const DESIGNATED_PAYER_EMAIL = 'designated-payer@mail.com'; + const policyChat = createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + const fakePolicy: Policy = { + ...createRandomPolicy(1), + id: 'AA', + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + reimbursementChoice: undefined, + role: CONST.POLICY.ROLE.ADMIN, + achAccount: { + reimburser: DESIGNATED_PAYER_EMAIL, + bankAccountID: 1, + accountNumber: '1234567890', + routingNumber: '987654321', + addressName: 'Test Address', + bankName: 'Test Bank', + }, + }; + + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: 'AA', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: CARLOS_ACCOUNT_ID, + managerID: CARLOS_ACCOUNT_ID, + isWaitingOnBankAccount: false, + total: -100, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + + expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, RORY_EMAIL, RORY_ACCOUNT_ID, [], false, false)).toBeFalsy(); + }); + it('allows admins to mark report with only non-reimbursable expenses as paid (onlyShowPayElsewhere=true)', async () => { const policyChat = createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); const reportID = '999'; @@ -4001,6 +4140,50 @@ describe('actions/IOU/ReportWorkflow', () => { expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, RORY_EMAIL, RORY_ACCOUNT_ID, transactions, true, false)).toBeFalsy(); }); + it('should return false for report with only non-reimbursable expenses when preventPayoutNonReimbursableReports is true', async () => { + const policyChat = createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + const reportID = '9981'; + + const fakePolicy: Policy = { + ...createRandomPolicy(1), + id: 'AA', + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + role: CONST.POLICY.ROLE.ADMIN, + preventPayoutNonReimbursableReports: true, + }; + + const fakeReport: Report = { + ...createRandomReport(Number(reportID), undefined), + reportID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID: 'AA', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: CARLOS_ACCOUNT_ID, + managerID: RORY_ACCOUNT_ID, + isWaitingOnBankAccount: false, + total: -6500, + nonReimbursableTotal: -6500, + }; + + const zeroAmountNonReimbursableTransactions: Transaction[] = [ + { + ...createRandomTransaction(1), + reportID, + amount: 0, + currency: 'USD', + reimbursable: false, + }, + ]; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + + expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, RORY_EMAIL, RORY_ACCOUNT_ID, zeroAmountNonReimbursableTransactions, false, false)).toBeFalsy(); + expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, RORY_EMAIL, RORY_ACCOUNT_ID, zeroAmountNonReimbursableTransactions, true, false)).toBeFalsy(); + }); + it('allows non-reimburser admin to pay in manual reimbursement mode', async () => { const policyChat = createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 6807927a6038..8205336e5039 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -41,7 +41,7 @@ import { updateSplitExpenseField, } from '@userActions/IOU/SplitExpenseItems'; import type {UpdateSplitTransactionsParams} from '@userActions/IOU/SplitTransactionUpdate'; -import {updateSplitTransactions} from '@userActions/IOU/SplitTransactionUpdate'; +import {getExpenseReportIDsEmptiedBySplitUpdate, updateSplitTransactions, willSplitUpdateEmptyExpenseReport} from '@userActions/IOU/SplitTransactionUpdate'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -2689,6 +2689,190 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { expect(isDeleted).toBe(true); }); + it('should keep the expense report totals unchanged when a split is reverted while offline', async () => { + const amount = 10000; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let originalTransactionID: string | undefined; + + const policyID = generatePolicyID(); + createWorkspace({ + conciergeChat: undefined, + policyOwner: {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + policyID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + currency: undefined, + isSelfTourViewed: false, + hasActiveAdminPolicies: false, + hasOwnedPaidPolicy: false, + activePolicy: undefined, + delegateAccountID: undefined, + }); + const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + setWorkspaceApprovalMode(policy, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC, RORY_ACCOUNT_ID, RORY_EMAIL, false, undefined); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + callback: (allReports) => { + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + }, + }); + + requestMoney({ + getCurrencyDecimals: getCurrencyDecimalsLocal, + conciergeChat: undefined, + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'TestMerchant', + comment: '', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + isVendorMatchingBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + isSelfTourViewed: false, + existingTransactionDraft: undefined, + personalDetails: {}, + delegateAccountID: undefined, + isTrackIntentUser: false, + formatPhoneNumber, + rules: undefined, + }); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + }, + }); + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + callback: (allReportActions) => { + const iouAction = Object.values(allReportActions ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + originalTransactionID = iouAction ? getOriginalMessage(iouAction)?.IOUTransactionID : undefined; + }, + }); + + const expenseReportKey = `${ONYXKEYS.COLLECTION.REPORT}${expenseReport?.reportID}` as const; + const draftKey = `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}` as const; + const getTotals = async () => { + const report = await getOnyxValue(expenseReportKey); + return { + total: report?.total, + unheldTotal: report?.unheldTotal, + reimbursableTotal: report?.reimbursableTotal, + unheldReimbursableTotal: report?.unheldReimbursableTotal, + }; + }; + const totalsBeforeSplit = await getTotals(); + + const saveSplitExpenses = async (splitExpenses: SplitExpense[], splitExpensesTotal: number) => { + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportActions: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + await getOnyxData({key: ONYXKEYS.COLLECTION.TRANSACTION, callback: (value) => (allTransactions = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT, callback: (value) => (allReports = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, callback: (value) => (allReportActions = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, callback: (value) => (allReportNameValuePairs = value)}); + const allPolicyTags = await getAllPolicyTags(); + const currentExpenseReport = await getOnyxValue(expenseReportKey); + const {transactionReport} = getTransactionAndExpenseReports(splitExpenses.at(0)?.reportID ?? String(CONST.DEFAULT_NUMBER_ID)); + + updateSplitTransactionsFromSplitExpensesFlow({ + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + isVendorMatchingBetaEnabled: false, + allTransactionsList: allTransactions, + allReportsList: allReports, + reportDrafts: {}, + allReportActionsList: allReportActions, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID: expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), + originalTransactionID: originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses, + splitExpensesTotal, + }, + policyCategories: undefined, + policy, + policyRecentlyUsedCategories: [], + iouReport: currentExpenseReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + allPolicyTags, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport, + expenseReport: currentExpenseReport, + isOffline: true, + delegateAccountID: undefined, + isTrackIntentUser: false, + formatPhoneNumber, + rules: undefined, + }); + await waitForBatchedUpdates(); + }; + + mockFetch?.pause?.(); + + const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); + initSplitExpense(originalTransaction, expenseReport, policy, undefined, undefined, undefined, getCurrencyDecimalsLocal, getCurrencySymbolLocal); + await waitForBatchedUpdates(); + let draftTransaction = await getOnyxValue(draftKey); + await saveSplitExpenses(draftTransaction?.comment?.splitExpenses ?? [], draftTransaction?.comment?.splitExpensesTotal ?? 0); + + expect(await getTotals()).toEqual(totalsBeforeSplit); + + let childTransactions: Array> = []; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + callback: (allTransactions) => { + childTransactions = Object.values(allTransactions ?? {}).filter((transaction) => transaction?.comment?.originalTransactionID === originalTransactionID); + }, + }); + expect(childTransactions.length).toBe(2); + + initSplitExpense(childTransactions.at(0), expenseReport, policy, undefined, undefined, undefined, getCurrencyDecimalsLocal, getCurrencySymbolLocal); + await waitForBatchedUpdates(); + draftTransaction = await getOnyxValue(draftKey); + const removedSplitTransactionID = childTransactions.at(1)?.transactionID; + expect(removedSplitTransactionID).toBeDefined(); + removeSplitExpenseField(draftTransaction, removedSplitTransactionID ?? CONST.DEFAULT_NUMBER_ID.toString(), getCurrencyDecimalsLocal); + await waitForBatchedUpdates(); + draftTransaction = await getOnyxValue(draftKey); + await saveSplitExpenses(draftTransaction?.comment?.splitExpenses ?? [], draftTransaction?.comment?.splitExpensesTotal ?? 0); + + expect(await getTotals()).toEqual(totalsBeforeSplit); + + // Resume the paused requests so they don't block the request queue for later tests + await mockFetch?.resume?.(); + }); + it('should not leave a duplicate IOU action behind when a transaction is split, reverted, split again, and reverted again', async () => { const amount = 10000; let expenseReport: OnyxEntry; @@ -11010,6 +11194,7 @@ describe('createDistanceRequest', () => { isVendorMatchingBetaEnabled: false, getCurrencyDecimals: getCurrencyDecimalsLocal, report, + moneyRequestReportTransactions: [], participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, @@ -11165,11 +11350,13 @@ describe('createDistanceRequest', () => { it('flags the new transaction for the 1→2 highlight fallback when added to an expense report that already has one transaction', async () => { const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; const existingTransactionID = 'existing-txn-one-to-two'; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransactionID}`, {transactionID: existingTransactionID, reportID: '123'}); + const existingTransaction = {...createRandomTransaction(1), transactionID: existingTransactionID, reportID: '123'}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransactionID}`, existingTransaction); await waitForBatchedUpdates(); const result = createDistanceRequest({ ...getDefaultDistanceRequestParams({reportID: '123', type: CONST.REPORT.TYPE.EXPENSE}, {amount: 1}, recentWaypoints), + moneyRequestReportTransactions: [existingTransaction], participants: [], }); await waitForBatchedUpdates(); @@ -11970,3 +12157,566 @@ describe('resolveOptimisticSplitChatReportID', () => { expect(splitChatReport?.reportID).toBe(optimisticSplitChatReportID); }); }); + +describe('willSplitUpdateEmptyExpenseReport', () => { + const expenseReportID = 'empty-check-expense-report'; + const otherReportID = 'empty-check-other-report'; + const originalTransactionID = 'empty-check-original'; + + const buildTransaction = (transactionID: string, reportID: string, overrides: Partial = {}): Transaction => ({ + transactionID, + reportID, + amount: -5000, + currency: 'USD', + created: '2026-10-12', + merchant: 'Merchant', + comment: {originalTransactionID, source: CONST.IOU.TYPE.SPLIT}, + ...overrides, + }); + + const toCollection = (transactions: Transaction[]): OnyxCollection => + Object.fromEntries(transactions.map((transaction) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction])); + + it('returns true when every split in the expense report is removed and the rest go to other reports', () => { + // Given an expense report holding one split of the expense, and a sibling split on another report + const allTransactions = toCollection([buildTransaction('removed-split', expenseReportID), buildTransaction('kept-split', otherReportID)]); + + // When only the sibling split on the other report is saved + const result = willSplitUpdateEmptyExpenseReport( + expenseReportID, + originalTransactionID, + [{transactionID: 'kept-split', amount: 5000, created: '2026-10-12', reportID: otherReportID}], + allTransactions, + ); + + // Then the expense report is left without transactions, so it has to be removed + expect(result).toBe(true); + }); + + it('returns false when a saved split stays in the expense report', () => { + // Given an expense report holding one split of the expense + const allTransactions = toCollection([buildTransaction('kept-split', expenseReportID), buildTransaction('removed-split', otherReportID)]); + + // When the split that lives in the expense report is kept + const result = willSplitUpdateEmptyExpenseReport( + expenseReportID, + originalTransactionID, + [{transactionID: 'kept-split', amount: 5000, created: '2026-10-12', reportID: expenseReportID}], + allTransactions, + ); + + // Then the expense report still has a transaction and must not be removed + expect(result).toBe(false); + }); + + it('returns false when a saved split has no known destination report', () => { + // Given an expense report holding one split, and a saved split with neither a reportID nor a live transaction + const allTransactions = toCollection([buildTransaction('removed-split', expenseReportID)]); + + // When that split is saved - a split without a destination ends up in the expense report + const result = willSplitUpdateEmptyExpenseReport(expenseReportID, originalTransactionID, [{transactionID: 'new-split', amount: 5000, created: '2026-10-12'}], allTransactions); + + // Then the report is treated as kept alive, so it is never removed by mistake + expect(result).toBe(false); + }); + + it('returns false when the expense report holds an unrelated active expense', () => { + // Given an expense report holding a split of this expense and an unrelated expense + const allTransactions = toCollection([ + buildTransaction('removed-split', expenseReportID), + buildTransaction('unrelated-expense', expenseReportID, {comment: {}}), + buildTransaction('kept-split', otherReportID), + ]); + + // When the split in the expense report is removed + const result = willSplitUpdateEmptyExpenseReport( + expenseReportID, + originalTransactionID, + [{transactionID: 'kept-split', amount: 5000, created: '2026-10-12', reportID: otherReportID}], + allTransactions, + ); + + // Then the unrelated expense keeps the report alive + expect(result).toBe(false); + }); + + it('returns true when the only other expense in the report is already pending deletion', () => { + // Given an expense report holding a split of this expense and an unrelated expense that was deleted offline + const allTransactions = toCollection([ + buildTransaction('removed-split', expenseReportID), + buildTransaction('unrelated-expense', expenseReportID, {comment: {}, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}), + buildTransaction('kept-split', otherReportID), + ]); + + // When the split in the expense report is removed + const result = willSplitUpdateEmptyExpenseReport( + expenseReportID, + originalTransactionID, + [{transactionID: 'kept-split', amount: 5000, created: '2026-10-12', reportID: otherReportID}], + allTransactions, + ); + + // Then the pending-delete expense doesn't keep the report alive, so navigation and the report removal agree on it + expect(result).toBe(true); + }); +}); + +describe('updateSplitTransactions - emptied expense report', () => { + it('optimistically removes the expense report and its preview when its last split is removed and the remaining split lives on an approved report', async () => { + // Given an expense split across an approved report and a draft report, each holding one split with a transaction thread + const chatReportID = 'emptied-report-chat'; + const approvedReportID = 'emptied-report-approved'; + const draftReportID = 'emptied-report-draft'; + const originalTransactionID = 'emptied-report-original'; + const policyID = 'emptied-report-policy'; + const draftReportPreviewActionID = 'emptied-report-draft-preview'; + const buildIOUAction = (reportActionID: string, transactionID: string, reportID: string) => + ({ + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + reportID, + actorAccountID: RORY_ACCOUNT_ID, + created: DateUtils.getDBTime(), + childReportID: `${reportActionID}-thread`, + message: [{type: 'COMMENT', html: '', text: ''}], + originalMessage: {IOUTransactionID: transactionID, IOUReportID: reportID, amount: 5000, currency: 'USD', type: CONST.IOU.REPORT_ACTION_TYPE.CREATE}, + }) as ReportAction; + const draftReport: Report = { + reportID: draftReportID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + chatReportID, + parentReportID: chatReportID, + parentReportActionID: draftReportPreviewActionID, + ownerAccountID: RORY_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: -5000, + currency: 'USD', + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {...createRandomPolicy(1), id: policyID, type: CONST.POLICY.TYPE.TEAM}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${approvedReportID}`, { + reportID: approvedReportID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + chatReportID, + parentReportID: chatReportID, + ownerAccountID: RORY_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + total: -5000, + currency: 'USD', + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${draftReportID}`, draftReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${approvedReportID}`, {approvedIOU: buildIOUAction('approvedIOU', 'approved-split', approvedReportID)}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${draftReportID}`, {draftIOU: buildIOUAction('draftIOU', 'draft-split', draftReportID)}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, { + [draftReportPreviewActionID]: { + reportActionID: draftReportPreviewActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + childReportID: draftReportID, + created: DateUtils.getDBTime(), + message: [{type: 'COMMENT', html: '', text: ''}], + originalMessage: {linkedReportID: draftReportID}, + } as ReportAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, { + transactionID: originalTransactionID, + amount: -10000, + currency: 'USD', + merchant: 'Merchant', + created: '2026-10-12', + reportID: CONST.REPORT.SPLIT_REPORT_ID, + comment: {}, + }); + for (const [transactionID, reportID] of [ + ['approved-split', approvedReportID], + ['draft-split', draftReportID], + ]) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + transactionID, + amount: -5000, + currency: 'USD', + merchant: 'Merchant', + created: '2026-10-12', + reportID, + comment: {originalTransactionID, source: CONST.IOU.TYPE.SPLIT}, + }); + } + + // Hold the request so only the optimistic data is applied, as it is while offline + mockFetch?.pause?.(); + await waitForBatchedUpdates(); + + // When the draft split is removed offline and only the frozen approved split is saved + updateSplitTransactions({ + isVendorMatchingBetaEnabled: false, + rules: undefined, + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + allTransactionsList: await getOnyxValue(ONYXKEYS.COLLECTION.TRANSACTION), + allReportsList: await getOnyxValue(ONYXKEYS.COLLECTION.REPORT), + reportDrafts: {}, + allReportActionsList: await getOnyxValue(ONYXKEYS.COLLECTION.REPORT_ACTIONS), + allReportNameValuePairsList: {}, + transactionData: { + reportID: draftReportID, + originalTransactionID, + splitExpenses: [{transactionID: 'approved-split', amount: 5000, created: '2026-10-12', reportID: approvedReportID, isManuallyEdited: true}], + splitExpensesTotal: 5000, + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: draftReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + allPolicyTags: {}, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: draftReport, + expenseReport: draftReport, + isOffline: true, + delegateAccountID: undefined, + isTrackIntentUser: false, + formatPhoneNumber, + }); + await waitForBatchedUpdates(); + + // Then the draft report is removed and its preview is pending deletion, so it can't be opened into a Not Found page + const draftReportAfter = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${draftReportID}`); + expect(draftReportAfter?.reportID).toBeFalsy(); + const chatReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`); + expect(chatReportActions?.[draftReportPreviewActionID]?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + + // And the approved report the remaining split lives on stays untouched + const approvedReportAfter = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${approvedReportID}`); + expect(approvedReportAfter?.reportID).toBe(approvedReportID); + }); +}); + +describe('getExpenseReportIDsEmptiedBySplitUpdate', () => { + const openedReportID = 'emptied-ids-opened-report'; + const otherReportID = 'emptied-ids-other-report'; + const selfDMReportID = 'emptied-ids-self-dm'; + const originalTransactionID = 'emptied-ids-original'; + + const buildTransaction = (transactionID: string, reportID: string, overrides: Partial = {}): Transaction => ({ + transactionID, + reportID, + amount: -5000, + currency: 'USD', + created: '2026-10-12', + merchant: 'Merchant', + comment: {originalTransactionID, source: CONST.IOU.TYPE.SPLIT}, + ...overrides, + }); + + const toCollection = (transactions: Transaction[]): OnyxCollection => + Object.fromEntries(transactions.map((transaction) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction])); + + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}${openedReportID}`]: {reportID: openedReportID, type: CONST.REPORT.TYPE.EXPENSE}, + [`${ONYXKEYS.COLLECTION.REPORT}${otherReportID}`]: {reportID: otherReportID, type: CONST.REPORT.TYPE.EXPENSE}, + [`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`]: {reportID: selfDMReportID, type: CONST.REPORT.TYPE.CHAT, chatType: CONST.REPORT.CHAT_TYPE.SELF_DM}, + }; + + it('returns a report other than the opened one when its only split is removed', () => { + // Given the flow is opened from a report whose split is kept, and another expense report holds only a split that is removed + const allTransactions = toCollection([buildTransaction('kept-split', openedReportID), buildTransaction('removed-split', otherReportID)]); + + // When only the split on the opened report is saved + const result = getExpenseReportIDsEmptiedBySplitUpdate( + openedReportID, + originalTransactionID, + [{transactionID: 'kept-split', amount: 5000, created: '2026-10-12', reportID: openedReportID}], + allTransactions, + reports, + ); + + // Then the other report is emptied too, so it has to be removed instead of being left with a pending-delete split + expect(result).toEqual([otherReportID]); + }); + + it('returns a report a split is moved out of when nothing else is left in it', () => { + // Given two splits on the opened report and one on another expense report + const allTransactions = toCollection([ + buildTransaction('first-split', openedReportID), + buildTransaction('second-split', openedReportID), + buildTransaction('moved-split', otherReportID), + ]); + + // When the split on the other report is moved into the opened report + const result = getExpenseReportIDsEmptiedBySplitUpdate( + openedReportID, + originalTransactionID, + [ + {transactionID: 'first-split', amount: 5000, created: '2026-10-12', reportID: openedReportID}, + {transactionID: 'second-split', amount: 5000, created: '2026-10-12', reportID: openedReportID}, + {transactionID: 'moved-split', amount: 5000, created: '2026-10-12', reportID: openedReportID}, + ], + allTransactions, + reports, + ); + + // Then the report it was moved out of is emptied + expect(result).toEqual([otherReportID]); + }); + + it('never returns a report that is not an expense report', () => { + // Given the flow is opened from a report whose split is kept, and a split in the selfDM that is removed + const allTransactions = toCollection([buildTransaction('kept-split', openedReportID), buildTransaction('self-dm-split', selfDMReportID)]); + + // When only the split on the opened report is saved + const result = getExpenseReportIDsEmptiedBySplitUpdate( + openedReportID, + originalTransactionID, + [{transactionID: 'kept-split', amount: 5000, created: '2026-10-12', reportID: openedReportID}], + allTransactions, + reports, + ); + + // Then the selfDM is left alone, because removing it would drop the user's whole selfDM chat + expect(result).toEqual([]); + }); +}); + +describe('updateSplitTransactions - emptied expense reports', () => { + const chatReportID = 'emptied-reports-chat'; + const policyID = 'emptied-reports-policy'; + const originalTransactionID = 'emptied-reports-original'; + + const buildExpenseReport = (reportID: string, overrides: Partial = {}): Report => ({ + reportID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + chatReportID, + parentReportID: chatReportID, + parentReportActionID: `${reportID}-preview`, + ownerAccountID: RORY_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: -5000, + currency: 'USD', + ...overrides, + }); + + const buildIOUAction = (reportActionID: string, transactionID: string, reportID: string) => + ({ + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + reportID, + actorAccountID: RORY_ACCOUNT_ID, + created: DateUtils.getDBTime(), + childReportID: `${reportActionID}-thread`, + message: [{type: 'COMMENT', html: '', text: ''}], + originalMessage: {IOUTransactionID: transactionID, IOUReportID: reportID, amount: 5000, currency: 'USD', type: CONST.IOU.REPORT_ACTION_TYPE.CREATE}, + }) as ReportAction; + + // Stores a workspace chat, the given expense reports with their previews, the original expense, and one split per + // [transactionID, reportID] pair, plus any extra transactions. + const setUpSplitAcrossReports = async (expenseReports: Report[], splits: Array<[string, string]>, extraTransactions: Transaction[] = []) => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {...createRandomPolicy(1), id: policyID, type: CONST.POLICY.TYPE.TEAM}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID, + }); + const previewActions: Record = {}; + for (const expenseReport of expenseReports) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); + previewActions[`${expenseReport.reportID}-preview`] = { + reportActionID: `${expenseReport.reportID}-preview`, + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + childReportID: expenseReport.reportID, + created: DateUtils.getDBTime(), + message: [{type: 'COMMENT', html: '', text: ''}], + originalMessage: {linkedReportID: expenseReport.reportID}, + } as ReportAction; + } + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, previewActions); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, { + transactionID: originalTransactionID, + amount: -15000, + currency: 'USD', + merchant: 'Merchant', + created: '2026-10-12', + reportID: CONST.REPORT.SPLIT_REPORT_ID, + comment: {}, + }); + for (const [transactionID, reportID] of splits) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[`${transactionID}-iou`]: buildIOUAction(`${transactionID}-iou`, transactionID, reportID)}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + transactionID, + amount: -5000, + currency: 'USD', + merchant: 'Merchant', + created: '2026-10-12', + reportID, + comment: {originalTransactionID, source: CONST.IOU.TYPE.SPLIT}, + }); + } + for (const transaction of extraTransactions) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + } + + // Hold the request so only the optimistic data is applied, as it is while offline + mockFetch?.pause?.(); + await waitForBatchedUpdates(); + }; + + const saveSplits = async (openedFromReport: Report, splitExpenses: SplitExpense[]) => { + updateSplitTransactions({ + isVendorMatchingBetaEnabled: false, + rules: undefined, + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + allTransactionsList: await getOnyxValue(ONYXKEYS.COLLECTION.TRANSACTION), + allReportsList: await getOnyxValue(ONYXKEYS.COLLECTION.REPORT), + reportDrafts: {}, + allReportActionsList: await getOnyxValue(ONYXKEYS.COLLECTION.REPORT_ACTIONS), + allReportNameValuePairsList: {}, + transactionData: { + reportID: openedFromReport.reportID, + originalTransactionID, + splitExpenses, + splitExpensesTotal: splitExpenses.reduce((total, splitExpense) => total + splitExpense.amount, 0), + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: openedFromReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + allPolicyTags: {}, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: openedFromReport, + expenseReport: openedFromReport, + isOffline: true, + delegateAccountID: undefined, + isTrackIntentUser: false, + formatPhoneNumber, + }); + await waitForBatchedUpdates(); + }; + + it('removes the opened report on a reverse split when the only other expense left in it is pending deletion', async () => { + // Given report A holds one split and an unrelated expense that was deleted offline, and report B holds the other split + const reportA = buildExpenseReport('emptied-reports-reverse-a'); + const reportB = buildExpenseReport('emptied-reports-reverse-b'); + await setUpSplitAcrossReports( + [reportA, reportB], + [ + ['reverse-split-a', reportA.reportID], + ['reverse-split-b', reportB.reportID], + ], + [ + { + transactionID: 'reverse-deleted-expense', + reportID: reportA.reportID, + amount: -1000, + currency: 'USD', + created: '2026-10-12', + merchant: 'Merchant', + comment: {}, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }, + ], + ); + + // When the flow is opened from report A and only the split on report B is saved, which reverses the split + await saveSplits(reportA, [{transactionID: 'reverse-split-b', amount: 5000, created: '2026-10-12', reportID: reportB.reportID, isManuallyEdited: true}]); + + // Then report A is removed with its preview, matching the navigation that already sent the user to the workspace chat + const reportAAfter = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportA.reportID}`); + expect(reportAAfter?.reportID).toBeFalsy(); + const chatReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`); + expect(chatReportActions?.[`${reportA.reportID}-preview`]?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + + // And report B, where the expense is restored, stays + const reportBAfter = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportB.reportID}`); + expect(reportBAfter?.reportID).toBe(reportB.reportID); + }); + + it('removes another report the split is removed from, not only the report the flow was opened from', async () => { + // Given an expense split across reports A, B and an approved report C, one split each + const reportA = buildExpenseReport('emptied-reports-multi-a'); + const reportB = buildExpenseReport('emptied-reports-multi-b'); + const reportC = buildExpenseReport('emptied-reports-multi-c', {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED}); + await setUpSplitAcrossReports( + [reportA, reportB, reportC], + [ + ['multi-split-a', reportA.reportID], + ['multi-split-b', reportB.reportID], + ['multi-split-c', reportC.reportID], + ], + ); + + // When the flow is opened from report B and the split on report A is removed + await saveSplits(reportB, [ + {transactionID: 'multi-split-b', amount: 5000, created: '2026-10-12', reportID: reportB.reportID, isManuallyEdited: true}, + {transactionID: 'multi-split-c', amount: 5000, created: '2026-10-12', reportID: reportC.reportID, isManuallyEdited: true}, + ]); + + // Then report A is removed with its preview, so it can't be opened into a Not Found page + const reportAAfter = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportA.reportID}`); + expect(reportAAfter?.reportID).toBeFalsy(); + const chatReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`); + expect(chatReportActions?.[`${reportA.reportID}-preview`]?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + + // And reports B and C, which still hold a split, stay + const reportBAfter = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportB.reportID}`); + expect(reportBAfter?.reportID).toBe(reportB.reportID); + const reportCAfter = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportC.reportID}`); + expect(reportCAfter?.reportID).toBe(reportC.reportID); + }); + + it('keeps a split whose amount changes on its own report instead of the latest report of the workspace chat', async () => { + // Given an expense split across reports A, B and C, where C is the latest open report of the workspace chat + const reportA = buildExpenseReport('emptied-reports-amount-a'); + const reportB = buildExpenseReport('emptied-reports-amount-b'); + const reportC = buildExpenseReport('emptied-reports-amount-c'); + await setUpSplitAcrossReports( + [reportA, reportB, reportC], + [ + ['amount-split-a', reportA.reportID], + ['amount-split-b', reportB.reportID], + ['amount-split-c', reportC.reportID], + ], + ); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, {ownerAccountID: RORY_ACCOUNT_ID, iouReportID: reportC.reportID}); + await waitForBatchedUpdates(); + + // When the flow is opened from report B and the split on report A is removed, so its amount goes to the other splits + await saveSplits(reportB, [ + {transactionID: 'amount-split-b', amount: 7500, created: '2026-10-12', isManuallyEdited: true}, + {transactionID: 'amount-split-c', amount: 7500, created: '2026-10-12', isManuallyEdited: true}, + ]); + + // Then the split on report B stays there, rather than moving to report C, which the workspace chat points to + const splitB = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}amount-split-b`); + expect(splitB?.reportID).toBe(reportB.reportID); + const splitC = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}amount-split-c`); + expect(splitC?.reportID).toBe(reportC.reportID); + + // And each report total reflects only its own split's new amount + const reportBAfter = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportB.reportID}`); + expect(reportBAfter?.total).toBe(-7500); + const reportCAfter = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportC.reportID}`); + expect(reportCAfter?.total).toBe(-7500); + }); +}); diff --git a/tests/actions/IOUTest/TrackExpenseTest.ts b/tests/actions/IOUTest/TrackExpenseTest.ts index 3d808587a62a..8117690d18a6 100644 --- a/tests/actions/IOUTest/TrackExpenseTest.ts +++ b/tests/actions/IOUTest/TrackExpenseTest.ts @@ -13,6 +13,7 @@ import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {addComment, openReport} from '@libs/actions/Report'; import {subscribeToUserEvents} from '@libs/actions/User'; import {WRITE_COMMANDS} from '@libs/API/types'; +import {isReceiptError} from '@libs/ErrorUtils'; import {getLoginsByAccountIDs} from '@libs/PersonalDetailsUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; import {getOriginalMessage, isActionableTrackExpense, isMoneyRequestAction} from '@libs/ReportActionsUtils'; @@ -1389,6 +1390,153 @@ describe('actions/IOU/TrackExpense', () => { expect(actionableWhisper).toBeTruthy(); }); + it('reuses the failed IOU action and thread on a receipt retry, and clears the thread creation error', async () => { + // Given a self-DM and the thread a failed track left behind, still carrying its creation error + mockFetch?.pause?.(); + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-retry', + }; + const failedIOUActionID = '1111111111111111'; + const failedThreadReportID = '2222222222222222'; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${failedThreadReportID}`, { + reportID: failedThreadReportID, + parentReportID: selfDMReport.reportID, + parentReportActionID: failedIOUActionID, + errorFields: {createChat: {error: 'report.genericCreateReportFailureMessage'}}, + }); + + // When the receipt retry calls trackExpense with the failed attempt's IDs + trackExpense({ + ...getDefaultTrackExpenseParams(selfDMReport, {amount: 1200}), + optimisticTransactionID: '3333333333333333', + currentReportActionID: failedIOUActionID, + existingTransactionThreadReportID: failedThreadReportID, + isRetry: true, + }); + await waitForBatchedUpdates(); + + // Then the self-DM holds one IOU action under the failed ID, linked to the same thread, so no second expense or thread appears + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`); + const iouActions = Object.values(reportActions ?? {}).filter((action) => isMoneyRequestAction(action)); + expect(iouActions).toHaveLength(1); + expect(iouActions.at(0)?.reportActionID).toBe(failedIOUActionID); + expect(iouActions.at(0)?.childReportID).toBe(failedThreadReportID); + + // And the thread no longer shows the creation error from the failed attempt + const threadReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${failedThreadReportID}`); + expect(threadReport?.errorFields?.createChat).toBeFalsy(); + }); + + it('rebuilds the failed thread CREATED action on a receipt retry, so the success clears its pending state and errors', async () => { + // Given a self-DM, and the thread and CREATED action a failed track left behind, still pending and carrying an error + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-retry-created', + }; + const failedIOUActionID = '4444444444444444'; + const failedThreadReportID = '5555555555555555'; + const failedThreadCreatedActionID = '6666666666666666'; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${failedThreadReportID}`, { + reportID: failedThreadReportID, + parentReportID: selfDMReport.reportID, + parentReportActionID: failedIOUActionID, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${failedThreadReportID}`, { + [failedThreadCreatedActionID]: { + reportActionID: failedThreadCreatedActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: '2026-10-01 00:00:00.000', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + errors: {genericCreateFailure: 'iou.error.genericCreateFailureMessage'}, + } as ReportAction, + }); + + // When the receipt retry calls trackExpense with the failed attempt's IDs, and the request succeeds + trackExpense({ + ...getDefaultTrackExpenseParams(selfDMReport, {amount: 1200}), + optimisticTransactionID: '7777777777777777', + currentReportActionID: failedIOUActionID, + existingTransactionThreadReportID: failedThreadReportID, + existingTransactionThreadCreatedReportActionID: failedThreadCreatedActionID, + isRetry: true, + }); + await waitForBatchedUpdates(); + + // Then the backend gets the same CREATED action ID, so it doesn't add a second one to the thread + const requestBody = TestHelper.getFetchMockCalls(WRITE_COMMANDS.TRACK_EXPENSE).at(0)?.[1]?.body; + const requestParams = requestBody instanceof FormData ? Object.fromEntries(requestBody) : {}; + expect(requestParams).toEqual(expect.objectContaining({createdReportActionIDForThread: failedThreadCreatedActionID})); + + // And the failed CREATED action loses its pending state and error, so the expense no longer looks unsaved + const threadReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${failedThreadReportID}`); + expect(threadReportActions?.[failedThreadCreatedActionID]?.pendingAction).toBeFalsy(); + expect(threadReportActions?.[failedThreadCreatedActionID]?.errors).toBeFalsy(); + }); + + it('keeps one whisper after a receipt retry, because the retry reuses the whisper ID the failure saved', async () => { + // Given a scan tracked in the self-DM whose request fails, which deletes the whisper locally + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-retry-whisper', + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + const scanParams = { + ...getDefaultTrackExpenseParams(selfDMReport, {amount: 0, merchant: '', receipt: {source: 'file:///receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCAN_READY}}), + existingTransaction: createMock({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN}), + optimisticTransactionID: '8888888888888888', + }; + mockFetch?.fail?.(); + trackExpense(scanParams); + await waitForBatchedUpdates(); + + const failedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}8888888888888888`); + const receiptError = Object.values(failedTransaction?.errors ?? {}).find((error) => isReceiptError(error)); + const storedRetryParams: unknown = isReceiptError(receiptError) && typeof receiptError.retryParams === 'string' ? JSON.parse(receiptError.retryParams) : undefined; + const failedWhisperID = + !!storedRetryParams && + typeof storedRetryParams === 'object' && + 'actionableWhisperReportActionID' in storedRetryParams && + typeof storedRetryParams.actionableWhisperReportActionID === 'string' + ? storedRetryParams.actionableWhisperReportActionID + : undefined; + const selfDMActionsAfterFailure = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`); + const failedIOUAction = Object.values(selfDMActionsAfterFailure ?? {}).find((action) => isMoneyRequestAction(action)); + expect(failedWhisperID).toBeTruthy(); + if (!failedWhisperID) { + throw new Error('The failure did not save the whisper ID with the receipt error'); + } + + // And the server, which already handled the first request, sends its copy of that whisper back + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, { + [failedWhisperID]: createMock({ + reportActionID: failedWhisperID, + actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER, + created: '2026-10-01 00:00:00.000', + originalMessage: {transactionID: '8888888888888888'}, + }), + }); + + // When the receipt retry calls trackExpense with the failed attempt's IDs, and the request succeeds + mockFetch?.succeed?.(); + trackExpense({ + ...scanParams, + currentReportActionID: failedIOUAction?.reportActionID, + existingTransactionThreadReportID: failedIOUAction?.childReportID, + existingActionableWhisperReportActionID: failedWhisperID, + isRetry: true, + }); + await waitForBatchedUpdates(); + + // Then the self-DM shows one whisper, under the original ID, instead of a second set next to the server's copy + const selfDMActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`); + const whispers = Object.values(selfDMActions ?? {}).filter((action) => isActionableTrackExpense(action)); + expect(whispers).toHaveLength(1); + expect(whispers.at(0)?.reportActionID).toBe(failedWhisperID); + }); + it('should set correct tax fields when tax parameters are provided', async () => { // Given a selfDM report and transaction with tax const selfDMReport: Report = { diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index c3cb07138a8f..ef04c2a94672 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -22,11 +22,13 @@ import {buildOptimisticIOUReportAction} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; +import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyTagLists, RecentlyUsedTags, RecentWaypoint, Report, SearchResults, TransactionViolation} from '@src/types/onyx'; +import type {Policy, PolicyTagLists, PolicyVendors, RecentlyUsedTags, RecentWaypoint, Report, SearchResults, TransactionViolation} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; +import type {Connections} from '@src/types/onyx/Policy'; import type {Routes} from '@src/types/onyx/Transaction'; import type Transaction from '@src/types/onyx/Transaction'; @@ -47,6 +49,10 @@ import getOnyxValue from '../../utils/getOnyxValue'; import {createGlobalFetchMock, getCurrencyDecimalsLocal, getCurrencySymbolLocal} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; +jest.mock('@expensify/react-native-hybrid-app', () => ({ + isHybridApp: jest.fn(() => false), +})); + const topMostReportID = '23423423'; jest.mock('@src/libs/Navigation/Navigation', () => ({ navigate: jest.fn(), @@ -3054,6 +3060,13 @@ describe('actions/IOU/UpdateMoneyRequest', () => { describe('editTransactionMerchantInline delegateAccountID forwarding', () => { const DELEGATE_ACCOUNT_ID = 999; + function translate(path: TPath, ...parameters: TranslationParameters): string { + if (parameters.length === 0) { + return path; + } + return `${path}|${parameters.map(String).join('|')}`; + } + it('sets delegateAccountID when delegateAccountID is provided', async () => { const transactionID = 'txnMerchantDelegate1'; const transactionThreadReportID = 'threadMerchantDelegate1'; @@ -3088,6 +3101,7 @@ describe('actions/IOU/UpdateMoneyRequest', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); editTransactionMerchantInline( + translate, { isVendorMatchingBetaEnabled: false, hash: undefined, @@ -3131,4 +3145,180 @@ describe('actions/IOU/UpdateMoneyRequest', () => { expect(modifiedExpenseAction?.delegateAccountID).toBe(DELEGATE_ACCOUNT_ID); }); }); + + describe('vendor matching inactiveVendor violation retention', () => { + it('retains inactiveVendor violation during optimistic expense update when assigned vendor is disabled in policyVendors', async () => { + const transactionID = 'txnVendorDisabled1'; + const transactionThreadReportID = 'threadVendorDisabled1'; + const parentReportID = 'parentVendorDisabled1'; + const policyID = '50'; + const vendorID = 'v-disabled'; + + const policy = createMock({ + ...createRandomPolicy(Number(policyID), CONST.POLICY.TYPE.TEAM), + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: [{id: vendorID, name: 'Acme Disabled', currency: 'USD', email: 'vendor@example.com'}]}, + }, + }), + }); + + const parentReport: Report = { + ...createRandomReport(1, undefined), + reportID: parentReportID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + ownerAccountID: RORY_ACCOUNT_ID, + }; + const transactionThreadReport: Report = { + ...createRandomReport(2, undefined), + reportID: transactionThreadReportID, + parentReportID, + type: CONST.REPORT.TYPE.CHAT, + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(3), + transactionID, + reportID: parentReportID, + category: '', + reimbursable: false, + comment: {vendor: {externalID: vendorID, wasManuallySet: false}}, + }; + const inactiveVendorViolation: TransactionViolation = { + name: CONST.VIOLATIONS.INACTIVE_VENDOR, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + }; + + const policyVendors: PolicyVendors = { + [vendorID]: { + externalID: vendorID, + name: 'Acme Disabled', + enabled: false, + }, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, parentReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, fakeTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, policyVendors); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [inactiveVendorViolation]); + + updateMoneyRequestDescription({ + isVendorMatchingBetaEnabled: true, + transactionID, + transactionThreadReport, + parentReport, + rules: undefined, + iouReportOwnerLogin: undefined, + comment: 'Updated description', + policy, + policyTagList: undefined, + policyCategories: undefined, + policyVendors, + reportPolicyTags: undefined, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + isASAPSubmitBetaEnabled: false, + delegateAccountID: undefined, + isTrackIntentUser: false, + violations: [inactiveVendorViolation], + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + await waitForBatchedUpdates(); + + expect(await getStoredViolationNames(transactionID)).toEqual([CONST.VIOLATIONS.INACTIVE_VENDOR]); + }); + + it('clears inactiveVendor violation during optimistic expense update when assigned vendor is re-enabled in policyVendors', async () => { + const transactionID = 'txnVendorEnabled1'; + const transactionThreadReportID = 'threadVendorEnabled1'; + const parentReportID = 'parentVendorEnabled1'; + const policyID = '51'; + const vendorID = 'v-enabled'; + + const policy = createMock({ + ...createRandomPolicy(Number(policyID), CONST.POLICY.TYPE.TEAM), + requiresCategory: false, + requiresTag: false, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: [{id: vendorID, name: 'Acme Enabled', currency: 'USD', email: 'vendor@example.com'}]}, + }, + }), + }); + + const parentReport: Report = { + ...createRandomReport(1, undefined), + reportID: parentReportID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + ownerAccountID: RORY_ACCOUNT_ID, + }; + const transactionThreadReport: Report = { + ...createRandomReport(2, undefined), + reportID: transactionThreadReportID, + parentReportID, + type: CONST.REPORT.TYPE.CHAT, + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(4), + transactionID, + reportID: parentReportID, + category: '', + reimbursable: false, + comment: {vendor: {externalID: vendorID, wasManuallySet: false}}, + }; + const inactiveVendorViolation: TransactionViolation = { + name: CONST.VIOLATIONS.INACTIVE_VENDOR, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + }; + + const policyVendors: PolicyVendors = { + [vendorID]: { + externalID: vendorID, + name: 'Acme Enabled', + enabled: true, + }, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, parentReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, fakeTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, policyVendors); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [inactiveVendorViolation]); + + updateMoneyRequestDescription({ + isVendorMatchingBetaEnabled: true, + transactionID, + transactionThreadReport, + parentReport, + rules: undefined, + iouReportOwnerLogin: undefined, + comment: 'Updated description', + policy, + policyTagList: undefined, + policyCategories: undefined, + policyVendors, + reportPolicyTags: undefined, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + isASAPSubmitBetaEnabled: false, + delegateAccountID: undefined, + isTrackIntentUser: false, + violations: [inactiveVendorViolation], + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + await waitForBatchedUpdates(); + + expect(await getStoredViolationNames(transactionID)).toEqual([]); + }); + }); }); diff --git a/tests/actions/MergeTransactionTest.ts b/tests/actions/MergeTransactionTest.ts index 1b567012c9f1..9b581b542a9b 100644 --- a/tests/actions/MergeTransactionTest.ts +++ b/tests/actions/MergeTransactionTest.ts @@ -203,6 +203,7 @@ function runCrossReportMergeToSourceReportRequest(fixtures: CrossReportMergeToSo mergeTransactionID, mergeTransaction, targetTransaction, + transactionsOfDeletableReportCollection: {[targetTransaction.transactionID]: targetTransaction}, sourceTransaction, policy: undefined, policyTags: undefined, @@ -329,6 +330,7 @@ describe('mergeTransactionRequest', () => { mergeTransactionID, mergeTransaction, targetTransaction, + transactionsOfDeletableReportCollection: {[sourceTransaction.transactionID]: sourceTransaction}, sourceTransaction, sourceIOUActionThreadReport: undefined, targetTransactionThreadReport: {reportID: 'target-report-456'}, @@ -456,6 +458,7 @@ describe('mergeTransactionRequest', () => { mergeTransactionID, mergeTransaction, targetTransaction, + transactionsOfDeletableReportCollection: {[sourceTransaction.transactionID]: sourceTransaction}, sourceTransaction, sourceIOUActionThreadReport: undefined, targetTransactionThreadReport: {reportID: targetReportID}, @@ -566,6 +569,7 @@ describe('mergeTransactionRequest', () => { mergeTransactionID, mergeTransaction, targetTransaction, + transactionsOfDeletableReportCollection: {[sourceTransaction.transactionID]: sourceTransaction}, sourceTransaction, sourceIOUActionThreadReport: undefined, targetTransactionThreadReport: {reportID: targetExpenseReport.reportID}, @@ -726,6 +730,7 @@ describe('mergeTransactionRequest', () => { mergeTransactionID, mergeTransaction, targetTransaction, + transactionsOfDeletableReportCollection: {[targetTransaction.transactionID]: targetTransaction}, sourceTransaction, sourceIOUActionThreadReport: undefined, targetTransactionThreadReport: {reportID: 'target-report-456'}, @@ -841,6 +846,7 @@ describe('mergeTransactionRequest', () => { mergeTransactionID, mergeTransaction, targetTransaction, + transactionsOfDeletableReportCollection: {[sourceTransaction.transactionID]: sourceTransaction}, sourceTransaction, sourceIOUActionThreadReport: undefined, targetTransactionThreadReport: {reportID: 'target123'}, @@ -1079,6 +1085,7 @@ describe('mergeTransactionRequest', () => { mergeTransactionID, mergeTransaction, targetTransaction, + transactionsOfDeletableReportCollection: {[sourceTransaction.transactionID]: sourceTransaction, [otherTransaction.transactionID]: otherTransaction}, sourceTransaction, sourceIOUActionThreadReport: undefined, targetTransactionThreadReport: {reportID: 'target-report-456'}, @@ -1291,6 +1298,7 @@ describe('mergeTransactionRequest', () => { mergeTransactionID, mergeTransaction, targetTransaction, + transactionsOfDeletableReportCollection: {[sourceTransaction.transactionID]: sourceTransaction, [otherTransaction.transactionID]: otherTransaction}, sourceTransaction, sourceIOUActionThreadReport: thread, targetTransactionThreadReport: {reportID: 'target-report-456'}, @@ -1452,6 +1460,7 @@ describe('mergeTransactionRequest', () => { mergeTransactionID, mergeTransaction, targetTransaction, + transactionsOfDeletableReportCollection: {[sourceTransaction.transactionID]: sourceTransaction}, sourceTransaction, sourceIOUActionThreadReport: thread, targetTransactionThreadReport: {reportID: 'target-report-456'}, @@ -1589,6 +1598,7 @@ describe('mergeTransactionRequest', () => { mergeTransactionID, mergeTransaction, targetTransaction, + transactionsOfDeletableReportCollection: {[sourceTransaction.transactionID]: sourceTransaction}, sourceTransaction, sourceIOUActionThreadReport, targetTransactionThreadReport: {reportID: targetReportID}, @@ -1736,6 +1746,7 @@ describe('mergeTransactionRequest', () => { mergeTransactionID, mergeTransaction, targetTransaction, + transactionsOfDeletableReportCollection: {[sourceTransaction.transactionID]: sourceTransaction}, sourceTransaction, sourceIOUActionThreadReport, targetTransactionThreadReport: {reportID: targetReportID}, @@ -1816,6 +1827,7 @@ describe('getTransactionsForMerging', () => { report: undefined, currentUserLogin: undefined, rules: undefined, + personalDetails: undefined, }); await waitForBatchedUpdates(); @@ -1825,11 +1837,556 @@ describe('getTransactionsForMerging', () => { expect(mergeTransaction).toEqual({}); }); + it('should allow admin to see cash candidates from another open draft report in the same workspace (offline)', async () => { + // Given an admin user and two open draft expense reports in the same workspace + const adminLogin = 'admin@test.com'; + const submitterAccountID = 42; + + const policy = { + ...createRandomPolicy(1), + id: 'workspace-1', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + }; + + const targetReport = { + ...createExpenseReport(1), + reportID: 'target-report-1', + policyID: 'workspace-1', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + const otherDraftReport = { + ...createExpenseReport(2), + reportID: 'other-report-2', + policyID: 'workspace-1', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + // Card expense (target) on the first report + const targetTransaction = { + ...createRandomTransaction(1), + transactionID: 'card-tx-1', + reportID: 'target-report-1', + // Make it a card transaction by giving it a cardNumber + cardNumber: '1234', + bank: undefined, + }; + + // Cash expense candidate on the other draft report + const candidateTransaction = { + ...createRandomTransaction(2), + transactionID: 'cash-tx-2', + reportID: 'other-report-2', + // Cash: no cardNumber, bank, or managedCard + cardNumber: undefined, + bank: undefined, + managedCard: false, + amount: 500, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`, targetReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${otherDraftReport.reportID}`, otherDraftReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await waitForBatchedUpdates(); + + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`]: targetTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${candidateTransaction.transactionID}`]: candidateTransaction, + }; + const allPolicies = { + [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: policy, + }; + + // When the admin requests merge candidates offline + const allReports = { + [`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`]: targetReport, + [`${ONYXKEYS.COLLECTION.REPORT}${otherDraftReport.reportID}`]: otherDraftReport, + }; + + getTransactionsForMerging({ + isOffline: true, + targetTransaction, + transactions: allTransactions, + reportTransactions: [], + policy, + report: targetReport, + currentUserLogin: adminLogin, + rules: undefined, + allPolicies, + allReports, + personalDetails: undefined, + }); + await waitForBatchedUpdates(); + + // Then the cash candidate from the other draft report appears in eligible transactions + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${targetTransaction.transactionID}`); + expect(mergeTransaction?.eligibleTransactions?.some((t) => t.transactionID === candidateTransaction.transactionID)).toBe(true); + }); + + it('should allow admin to see cash candidates from a submitted report pending final approval in the same workspace (offline)', async () => { + // Given an admin user, an open draft report, and a submitted report awaiting approval in the same workspace + const adminLogin = 'admin@test.com'; + const submitterAccountID = 42; + + const policy = { + ...createRandomPolicy(1), + id: 'workspace-1', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + }; + + const targetReport = { + ...createExpenseReport(1), + reportID: 'target-report-1', + policyID: 'workspace-1', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + const submittedReport = { + ...createExpenseReport(2), + reportID: 'submitted-report-2', + policyID: 'workspace-1', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }; + + const targetTransaction = { + ...createRandomTransaction(1), + transactionID: 'card-tx-1', + reportID: 'target-report-1', + cardNumber: '1234', + bank: undefined, + }; + + const candidateTransaction = { + ...createRandomTransaction(2), + transactionID: 'cash-tx-2', + reportID: 'submitted-report-2', + cardNumber: undefined, + bank: undefined, + managedCard: false, + amount: 500, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`, targetReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${submittedReport.reportID}`, submittedReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await waitForBatchedUpdates(); + + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`]: targetTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${candidateTransaction.transactionID}`]: candidateTransaction, + }; + const allPolicies = { + [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: policy, + }; + const allReports = { + [`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`]: targetReport, + [`${ONYXKEYS.COLLECTION.REPORT}${submittedReport.reportID}`]: submittedReport, + }; + + // When the admin requests merge candidates offline + getTransactionsForMerging({ + isOffline: true, + targetTransaction, + transactions: allTransactions, + reportTransactions: [], + policy, + report: targetReport, + currentUserLogin: adminLogin, + rules: undefined, + allPolicies, + allReports, + personalDetails: undefined, + }); + await waitForBatchedUpdates(); + + // Then the cash candidate from the submitted report appears in eligible transactions, because admins can still edit reports before final approval + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${targetTransaction.transactionID}`); + expect(mergeTransaction?.eligibleTransactions?.some((t) => t.transactionID === candidateTransaction.transactionID)).toBe(true); + }); + + it('should NOT allow admin to see candidates from another workspace where they are not an admin (offline)', async () => { + // Given an admin in workspace-1 but only a member in workspace-2 + const adminLogin = 'admin@test.com'; + const submitterAccountID = 42; + + const adminPolicy = { + ...createRandomPolicy(1), + id: 'workspace-1', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + }; + const memberPolicy = { + ...createRandomPolicy(2), + id: 'workspace-2', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + }; + + const targetReport = { + ...createExpenseReport(1), + reportID: 'admin-report-1', + policyID: 'workspace-1', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + const otherWorkspaceReport = { + ...createExpenseReport(2), + reportID: 'member-report-2', + policyID: 'workspace-2', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const targetTransaction = { + ...createRandomTransaction(1), + transactionID: 'card-tx-a', + reportID: 'admin-report-1', + cardNumber: '1234', + bank: undefined, + }; + + const candidateTransaction = { + ...createRandomTransaction(2), + transactionID: 'cash-tx-b', + reportID: 'member-report-2', + cardNumber: undefined, + bank: undefined, + managedCard: false, + amount: 500, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`, targetReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${otherWorkspaceReport.reportID}`, otherWorkspaceReport); + await waitForBatchedUpdates(); + + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`]: targetTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${candidateTransaction.transactionID}`]: candidateTransaction, + }; + const allPolicies = { + [`${ONYXKEYS.COLLECTION.POLICY}${adminPolicy.id}`]: adminPolicy, + [`${ONYXKEYS.COLLECTION.POLICY}${memberPolicy.id}`]: memberPolicy, + }; + + const allReports = { + [`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`]: targetReport, + [`${ONYXKEYS.COLLECTION.REPORT}${otherWorkspaceReport.reportID}`]: otherWorkspaceReport, + }; + + getTransactionsForMerging({ + isOffline: true, + targetTransaction, + transactions: allTransactions, + reportTransactions: [], + policy: adminPolicy, + report: targetReport, + currentUserLogin: adminLogin, + rules: undefined, + allPolicies, + allReports, + personalDetails: undefined, + }); + await waitForBatchedUpdates(); + + // Then the candidate from the non-admin workspace is NOT in the eligible list + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${targetTransaction.transactionID}`); + expect(mergeTransaction?.eligibleTransactions?.some((t) => t.transactionID === candidateTransaction.transactionID)).toBe(false); + }); + + it('should exclude candidates when their policy is missing from the local cache (offline)', async () => { + // Given an admin with a candidate whose policy is not cached locally + const adminLogin = 'admin@test.com'; + const submitterAccountID = 42; + + const adminPolicy = { + ...createRandomPolicy(1), + id: 'workspace-known', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + }; + + const targetReport = { + ...createExpenseReport(1), + reportID: 'known-report', + policyID: 'workspace-known', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + const unknownPolicyReport = { + ...createExpenseReport(2), + reportID: 'unknown-policy-report', + policyID: 'workspace-unknown', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const targetTransaction = { + ...createRandomTransaction(1), + transactionID: 'card-tx-known', + reportID: 'known-report', + cardNumber: '9999', + bank: undefined, + }; + const candidateTransaction = { + ...createRandomTransaction(2), + transactionID: 'cash-tx-unknown', + reportID: 'unknown-policy-report', + cardNumber: undefined, + bank: undefined, + managedCard: false, + amount: 300, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`, targetReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${unknownPolicyReport.reportID}`, unknownPolicyReport); + await waitForBatchedUpdates(); + + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`]: targetTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${candidateTransaction.transactionID}`]: candidateTransaction, + }; + // workspace-unknown is intentionally absent from the local policy cache + const allPolicies = { + [`${ONYXKEYS.COLLECTION.POLICY}${adminPolicy.id}`]: adminPolicy, + }; + + const allReports = { + [`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`]: targetReport, + [`${ONYXKEYS.COLLECTION.REPORT}${unknownPolicyReport.reportID}`]: unknownPolicyReport, + }; + + getTransactionsForMerging({ + isOffline: true, + targetTransaction, + transactions: allTransactions, + reportTransactions: [], + policy: adminPolicy, + report: targetReport, + currentUserLogin: adminLogin, + rules: undefined, + allPolicies, + allReports, + personalDetails: undefined, + }); + await waitForBatchedUpdates(); + + // Then the candidate from the uncached workspace is excluded rather than allowed + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${targetTransaction.transactionID}`); + expect(mergeTransaction?.eligibleTransactions?.some((t) => t.transactionID === candidateTransaction.transactionID)).toBe(false); + }); + + it('should limit manager (non-admin approver) to same-report candidates on processing reports (offline)', async () => { + // Given a manager (not admin) reviewing a processing report + const managerLogin = 'manager@test.com'; + const managerAccountID = 99; + const submitterAccountID = 42; + + // Set the session so module-level currentUserAccountID resolves to the manager + await Onyx.set(ONYXKEYS.SESSION, {email: managerLogin, accountID: managerAccountID}); + await waitForBatchedUpdates(); + + const policy = { + ...createRandomPolicy(1), + id: 'workspace-manager', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + }; + + const processingReport = { + ...createExpenseReport(1), + reportID: 'processing-report', + policyID: 'workspace-manager', + ownerAccountID: submitterAccountID, + managerID: managerAccountID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }; + const otherOpenReport = { + ...createExpenseReport(2), + reportID: 'other-open-report', + policyID: 'workspace-manager', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const targetTransaction = { + ...createRandomTransaction(1), + transactionID: 'card-tx-mgr', + reportID: 'processing-report', + cardNumber: '5678', + bank: undefined, + }; + const sameReportCandidate = { + ...createRandomTransaction(2), + transactionID: 'cash-tx-same', + reportID: 'processing-report', + cardNumber: undefined, + bank: undefined, + managedCard: false, + amount: 400, + }; + const otherReportCandidate = { + ...createRandomTransaction(3), + transactionID: 'cash-tx-other', + reportID: 'other-open-report', + cardNumber: undefined, + bank: undefined, + managedCard: false, + amount: 600, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`, targetTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${sameReportCandidate.transactionID}`, sameReportCandidate); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${otherReportCandidate.transactionID}`, otherReportCandidate); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${processingReport.reportID}`, processingReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${otherOpenReport.reportID}`, otherOpenReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await waitForBatchedUpdates(); + + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`]: targetTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${sameReportCandidate.transactionID}`]: sameReportCandidate, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${otherReportCandidate.transactionID}`]: otherReportCandidate, + }; + const allPolicies = { + [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: policy, + }; + + const allReports = { + [`${ONYXKEYS.COLLECTION.REPORT}${processingReport.reportID}`]: processingReport, + [`${ONYXKEYS.COLLECTION.REPORT}${otherOpenReport.reportID}`]: otherOpenReport, + }; + + getTransactionsForMerging({ + isOffline: true, + targetTransaction, + transactions: allTransactions, + reportTransactions: [targetTransaction, sameReportCandidate], + policy, + report: processingReport, + currentUserLogin: managerLogin, + rules: undefined, + allPolicies, + allReports, + personalDetails: undefined, + }); + await waitForBatchedUpdates(); + + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${targetTransaction.transactionID}`); + // Same-report candidate is included; cross-report candidate is excluded for a non-admin manager + expect(mergeTransaction?.eligibleTransactions?.some((t) => t.transactionID === sameReportCandidate.transactionID)).toBe(true); + expect(mergeTransaction?.eligibleTransactions?.some((t) => t.transactionID === otherReportCandidate.transactionID)).toBe(false); + }); + + it('should include candidates from other draft reports for admin who is also the submitter (treated as submitter)', async () => { + // Given an admin who is also the report owner (submitter), they go through the submitter path + const adminLogin = 'admin-owner@test.com'; + const adminAccountID = 7; + + // Set the session so module-level currentUserAccountID resolves to the admin/submitter + await Onyx.set(ONYXKEYS.SESSION, {email: adminLogin, accountID: adminAccountID}); + await waitForBatchedUpdates(); + + const policy = { + ...createRandomPolicy(1), + id: 'workspace-self', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + }; + + const ownReport = { + ...createExpenseReport(1), + reportID: 'own-report', + policyID: 'workspace-self', + ownerAccountID: adminAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + const otherOwnDraftReport = { + ...createExpenseReport(2), + reportID: 'own-other-report', + policyID: 'workspace-self', + ownerAccountID: adminAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const targetTransaction = { + ...createRandomTransaction(1), + transactionID: 'self-card-tx', + reportID: 'own-report', + cardNumber: '1111', + bank: undefined, + }; + const candidateTransaction = { + ...createRandomTransaction(2), + transactionID: 'self-cash-tx', + reportID: 'own-other-report', + cardNumber: undefined, + bank: undefined, + managedCard: false, + amount: 250, + }; + + // Set individual transactions in Onyx so the offline filter can access them + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`, targetTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${candidateTransaction.transactionID}`, candidateTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${ownReport.reportID}`, ownReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${otherOwnDraftReport.reportID}`, otherOwnDraftReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await waitForBatchedUpdates(); + + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`]: targetTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${candidateTransaction.transactionID}`]: candidateTransaction, + }; + const allPolicies = { + [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: policy, + }; + + // Session matches the report ownerAccountID so this is treated as the submitter path + const allReports = { + [`${ONYXKEYS.COLLECTION.REPORT}${ownReport.reportID}`]: ownReport, + [`${ONYXKEYS.COLLECTION.REPORT}${otherOwnDraftReport.reportID}`]: otherOwnDraftReport, + }; + + getTransactionsForMerging({ + isOffline: true, + targetTransaction, + transactions: allTransactions, + reportTransactions: [], + policy, + report: ownReport, + currentUserLogin: adminLogin, + rules: undefined, + allPolicies, + allReports, + personalDetails: undefined, + }); + await waitForBatchedUpdates(); + + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${targetTransaction.transactionID}`); + expect(mergeTransaction?.eligibleTransactions?.some((t) => t.transactionID === candidateTransaction.transactionID)).toBe(true); + }); + it('builds the eligible list from reportTransactions when a workspace approver merges from a report they did not submit', async () => { - // Given a workspace approver (policy admin) reviewing a report submitted by someone else (TEST_ACCOUNT_ID + // Given a non-admin workspace approver reviewing a report submitted by someone else (TEST_ACCOUNT_ID // is the signed-in user for this file, so ownerAccountID must differ from it for isCurrentUserSubmitter to be false) - const policy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), role: CONST.POLICY.ROLE.ADMIN}; - const report = {...createRandomReport(1), ownerAccountID: TEST_ACCOUNT_ID + 1}; + const policy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), role: CONST.POLICY.ROLE.USER}; + const report = {...createRandomReport(1), ownerAccountID: TEST_ACCOUNT_ID + 1, managerID: TEST_ACCOUNT_ID}; const targetTransaction = {...createRandomTransaction(1), reportID: report.reportID, managedCard: false, cardName: CONST.EXPENSE.TYPE.CASH_CARD_NAME, amount: 1000}; const eligibleTransaction = {...createRandomTransaction(2), reportID: report.reportID, managedCard: false, cardName: CONST.EXPENSE.TYPE.CASH_CARD_NAME, amount: 2000}; const pendingDeleteTransaction = { @@ -1852,6 +2409,7 @@ describe('getTransactionsForMerging', () => { report, currentUserLogin: 'approver@example.com', rules: undefined, + personalDetails: undefined, }); await waitForBatchedUpdates(); diff --git a/tests/actions/PaymentMethodsTest.ts b/tests/actions/PaymentMethodsTest.ts new file mode 100644 index 000000000000..51e10429fec4 --- /dev/null +++ b/tests/actions/PaymentMethodsTest.ts @@ -0,0 +1,91 @@ +import {makeDefaultPaymentMethod} from '@libs/actions/PaymentMethods'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import type {MockFetch} from '../utils/TestHelper'; + +import createMockPaymentMethod from '../utils/collections/paymentMethods'; +import createRandomPolicy from '../utils/collections/policies'; +import getOnyxValue from '../utils/getOnyxValue'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const ACCOUNT_ID = 1; +const BANK_ACCOUNT_ID = 1234; +const PAYMENTS_ON_POLICY_ID = 'paymentsOn'; +const PAYMENTS_OFF_POLICY_ID = 'paymentsOff'; +const MEMBER_POLICY_ID = 'member'; + +function buildPolicy(id: string, role: Policy['role'], reimbursementChoice: Policy['reimbursementChoice']): Policy { + return {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), id, role, reimbursementChoice}; +} + +describe('actions/PaymentMethods', () => { + let mockFetch: MockFetch; + + const policies = { + [`${ONYXKEYS.COLLECTION.POLICY}${PAYMENTS_ON_POLICY_ID}`]: buildPolicy(PAYMENTS_ON_POLICY_ID, CONST.POLICY.ROLE.ADMIN, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL), + [`${ONYXKEYS.COLLECTION.POLICY}${PAYMENTS_OFF_POLICY_ID}`]: buildPolicy(PAYMENTS_OFF_POLICY_ID, CONST.POLICY.ROLE.ADMIN, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO), + [`${ONYXKEYS.COLLECTION.POLICY}${MEMBER_POLICY_ID}`]: buildPolicy(MEMBER_POLICY_ID, CONST.POLICY.ROLE.USER, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL), + }; + const previousBankAccount = {bankName: CONST.BANK_NAMES.CHASE, bankAccountLastFour: '1111'}; + const policyMemberBankAccounts = { + [`${ONYXKEYS.COLLECTION.POLICY_MEMBER_BANK_ACCOUNTS}${PAYMENTS_ON_POLICY_ID}`]: {[ACCOUNT_ID]: previousBankAccount}, + }; + const newDefault = createMockPaymentMethod({ + methodID: BANK_ACCOUNT_ID, + accountData: {bankAccountID: BANK_ACCOUNT_ID, accountNumber: 'XXXXXX5678', additionalData: {bankName: CONST.BANK_NAMES.CITIBANK}}, + }); + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockFetch = TestHelper.createGlobalFetchMock(); + global.fetch = mockFetch; + await Onyx.clear(); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_MEMBER_BANK_ACCOUNTS}${PAYMENTS_ON_POLICY_ID}`, {[ACCOUNT_ID]: previousBankAccount}); + await waitForBatchedUpdates(); + }); + + afterEach(() => { + mockFetch?.resume?.(); + }); + + describe('makeDefaultPaymentMethod', () => { + test("shows the admin's new default bank account on the Members page of workspaces with payments on", async () => { + // Given the request is held so the optimistic state can be observed + mockFetch.pause?.(); + + // When an admin makes a different bank account their default + makeDefaultPaymentMethod(BANK_ACCOUNT_ID, 0, undefined, newDefault, policies, policyMemberBankAccounts, ACCOUNT_ID); + await waitForBatchedUpdates(); + + // Then the workspace they admin with payments on shows the new last four right away + const paymentsOn = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_MEMBER_BANK_ACCOUNTS}${PAYMENTS_ON_POLICY_ID}`); + expect(paymentsOn?.[ACCOUNT_ID]).toEqual({bankName: CONST.BANK_NAMES.CITIBANK, bankAccountLastFour: '5678'}); + + // And workspaces where they aren't an admin, or payments are off, are left alone since they don't show the column + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_MEMBER_BANK_ACCOUNTS}${PAYMENTS_OFF_POLICY_ID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_MEMBER_BANK_ACCOUNTS}${MEMBER_POLICY_ID}`)).toBeUndefined(); + }); + + test('restores the previous bank account when the request fails', async () => { + // Given the backend rejects the change + mockFetch.fail?.(); + + // When an admin makes a different bank account their default + makeDefaultPaymentMethod(BANK_ACCOUNT_ID, 0, undefined, newDefault, policies, policyMemberBankAccounts, ACCOUNT_ID); + await waitForBatchedUpdates(); + + // Then the Members page goes back to the account the backend still has as the default + const paymentsOn = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_MEMBER_BANK_ACCOUNTS}${PAYMENTS_ON_POLICY_ID}`); + expect(paymentsOn?.[ACCOUNT_ID]).toEqual(previousBankAccount); + }); + }); +}); diff --git a/tests/actions/PolicyMemberTest.ts b/tests/actions/PolicyMemberTest.ts index 884d751f8bdc..332f9f26b14e 100644 --- a/tests/actions/PolicyMemberTest.ts +++ b/tests/actions/PolicyMemberTest.ts @@ -11,7 +11,7 @@ import type {InvitedEmailsToAccountIDs, PolicyEmployeeList, Policy as PolicyType import type {NetSuiteConnection, NetSuiteConnectionConfig, NetSuiteConnectionData} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {OnyxEntry, OnyxKey, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -1487,6 +1487,23 @@ describe('actions/PolicyMember', () => { expect(draft?.[user2Email]).toBe(user2AccountID); }); + it('should clear the previous arrangement choice when starting a new invite selection', async () => { + // Given a work-arrangement selection left by a previous invite for this workspace + const policyID = '1'; + // The type for this collection key is represented by the collection prefix, not its generated member keys. + const arrangementDraftKey = `${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_WORK_ARRANGEMENT_DRAFT}${policyID}` as OnyxKey; + const inviteeEmail = 'new-user@example.com'; + await Onyx.set(arrangementDraftKey, true); + await waitForBatchedUpdates(); + + // When a new set of invitees is selected + Member.setWorkspaceInviteMembersDraft(policyID, {[inviteeEmail]: 1234}); + await waitForBatchedUpdates(); + + // Then the new invite falls back to the workspace arrangement default + expect(await getOnyxValue(arrangementDraftKey)).toBeFalsy(); + }); + it('should update existing draft with new selections', async () => { // Given an existing draft const policyID = '1'; diff --git a/tests/actions/PolicyOfficeLocationTest.ts b/tests/actions/PolicyOfficeLocationTest.ts new file mode 100644 index 000000000000..7282fc1a25f0 --- /dev/null +++ b/tests/actions/PolicyOfficeLocationTest.ts @@ -0,0 +1,323 @@ +import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; +import {addOfficeLocation, clearOfficeLocationErrors, deleteOfficeLocation, updateOfficeLocation} from '@libs/actions/Policy/DistanceRate'; +import {WRITE_COMMANDS} from '@libs/API/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; +import type {CompanyAddress, OfficeLocation} from '@src/types/onyx/Policy'; + +import Onyx from 'react-native-onyx'; + +import type {MockFetch} from '../utils/TestHelper'; + +import createRandomPolicy from '../utils/collections/policies'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +OnyxUpdateManager(); + +const HEADQUARTERS_ADDRESS: CompanyAddress = { + addressStreet: '88 Kearny St', + city: 'San Francisco', + state: 'CA', + zipCode: '94108', + country: 'US', +}; + +const NEW_JERSEY_ADDRESS: CompanyAddress = { + addressStreet: '900 Asbury Ave\nSuite 2', + city: 'Ocean City', + state: 'NJ', + zipCode: '08226', + country: 'US', +}; + +const HEADQUARTERS: OfficeLocation = {name: 'Headquarters', address: HEADQUARTERS_ADDRESS, isDefault: true}; + +function getPolicy(policyID: string): Promise { + return new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + callback: (policy) => { + Onyx.disconnect(connection); + resolve(policy); + }, + }); + }); +} + +function getRequestParams(command: typeof WRITE_COMMANDS.ADD_OFFICE_LOCATION | typeof WRITE_COMMANDS.UPDATE_OFFICE_LOCATION): Record { + const body = TestHelper.getFetchMockCalls(command).at(0)?.[1]?.body; + if (!(body instanceof FormData)) { + return {}; + } + return Object.fromEntries(Array.from(body.entries(), ([key, value]) => [key, typeof value === 'string' ? value : ''])); +} + +describe('actions/PolicyOfficeLocation', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + let mockFetch: MockFetch; + let policyID: string; + beforeEach(async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + mockFetch = fetch as MockFetch; + await Onyx.clear(); + + // Given a workspace whose only office is its primary one + const policy = createRandomPolicy(0); + policyID = policy.id; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {...policy, officeLocations: {OFFICE1: HEADQUARTERS}}); + await waitForBatchedUpdates(); + }); + + describe('addOfficeLocation', () => { + it('shows the new office as pending, makes it the primary office, and clears the pending state on success', async () => { + // When a new office is added as the primary one while the request is in flight + mockFetch?.pause?.(); + addOfficeLocation(policyID, {OFFICE1: HEADQUARTERS}, NEW_JERSEY_ADDRESS, true, 'New Jersey Office', 'Office location 2'); + await waitForBatchedUpdates(); + + // Then the request carries the client-generated officeID and the address as JSON, which is how Auth reads it + const params = getRequestParams(WRITE_COMMANDS.ADD_OFFICE_LOCATION); + const newOfficeID = params.officeID; + expect(JSON.parse(params.address)).toEqual(NEW_JERSEY_ADDRESS); + + // And the new office shows as pending, and the previous primary office stops being the primary one + let officeLocations = (await getPolicy(policyID))?.officeLocations ?? {}; + expect(officeLocations[newOfficeID]).toMatchObject({ + name: 'New Jersey Office', + address: NEW_JERSEY_ADDRESS, + isDefault: true, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }); + expect(officeLocations.OFFICE1?.isDefault).toBe(false); + + // When the request succeeds + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + + // Then the new office is no longer pending + officeLocations = (await getPolicy(policyID))?.officeLocations ?? {}; + expect(officeLocations[newOfficeID]?.pendingAction).toBeFalsy(); + }); + + it('keeps a failed office with its error, restores the primary office, and removes the failed office once the error is dismissed', async () => { + // When adding a new primary office fails + mockFetch?.fail?.(); + addOfficeLocation(policyID, {OFFICE1: HEADQUARTERS}, NEW_JERSEY_ADDRESS, true, 'New Jersey Office', 'Office location 2'); + await waitForBatchedUpdates(); + + // Then the failed office stays visible as pending with an error, so the admin can see it didn't save + const failedOfficeID = getRequestParams(WRITE_COMMANDS.ADD_OFFICE_LOCATION).officeID; + let officeLocations = (await getPolicy(policyID))?.officeLocations ?? {}; + const failedOffice = officeLocations[failedOfficeID]; + expect(failedOffice?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(Object.keys(failedOffice?.errors ?? {}).length).toBeGreaterThan(0); + + // And the previous primary office is the only primary one again + expect(officeLocations.OFFICE1?.isDefault).toBe(true); + expect(failedOffice?.isDefault).toBe(false); + + // When the error is dismissed + clearOfficeLocationErrors(policyID, failedOfficeID, failedOffice?.pendingAction); + await waitForBatchedUpdates(); + + // Then the office that never reached the server is removed + officeLocations = (await getPolicy(policyID))?.officeLocations ?? {}; + expect(Object.keys(officeLocations)).toEqual(['OFFICE1']); + }); + }); + + describe('addOfficeLocation without a name', () => { + it('shows the default name until the server names the office, without sending a name', async () => { + // When an office is added without a name while the request is in flight + mockFetch?.pause?.(); + addOfficeLocation(policyID, {OFFICE1: HEADQUARTERS}, NEW_JERSEY_ADDRESS, false, '', 'Office location 2'); + await waitForBatchedUpdates(); + + // Then no name is sent, so the server names the office + const params = getRequestParams(WRITE_COMMANDS.ADD_OFFICE_LOCATION); + expect(params.officeName).toBeUndefined(); + + // And the office shows the default name meanwhile + const officeLocations = (await getPolicy(policyID))?.officeLocations ?? {}; + expect(officeLocations[params.officeID]?.name).toBe('Office location 2'); + + await mockFetch?.resume?.(); + }); + + it('keeps an unsaved office pending addition when it is edited, so dismissing its error still removes it', async () => { + // Given an office whose addition failed + mockFetch?.fail?.(); + addOfficeLocation(policyID, {OFFICE1: HEADQUARTERS}, NEW_JERSEY_ADDRESS, false, 'New Jersey Office', 'Office location 2'); + await waitForBatchedUpdates(); + const failedOfficeID = getRequestParams(WRITE_COMMANDS.ADD_OFFICE_LOCATION).officeID; + + // When it is edited, which fails too since the server doesn't have it + const officeLocations = (await getPolicy(policyID))?.officeLocations; + updateOfficeLocation(policyID, officeLocations, failedOfficeID, {name: 'NJ Office'}); + await waitForBatchedUpdates(); + + // Then it is still pending addition + const failedOffice = (await getPolicy(policyID))?.officeLocations?.[failedOfficeID]; + expect(failedOffice?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + // And dismissing its error removes it, like any office that never reached the server + clearOfficeLocationErrors(policyID, failedOfficeID, failedOffice?.pendingAction); + await waitForBatchedUpdates(); + expect(Object.keys((await getPolicy(policyID))?.officeLocations ?? {})).toEqual(['OFFICE1']); + }); + }); + + describe('updateOfficeLocation', () => { + it('shows the changes as pending and clears the pending state on success', async () => { + // When the office is renamed and moved while the request is in flight + mockFetch?.pause?.(); + updateOfficeLocation(policyID, {OFFICE1: HEADQUARTERS}, 'OFFICE1', {name: 'HQ', address: NEW_JERSEY_ADDRESS}); + await waitForBatchedUpdates(); + + // Then the office shows the new values as pending + let officeLocation = (await getPolicy(policyID))?.officeLocations?.OFFICE1; + expect(officeLocation).toMatchObject({name: 'HQ', address: NEW_JERSEY_ADDRESS, isDefault: true, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}); + + // And only the changed fields are sent + const params = getRequestParams(WRITE_COMMANDS.UPDATE_OFFICE_LOCATION); + expect(params.officeName).toBe('HQ'); + expect(JSON.parse(params.address)).toEqual(NEW_JERSEY_ADDRESS); + expect(params.isDefault).toBeUndefined(); + + // When the request succeeds + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + + // Then the office keeps the new values and is no longer pending + officeLocation = (await getPolicy(policyID))?.officeLocations?.OFFICE1; + expect(officeLocation?.name).toBe('HQ'); + expect(officeLocation?.pendingAction).toBeFalsy(); + }); + + it('restores both offices and shows an error when making another office the primary one fails', async () => { + // Given a second office that isn't the primary one + const newJerseyOffice: OfficeLocation = {name: 'New Jersey Office', address: NEW_JERSEY_ADDRESS, isDefault: false}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {officeLocations: {OFFICE2: newJerseyOffice}}); + + // When making it the primary office fails + mockFetch?.fail?.(); + updateOfficeLocation(policyID, {OFFICE1: HEADQUARTERS, OFFICE2: newJerseyOffice}, 'OFFICE2', {isDefault: true}); + await waitForBatchedUpdates(); + + // Then the previous primary office is the primary one again, and the updated office shows an error + let officeLocations = (await getPolicy(policyID))?.officeLocations ?? {}; + expect(officeLocations.OFFICE1?.isDefault).toBe(true); + expect(officeLocations.OFFICE2?.isDefault).toBe(false); + expect(officeLocations.OFFICE2?.pendingAction).toBeFalsy(); + expect(Object.keys(officeLocations.OFFICE2?.errors ?? {}).length).toBeGreaterThan(0); + + // When the error is dismissed + clearOfficeLocationErrors(policyID, 'OFFICE2', officeLocations.OFFICE2?.pendingAction); + await waitForBatchedUpdates(); + + // Then the office stays, without the error, since it exists on the server + officeLocations = (await getPolicy(policyID))?.officeLocations ?? {}; + expect(officeLocations.OFFICE2?.name).toBe('New Jersey Office'); + expect(officeLocations.OFFICE2?.errors).toBeFalsy(); + }); + + it('takes the primary flag off the primary office to make the company address primary, and restores it when that fails', async () => { + // When the primary office stops being primary while the request is in flight, as making the company address + // primary does + mockFetch?.pause?.(); + updateOfficeLocation(policyID, {OFFICE1: HEADQUARTERS}, 'OFFICE1', {isDefault: false}); + await waitForBatchedUpdates(); + + // Then no office shows as primary, and the request clears the flag + expect((await getPolicy(policyID))?.officeLocations?.OFFICE1?.isDefault).toBe(false); + expect(getRequestParams(WRITE_COMMANDS.UPDATE_OFFICE_LOCATION).isDefault).toBe('false'); + + // When the request fails + mockFetch?.fail?.(); + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + + // Then the office is the primary one again and shows an error + const officeLocation = (await getPolicy(policyID))?.officeLocations?.OFFICE1; + expect(officeLocation?.isDefault).toBe(true); + expect(Object.keys(officeLocation?.errors ?? {}).length).toBeGreaterThan(0); + }); + }); + + describe('deleteOfficeLocation', () => { + it('shows the office as pending deletion and removes it on success', async () => { + // When the office is deleted while the request is in flight + mockFetch?.pause?.(); + deleteOfficeLocation(policyID, 'OFFICE1', HEADQUARTERS); + await waitForBatchedUpdates(); + + // Then it shows as pending deletion + expect((await getPolicy(policyID))?.officeLocations?.OFFICE1?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + + // When the request succeeds + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + + // Then the office is removed + expect((await getPolicy(policyID))?.officeLocations?.OFFICE1).toBeUndefined(); + }); + + it('restores the office with an error when the deletion fails', async () => { + // When deleting the office fails + mockFetch?.fail?.(); + deleteOfficeLocation(policyID, 'OFFICE1', HEADQUARTERS); + await waitForBatchedUpdates(); + + // Then the office is back, with an error explaining it wasn't deleted + const officeLocation = (await getPolicy(policyID))?.officeLocations?.OFFICE1; + expect(officeLocation?.name).toBe('Headquarters'); + expect(officeLocation?.pendingAction).toBeFalsy(); + expect(Object.keys(officeLocation?.errors ?? {}).length).toBeGreaterThan(0); + }); + + it('removes an office whose addition failed without asking the server to delete it', async () => { + // Given an office whose addition failed, so the server never stored it + const failedOffice: OfficeLocation = {...HEADQUARTERS, isDefault: false, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, errors: {addFailed: 'Something went wrong'}}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {officeLocations: {OFFICE2: failedOffice}}); + + // When it is deleted + deleteOfficeLocation(policyID, 'OFFICE2', failedOffice); + await waitForBatchedUpdates(); + + // Then it is removed right away, without a request for an office the server doesn't have + expect((await getPolicy(policyID))?.officeLocations?.OFFICE2).toBeUndefined(); + expect(TestHelper.getFetchMockCalls(WRITE_COMMANDS.DELETE_OFFICE_LOCATION)).toHaveLength(0); + }); + + it('keeps an unsaved office pending addition when deleting it fails, so dismissing the error still removes it', async () => { + // Given an office still waiting to be added + const unsavedOffice: OfficeLocation = {...HEADQUARTERS, isDefault: false, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {officeLocations: {OFFICE2: unsavedOffice}}); + + // When deleting it fails + mockFetch?.fail?.(); + deleteOfficeLocation(policyID, 'OFFICE2', unsavedOffice); + await waitForBatchedUpdates(); + + // Then it is still pending addition, with an error + let officeLocation = (await getPolicy(policyID))?.officeLocations?.OFFICE2; + expect(officeLocation?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(Object.keys(officeLocation?.errors ?? {}).length).toBeGreaterThan(0); + + // When the error is dismissed + clearOfficeLocationErrors(policyID, 'OFFICE2', officeLocation?.pendingAction); + await waitForBatchedUpdates(); + + // Then the office is removed instead of counting as a saved one + officeLocation = (await getPolicy(policyID))?.officeLocations?.OFFICE2; + expect(officeLocation).toBeUndefined(); + }); + }); +}); diff --git a/tests/actions/PolicyRulesTest.ts b/tests/actions/PolicyRulesTest.ts index 2f9fc87051ea..05294a26a926 100644 --- a/tests/actions/PolicyRulesTest.ts +++ b/tests/actions/PolicyRulesTest.ts @@ -1,14 +1,7 @@ import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; -import { - addPolicyAgentRule, - clearMerchantRuleErrors, - clearPolicyAgentRuleErrors, - deleteMerchantRule, - deletePolicyAgentRule, - setMerchantRule, - updatePolicyAgentRule, -} from '@libs/actions/Policy/Rules'; +import {addPolicyAgentRule, clearMerchantRuleErrors, clearPolicyAgentRuleErrors, deleteRule, deletePolicyAgentRule, setRule, updatePolicyAgentRule} from '@libs/actions/Policy/Rules'; import {WRITE_COMMANDS} from '@libs/API/types'; +import {buildMerchantRule} from '@libs/ExpenseDefaultRuleUtils'; import {flush as flushSequentialQueue} from '@libs/Network/SequentialQueue'; import {toIndexMap} from '@libs/RuleUtils'; @@ -462,13 +455,17 @@ describe('actions/PolicyRules', () => { }); }); - describe('setMerchantRule', () => { + describe('setRule', () => { it('writes the rule under its own key, scoped to the policy', async () => { const fakePolicy = createRandomPolicy(0); mockFetch?.pause?.(); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); - setMerchantRule(fakePolicy.id, {merchantToMatch: 'Starbucks', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, category: 'Coffee'}, fakePolicy); + setRule( + fakePolicy.id, + buildMerchantRule({merchantToMatch: 'Starbucks', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, category: 'Coffee'}, fakePolicy), + CONST.RULES.EXPENSE_DEFAULT.PRIORITY, + ); await waitForBatchedUpdates(); const rules = await getRules(); @@ -497,11 +494,11 @@ describe('actions/PolicyRules', () => { mockFetch?.pause?.(); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); - setMerchantRule(fakePolicy.id, {merchantToMatch: 'Starbucks', category: 'Coffee', tag: 'Team A'}, fakePolicy, ruleID); + setRule(fakePolicy.id, buildMerchantRule({merchantToMatch: 'Starbucks', category: 'Coffee', tag: 'Team A'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID); await waitForBatchedUpdates(); const existingRule = (await getRules())?.[`${ONYXKEYS.COLLECTION.RULE}${ruleID}`]; - setMerchantRule(fakePolicy.id, {merchantToMatch: 'Starbucks', category: 'Coffee'}, fakePolicy, ruleID, existingRule); + setRule(fakePolicy.id, buildMerchantRule({merchantToMatch: 'Starbucks', category: 'Coffee'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, existingRule); await waitForBatchedUpdates(); const updatedRule = (await getRules())?.[`${ONYXKEYS.COLLECTION.RULE}${ruleID}`]; @@ -509,20 +506,54 @@ describe('actions/PolicyRules', () => { const fields = updatedActions.map((action) => action.field); expect(fields).toEqual([CONST.RULES.EXPENSE_DEFAULT.FIELD.CATEGORY]); expect(updatedRule?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + + // Drain the paused requests, otherwise they sit in the sequential queue and every later write in this file stalls behind them. + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); }); - it('does nothing when the form has no merchant to match', async () => { + it('does nothing when the editor produced no rule body', async () => { const fakePolicy = createRandomPolicy(0); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); - setMerchantRule(fakePolicy.id, {merchantToMatch: '', category: 'Coffee'}, fakePolicy); + setRule(fakePolicy.id, buildMerchantRule({merchantToMatch: '', category: 'Coffee'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY); await waitForBatchedUpdates(); expect(Object.keys((await getRules()) ?? {})).toHaveLength(0); }); + + it('sends SetRule with the rule body stringified, since FormData cannot carry an object', async () => { + // Given a policy the admin is adding a merchant rule to + const fakePolicy = createRandomPolicy(0); + const ruleID = 'merchantRule1'; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + + // When the rule is saved + setRule( + fakePolicy.id, + buildMerchantRule({merchantToMatch: 'Starbucks', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, category: 'Coffee'}, fakePolicy), + CONST.RULES.EXPENSE_DEFAULT.PRIORITY, + ruleID, + ); + await waitForBatchedUpdates(); + + // Then SetRule carries the scope, the priority the caller passed and the serialized body + TestHelper.expectAPICommandToHaveBeenCalledWith(WRITE_COMMANDS.SET_RULE, 0, { + scope: CONST.RULES.SCOPE.POLICY, + scopeID: fakePolicy.id, + ruleID, + priority: CONST.RULES.EXPENSE_DEFAULT.PRIORITY, + value: JSON.stringify({ + triggers: toIndexMap([CONST.RULES.TRIGGERS.CREATE_TRANSACTION]), + filters: {left: CONST.RULES.EXPENSE_DEFAULT.FIELD.MERCHANT, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, right: 'Starbucks'}, + actions: toIndexMap([{name: CONST.RULES.ACTIONS.SET, field: CONST.RULES.EXPENSE_DEFAULT.FIELD.CATEGORY, value: 'Coffee'}]), + }), + shouldUpdateMatchingTransactions: false, + }); + }); }); - describe('deleteMerchantRule', () => { + describe('deleteRule', () => { it('marks the rule as pending delete', async () => { const fakePolicy = createRandomPolicy(0); const ruleID = 'merchantRule1'; @@ -530,7 +561,7 @@ describe('actions/PolicyRules', () => { mockFetch?.pause?.(); await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}${ruleID}`, rule); - deleteMerchantRule(fakePolicy.id, ruleID, rule); + deleteRule(ruleID, rule); await waitForBatchedUpdates(); expect((await getRules())?.[`${ONYXKEYS.COLLECTION.RULE}${ruleID}`]?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); @@ -538,6 +569,21 @@ describe('actions/PolicyRules', () => { await mockFetch?.resume?.(); await waitForBatchedUpdates(); }); + + it('sends DeleteRule with just the ruleID', async () => { + // Given a saved merchant rule + const fakePolicy = createRandomPolicy(0); + const ruleID = 'merchantRule1'; + const rule = buildMerchantRuleForPolicy(fakePolicy.id); + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}${ruleID}`, rule); + + // When it is deleted + deleteRule(ruleID, rule); + await waitForBatchedUpdates(); + + // Then DeleteRule identifies it by ID alone, since rules are no longer addressed through their policy + TestHelper.expectAPICommandToHaveBeenCalledWith(WRITE_COMMANDS.DELETE_RULE, 0, {ruleID}); + }); }); describe('clearMerchantRuleErrors', () => { diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 15d1ece85de0..b20b89b45c69 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -2706,6 +2706,64 @@ describe('actions/Policy', () => { }); }); + describe('getCashExpenseReimbursableMode', () => { + // The Rules page and the expense creation path both read `defaultReimbursable`. When they disagree + // about an ABSENT value the page advertises a rule that new expenses never apply, so these cases + // pin the absent value to "reimbursable" on both sides. + it('reads an absent defaultReimbursable as reimbursable when the field is not locked', () => { + // Given a workspace whose policy blob has no defaultReimbursable and no lock + const policy = {...createRandomPolicy(0), defaultReimbursable: undefined, disabledFields: {reimbursable: false}}; + + // When resolving the cash expense mode + const mode = Policy.getCashExpenseReimbursableMode(policy); + + // Then it shows "reimbursable by default" rather than claiming the non-reimbursable rule is on + expect(mode).toBe(CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.REIMBURSABLE_DEFAULT); + }); + + it('reads an absent defaultReimbursable as reimbursable when the field is locked', () => { + // Given a workspace with a locked reimbursable field but no stored defaultReimbursable + const policy = {...createRandomPolicy(0), defaultReimbursable: undefined, disabledFields: {reimbursable: true}}; + + // When resolving the cash expense mode + const mode = Policy.getCashExpenseReimbursableMode(policy); + + // Then it shows "always reimbursable", matching the value new expenses are actually created with + expect(mode).toBe(CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.ALWAYS_REIMBURSABLE); + }); + + it('reports always non-reimbursable only when defaultReimbursable is explicitly false and locked', () => { + // Given a workspace explicitly set to "cash expenses are always non-reimbursable" + const policy = {...createRandomPolicy(0), defaultReimbursable: false, disabledFields: {reimbursable: true}}; + + // When resolving the cash expense mode + const mode = Policy.getCashExpenseReimbursableMode(policy); + + // Then it shows the always non-reimbursable rule + expect(mode).toBe(CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.ALWAYS_NON_REIMBURSABLE); + }); + + it('reports non-reimbursable by default when defaultReimbursable is explicitly false and unlocked', () => { + // Given a workspace defaulting to non-reimbursable without locking the field + const policy = {...createRandomPolicy(0), defaultReimbursable: false, disabledFields: {reimbursable: false}}; + + // When resolving the cash expense mode + const mode = Policy.getCashExpenseReimbursableMode(policy); + + // Then it shows the non-reimbursable default + expect(mode).toBe(CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.NON_REIMBURSABLE_DEFAULT); + }); + + it('returns undefined when there is no policy', () => { + // Given no policy + // When resolving the cash expense mode + const mode = Policy.getCashExpenseReimbursableMode(undefined); + + // Then there is no mode to show + expect(mode).toBeUndefined(); + }); + }); + describe('setPolicyReimbursableMode', () => { it('should update reimbursable mode to REIMBURSABLE_DEFAULT optimistically and succeed', async () => { // Given a workspace with default reimbursable as false and disabled as true @@ -3380,7 +3438,7 @@ describe('actions/Policy', () => { expect(policy?.eReceipts).toBe(fakePolicy.eReceipts); }); - it('upgradeToCorporate should optimistically set receipt defaults but not max expense age/amount', async () => { + it('upgradeToCorporate should not optimistically set receipt defaults or max expense age/amount', async () => { // Given a Collect policy with expense limits disabled (empty in Rules UI) const fakePolicy: PolicyType = { ...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), @@ -3406,13 +3464,35 @@ describe('actions/Policy', () => { }); // Then age/amount stay disabled (API leaves them empty — avoids #74401 flash), - // while receipt thresholds get Control defaults (matches post-upgrade Rules UI). + // and receipt thresholds stay disabled too, because the API never saves Control defaults for them (#101524) expect(policy?.maxExpenseAge).toBe(CONST.DISABLED_MAX_EXPENSE_VALUE); expect(policy?.maxExpenseAmount).toBe(CONST.DISABLED_MAX_EXPENSE_VALUE); - expect(policy?.maxExpenseAmountNoReceipt).toBe(CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT); - expect(policy?.maxExpenseAmountNoItemizedReceipt).toBe(CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT); + expect(policy?.maxExpenseAmountNoReceipt).toBe(CONST.DISABLED_MAX_EXPENSE_VALUE); + expect(policy?.maxExpenseAmountNoItemizedReceipt).toBe(CONST.DISABLED_MAX_EXPENSE_VALUE); expect(policy?.type).toBe(CONST.POLICY.TYPE.CORPORATE); }); + + it('upgradeToCorporate should re-apply receipt thresholds the policy already had', async () => { + // Given a Collect policy that already requires receipts above custom amounts + const receiptAmount = 5000; + const itemizedAmount = 10000; + const fakePolicy: PolicyType = { + ...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), + maxExpenseAmountNoReceipt: receiptAmount, + maxExpenseAmountNoItemizedReceipt: itemizedAmount, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + + // When upgrading to corporate + Policy.upgradeToCorporate(fakePolicy); + await waitForBatchedUpdates(); + + const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`); + + // Then the existing thresholds are kept, so the Rules row does not flash "Don't require receipts" while the upgrade syncs + expect(policy?.maxExpenseAmountNoReceipt).toBe(receiptAmount); + expect(policy?.maxExpenseAmountNoItemizedReceipt).toBe(itemizedAmount); + }); }); describe('upgradeSubmit', () => { @@ -6353,6 +6433,54 @@ describe('actions/Policy', () => { }); }); + describe('setPolicyPreventPayoutNonReimbursableReports', () => { + it('should update preventPayoutNonReimbursableReports optimistically and succeed', async () => { + // Given a workspace with preventPayoutNonReimbursableReports disabled + const policy = { + ...createRandomPolicy(0), + preventPayoutNonReimbursableReports: undefined, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + mockFetch.pause(); + + // When enabling preventPayoutNonReimbursableReports + Policy.setPolicyPreventPayoutNonReimbursableReports(policy.id, true, policy.preventPayoutNonReimbursableReports); + + // Then optimistic data should be set in Onyx + await waitForBatchedUpdates(); + let updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(updatedPolicy?.preventPayoutNonReimbursableReports).toBe(true); + expect(updatedPolicy?.pendingFields?.preventPayoutNonReimbursableReports).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + + // When the fetch resumes and succeeds + await mockFetch.resume(); + + // Then pendingFields should be cleared + updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(updatedPolicy?.pendingFields?.preventPayoutNonReimbursableReports).toBeUndefined(); + }); + + it('should revert preventPayoutNonReimbursableReports when fail', async () => { + // Given a workspace with preventPayoutNonReimbursableReports disabled + const policy = { + ...createRandomPolicy(0), + preventPayoutNonReimbursableReports: undefined, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + // When enabling preventPayoutNonReimbursableReports but fail + mockFetch.fail(); + Policy.setPolicyPreventPayoutNonReimbursableReports(policy.id, true, policy.preventPayoutNonReimbursableReports); + await waitForBatchedUpdates(); + + // Then it should be reverted + const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(updatedPolicy?.preventPayoutNonReimbursableReports).toBeUndefined(); + expect(updatedPolicy?.pendingFields?.preventPayoutNonReimbursableReports).toBeUndefined(); + expect(updatedPolicy?.errorFields?.preventPayoutNonReimbursableReports).toBeTruthy(); + }); + }); + describe('setPolicyAutomaticApprovalLimit', () => { it('should update automatic approval limit optimistically and succeed', async () => { // Given a workspace with auto approval limit 100 diff --git a/tests/actions/ReplaceOptimisticAgentWithActualAgentTest.ts b/tests/actions/ReplaceOptimisticAgentWithActualAgentTest.ts index 9b3cb83156d2..202709a5eeb0 100644 --- a/tests/actions/ReplaceOptimisticAgentWithActualAgentTest.ts +++ b/tests/actions/ReplaceOptimisticAgentWithActualAgentTest.ts @@ -56,7 +56,7 @@ function buildAgentEditNavigationState(accountID: number): PartialState { await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { [optimisticAccountID]: {displayName: 'Concierge Travel', isOptimisticPersonalDetail: true}, }); - await Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, {prompt: 'Book my flights'}); + await Onyx.set(`${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, {prompt: 'Book my flights'}); await waitForBatchedUpdates(); replaceOptimisticAgentWithActualAgent(optimisticAccountID, realAccountID); @@ -158,14 +158,14 @@ describe('replaceOptimisticAgentWithActualAgent', () => { const reportAfterFirstRun = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const personalDetailsAfterFirstRun = await getOnyxValue(ONYXKEYS.PERSONAL_DETAILS_LIST); - const promptAfterFirstRun = await getOnyxValue(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${realAccountID}`); + const promptAfterFirstRun = await getOnyxValue(`${ONYXKEYS.COLLECTION.AGENT}${realAccountID}`); replaceOptimisticAgentWithActualAgent(optimisticAccountID, realAccountID); await waitForBatchedUpdates(); expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toStrictEqual(reportAfterFirstRun); expect(await getOnyxValue(ONYXKEYS.PERSONAL_DETAILS_LIST)).toStrictEqual(personalDetailsAfterFirstRun); - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${realAccountID}`)).toStrictEqual(promptAfterFirstRun); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.AGENT}${realAccountID}`)).toStrictEqual(promptAfterFirstRun); }); it('clears the optimistic personal detail and prompt without carrying their stale state onto the real agent', async () => { @@ -198,8 +198,8 @@ describe('replaceOptimisticAgentWithActualAgent', () => { errorFields: {avatar: {[errorTimestamp]: 'Unexpected error updating the avatar'}}, }, }); - await Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${realAccountID}`, realAgentPrompt); - await Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, { + await Onyx.set(`${ONYXKEYS.COLLECTION.AGENT}${realAccountID}`, realAgentPrompt); + await Onyx.set(`${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, { prompt: 'Book my flights and hotels', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: {[errorTimestamp]: 'Unexpected error updating the prompt'}, @@ -214,8 +214,8 @@ describe('replaceOptimisticAgentWithActualAgent', () => { const personalDetails = await getOnyxValue(ONYXKEYS.PERSONAL_DETAILS_LIST); expect(personalDetails?.[realAccountID]).toStrictEqual(realPersonalDetail); expect(personalDetails?.[optimisticAccountID]).toBeUndefined(); - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${realAccountID}`)).toStrictEqual(realAgentPrompt); - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.AGENT}${realAccountID}`)).toStrictEqual(realAgentPrompt); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`)).toBeUndefined(); const mapping = await getOnyxValue(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING); expect(mapping?.[optimisticAccountID]).toBeUndefined(); }); @@ -229,7 +229,7 @@ describe('replaceOptimisticAgentWithActualAgent', () => { await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { [optimisticAccountID]: {accountID: optimisticAccountID, displayName: 'Concierge Travel', isOptimisticPersonalDetail: true}, }); - await Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, { + await Onyx.set(`${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, { prompt: 'Book my flights', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, }); @@ -242,8 +242,8 @@ describe('replaceOptimisticAgentWithActualAgent', () => { const personalDetails = await getOnyxValue(ONYXKEYS.PERSONAL_DETAILS_LIST); expect(personalDetails?.[realAccountID]).toBeUndefined(); expect(personalDetails?.[optimisticAccountID]).toBeUndefined(); - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${realAccountID}`)).toBeUndefined(); - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.AGENT}${realAccountID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`)).toBeUndefined(); }); it('redirects every agent settings screen in the navigation stack to the real accountID, skipping unrelated screens', async () => { @@ -264,8 +264,8 @@ describe('replaceOptimisticAgentWithActualAgent', () => { key: 'agent-settings-stack-key', index: 1, routes: [ - {key: 'agents-edit-route-key', name: SCREENS.SETTINGS.AGENTS.EDIT, params: {accountID: String(optimisticAccountID)}}, - {key: 'agents-edit-name-route-key', name: SCREENS.SETTINGS.AGENTS.EDIT_NAME, params: {accountID: String(optimisticAccountID)}}, + {key: 'agents-edit-route-key', name: SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT, params: {accountID: String(optimisticAccountID)}}, + {key: 'agents-edit-name-route-key', name: SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_NAME, params: {accountID: String(optimisticAccountID)}}, ], }, }, @@ -294,7 +294,7 @@ describe('replaceOptimisticAgentWithActualAgent', () => { const optimisticPersonalDetail = {accountID: optimisticAccountID, displayName: 'Concierge Travel', isOptimisticPersonalDetail: true}; const optimisticAgentPrompt = {prompt: 'Book my flights'}; await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[optimisticAccountID]: optimisticPersonalDetail}); - await Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, optimisticAgentPrompt); + await Onyx.set(`${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, optimisticAgentPrompt); await waitForBatchedUpdates(); // When the mapping is consumed before navigation is ready @@ -306,7 +306,7 @@ describe('replaceOptimisticAgentWithActualAgent', () => { expect(resolveAgentAccountID(optimisticAccountID)).toBe(realAccountID); expect(mockSetParams).not.toHaveBeenCalled(); expect((await getOnyxValue(ONYXKEYS.PERSONAL_DETAILS_LIST))?.[optimisticAccountID]).toStrictEqual(optimisticPersonalDetail); - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`)).toStrictEqual(optimisticAgentPrompt); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`)).toStrictEqual(optimisticAgentPrompt); expect((await getOnyxValue(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING))?.[optimisticAccountID]).toBe(realAccountID); // When navigation becomes ready @@ -318,7 +318,7 @@ describe('replaceOptimisticAgentWithActualAgent', () => { expect(mockSetParams).toHaveBeenCalledTimes(1); expect(mockSetParams).toHaveBeenCalledWith({accountID: realAccountID}, 'agents-edit-route-key', 'agent-settings-stack-key'); expect((await getOnyxValue(ONYXKEYS.PERSONAL_DETAILS_LIST))?.[optimisticAccountID]).toBeUndefined(); - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`)).toBeUndefined(); expect((await getOnyxValue(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING))?.[optimisticAccountID]).toBeUndefined(); }); @@ -356,7 +356,7 @@ describe('replaceOptimisticAgentWithActualAgent', () => { const agentPrompt = {prompt: 'Book my flights'}; await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[accountID]: personalDetail}); - await Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, agentPrompt); + await Onyx.set(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, agentPrompt); await waitForBatchedUpdates(); await Onyx.merge(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, {[accountID]: accountID}); @@ -364,7 +364,7 @@ describe('replaceOptimisticAgentWithActualAgent', () => { const personalDetails = await getOnyxValue(ONYXKEYS.PERSONAL_DETAILS_LIST); expect(personalDetails?.[accountID]).toStrictEqual(personalDetail); - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`)).toStrictEqual(agentPrompt); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`)).toStrictEqual(agentPrompt); const mapping = await getOnyxValue(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING); expect(mapping?.[accountID]).toBeUndefined(); }); @@ -381,8 +381,8 @@ describe('replaceOptimisticAgentWithActualAgent', () => { [zeroMappedAccountID]: zeroMappedPersonalDetail, [negativeMappedAccountID]: negativeMappedPersonalDetail, }); - await Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${zeroMappedAccountID}`, agentPrompt); - await Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${negativeMappedAccountID}`, agentPrompt); + await Onyx.set(`${ONYXKEYS.COLLECTION.AGENT}${zeroMappedAccountID}`, agentPrompt); + await Onyx.set(`${ONYXKEYS.COLLECTION.AGENT}${negativeMappedAccountID}`, agentPrompt); await waitForBatchedUpdates(); await Onyx.merge(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, {[zeroMappedAccountID]: 0, [negativeMappedAccountID]: -1}); @@ -394,8 +394,8 @@ describe('replaceOptimisticAgentWithActualAgent', () => { [zeroMappedAccountID]: zeroMappedPersonalDetail, [negativeMappedAccountID]: negativeMappedPersonalDetail, }); - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${zeroMappedAccountID}`)).toStrictEqual(agentPrompt); - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${negativeMappedAccountID}`)).toStrictEqual(agentPrompt); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.AGENT}${zeroMappedAccountID}`)).toStrictEqual(agentPrompt); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.AGENT}${negativeMappedAccountID}`)).toStrictEqual(agentPrompt); const mapping = await getOnyxValue(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING); expect(mapping?.[zeroMappedAccountID]).toBeUndefined(); expect(mapping?.[negativeMappedAccountID]).toBeUndefined(); @@ -426,7 +426,7 @@ describe('replaceOptimisticAgentWithActualAgent', () => { await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { [optimisticAccountID]: {accountID: optimisticAccountID, displayName: 'Concierge Travel', isOptimisticPersonalDetail: true}, }); - await Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, {prompt: 'Book my flights', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); + await Onyx.set(`${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, {prompt: 'Book my flights', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); await waitForBatchedUpdates(); mockIsReady.mockReturnValue(true); @@ -453,7 +453,7 @@ describe('replaceOptimisticAgentWithActualAgent', () => { [realAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, }); expect((await getColdStartOnyxValue(coldStartOnyxKeys.PERSONAL_DETAILS_LIST))?.[optimisticAccountID]).toBeUndefined(); - expect(await getColdStartOnyxValue(`${coldStartOnyxKeys.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`)).toBeUndefined(); + expect(await getColdStartOnyxValue(`${coldStartOnyxKeys.COLLECTION.AGENT}${optimisticAccountID}`)).toBeUndefined(); expect((await getColdStartOnyxValue(coldStartOnyxKeys.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING))?.[optimisticAccountID]).toBeUndefined(); }); }); diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 21a50b00e8ff..d496e2a461aa 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -6,7 +6,7 @@ import type * as PolicyUtils from '@libs/PolicyUtils'; import {getValidConnectedIntegration, isGroupPolicy} from '@libs/PolicyUtils'; import getReportPreviewAction from '@libs/ReportPreviewActionUtils'; import type * as ReportUtils from '@libs/ReportUtils'; -import {hasOnlyNonReimbursableTransactions} from '@libs/ReportUtils'; +import {hasOnlyNonReimbursableTransactions, isPayer} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -989,6 +989,100 @@ describe('getReportPreviewAction', () => { ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY); }); + /** + * Builds a non-payer admin scenario: the current user is a workspace admin on a paid group policy, but someone else is the + * designated reimburser. The policy is written to Onyx (and referenced by `policyID`) because `isPayer` resolves the policy + * type through `ReportUtils.isPaidGroupPolicy`, which reads Onyx. Without it, `isPayer` falls back to a bare admin check and + * would report the user as the payer, hiding whatever `canAdminPayReport` decides. + */ + async function setUpNonPayerAdminScenario(reimbursementChoice: Policy['reimbursementChoice']) { + const DESIGNATED_PAYER_EMAIL = 'designated-payer@mail.com'; + const policy = createRandomPolicy(0); + policy.role = CONST.POLICY.ROLE.ADMIN; + policy.type = CONST.POLICY.TYPE.CORPORATE; + policy.reimbursementChoice = reimbursementChoice; + policy.achAccount = { + reimburser: DESIGNATED_PAYER_EMAIL, + bankAccountID: 1, + accountNumber: '1234567890', + routingNumber: '987654321', + addressName: 'Test Address', + bankName: 'Test Bank', + }; + + const report = { + ...createRandomReport(REPORT_ID, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: policy.id, + ownerAccountID: CURRENT_USER_ACCOUNT_ID + 1, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + total: -100, + isWaitingOnBankAccount: false, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const transaction = { + ...createRandomTransaction(REPORT_ID), + reportID: `${REPORT_ID}`, + }; + + return {policy, report, transaction}; + } + + it('canPay should return PAY for non-payer admin when a bank account is connected (reimburseYes)', async () => { + // Given an approved expense preview on a workspace with bank reimbursement enabled. + // When an admin who is not the designated payer opens the preview. + // Then the preview offers Pay using admin payment permission. + const {policy, report, transaction} = await setUpNonPayerAdminScenario(CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES); + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + await waitForBatchedUpdatesWithAct(); + + // The Pay option must come from canAdminPayReport, not from isPayer. + expect(isPayer(CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, report, {}, policy, false)).toBe(false); + expect( + getReportPreviewAction({ + isReportArchived: isReportArchived.current, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_EMAIL, + report, + policy, + transactions: [transaction], + bankAccountList: {}, + reportMetadata: undefined, + ownerLogin: CURRENT_USER_EMAIL, + rules: undefined, + }), + ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY); + }); + + it('canPay should not return PAY for non-payer admin when reimbursementChoice is not configured', async () => { + // Given an approved expense preview on a workspace without a reimbursement choice. + // When a non-payer admin opens the preview. + // Then Pay is unavailable because workspace reimbursement has not been configured. + const {policy, report, transaction} = await setUpNonPayerAdminScenario(undefined); + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + await waitForBatchedUpdatesWithAct(); + expect( + getReportPreviewAction({ + isReportArchived: isReportArchived.current, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_EMAIL, + report, + policy, + transactions: [transaction], + bankAccountList: {}, + reportMetadata: undefined, + ownerLogin: CURRENT_USER_EMAIL, + rules: undefined, + }), + ).not.toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY); + }); + it('canPay should not return PAY for the expense owner in a 1:1 IOU on a personal policy with manual reimbursement', async () => { const managerAccountID = CURRENT_USER_ACCOUNT_ID + 1; const report = { @@ -1176,6 +1270,50 @@ describe('getReportPreviewAction', () => { ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY); }); + it('getReportPreviewAction should return VIEW for expense report with only non-reimbursable expenses when preventPayoutNonReimbursableReports is true', async () => { + const report = { + ...createRandomReport(REPORT_ID, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + total: -3000, + nonReimbursableTotal: -3000, + isWaitingOnBankAccount: false, + }; + + const policy = createRandomPolicy(0); + policy.role = CONST.POLICY.ROLE.ADMIN; + policy.type = CONST.POLICY.TYPE.CORPORATE; + policy.reimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; + policy.preventPayoutNonReimbursableReports = true; + + jest.mocked(hasOnlyNonReimbursableTransactions).mockReturnValueOnce(true); + jest.mocked(getValidConnectedIntegration).mockReturnValueOnce(undefined); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const transaction = createMock({ + reportID: `${REPORT_ID}`, + }); + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + await waitForBatchedUpdatesWithAct(); + expect( + getReportPreviewAction({ + isReportArchived: isReportArchived.current, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_EMAIL, + report, + policy, + transactions: [transaction], + bankAccountList: {}, + reportMetadata: undefined, + ownerLogin: CURRENT_USER_EMAIL, + rules: undefined, + }), + ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); + }); + it('canPay should return true for submitted invoice', async () => { const report = { ...createRandomReport(REPORT_ID, undefined), diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 124676c4c9c3..9740b61dff00 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -919,7 +919,18 @@ describe('actions/Report', () => { .then(() => { rerender(report); // If the user deletes a comment that is before the last read - Report.deleteReportComment(report, {...reportActions[200]}, undefined, undefined, ancestors.current, undefined, undefined, USER_1_LOGIN, false); + Report.deleteReportComment({ + report, + reportAction: {...reportActions[200]}, + originalReportActions: undefined, + reportActions: undefined, + ancestors: ancestors.current, + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: USER_1_LOGIN, + isOffline: false, + currentUserAccountID: 1, + }); return waitForBatchedUpdates(); }) .then(() => { @@ -938,7 +949,18 @@ describe('actions/Report', () => { rerender(report); // If the user deletes the last comment after the lastReadTime the lastMessageText will reflect the new last comment - Report.deleteReportComment(report, {...reportActions[400]}, undefined, undefined, ancestors.current, undefined, undefined, USER_1_LOGIN, false); + Report.deleteReportComment({ + report, + reportAction: {...reportActions[400]}, + originalReportActions: undefined, + reportActions: undefined, + ancestors: ancestors.current, + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: USER_1_LOGIN, + isOffline: false, + currentUserAccountID: 1, + }); return waitForBatchedUpdates(); }) .then(() => getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}` as const)) @@ -1694,7 +1716,18 @@ describe('actions/Report', () => { }); rerender(originalReport); - Report.deleteReportComment(originalReport, newReportAction, undefined, undefined, ancestors.current, undefined, undefined, '', false); + Report.deleteReportComment({ + report: originalReport, + reportAction: newReportAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: ancestors.current, + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); await waitForBatchedUpdates(); expect(PersistedRequests.getAll().length).toBe(0); @@ -1751,7 +1784,18 @@ describe('actions/Report', () => { expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_COMMENT); rerender(originalReport); - Report.deleteReportComment(originalReport, newReportAction, undefined, undefined, ancestors.current, undefined, undefined, currentUserEmail, false); + Report.deleteReportComment({ + report: originalReport, + reportAction: newReportAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: ancestors.current, + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: currentUserEmail, + isOffline: false, + currentUserAccountID: 1, + }); await waitForBatchedUpdates(); expect(PersistedRequests.getAll().length).toBe(0); @@ -1821,7 +1865,18 @@ describe('actions/Report', () => { }); rerender(originalReport); - Report.deleteReportComment(originalReport, reportAction, undefined, undefined, ancestors.current, undefined, undefined, '', false); + Report.deleteReportComment({ + report: originalReport, + reportAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: ancestors.current, + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); await waitForBatchedUpdates(); expect(PersistedRequests.getAll().length).toBe(1); @@ -1880,7 +1935,18 @@ describe('actions/Report', () => { }), ); - Report.deleteReportComment(REPORT, reportAction, undefined, undefined, [], undefined, undefined, '', false); + Report.deleteReportComment({ + report: REPORT, + reportAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: [], + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); jest.runOnlyPendingTimers(); await waitForBatchedUpdates(); @@ -1956,7 +2022,18 @@ describe('actions/Report', () => { }); }); - Report.deleteReportComment(REPORT, newReportAction, undefined, undefined, [], undefined, undefined, '', false); + Report.deleteReportComment({ + report: REPORT, + reportAction: newReportAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: [], + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); await waitForBatchedUpdates(); expect(PersistedRequests.getAll().length).toBe(0); @@ -1979,6 +2056,76 @@ describe('actions/Report', () => { TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0); }); + it('resolves Concierge followups only when the Concierge message is the last action the user can see', async () => { + const CONCIERGE_ACTION_ID = 'conciergeFollowups'; + const WHISPER_TARGET_ACCOUNT_ID = 505; + const UNRESOLVED_HTML = `

Here is some helpful information.

\n\n First?\n`; + + /** Seeds a report whose Concierge message carries unresolved followups, below a newer whisper for one account. */ + const seedReport = async (reportID: string) => { + const conciergeAction: OnyxTypes.ReportAction = { + reportActionID: CONCIERGE_ACTION_ID, + reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + created: '2026-09-01 10:00:00.000', + message: [{type: CONST.REPORT.MESSAGE.TYPE.COMMENT, html: UNRESOLVED_HTML, text: 'Here is some helpful information.'}], + originalMessage: {html: UNRESOLVED_HTML}, + }; + const whisper: OnyxTypes.ReportAction = { + reportActionID: 'conciergeWhisper', + reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + actorAccountID: WHISPER_TARGET_ACCOUNT_ID, + created: '2026-09-01 11:00:00.000', + message: [{type: 'COMMENT', html: 'changed the amount', text: 'changed the amount', whisperedTo: [WHISPER_TARGET_ACCOUNT_ID]}], + originalMessage: {whisperedTo: [WHISPER_TARGET_ACCOUNT_ID]}, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[CONCIERGE_ACTION_ID]: conciergeAction, [whisper.reportActionID]: whisper}); + await waitForBatchedUpdates(); + }; + + const getConciergeHtml = async (reportID: string) => { + const actions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` as const); + const message = actions?.[CONCIERGE_ACTION_ID]?.message; + return Array.isArray(message) ? message.at(0)?.html : undefined; + }; + + // Given the whisper is visible to the commenting user, so it outranks the Concierge message + const whisperVisibleReportID = '60001'; + await seedReport(whisperVisibleReportID); + Report.addAttachmentWithComment({ + report: {...createRandomReport(60001, undefined), reportID: whisperVisibleReportID}, + notifyReportID: whisperVisibleReportID, + ancestors: [], + attachments: new File([''], 'a.txt', {type: 'text/plain'}), + currentUserAccountID: WHISPER_TARGET_ACCOUNT_ID, + delegateAccountID: undefined, + conciergeReportID: undefined, + }); + await waitForBatchedUpdates(); + + // Then the followups stay unresolved, because the Concierge message is not the last action + expect(await getConciergeHtml(whisperVisibleReportID)).toContain(''); + + // Given the whisper targets somebody else, leaving the Concierge message last + const whisperHiddenReportID = '60002'; + await seedReport(whisperHiddenReportID); + Report.addAttachmentWithComment({ + report: {...createRandomReport(60002, undefined), reportID: whisperHiddenReportID}, + notifyReportID: whisperHiddenReportID, + ancestors: [], + attachments: new File([''], 'b.txt', {type: 'text/plain'}), + currentUserAccountID: WHISPER_TARGET_ACCOUNT_ID + 1, + delegateAccountID: undefined, + conciergeReportID: undefined, + }); + await waitForBatchedUpdates(); + + // Then the followups are optimistically marked resolved + expect(await getConciergeHtml(whisperHiddenReportID)).toContain(''); + }); + it('should send not DeleteComment request and remove AddTextAndAttachment accordingly', async () => { global.fetch = TestHelper.createGlobalFetchMock({ headers: new Headers({ @@ -2040,7 +2187,18 @@ describe('actions/Report', () => { }); }); - Report.deleteReportComment(REPORT, newReportAction, undefined, undefined, [], undefined, undefined, '', false); + Report.deleteReportComment({ + report: REPORT, + reportAction: newReportAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: [], + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); await waitForBatchedUpdates(); expect(PersistedRequests.getAll().length).toBe(0); @@ -2257,7 +2415,18 @@ describe('actions/Report', () => { }), }; - Report.deleteReportComment(REPORT, commentAction, reportActionsForReport, undefined, [], undefined, undefined, '', false, undefined); + Report.deleteReportComment({ + report: REPORT, + reportAction: commentAction, + originalReportActions: reportActionsForReport, + reportActions: undefined, + ancestors: [], + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); await waitForBatchedUpdates(); const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); @@ -2343,7 +2512,18 @@ describe('actions/Report', () => { }; // Delete comment B — only whisper B (at commentB + 1) should be marked deleted - Report.deleteReportComment(REPORT, commentBAction, reportActionsForReport, undefined, [], undefined, undefined, '', false, undefined); + Report.deleteReportComment({ + report: REPORT, + reportAction: commentBAction, + originalReportActions: reportActionsForReport, + reportActions: undefined, + ancestors: [], + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); await waitForBatchedUpdates(); const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); @@ -2442,7 +2622,18 @@ describe('actions/Report', () => { }); }); - Report.deleteReportComment(REPORT, newReportAction, undefined, undefined, [], undefined, undefined, '', false); + Report.deleteReportComment({ + report: REPORT, + reportAction: newReportAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: [], + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); await waitForBatchedUpdates(); expect(PersistedRequests.getAll().length).toBe(0); @@ -2544,7 +2735,18 @@ describe('actions/Report', () => { }); }); - Report.deleteReportComment(REPORT, reportAction, undefined, undefined, [], undefined, undefined, '', false); + Report.deleteReportComment({ + report: REPORT, + reportAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: [], + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); await waitForBatchedUpdates(); expect(PersistedRequests.getAll().length).toBe(1); @@ -2607,7 +2809,18 @@ describe('actions/Report', () => { const {result: ancestors} = renderHook(() => useAncestors({reportID: REPORT_ID})); - Report.deleteReportComment(REPORT, reportAction, undefined, undefined, ancestors.current, undefined, undefined, '', false); + Report.deleteReportComment({ + report: REPORT, + reportAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: ancestors.current, + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); expect(PersistedRequests.getAll().length).toBe(3); @@ -2913,8 +3126,30 @@ describe('actions/Report', () => { const {result: ancestors} = renderHook(() => useAncestors(report)); - Report.deleteReportComment(report, mentionAction, undefined, undefined, ancestors.current, undefined, undefined, '', false); - Report.deleteReportComment(report, mentionAction2, undefined, undefined, ancestors.current, undefined, undefined, '', false); + Report.deleteReportComment({ + report, + reportAction: mentionAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: ancestors.current, + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); + Report.deleteReportComment({ + report, + reportAction: mentionAction2, + originalReportActions: undefined, + reportActions: undefined, + ancestors: ancestors.current, + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); await waitForBatchedUpdates(); @@ -2951,7 +3186,18 @@ describe('actions/Report', () => { mockNavigation.getTopmostSearchReportRouteParams.mockReturnValue({reportID: CHILD_REPORT_ID}); const {result: ancestors} = renderHook(() => useAncestors(parentReport)); - Report.deleteReportComment(parentReport, reportAction, undefined, undefined, ancestors.current, undefined, undefined, '', false); + Report.deleteReportComment({ + report: parentReport, + reportAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: ancestors.current, + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); await waitForBatchedUpdates(); expect(mockNavigation.goBack).toHaveBeenCalled(); @@ -2969,7 +3215,18 @@ describe('actions/Report', () => { mockNavigation.getTopmostSearchReportRouteParams.mockReturnValue({reportID: CHILD_REPORT_ID, reportActionID: 'action-999'}); const {result: ancestors} = renderHook(() => useAncestors(parentReport)); - Report.deleteReportComment(parentReport, reportAction, undefined, undefined, ancestors.current, undefined, undefined, '', false); + Report.deleteReportComment({ + report: parentReport, + reportAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: ancestors.current, + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); await waitForBatchedUpdates(); expect(mockNavigation.goBack).toHaveBeenCalled(); @@ -3804,15 +4061,19 @@ describe('actions/Report', () => { describe('updateDescription', () => { const currentUserAccountID = 1; it('should not call UpdateRoomDescription API if the description is not changed', async () => { + // Given a room whose stored description has a heading and mentions another room by reportID global.fetch = TestHelper.createGlobalFetchMock(); const report: OnyxTypes.Report = { ...createRandomReport(1, undefined), - description: '

test

', + description: '

test

', }; - Report.updateDescription(report, '# test', currentUserAccountID); + + // When the user saves the same description, with the reportID-to-name map used to seed the input + Report.updateDescription(report, '# test #room', currentUserAccountID, {'123': '#room'}, {}); await waitForBatchedUpdates(); + // Then the stored description resolves to the same markdown, so no API request is made TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.UPDATE_ROOM_DESCRIPTION, 0); }); @@ -3827,7 +4088,7 @@ describe('actions/Report', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); mockFetch?.fail?.(); - Report.updateDescription(report, '# test1', currentUserAccountID); + Report.updateDescription(report, '# test1', currentUserAccountID, {}, {}); await waitForBatchedUpdates(); let updateReport: OnyxEntry; @@ -6144,6 +6405,19 @@ describe('actions/Report', () => { }); }); + describe('shareReport', () => { + it('sends the recipient and the permissions the API expects', () => { + // Given a recipient entered as a phone number + const phoneNumber = '+15005550006'; + + // When the report is shared with them + Report.shareReport('1', phoneNumber); + + // Then the request names them by their SMS login and gives read and write access + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SHARE_REPORT, {reportID: '1', shareToEmail: `${phoneNumber}${CONST.SMS.DOMAIN}`, permissions: 'read, write'}); + }); + }); + describe('openReport with introSelected', () => { it('should call OpenReport API with introSelected parameter', async () => { global.fetch = TestHelper.createGlobalFetchMock(); @@ -6453,6 +6727,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: false, }); @@ -6471,6 +6746,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: true, }); @@ -6493,6 +6769,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: false, }); @@ -6509,6 +6786,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: true, reportActionID, @@ -6532,6 +6810,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: false, linkToOptions, @@ -6554,6 +6833,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: false, checkIfCurrentPageActive, @@ -6577,6 +6857,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: false, }); @@ -6594,6 +6875,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: false, }); @@ -6613,6 +6895,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: true, checkIfCurrentPageActive, @@ -6640,6 +6923,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: false, }); @@ -6669,6 +6953,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: false, }); @@ -6677,6 +6962,76 @@ describe('actions/Report', () => { TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); }); + /** + * `hasCompletedGuidedSetupFlow` is only read on the branch where `conciergeReportID` is undefined, because that + * is the only path reaching `navigateToAndOpenReport`. There the flag decides `isOnboardingPending`, which gates + * whether an onboarding `OpenReport` is enqueued for the Concierge chat. The two tests below pin that branch: + * identical inputs, only the flag differs, and the request is issued or skipped accordingly. + */ + const setUpExistingConciergeChat = async () => { + const TEST_USER_LOGIN = 'test@user.com'; + const EXISTING_CONCIERGE_CHAT_REPORT_ID = '777888'; + + await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); + await TestHelper.setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [CONST.ACCOUNT_ID.CONCIERGE]: { + accountID: CONST.ACCOUNT_ID.CONCIERGE, + login: CONST.EMAIL.CONCIERGE, + displayName: 'Concierge', + }, + }); + // A 1:1 DM with Concierge, so navigateToAndOpenReport takes the existing-chat branch. + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${EXISTING_CONCIERGE_CHAT_REPORT_ID}`, { + reportID: EXISTING_CONCIERGE_CHAT_REPORT_ID, + type: CONST.REPORT.TYPE.CHAT, + participants: { + [TEST_USER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [CONST.ACCOUNT_ID.CONCIERGE]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + }); + await waitForBatchedUpdates(); + }; + + it('should enqueue the onboarding OpenReport when conciergeReportID is undefined and hasCompletedGuidedSetupFlow is false', async () => { + // Given a signed-in user whose Concierge DM already exists locally, but whose conciergeReportID is not cached + await setUpExistingConciergeChat(); + + // When navigating to Concierge with onboarding reported as still pending + Report.navigateToConciergeChat({ + conciergeReportID: undefined, + introSelected: testIntroSelected, + currentUserAccountID: TEST_USER_ACCOUNT_ID, + isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: false, + conciergePersonalDetails: undefined, + shouldDismissModal: false, + }); + await waitForBatchedUpdates(); + + // Then the onboarding OpenReport is enqueued so the server creates the guided setup tasks/messages + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); + }); + + it('should skip the onboarding OpenReport when conciergeReportID is undefined and hasCompletedGuidedSetupFlow is true', async () => { + // Given the same user and the same locally-known Concierge DM + await setUpExistingConciergeChat(); + + // When navigating to Concierge with onboarding reported as already completed + Report.navigateToConciergeChat({ + conciergeReportID: undefined, + introSelected: testIntroSelected, + currentUserAccountID: TEST_USER_ACCOUNT_ID, + isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: true, + conciergePersonalDetails: undefined, + shouldDismissModal: false, + }); + await waitForBatchedUpdates(); + + // Then no onboarding OpenReport is enqueued, proving the flag alone drives this branch + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 0); + }); describe('personalDetails', () => { const TEST_USER_LOGIN = 'test@user.com'; const CONCIERGE_PERSONAL_DETAIL: OnyxTypes.PersonalDetails = { @@ -6700,6 +7055,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: {[CONST.ACCOUNT_ID.CONCIERGE]: CONCIERGE_PERSONAL_DETAIL}, shouldDismissModal: false, }); @@ -6719,6 +7075,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: {}, shouldDismissModal: false, }); @@ -6741,6 +7098,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: true, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: false, }); @@ -6752,6 +7110,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: false, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: false, }); @@ -6763,6 +7122,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: false, }); @@ -6777,6 +7137,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: true, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: false, checkIfCurrentPageActive, @@ -6798,6 +7159,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: false, checkIfCurrentPageActive, @@ -6818,6 +7180,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: true, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: false, }); @@ -6837,6 +7200,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: undefined, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: false, }); @@ -6856,6 +7220,7 @@ describe('actions/Report', () => { introSelected: testIntroSelected, currentUserAccountID: TEST_USER_ACCOUNT_ID, isSelfTourViewed: true, + hasCompletedGuidedSetupFlow: undefined, conciergePersonalDetails: undefined, shouldDismissModal: true, }); @@ -7448,6 +7813,43 @@ describe('actions/Report', () => { expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(EXISTING_CHILD_REPORT.reportID)); }); + it('should bump the explained action reply count so the parent shows the Replies link', async () => { + const PARENT_REPORT = createRandomReport(1, undefined); + const EXISTING_CHILD_REPORT = {...createRandomReport(2, undefined), parentReportID: PARENT_REPORT.reportID, parentReportActionID: '1'}; + const REPORT_ACTION: OnyxTypes.ReportAction = { + ...createRandomReportAction(REPORT_ACTION_ID), + reportActionID: '1', + actorAccountID: TEST_USER_ACCOUNT_ID, + childReportID: EXISTING_CHILD_REPORT.reportID, + childVisibleActionCount: 0, + childCommenterCount: 0, + }; + + await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT.reportID}`, {[REPORT_ACTION.reportActionID]: REPORT_ACTION}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${EXISTING_CHILD_REPORT.reportID}`, EXISTING_CHILD_REPORT); + await waitForBatchedUpdates(); + + Report.explain({ + childReport: EXISTING_CHILD_REPORT, + originalReport: PARENT_REPORT, + reportAction: REPORT_ACTION, + translate: TestHelper.translateLocal, + currentUserAccountID: TEST_USER_ACCOUNT_ID, + introSelected: INTRO_SELECTED, + conciergeChat: undefined, + isSelfTourViewed: undefined, + delegateAccountID: undefined, + participantsPersonalDetails: undefined, + timezone: CONST.DEFAULT_TIME_ZONE, + }); + await waitForBatchedUpdates(); + + const parentActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT.reportID}`); + expect(parentActions?.[REPORT_ACTION.reportActionID]?.childVisibleActionCount).toBe(1); + expect(parentActions?.[REPORT_ACTION.reportActionID]?.childCommenterCount).toBe(1); + }); + it('should work with new explain thread scenario', async () => { const PARENT_REPORT = createRandomReport(1, undefined); const REPORT_ACTION: OnyxTypes.ReportAction = { @@ -9462,7 +9864,7 @@ describe('actions/Report', () => { const testIntroSelected: OnyxTypes.IntroSelected = {choice: CONST.ONBOARDING_CHOICES.ADMIN}; const TEST_USER_ACCOUNT_ID = 1; expect(() => { - handleWalletStatementNavigation('123', testIntroSelected, TEST_USER_ACCOUNT_ID, undefined, undefined, 'invalidType', undefined); + handleWalletStatementNavigation('123', testIntroSelected, TEST_USER_ACCOUNT_ID, undefined, undefined, undefined, 'invalidType', undefined); }).not.toThrow(); }); @@ -9473,7 +9875,7 @@ describe('actions/Report', () => { const testIntroSelected: OnyxTypes.IntroSelected = {choice: CONST.ONBOARDING_CHOICES.ADMIN}; const TEST_USER_ACCOUNT_ID = 1; - handleWalletStatementNavigation('123', testIntroSelected, TEST_USER_ACCOUNT_ID, undefined, undefined, CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE, undefined); + handleWalletStatementNavigation('123', testIntroSelected, TEST_USER_ACCOUNT_ID, undefined, undefined, undefined, CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE, undefined); await waitForBatchedUpdates(); @@ -9484,7 +9886,7 @@ describe('actions/Report', () => { it('should not throw with undefined introSelected', () => { const TEST_USER_ACCOUNT_ID = 1; expect(() => { - handleWalletStatementNavigation('123', undefined, TEST_USER_ACCOUNT_ID, undefined, undefined, CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE, undefined); + handleWalletStatementNavigation('123', undefined, TEST_USER_ACCOUNT_ID, undefined, undefined, undefined, CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE, undefined); }).not.toThrow(); }); @@ -9505,7 +9907,16 @@ describe('actions/Report', () => { const isSelfTourViewed = true; expect(() => { - localHandleWalletStatementNavigation('123', testIntroSelected, TEST_USER_ACCOUNT_ID, isSelfTourViewed, undefined, CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE, undefined); + localHandleWalletStatementNavigation( + '123', + testIntroSelected, + TEST_USER_ACCOUNT_ID, + isSelfTourViewed, + undefined, + undefined, + CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE, + undefined, + ); }).not.toThrow(); }); @@ -9515,7 +9926,7 @@ describe('actions/Report', () => { const isSelfTourViewed = false; expect(() => { - handleWalletStatementNavigation('123', testIntroSelected, TEST_USER_ACCOUNT_ID, isSelfTourViewed, undefined, CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE, undefined); + handleWalletStatementNavigation('123', testIntroSelected, TEST_USER_ACCOUNT_ID, isSelfTourViewed, undefined, undefined, CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE, undefined); }).not.toThrow(); }); }); @@ -12117,4 +12528,195 @@ describe('actions/Report', () => { }); }); }); + + describe('optimisticReportLastData', () => { + const REPORT_ID = '95001'; + const WHISPER_TARGET_ACCOUNT_ID = 606; + const COMMENT_ACTOR_ACCOUNT_ID = 707; + + const comment: OnyxTypes.ReportAction = { + reportActionID: 'lastDataComment', + reportID: REPORT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: COMMENT_ACTOR_ACCOUNT_ID, + created: '2027-01-01 10:00:00.000', + message: [{type: 'COMMENT', html: 'Older comment', text: 'Older comment'}], + originalMessage: {html: 'Older comment'}, + }; + + /** Newer than `comment`, and only the whispered-to account can see it. */ + const whisper: OnyxTypes.ReportAction = { + reportActionID: 'lastDataWhisper', + reportID: REPORT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + actorAccountID: WHISPER_TARGET_ACCOUNT_ID, + created: '2027-01-01 12:00:00.000', + message: [{type: 'COMMENT', html: 'changed the amount', text: 'changed the amount', whisperedTo: [WHISPER_TARGET_ACCOUNT_ID]}], + originalMessage: {whisperedTo: [WHISPER_TARGET_ACCOUNT_ID]}, + }; + + beforeEach(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[comment.reportActionID]: comment, [whisper.reportActionID]: whisper}); + await waitForBatchedUpdates(); + }); + + // `lastMessageText` is resolved by a separate helper that still reads the signed-in account, so only the + // action-derived fields are asserted here. + it('should point the last visible action at the whisper when the user is the one it targets', () => { + // Given a report whose newest action is a whisper aimed at the user + // When the optimistic last-report data is built for that user + const result = Report.optimisticReportLastData(REPORT_ID, WHISPER_TARGET_ACCOUNT_ID, {}, true, false); + + // Then the action fields describe the whisper + expect(result.lastVisibleActionCreated).toBe(whisper.created); + expect(result.lastActorAccountID).toBe(WHISPER_TARGET_ACCOUNT_ID); + }); + + it('should skip the whisper and point at the older comment when it targets somebody else', () => { + // Given the same report, seen by an account the whisper does not target + // When the optimistic last-report data is built for that account + const result = Report.optimisticReportLastData(REPORT_ID, WHISPER_TARGET_ACCOUNT_ID + 1, {}, true, false); + + // Then the whisper is invisible and the action fields describe the older comment + expect(result.lastVisibleActionCreated).toBe(comment.created); + expect(result.lastActorAccountID).toBe(COMMENT_ACTOR_ACCOUNT_ID); + }); + }); + + describe('deleteReportComment last-visible-action resolution', () => { + it("should resolve the report's remaining last visible action from the deleting user's own visibility", async () => { + global.fetch = TestHelper.createGlobalFetchMock(); + const REPORT_ID = '96001'; + const WHISPER_TARGET_ACCOUNT_ID = 808; + + const buildActions = (idBase: number) => ({ + deletedComment: { + reportActionID: `${idBase + 1}`, + reportID: REPORT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: WHISPER_TARGET_ACCOUNT_ID, + created: '2027-02-01 09:00:00.000', + message: [{type: 'COMMENT', html: 'Comment being deleted', text: 'Comment being deleted'}], + originalMessage: {html: 'Comment being deleted'}, + } as OnyxTypes.ReportAction, + olderComment: { + reportActionID: `${idBase + 2}`, + reportID: REPORT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: WHISPER_TARGET_ACCOUNT_ID, + created: '2027-02-01 10:00:00.000', + message: [{type: 'COMMENT', html: 'Older comment', text: 'Older comment'}], + originalMessage: {html: 'Older comment'}, + } as OnyxTypes.ReportAction, + /** Newer than `olderComment`, and only the whispered-to account can see it. */ + whisper: { + reportActionID: `${idBase + 3}`, + reportID: REPORT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + actorAccountID: WHISPER_TARGET_ACCOUNT_ID, + created: '2027-02-01 12:00:00.000', + message: [{type: 'COMMENT', html: 'changed the amount', text: 'changed the amount', whisperedTo: [WHISPER_TARGET_ACCOUNT_ID]}], + originalMessage: {whisperedTo: [WHISPER_TARGET_ACCOUNT_ID]}, + } as OnyxTypes.ReportAction, + }); + + const deleteAs = async (currentUserAccountID: number, idBase: number) => { + const {deletedComment, olderComment, whisper} = buildActions(idBase); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID, lastVisibleActionCreated: ''}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [deletedComment.reportActionID]: deletedComment, + [olderComment.reportActionID]: olderComment, + [whisper.reportActionID]: whisper, + }); + await waitForBatchedUpdates(); + + Report.deleteReportComment({ + report: {reportID: REPORT_ID}, + reportAction: deletedComment, + originalReportActions: undefined, + reportActions: undefined, + ancestors: [], + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID, + }); + await waitForBatchedUpdates(); + + return (await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}` as const))?.lastVisibleActionCreated; + }; + + // Given a report whose newest remaining action is a whisper aimed at the deleting user + // Then the report tracks the whisper, which that user can see + expect(await deleteAs(WHISPER_TARGET_ACCOUNT_ID, 96010)).toBe('2027-02-01 12:00:00.000'); + + // Given the same report deleted by somebody the whisper does not target + // Then the whisper is invisible to them and the older comment is tracked instead + expect(await deleteAs(WHISPER_TARGET_ACCOUNT_ID + 1, 96020)).toBe('2027-02-01 10:00:00.000'); + }); + }); + + describe('flagComment last-visible-action resolution', () => { + it("should resolve the report's last visible action from the flagging user's own visibility", async () => { + global.fetch = TestHelper.createGlobalFetchMock(); + const REPORT_ID = '97001'; + const WHISPER_TARGET_ACCOUNT_ID = 707; + + const buildActions = (idBase: number) => ({ + flaggedComment: { + reportActionID: `${idBase + 1}`, + reportID: REPORT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: WHISPER_TARGET_ACCOUNT_ID, + created: '2027-04-01 09:00:00.000', + message: [{type: 'COMMENT', html: 'Comment being flagged', text: 'Comment being flagged'}], + originalMessage: {html: 'Comment being flagged'}, + } as OnyxTypes.ReportAction, + olderComment: { + reportActionID: `${idBase + 2}`, + reportID: REPORT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: WHISPER_TARGET_ACCOUNT_ID, + created: '2027-04-01 10:00:00.000', + message: [{type: 'COMMENT', html: 'Older comment', text: 'Older comment'}], + originalMessage: {html: 'Older comment'}, + } as OnyxTypes.ReportAction, + /** Newer than `olderComment`, and only the whispered-to account can see it. */ + whisper: { + reportActionID: `${idBase + 3}`, + reportID: REPORT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + actorAccountID: WHISPER_TARGET_ACCOUNT_ID, + created: '2027-04-01 12:00:00.000', + message: [{type: 'COMMENT', html: 'changed the amount', text: 'changed the amount', whisperedTo: [WHISPER_TARGET_ACCOUNT_ID]}], + originalMessage: {whisperedTo: [WHISPER_TARGET_ACCOUNT_ID]}, + } as OnyxTypes.ReportAction, + }); + + const flagAs = async (currentUserAccountID: number, idBase: number) => { + const {flaggedComment, olderComment, whisper} = buildActions(idBase); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID, lastVisibleActionCreated: ''}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [flaggedComment.reportActionID]: flaggedComment, + [olderComment.reportActionID]: olderComment, + [whisper.reportActionID]: whisper, + }); + await waitForBatchedUpdates(); + + Report.flagComment(flaggedComment, CONST.MODERATION.FLAG_SEVERITY_HARASSMENT, {reportID: REPORT_ID}, false, currentUserAccountID); + await waitForBatchedUpdates(); + + return (await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}` as const))?.lastVisibleActionCreated; + }; + + // Given a report whose newest action is a whisper aimed at the flagging user + // Then the report tracks the whisper, which that user can see + expect(await flagAs(WHISPER_TARGET_ACCOUNT_ID, 97010)).toBe('2027-04-01 12:00:00.000'); + + // Given the same report flagged by somebody the whisper does not target + // Then the whisper is invisible to them and the older comment is tracked instead + expect(await flagAs(WHISPER_TARGET_ACCOUNT_ID + 1, 97020)).toBe('2027-04-01 10:00:00.000'); + }); + }); }); diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts index d8f737594cec..a0c4e33edc20 100644 --- a/tests/actions/SessionTest.ts +++ b/tests/actions/SessionTest.ts @@ -3,6 +3,7 @@ import {beforeEach, jest, test} from '@jest/globals'; import {openApp, reconnectApp} from '@libs/actions/App'; +import * as DelegateActions from '@libs/actions/Delegate'; import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; import {getAll as getAllPersistedRequests} from '@libs/actions/PersistedRequests'; import {initReconnect} from '@libs/actions/Reconnect'; @@ -144,6 +145,41 @@ describe('Session', () => { redirectToSignInSpy.mockRestore(); }); + test('reauthenticate restores the original account with the stashed credentials when the delegate field is gone but SESSION still holds a delegate token', async () => { + // Given a revoked copilot: SESSION still holds the restricted token, ACCOUNT lost `delegate` and the copilot's login lives only in the stash + const stashedCredentials = {login: 'copilot@expensify.com', autoGeneratedLogin: 'copilotAutoLogin', autoGeneratedPassword: 'copilotAutoPassword'}; + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {email: 'delegator@expensify.com', accountID: 1, authToken: 'revokedRestrictedToken', authTokenType: CONST.AUTH_TOKEN_TYPES.DELEGATE}, + [ONYXKEYS.STASHED_CREDENTIALS]: stashedCredentials, + [ONYXKEYS.STASHED_SESSION]: {email: 'copilot@expensify.com', accountID: 2, authToken: 'copilotAuthToken'}, + [ONYXKEYS.ACCOUNT]: {delegatedAccess: {delegates: []}}, + }); + await waitForBatchedUpdates(); + + const redirectToSignInSpy = jest.spyOn(SignInRedirect, 'default').mockImplementation(() => Promise.resolve()); + const restoreDelegateSessionSpy = jest.spyOn(DelegateActions, 'restoreDelegateSession').mockResolvedValue(undefined); + jest.mocked(HttpUtils.xhr) + .mockClear() + .mockImplementationOnce(() => Promise.resolve({jsonCode: CONST.JSON_CODE.SUCCESS, accountID: 2, authToken: 'copilotRefreshedToken', email: 'copilot@expensify.com'})); + + // When the revoked token triggers reauthentication + const result = await reauthenticate('TestCommand'); + await waitForBatchedUpdates(); + + // Then it authenticates with the copilot's stashed login and restores their account instead of signing out + expect(redirectToSignInSpy).not.toHaveBeenCalled(); + expect(jest.mocked(HttpUtils.xhr).mock.calls.at(0)?.at(0)).toBe('Authenticate'); + expect(jest.mocked(HttpUtils.xhr).mock.calls.at(0)?.at(1)).toEqual(expect.objectContaining({partnerUserID: stashedCredentials.autoGeneratedLogin})); + expect(restoreDelegateSessionSpy).toHaveBeenCalledWith( + expect.objectContaining({authToken: 'copilotRefreshedToken', accountID: 2, email: 'copilot@expensify.com', stashedCredentials}), + ); + expect(result).toEqual({wasSuccessful: false, didRestoreOriginalSession: true}); + + redirectToSignInSpy.mockRestore(); + restoreDelegateSessionSpy.mockRestore(); + NetworkStore.setIsAuthenticating(false); + }); + test('reauthenticate aborts when RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN is true', async () => { // Given a SignIn with short lived token is currently in flight await Onyx.set(ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, true); diff --git a/tests/actions/SupportTicketTest.ts b/tests/actions/SupportTicketTest.ts index 970fbcb05c62..1796bdc2bc42 100644 --- a/tests/actions/SupportTicketTest.ts +++ b/tests/actions/SupportTicketTest.ts @@ -7,6 +7,12 @@ import {generateReportID} from '@libs/ReportUtils'; import {isNoSupportRepAvailableResponse, openSupportTicket} from '@userActions/Report'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; jest.mock('@libs/API'); jest.mock('@libs/Navigation/helpers/getReportRouteForCurrentContext'); @@ -23,7 +29,13 @@ const mockGenerateReportID = jest.mocked(generateReportID); const newSupportTicketReportID = 'optimisticSupportTicketReportID'; describe('actions/Report', () => { - beforeEach(() => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); mockMakeRequestWithSideEffects.mockResolvedValue({jsonCode: CONST.JSON_CODE.SUCCESS}); mockGetReportRouteForCurrentContext.mockReturnValue(`r/${newSupportTicketReportID}`); mockGenerateReportID.mockReturnValue(newSupportTicketReportID); @@ -33,6 +45,10 @@ describe('actions/Report', () => { mockNavigate.mockClear(); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('opens a pending report and sends the expected payload for a new support ticket', async () => { // Given a request for a new support ticket // When the customer asks to talk to a human @@ -42,7 +58,46 @@ describe('actions/Report', () => { expect(mockGenerateReportID).toHaveBeenCalled(); expect(mockGetReportRouteForCurrentContext).toHaveBeenCalledWith({reportID: newSupportTicketReportID, isPendingCreation: true}); expect(mockNavigate).toHaveBeenCalledWith(`r/${newSupportTicketReportID}`); - expect(mockMakeRequestWithSideEffects).toHaveBeenCalledWith(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_SUPPORT_TICKET, {newSupportTicketReportID}); + expect(mockMakeRequestWithSideEffects).toHaveBeenCalledWith( + SIDE_EFFECT_REQUEST_COMMANDS.CREATE_SUPPORT_TICKET, + {newSupportTicketReportID}, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, + value: {isCreatingSupportTicket: true}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, + value: {reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, + value: {isCreatingSupportTicket: false}, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, + value: null, + }, + ], + }, + ); + const requestCallOrder = mockMakeRequestWithSideEffects.mock.invocationCallOrder.at(0) ?? Number.MAX_SAFE_INTEGER; + const navigationCallOrder = mockNavigate.mock.invocationCallOrder.at(0) ?? Number.MIN_SAFE_INTEGER; + expect(requestCallOrder).toBeLessThan(navigationCallOrder); await request; @@ -50,6 +105,119 @@ describe('actions/Report', () => { expect(mockNavigate).toHaveBeenCalledTimes(1); }); + it('marks a new ticket optimistic before publishing its report ID', async () => { + // Given a screen observing a ticket that has not been created on the server + let isCreatingSupportTicket = false; + const reportStates: boolean[] = []; + // Observe both keys independently, as the report screen does while creation is pending. + const loadingStateConnection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, + callback: (loadingState) => { + isCreatingSupportTicket = !!loadingState?.isCreatingSupportTicket; + }, + }); + const reportConnection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, + callback: (report) => { + if (!report?.reportID) { + return; + } + reportStates.push(isCreatingSupportTicket); + }, + }); + await waitForBatchedUpdates(); + mockMakeRequestWithSideEffects.mockImplementationOnce(async (_command, _parameters, onyxData) => { + await Onyx.update(onyxData?.optimisticData ?? []); + }); + + try { + // When optimistic updates make the pending ticket visible + await openSupportTicket(); + await waitForBatchedUpdates(); + + // Then the report ID is never observable without its fetch guard + expect(reportStates).toEqual([true]); + } finally { + Onyx.disconnect(reportConnection); + Onyx.disconnect(loadingStateConnection); + } + }); + + it('removes the pending report when the request fails before receiving a response', async () => { + // Given an optimistic support ticket in Onyx and a request that fails before the API can apply its failure data + const networkError = new Error('Network request failed'); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, { + reportID: newSupportTicketReportID, + type: CONST.REPORT.TYPE.SUPPORT_TICKET, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, {isCreatingSupportTicket: true}); + mockMakeRequestWithSideEffects.mockRejectedValueOnce(networkError); + + // When the customer asks to talk to a human + const request = openSupportTicket(); + + // Then the App removes the pending report and preserves the request failure + await expect(request).rejects.toBe(networkError); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`)).resolves.toBeUndefined(); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`)).resolves.toBeUndefined(); + }); + + it('keeps the report and releases its fetch guard after creation succeeds', async () => { + // Given a new ticket whose server response applies the API's success updates + mockMakeRequestWithSideEffects.mockImplementationOnce(async (_command, _parameters, onyxData) => { + await Onyx.update(onyxData?.optimisticData ?? []); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`)).resolves.toEqual({isCreatingSupportTicket: true}); + await Onyx.update(onyxData?.successData ?? []); + return {jsonCode: CONST.JSON_CODE.SUCCESS}; + }); + + // When the server accepts the creation request + await openSupportTicket(); + + // Then the ticket remains available and can be fetched + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`)).resolves.toEqual({isCreatingSupportTicket: false}); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`)).resolves.toEqual({ + reportID: newSupportTicketReportID, + type: CONST.REPORT.TYPE.SUPPORT_TICKET, + }); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`)).resolves.toBeUndefined(); + }); + + it('removes the pending report and loading state when the server rejects creation', async () => { + // Given a new ticket whose server response applies the API's failure updates + mockMakeRequestWithSideEffects.mockImplementationOnce(async (_command, _parameters, onyxData) => { + await Onyx.update(onyxData?.optimisticData ?? []); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`)).resolves.toEqual({ + reportID: newSupportTicketReportID, + type: CONST.REPORT.TYPE.SUPPORT_TICKET, + }); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`)).resolves.toEqual({isCreatingSupportTicket: true}); + await Onyx.update(onyxData?.failureData ?? []); + return {jsonCode: CONST.JSON_CODE.EXP_ERROR}; + }); + + // When the server rejects the creation request + await openSupportTicket(); + + // Then neither the pending ticket nor its fetch guard remains + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`)).resolves.toBeUndefined(); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`)).resolves.toBeUndefined(); + }); + + it('does not update Onyx when reopening a support ticket fails', async () => { + // Given a resolved ticket and a failed reassignment request + const resolvedSupportTicketReportID = 'resolvedSupportTicketReportID'; + const networkError = new Error('Network request failed'); + const onyxUpdateSpy = jest.spyOn(Onyx, 'update'); + mockMakeRequestWithSideEffects.mockRejectedValueOnce(networkError); + + // When the customer reopens the ticket + await expect(openSupportTicket(resolvedSupportTicketReportID)).rejects.toBe(networkError); + + // Then no optimistic report exists to remove + expect(onyxUpdateSpy).not.toHaveBeenCalled(); + }); + it('navigates to a newly reassigned support ticket', async () => { // Given a resolved support ticket and a server response for its reassignment const resolvedSupportTicketReportID = 'resolvedSupportTicketReportID'; @@ -67,6 +235,7 @@ describe('actions/Report', () => { expect.objectContaining({ resolvedSupportTicketReportID, }), + {}, ); expect(mockMakeRequestWithSideEffects.mock.calls.at(0)?.[1]).not.toHaveProperty('newSupportTicketReportID'); expect(mockGenerateReportID).not.toHaveBeenCalled(); diff --git a/tests/actions/TableColumnWidthsTest.ts b/tests/actions/TableColumnWidthsTest.ts new file mode 100644 index 000000000000..b96b5090847f --- /dev/null +++ b/tests/actions/TableColumnWidthsTest.ts @@ -0,0 +1,60 @@ +import {setTableColumnWidths} from '@libs/actions/TableColumnWidths'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type TableColumnWidths from '@src/types/onyx/TableColumnWidths'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const TABLE_ID = 'workspaceTaxes'; + +function getTableColumnWidths(): Promise { + return new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.TABLE_COLUMN_WIDTHS, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); +} + +describe('setTableColumnWidths', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('replaces the stored layout rather than merging into it', async () => { + // Given a layout stored while the table had a Tax code column + setTableColumnWidths(TABLE_ID, { + availableColumnsWidth: 1176, + columnWidths: {name: 356, taxRate: 360, taxCode: 460}, + resizedColumnKeys: ['taxCode'], + }); + await waitForBatchedUpdates(); + + // When a later drag is stored after the Tax code column was hidden + setTableColumnWidths(TABLE_ID, { + availableColumnsWidth: 1176, + columnWidths: {name: 400, taxRate: 776}, + resizedColumnKeys: ['taxCode', 'name'], + }); + await waitForBatchedUpdates(); + + // Then only the latest drag's columns remain, or the old Tax code width would shrink the visible columns on reload + const tableColumnWidths = await getTableColumnWidths(); + + expect(tableColumnWidths?.[TABLE_ID]).toEqual({ + availableColumnsWidth: 1176, + columnWidths: {name: 400, taxRate: 776}, + resizedColumnKeys: ['taxCode', 'name'], + }); + }); +}); diff --git a/tests/actions/TaskTest.ts b/tests/actions/TaskTest.ts index 97143413fa85..337162a05bf3 100644 --- a/tests/actions/TaskTest.ts +++ b/tests/actions/TaskTest.ts @@ -1976,6 +1976,70 @@ describe('actions/Task', () => { const cancelAction = getRequiredReportAction(reportActionsUpdate, CONST.REPORT.ACTIONS.TYPE.TASK_CANCELLED); expect(cancelAction.delegateAccountID).toBeUndefined(); }); + + it("should resolve the parent report's last visible action from the acting user's own visibility", async () => { + const parentReportID = 'parent_report_delete_whisper'; + const taskReportID = 'task_report_delete_whisper'; + const WHISPER_TARGET_ACCOUNT_ID = 909; + + const taskReport = { + reportID: taskReportID, + type: CONST.REPORT.TYPE.TASK, + reportName: 'Whisper visibility task', + parentReportID, + parentReportActionID: '90001', + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + ownerAccountID: mockCurrentUserAccountID, + }; + const parentReport = {reportID: parentReportID, type: CONST.REPORT.TYPE.CHAT}; + const parentReportAction = createMock>({reportActionID: '90001', reportID: parentReportID, childReportID: taskReportID}); + + const olderComment = createMock({ + reportActionID: '90002', + reportID: parentReportID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + created: '2027-03-01 10:00:00.000', + message: [{type: 'COMMENT', html: 'Older comment', text: 'Older comment'}], + }); + /** Newer than `olderComment`, and only the whispered-to account can see it. */ + const whisper = createMock({ + reportActionID: '90003', + reportID: parentReportID, + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + created: '2027-03-01 12:00:00.000', + message: [{type: 'COMMENT', html: 'changed the amount', text: 'changed the amount', whisperedTo: [WHISPER_TARGET_ACCOUNT_ID]}], + originalMessage: {whisperedTo: [WHISPER_TARGET_ACCOUNT_ID]}, + }); + + // Given a parent report whose newest action is a whisper aimed at one account + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`, taskReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, parentReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + [olderComment.reportActionID]: olderComment, + [whisper.reportActionID]: whisper, + }); + }); + await waitForBatchedUpdatesWithAct(); + + const deleteAsAndReadParentUpdate = async (currentUserAccountID: number) => { + mockWrite.mockClear(); + deleteTask(taskReport, parentReport, false, currentUserAccountID, false, parentReportAction, 'concierge_123', undefined, undefined); + await waitForBatchedUpdatesWithAct(); + + const update = getRequiredOnyxUpdate(getRequiredWriteOnyxData(), 'optimisticData', `${ONYXKEYS.COLLECTION.REPORT}${parentReportID}` as const); + return 'value' in update && update.value && typeof update.value === 'object' && 'lastVisibleActionCreated' in update.value + ? update.value.lastVisibleActionCreated + : undefined; + }; + + // When the whispered-to account deletes the task, the whisper is the newest action they can see + expect(await deleteAsAndReadParentUpdate(WHISPER_TARGET_ACCOUNT_ID)).toBe(whisper.created); + + // When somebody the whisper does not target deletes it, the older comment is tracked instead + expect(await deleteAsAndReadParentUpdate(WHISPER_TARGET_ACCOUNT_ID + 1)).toBe(olderComment.created); + }); }); describe('getShareDestination', () => { diff --git a/tests/actions/TransactionTest.ts b/tests/actions/TransactionTest.ts index 3b8bccc2cc6f..eb4938eea084 100644 --- a/tests/actions/TransactionTest.ts +++ b/tests/actions/TransactionTest.ts @@ -34,6 +34,7 @@ import {createCashCard} from 'tests/utils/collections/card'; import createRandomReportAction from 'tests/utils/collections/reportActions'; import {changeTransactionsReport as changeTransactionsReportAction, clearError} from '../../src/libs/actions/Transaction'; +import {CAROUSEL_SOURCE, clearActiveTransactionIDs, setActiveTransactionIDs} from '../../src/libs/actions/TransactionThreadNavigation'; import currencyList from '../unit/currencyList.json'; import createPersonalDetails from '../utils/collections/personalDetails'; import createRandomPolicy from '../utils/collections/policies'; @@ -579,6 +580,100 @@ describe('actions/Transaction', () => { expect(updatedThread?.parentReportActionID).toBe(newIOUAction?.reportActionID); }); + describe('carousel cleanup', () => { + const carouselPolicy: Policy = {...createRandomPolicy(90, CONST.POLICY.TYPE.TEAM, 'Carousel Workspace'), id: 'policy-for-carousel'}; + const sourceReport: Report = {...createRandomReport(91), reportID: '91', type: CONST.REPORT.TYPE.EXPENSE, policyID: carouselPolicy.id, ownerAccountID: CARLOS_ACCOUNT_ID}; + const destinationReport: Report = {...createRandomReport(92), reportID: '92', type: CONST.REPORT.TYPE.EXPENSE, policyID: carouselPolicy.id, ownerAccountID: CARLOS_ACCOUNT_ID}; + const buildTransaction = (transactionID: string): Transaction => ({ + transactionID, + amount: -1000, + currency: CONST.CURRENCY.USD, + merchant: 'merchant', + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + comment: {comment: ''}, + reportID: sourceReport.reportID, + }); + const transactionIDs = ['carousel-transaction-1', 'carousel-transaction-2', 'carousel-transaction-3']; + + const moveFirstTransactionToDestination = async () => { + await Onyx.merge(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${carouselPolicy.id}`, carouselPolicy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${sourceReport.reportID}`, sourceReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${destinationReport.reportID}`, destinationReport); + for (const transactionID of transactionIDs) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, buildTransaction(transactionID)); + } + await waitForBatchedUpdates(); + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + await getOnyxData({key: ONYXKEYS.COLLECTION.TRANSACTION, callback: (value) => (allTransactions = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT, callback: (value) => (allReports = value)}); + + changeTransactionsReport({ + isVendorMatchingBetaEnabled: false, + transactionIDs: [transactionIDs.at(0) ?? ''], + isASAPSubmitBetaEnabled: false, + accountID: CARLOS_ACCOUNT_ID, + email: CARLOS_EMAIL, + newReport: destinationReport, + policy: carouselPolicy, + allTransactions, + policyTagList: {}, + transactionViolations: {}, + reports: allReports, + isTrackIntentUser: false, + cardList: undefined, + }); + await waitForBatchedUpdates(); + }; + + beforeEach(() => clearActiveTransactionIDs().then(waitForBatchedUpdates)); + + it('removes a moved expense from a carousel seeded from the report it left', async () => { + // Given a carousel seeded by opening an expense row in the source report, which is how the report's arrows get their list + await setActiveTransactionIDs(transactionIDs, {source: CAROUSEL_SOURCE.reportRow(sourceReport.reportID)}); + await waitForBatchedUpdates(); + + // When one of the report's expenses is moved to another report + await moveFirstTransactionToDestination(); + + // Then the moved expense is gone from the carousel, so the arrows only page through the expenses still in the report + const carouselTransactionIDs = await getOnyxValue(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + expect(carouselTransactionIDs).toEqual(transactionIDs.slice(1)); + }); + + it('does not write the carousel back when the move fails', async () => { + // Given a carousel seeded by opening an expense row in the source report + await setActiveTransactionIDs(transactionIDs, {source: CAROUSEL_SOURCE.reportRow(sourceReport.reportID)}); + await waitForBatchedUpdates(); + + // When one of the report's expenses is moved but the request fails, which returns the expense to the report + const mockFetch = getGlobalFetchMock(); + mockFetch.fail(); + global.fetch = mockFetch; + await moveFirstTransactionToDestination(); + + // Then the failure leaves the carousel alone, because by the time it lands the screen that owned the list + // may already have cleared it, and writing it back would leave an orphaned list that nothing clears + const carouselTransactionIDs = await getOnyxValue(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + expect(carouselTransactionIDs).toEqual(transactionIDs.slice(1)); + }); + + it('leaves a carousel seeded from another source untouched', async () => { + // Given a carousel seeded from search results, which legitimately spans several reports + await setActiveTransactionIDs(transactionIDs, {source: CAROUSEL_SOURCE.search(12345)}); + await waitForBatchedUpdates(); + + // When one of the expenses is moved to another report + await moveFirstTransactionToDestination(); + + // Then the search carousel keeps every expense, since the moved one still matches the search + const carouselTransactionIDs = await getOnyxValue(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + expect(carouselTransactionIDs).toEqual(transactionIDs); + }); + }); + it('recomputes a distance expense amount/merchant/currency from the destination workspace rate when moved', async () => { // Given a destination workspace whose default distance rate is defined in GBP (200/mi) const policyID = generatePolicyID(); @@ -672,6 +767,79 @@ describe('actions/Transaction', () => { expect(updated?.modifiedMerchant).toContain('mi'); }); + it('does not save Search row display fields on the moved transaction', async () => { + // Given an unreported expense selected from Search, which hands over the whole row item with its display-only fields + const policyID = generatePolicyID(); + const mockPolicy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), id: policyID, outputCurrency: CONST.CURRENCY.USD, pendingAction: undefined}; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, mockPolicy); + + const transactionID = 'txn-search-row-move'; + const transaction: Transaction = { + transactionID, + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + amount: -1000, + currency: CONST.CURRENCY.USD, + merchant: 'Original merchant', + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + const searchRowItem = { + ...transaction, + formattedMerchant: 'Original merchant', + formattedFrom: 'Rory', + formattedTo: '', + formattedTotal: 1000, + date: transaction.created, + keyForList: transactionID, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + from: {accountID: RORY_ACCOUNT_ID}, + to: {accountID: CARLOS_ACCOUNT_ID}, + } as Transaction; + + const expenseReport = { + reportID: 'dest-expense-report-search-row', + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + chatReportID: 'dest-chat-report-search-row', + ownerAccountID: RORY_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + } as Report; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); + await waitForBatchedUpdates(); + + // When the row is moved to the report + changeTransactionsReport({ + isVendorMatchingBetaEnabled: false, + transactionIDs: [transactionID], + isASAPSubmitBetaEnabled: false, + accountID: RORY_ACCOUNT_ID, + email: RORY_EMAIL, + newReport: expenseReport, + policy: mockPolicy, + allTransactions: {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: searchRowItem}, + policyTagList: undefined, + transactionViolations: {}, + reports: undefined, + isTrackIntentUser: false, + cardList: undefined, + }); + await waitForBatchedUpdates(); + + // Then the transaction is moved without the row's display fields, so a later merchant edit isn't shadowed by a stale formattedMerchant + const updated = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(updated?.reportID).toBe(expenseReport.reportID); + expect(updated?.merchant).toBe('Original merchant'); + expect(updated).not.toHaveProperty('formattedMerchant'); + expect(updated).not.toHaveProperty('formattedFrom'); + expect(updated).not.toHaveProperty('formattedTotal'); + expect(updated).not.toHaveProperty('date'); + expect(updated).not.toHaveProperty('keyForList'); + expect(updated).not.toHaveProperty('action'); + expect(updated).not.toHaveProperty('from'); + expect(updated).not.toHaveProperty('to'); + }); + describe('unheldTotal', () => { const SOURCE_REPORT_ID = 'source-unheld-total'; const DESTINATION_REPORT_ID = 'destination-unheld-total'; diff --git a/tests/actions/UserTest.ts b/tests/actions/UserTest.ts index 6bd8b104a21d..fa52414a556a 100644 --- a/tests/actions/UserTest.ts +++ b/tests/actions/UserTest.ts @@ -1,9 +1,16 @@ +import * as ActiveClientManager from '@libs/ActiveClientManager'; import * as API from '@libs/API'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import DateUtils from '@libs/DateUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; +import Log from '@libs/Log'; +import * as SequentialQueue from '@libs/Network/SequentialQueue'; import Pusher from '@libs/Pusher'; +import type {EventData} from '@libs/Pusher/types'; import PusherUtils from '@libs/PusherUtils'; +import * as RequestModule from '@libs/Request'; +import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {NewLogin} from '@src/types/onyx'; @@ -12,11 +19,13 @@ import {CONST as COMMON_CONST} from 'expensify-common'; import Onyx from 'react-native-onyx'; import * as App from '../../src/libs/actions/App'; +import * as OnyxUpdatesReliably from '../../src/libs/actions/applyOnyxUpdatesReliably'; import * as DeviceActions from '../../src/libs/actions/Device'; import redirectToSignIn from '../../src/libs/actions/SignInRedirect'; import * as UserActions from '../../src/libs/actions/User'; import createMock from '../utils/createMock'; import getOnyxValue from '../utils/getOnyxValue'; +import PusherHelper from '../utils/PusherHelper'; import {anyArray, anyObject, anyString, formatPhoneNumber} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -37,6 +46,52 @@ describe('actions/User', () => { return Onyx.clear().then(waitForBatchedUpdates); }); + describe('joinExactOnlineWaitlist', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('queues the signup timestamp with a rollback to the absent value', () => { + // Given the account has not joined the waitlist + const signupTimestamp = '2026-10-14 10:00:00.000'; + const errorTimestamp = 1; + const getDBTime = jest.spyOn(DateUtils, 'getDBTime').mockReturnValue(signupTimestamp); + jest.spyOn(DateUtils, 'getMicroseconds').mockReturnValue(errorTimestamp); + + // When the account joins the waitlist + UserActions.joinExactOnlineWaitlist(); + + // Then the queued request and confirmed signup share one timestamp, with the previous value restored on failure + expect(getDBTime).toHaveBeenCalledTimes(1); + expect(mockAPI.write).toHaveBeenCalledTimes(1); + expect(mockAPI.write).toHaveBeenCalledWith( + WRITE_COMMANDS.SET_NAME_VALUE_PAIR, + {name: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: signupTimestamp}, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: {pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, errorFields: {exactOnlineWaitlist: null}}, + }, + ], + successData: [ + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: signupTimestamp}, + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, value: {pendingFields: {exactOnlineWaitlist: null}}}, + ], + failureData: [ + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: ''}, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: {pendingFields: {exactOnlineWaitlist: null}, errorFields: {exactOnlineWaitlist: {[errorTimestamp]: anyString}}}, + }, + ], + }, + ); + }); + }); + describe('clearContactMethod', () => { it('should return early when contactMethods array is empty', async () => { // Given an empty array @@ -1153,6 +1208,168 @@ describe('actions/User', () => { }); }); + describe('subscribeToUserEvents AGENT_ZERO_PROCESSING_STATUS', () => { + const CHANNEL_NAME = `${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}1${CONFIG.PUSHER.SUFFIX}`; + const ACTOR_ACCOUNT_ID = 123; + const REPORT_NVP_KEY = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}1001` as const; + const PARENT_REPORT_NVP_KEY = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}1002` as const; + const processingStatusEvent: EventData = { + updates: [REPORT_NVP_KEY, PARENT_REPORT_NVP_KEY].map((key) => ({ + onyxMethod: 'merge', + key, + value: {agentZeroProcessingRequestIndicator: {[ACTOR_ACCOUNT_ID]: 'Thinking...'}}, + })), + }; + + beforeEach(async () => { + jest.spyOn(ActiveClientManager, 'isClientTheLeader').mockReturnValue(true); + jest.spyOn(OnyxUpdatesReliably, 'default').mockResolvedValue(undefined); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: 1, email: 'test@example.com'}); + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 42); + await waitForBatchedUpdates(); + PusherHelper.setup(); + UserActions.subscribeToUserEvents(1, 'test@example.com', () => undefined, formatPhoneNumber, undefined); + await waitForBatchedUpdates(); + }); + + afterEach(() => { + PusherHelper.teardown(); + jest.restoreAllMocks(); + }); + + it('sets and clears processing labels without advancing or recovering the reliable update stream', async () => { + // Given a subscribed client with a synchronization watermark + expect(await getOnyxValue(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT)).toBe(42); + + // When temporary status updates arrive for a report and its parent + Pusher.sendEvent(CHANNEL_NAME, Pusher.TYPE.AGENT_ZERO_PROCESSING_STATUS, processingStatusEvent); + await waitForBatchedUpdates(); + + // Then both reports show the processing label + expect(await getOnyxValue(REPORT_NVP_KEY)).toEqual({agentZeroProcessingRequestIndicator: {[ACTOR_ACCOUNT_ID]: 'Thinking...'}}); + expect(await getOnyxValue(PARENT_REPORT_NVP_KEY)).toEqual({agentZeroProcessingRequestIndicator: {[ACTOR_ACCOUNT_ID]: 'Thinking...'}}); + + // When the agent finishes and clears its processing labels + Pusher.sendEvent(CHANNEL_NAME, Pusher.TYPE.AGENT_ZERO_PROCESSING_STATUS, { + updates: processingStatusEvent.updates.map((update) => ({...update, value: {agentZeroProcessingRequestIndicator: {[ACTOR_ACCOUNT_ID]: null}}})), + }); + await waitForBatchedUpdates(); + + // Then the labels clear without affecting the synchronization watermark or fetching catch-up updates + expect(await getOnyxValue(REPORT_NVP_KEY)).toEqual({agentZeroProcessingRequestIndicator: {}}); + expect(await getOnyxValue(PARENT_REPORT_NVP_KEY)).toEqual({agentZeroProcessingRequestIndicator: {}}); + expect(await getOnyxValue(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT)).toBe(42); + expect(OnyxUpdatesReliably.default).not.toHaveBeenCalled(); + expect(API.makeRequestWithSideEffects).not.toHaveBeenCalled(); + }); + + it('applies new processing status after an older OpenApp response clears the indicator', async () => { + // Given an earlier OpenApp request is still applying its response + let finishRequest = () => {}; + const currentRequest = new Promise((resolve) => { + finishRequest = resolve; + }); + jest.spyOn(SequentialQueue, 'getCurrentRequest').mockReturnValue(currentRequest); + + // When a new processing status arrives before that response completes + Pusher.sendEvent(CHANNEL_NAME, Pusher.TYPE.AGENT_ZERO_PROCESSING_STATUS, processingStatusEvent); + await waitForBatchedUpdates(); + + // Then the status waits for the earlier request + expect(await getOnyxValue(REPORT_NVP_KEY)).toBeUndefined(); + + // When the older snapshot clears the indicator and finishes + await Onyx.merge(REPORT_NVP_KEY, {agentZeroProcessingRequestIndicator: null}); + finishRequest(); + await waitForBatchedUpdates(); + + // Then the new label survives the older response + expect(await getOnyxValue(REPORT_NVP_KEY)).toEqual({agentZeroProcessingRequestIndicator: {[ACTOR_ACCOUNT_ID]: 'Thinking...'}}); + }); + + it('keeps a newer clear after status events arrive during different queued requests', async () => { + // Given two requests waiting in the real sequential queue + SequentialQueue.resetQueue(); + let finishFirstRequest = () => {}; + let finishSecondRequest = () => {}; + const firstRequest = new Promise((resolve) => { + finishFirstRequest = resolve; + }); + const secondRequest = new Promise((resolve) => { + finishSecondRequest = resolve; + }); + const processSpy = jest.spyOn(RequestModule, 'processWithMiddleware').mockReturnValueOnce(firstRequest).mockReturnValueOnce(secondRequest); + await SequentialQueue.push({command: 'OpenApp'}); + await waitForBatchedUpdates(); + await SequentialQueue.push({command: 'ReadNewestAction'}); + expect(processSpy).toHaveBeenCalledTimes(1); + + // When a thinking label arrives during the first request and that request finishes + Pusher.sendEvent(CHANNEL_NAME, Pusher.TYPE.AGENT_ZERO_PROCESSING_STATUS, processingStatusEvent); + finishFirstRequest(); + await waitForBatchedUpdates(); + + // Then the next request starts while the label is still waiting + expect(processSpy).toHaveBeenCalledTimes(2); + expect(await getOnyxValue(REPORT_NVP_KEY)).toBeUndefined(); + + // When the clear arrives during the second request and the queue drains + Pusher.sendEvent(CHANNEL_NAME, Pusher.TYPE.AGENT_ZERO_PROCESSING_STATUS, { + updates: processingStatusEvent.updates.map((update) => ({...update, value: {agentZeroProcessingRequestIndicator: {[ACTOR_ACCOUNT_ID]: null}}})), + }); + finishSecondRequest(); + await waitForBatchedUpdates(); + + // Then the newer clear wins over the earlier thinking label + expect(await getOnyxValue(REPORT_NVP_KEY)).toEqual({agentZeroProcessingRequestIndicator: {}}); + }); + + it('ignores processing status events on a non-leader client', async () => { + // Given another client is responsible for updating shared Onyx data + jest.mocked(ActiveClientManager.isClientTheLeader).mockReturnValue(false); + + // When a temporary processing status arrives + Pusher.sendEvent(CHANNEL_NAME, Pusher.TYPE.AGENT_ZERO_PROCESSING_STATUS, processingStatusEvent); + await waitForBatchedUpdates(); + + // Then this client leaves the report unchanged + expect(await getOnyxValue(REPORT_NVP_KEY)).toBeUndefined(); + }); + + it.each([undefined, 2])('ignores processing status from an earlier session after the account changes to %s', async (accountID) => { + // Given a status listener is waiting for the current account's API request + let finishRequest = () => {}; + const currentRequest = new Promise((resolve) => { + finishRequest = resolve; + }); + jest.spyOn(SequentialQueue, 'getCurrentRequest').mockReturnValue(currentRequest); + + // When a status arrives and the account signs out or changes before the request finishes + Pusher.sendEvent(CHANNEL_NAME, Pusher.TYPE.AGENT_ZERO_PROCESSING_STATUS, processingStatusEvent); + await Onyx.set(ONYXKEYS.SESSION, accountID ? {accountID} : null); + await waitForBatchedUpdates(); + finishRequest(); + await waitForBatchedUpdates(); + + // Then it cannot populate the new session's report data + expect(await getOnyxValue(REPORT_NVP_KEY)).toBeUndefined(); + }); + + it('reports a failed local status update', async () => { + // Given Onyx cannot apply the temporary status update + jest.spyOn(Onyx, 'update').mockRejectedValueOnce(new Error('apply failed')); + const alertSpy = jest.spyOn(Log, 'alert'); + + // When a processing status arrives + Pusher.sendEvent(CHANNEL_NAME, Pusher.TYPE.AGENT_ZERO_PROCESSING_STATUS, processingStatusEvent); + await waitForBatchedUpdates(); + + // Then the failure is logged without an unhandled rejection or a synchronization recovery + expect(alertSpy).toHaveBeenCalledWith('[subscribeToUserEvents] Applying Agent Zero processing status failed', expect.objectContaining({error: 'apply failed'})); + expect(OnyxUpdatesReliably.default).not.toHaveBeenCalled(); + }); + }); + describe('subscribeToUserEvents RECONNECT_APP', () => { const reconnectAppMock = jest.fn(); @@ -1180,18 +1397,39 @@ describe('actions/User', () => { expect(reconnectAppMock).toHaveBeenCalledWith(lastUpdateID); }); - it('calls reconnectApp without updateIDFrom for authenticated users', async () => { + it('passes lastUpdateIDAppliedToClient when reconnecting as an authenticated user', async () => { + // Given a signed-in device that has already applied updates up to a known ID + const lastUpdateID = 42; await Onyx.merge(ONYXKEYS.SESSION, {authTokenType: undefined, accountID: 1, authToken: 'token'}); - await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 42); + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, lastUpdateID); + await waitForBatchedUpdates(); + + UserActions.subscribeToUserEvents(1, 'test@test.com', () => {}, formatPhoneNumber, undefined); + await waitForBatchedUpdates(); + + // When the account-wide RECONNECT_APP event arrives (for example, because another device signed in) + await PusherUtils.triggerMultiEventHandler(Pusher.TYPE.MULTIPLE_EVENT_TYPE.RECONNECT_APP, []); + await waitForBatchedUpdates(); + + // Then the reconnect is incremental, so a full ReconnectApp doesn't wipe report actions this device already has + expect(reconnectAppMock).toHaveBeenCalledWith(lastUpdateID); + }); + + it('falls back to a full reconnect when the client has no lastUpdateIDAppliedToClient', async () => { + // Given a signed-in device that has not applied any updates yet + await Onyx.merge(ONYXKEYS.SESSION, {authTokenType: undefined, accountID: 1, authToken: 'token'}); + await Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, null); await waitForBatchedUpdates(); UserActions.subscribeToUserEvents(1, 'test@test.com', () => {}, formatPhoneNumber, undefined); await waitForBatchedUpdates(); + // When the RECONNECT_APP event arrives await PusherUtils.triggerMultiEventHandler(Pusher.TYPE.MULTIPLE_EVENT_TYPE.RECONNECT_APP, []); await waitForBatchedUpdates(); - expect(reconnectAppMock).toHaveBeenCalledWith(); + // Then no updateIDFrom is passed, so reconnectApp still does a full reconnect to recover + expect(reconnectAppMock).toHaveBeenCalledWith(undefined); }); }); }); diff --git a/tests/actions/VacationDelegateTest.ts b/tests/actions/VacationDelegateTest.ts index 9fb4c261e0c7..58c1084c9dfd 100644 --- a/tests/actions/VacationDelegateTest.ts +++ b/tests/actions/VacationDelegateTest.ts @@ -1,4 +1,5 @@ import {clearVacationDelegateError, deleteVacationDelegate, setVacationDelegate} from '@libs/actions/VacationDelegate'; +import * as API from '@libs/API'; import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import getVacationDelegateErrors from '@libs/getVacationDelegateErrors'; @@ -6,8 +7,11 @@ import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import type Response from '@src/types/onyx/Response'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {OnyxKey} from 'react-native-onyx'; + import Onyx from 'react-native-onyx'; import getOnyxValue from '../utils/getOnyxValue'; @@ -29,9 +33,25 @@ describe('actions/VacationDelegate', () => { afterEach(() => jest.restoreAllMocks()); describe('setVacationDelegate', () => { + /** + * Mocks the side effect request so it applies the optimistic data it is handed, then runs `respond` for the response. This is + * what the real request pipeline does, so the test sees what the action itself writes before the response arrives. + */ + function mockSideEffectRequest(respond: () => Response | Promise>) { + return jest.spyOn(API, 'makeRequestWithSideEffects').mockImplementation(async (_command, _parameters, onyxData) => { + await Onyx.update(onyxData?.optimisticData ?? []); + return respond(); + }); + } + + /** Builds the 305 policy diff warning Auth sends when the picked delegate is missing from some of the vacationer's workspaces */ + function getPolicyDiffWarningResponse(policyDiff = {adminPolicies: ['1'], nonAdminPolicies: [] as string[]}): Response { + return {jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff, phpCommandName: 'SetVacationDelegate', authWriteCommands: []}}; + } + it('sends SetVacationDelegate with the mapped params and clears policyDiff optimistically', async () => { // Given the API side-effect call is mocked, since only the shape of the outgoing request matters here - const apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve()); + const apiSideEffectSpy = mockSideEffectRequest(() => ({jsonCode: CONST.JSON_CODE.SUCCESS})); // When a delegate is picked while another delegate is already saved await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'}); @@ -64,6 +84,11 @@ describe('actions/VacationDelegate', () => { // failureData would make the request pipeline write an error we immediately have to clear, flashing a red // brick road. The action applies it from the response instead. expect(apiSideEffectSpy.mock.calls.at(0)?.at(2)).not.toHaveProperty('failureData'); + + // Then the pick is only in the success data, because the response can still be a 305 and applying it optimistically would flash it on Profile + const onyxData = apiSideEffectSpy.mock.calls.at(0)?.[2]; + expect(onyxData?.optimisticData?.at(0)?.value).not.toHaveProperty('delegate'); + expect(onyxData?.successData?.at(0)?.value).toEqual(expect.objectContaining({creator: 'admin@test.com', delegate: 'delegate@test.com'})); }); it('sends a persisted write instead of a side effect request once the policy diff warning is overridden', async () => { @@ -74,8 +99,13 @@ describe('actions/VacationDelegate', () => { // When the user confirms the missing-workspaces step, so the override flag is set await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', shouldOverridePolicyDiffWarning: true}); - // Then only the persisted write fires, because the 305 can no longer come back and the request needs to survive offline - expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({overridePolicyDiffWarning: true}), expect.anything()); + // Then only the persisted write fires, because the 305 can no longer come back and the request needs to survive offline, + // and the pick is applied optimistically, since there is no 305 left that could take it away + expect(apiWriteSpy).toHaveBeenCalledWith( + WRITE_COMMANDS.SET_VACATION_DELEGATE, + expect.objectContaining({overridePolicyDiffWarning: true}), + expect.objectContaining({optimisticData: [expect.objectContaining({value: expect.objectContaining({delegate: 'delegate@test.com'})})]}), + ); expect(apiSideEffectSpy).not.toHaveBeenCalled(); }); @@ -128,17 +158,7 @@ describe('actions/VacationDelegate', () => { callback: (value) => errorStates.push(value?.errors), }); - jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { - // The optimistic data is applied before the response resolves. - await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { - creator: 'admin@test.com', - delegate: 'delegate@test.com', - previousDelegate: 'old@test.com', - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - errors: null, - }); - return {jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff}}; - }); + mockSideEffectRequest(() => getPolicyDiffWarningResponse(policyDiff)); // When a delegate is picked await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'}); @@ -161,14 +181,7 @@ describe('actions/VacationDelegate', () => { // Given a 305 whose onyxData carries an errors payload const policyDiff = {adminPolicies: [], nonAdminPolicies: ['79705898949FB240']}; - jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { - await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { - creator: 'admin@test.com', - delegate: 'delegate@test.com', - previousDelegate: 'old@test.com', - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - errors: null, - }); + mockSideEffectRequest(async () => { // Auth returns 305 so the client can prompt, but the same response carries an errors payload in // onyxData, which the API layer applies before the caller ever sees the response. await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { @@ -176,7 +189,7 @@ describe('actions/VacationDelegate', () => { // eslint-disable-next-line @typescript-eslint/naming-convention errors: {1788970253939928: "Vacation delegate is not part of all of vacationer's policies."}, }); - return {jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff}}; + return getPolicyDiffWarningResponse(policyDiff); }); // When a delegate is picked @@ -192,18 +205,60 @@ describe('actions/VacationDelegate', () => { expect(vacationDelegate?.pendingDelegate).toBe('delegate@test.com'); }); - it('clears the delegate on a 305 when there was no delegate saved before the pick', async () => { - // Given no saved delegate and a mocked 305 - jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { + it('never writes the picked delegate to delegate when the response is a 305', async () => { + // Given a saved delegate, an Onyx subscriber recording every delegate value the NVP takes on, and a mocked 305 + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {creator: 'admin@test.com', delegate: 'old@test.com'}); + const delegateStates: Array = []; + const connection = Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, + callback: (value) => delegateStates.push(value?.delegate), + }); + mockSideEffectRequest(() => getPolicyDiffWarningResponse()); + + // When a member who is missing from one of the workspaces is picked + await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'}); + await waitForBatchedUpdates(); + Onyx.disconnect(connection); + + // Then the pick never shows up as the delegate, because Profile would flash it, take it away on the 305, and show it again after the + // missing workspaces step. The saved delegate stays in place the whole time. + expect(delegateStates.length).toBeGreaterThan(0); + expect(delegateStates).not.toContain('delegate@test.com'); + expect(delegateStates.every((delegate) => delegate === 'old@test.com')).toBe(true); + }); + + it('never surfaces the errors the 305 ships in its onyxData through getVacationDelegateErrors', async () => { + // Given an Onyx subscriber recording the errors getVacationDelegateErrors returns for every NVP value, and a mocked 305 whose + // onyxData carries an errors payload that the API layer applies before the caller sees the response + const visibleErrors: Array = []; + const connection = Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, + callback: (value) => visibleErrors.push(getVacationDelegateErrors(value)), + }); + mockSideEffectRequest(async () => { await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { - creator: 'admin@test.com', - delegate: 'delegate@test.com', - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - errors: null, + // an ID map key is not a name! + // eslint-disable-next-line @typescript-eslint/naming-convention + errors: {1788970253939928: "Vacation delegate is not part of all of vacationer's policies."}, }); - return {jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff: {adminPolicies: ['1'], nonAdminPolicies: []}}}; + return getPolicyDiffWarningResponse(); }); + // When a delegate is picked + await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com'}); + await waitForBatchedUpdates(); + Onyx.disconnect(connection); + + // Then the payload is never visible, because the optimistic pendingAction hides it until the action clears it, so the Profile row + // never flashes a red brick road for what is really the next step of the flow + expect(visibleErrors.length).toBeGreaterThan(0); + expect(visibleErrors.every(isEmptyObject)).toBe(true); + }); + + it('clears the delegate on a 305 when there was no delegate saved before the pick', async () => { + // Given no saved delegate and a mocked 305 + mockSideEffectRequest(() => getPolicyDiffWarningResponse()); + // When the first delegate is picked await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com'}); await waitForBatchedUpdates(); @@ -215,21 +270,37 @@ 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 + // Given a saved delegate and 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 + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {creator: 'admin@test.com', delegate: 'old@test.com'}); const response = {jsonCode: CONST.JSON_CODE.EXP_ERROR, message: 'Nope'}; - jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { - // The optimistic data is applied before the response resolves. - await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { - creator: 'admin@test.com', - delegate: 'delegate@test.com', - previousDelegate: 'old@test.com', - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - errors: null, - }); - return response; - }); + mockSideEffectRequest(() => response); // When the pick fails await expect(setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'})).resolves.toEqual(response); @@ -240,23 +311,16 @@ describe('actions/VacationDelegate', () => { expect(vacationDelegate?.pendingAction).toBeFalsy(); expect(vacationDelegate?.errors).toBeTruthy(); - // Then both delegate fields survive, because rolling back belongs to whoever dismisses the error and needs something to roll back to - expect(vacationDelegate?.delegate).toBe('delegate@test.com'); + // Then the saved delegate is still in place, since the pick was never applied, and previousDelegate survives, + // because rolling back belongs to whoever dismisses the error and needs something to roll back to + expect(vacationDelegate?.delegate).toBe('old@test.com'); expect(vacationDelegate?.previousDelegate).toBe('old@test.com'); }); it('leaves a failed response in a state where dismissing the error restores the last confirmed delegate', async () => { // Given a pick that fails over a previously saved delegate - jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { - await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { - creator: 'admin@test.com', - delegate: 'delegate@test.com', - previousDelegate: 'old@test.com', - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - errors: null, - }); - return {jsonCode: CONST.JSON_CODE.EXP_ERROR, message: 'Nope'}; - }); + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {creator: 'admin@test.com', delegate: 'old@test.com'}); + mockSideEffectRequest(() => ({jsonCode: CONST.JSON_CODE.EXP_ERROR, message: 'Nope'})); await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'}); await waitForBatchedUpdates(); @@ -351,5 +415,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/actions/connections/BusinessCentral.ts b/tests/actions/connections/BusinessCentral.ts index 2ac5fc496a44..c5e42badea85 100644 --- a/tests/actions/connections/BusinessCentral.ts +++ b/tests/actions/connections/BusinessCentral.ts @@ -5,23 +5,33 @@ import CONST from '@src/CONST'; import { clearBusinessCentralErrorField, connectToBusinessCentral, + updateBusinessCentralAccountingMethod, + updateBusinessCentralAutoCreateEntities, + updateBusinessCentralAutoSync, updateBusinessCentralCompany, updateBusinessCentralCustomerMapping, updateBusinessCentralDefaultVendor, updateBusinessCentralEnableNewCategories, updateBusinessCentralExportDate, updateBusinessCentralExporter, + updateBusinessCentralExportToNextOpenPeriod, updateBusinessCentralFieldMapping, updateBusinessCentralNonReimbursableAccount, updateBusinessCentralNonReimbursableExpensesExportDestination, updateBusinessCentralPaymentMethod, + updateBusinessCentralPostingMode, updateBusinessCentralReimbursableAccount, updateBusinessCentralReimbursableExpensesExportDestination, + updateBusinessCentralReimbursementBankAccount, + updateBusinessCentralSettlementsBankAccount, + updateBusinessCentralSyncExpensifyCardSettlements, updateBusinessCentralSyncItems, + updateBusinessCentralSyncReimbursedReports, updateBusinessCentralSyncTaxRates, } from '@src/libs/actions/connections/BusinessCentral'; import ONYXKEYS from '@src/ONYXKEYS'; +import {CONST as COMMON_CONST} from 'expensify-common'; import Onyx from 'react-native-onyx'; import getOnyxValue from '../../utils/getOnyxValue'; @@ -526,6 +536,38 @@ describe('actions/connections/BusinessCentral', () => { settingName: CONST.BUSINESS_CENTRAL_CONFIG.PAYMENT_METHOD_CODE, value: 'BANK', }, + { + name: 'updateBusinessCentralAccountingMethod', + update: () => updateBusinessCentralAccountingMethod(MOCK_POLICY_ID, COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH, COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.ACCRUAL), + command: WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_ACCOUNTING_METHOD, + parameters: {accountingMethod: COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH}, + settingName: CONST.BUSINESS_CENTRAL_CONFIG.ACCOUNTING_METHOD, + value: COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH, + }, + { + name: 'updateBusinessCentralPostingMode', + update: () => updateBusinessCentralPostingMode(MOCK_POLICY_ID, CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_ONLY, CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_AND_POST), + command: WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_POSTING_MODE, + parameters: {postingMode: CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_ONLY}, + settingName: CONST.BUSINESS_CENTRAL_CONFIG.POSTING_MODE, + value: CONST.BUSINESS_CENTRAL_POSTING_MODE.CREATE_ONLY, + }, + { + name: 'updateBusinessCentralAutoCreateEntities', + update: () => updateBusinessCentralAutoCreateEntities(MOCK_POLICY_ID, false, true), + command: WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_AUTO_CREATE_ENTITIES, + parameters: {enabled: false}, + settingName: CONST.BUSINESS_CENTRAL_CONFIG.AUTO_CREATE_ENTITIES, + value: false, + }, + { + name: 'updateBusinessCentralExportToNextOpenPeriod', + update: () => updateBusinessCentralExportToNextOpenPeriod(MOCK_POLICY_ID, true, false), + command: WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_EXPORT_TO_NEXT_OPEN_PERIOD, + parameters: {enabled: true}, + settingName: CONST.BUSINESS_CENTRAL_CONFIG.EXPORT_TO_NEXT_OPEN_PERIOD, + value: true, + }, ]; it.each(exportCases)('$name sends the request parameter the backend reads and optimistically updates the export config', ({update, command, parameters, settingName, value}) => { @@ -607,4 +649,151 @@ describe('actions/connections/BusinessCentral', () => { expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_PAYMENT_METHOD, {policyID: MOCK_POLICY_ID, value: ''}, expect.anything()); }); }); + + describe('updateBusinessCentralAutoSync', () => { + it('writes the auto-sync command and optimistically updates autoSync.enabled', () => { + // Given a policy with auto-sync off + // When an admin turns auto-sync on + updateBusinessCentralAutoSync(MOCK_POLICY_ID, true, false); + + // Then the command carries the toggle value, and autoSync.enabled flips right away with the pending state under the autoSync key the Advanced page reads + expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_AUTO_SYNC, {policyID: MOCK_POLICY_ID, enabled: true}, expect.anything()); + expect(getFirstWriteOnyxData()).toMatchObject({ + optimisticData: [ + { + key: POLICY_KEY, + value: { + connections: { + businessCentral: { + config: { + autoSync: {enabled: true}, + pendingFields: {[CONST.BUSINESS_CENTRAL_CONFIG.AUTO_SYNC]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {[CONST.BUSINESS_CENTRAL_CONFIG.AUTO_SYNC]: null}, + }, + }, + }, + }, + }, + ], + successData: [{key: POLICY_KEY, value: {connections: {businessCentral: {config: {pendingFields: {[CONST.BUSINESS_CENTRAL_CONFIG.AUTO_SYNC]: null}}}}}}], + }); + }); + + it('rolls autoSync.enabled back and sets an error on failure', () => { + // Given a policy with auto-sync off + // When an admin turns auto-sync on + updateBusinessCentralAutoSync(MOCK_POLICY_ID, true, false); + + // Then on failure the toggle goes back to off and shows an error, so the admin sees the change was not saved + expect(getFirstWriteOnyxData()).toMatchObject({ + failureData: [ + { + key: POLICY_KEY, + value: { + connections: { + businessCentral: { + config: { + autoSync: {enabled: false}, + pendingFields: {[CONST.BUSINESS_CENTRAL_CONFIG.AUTO_SYNC]: null}, + errorFields: {[CONST.BUSINESS_CENTRAL_CONFIG.AUTO_SYNC]: ANY_VALUE}, + }, + }, + }, + }, + }, + ], + }); + }); + }); + + describe('payment sync settings', () => { + const syncCases = [ + { + name: 'updateBusinessCentralSyncReimbursedReports', + update: () => updateBusinessCentralSyncReimbursedReports(MOCK_POLICY_ID, false, true), + command: WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_SYNC_REIMBURSED_REPORTS, + parameters: {enabled: false}, + settingName: CONST.BUSINESS_CENTRAL_CONFIG.SYNC_REIMBURSED_REPORTS, + value: false, + }, + { + name: 'updateBusinessCentralReimbursementBankAccount', + update: () => updateBusinessCentralReimbursementBankAccount(MOCK_POLICY_ID, 'bank-2', 'bank-1'), + command: WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_REIMBURSEMENT_BANK_ACCOUNT, + parameters: {reimbursementBankAccountID: 'bank-2'}, + settingName: CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSEMENT_BANK_ACCOUNT_ID, + value: 'bank-2', + }, + { + name: 'updateBusinessCentralSyncExpensifyCardSettlements', + update: () => updateBusinessCentralSyncExpensifyCardSettlements(MOCK_POLICY_ID, true, false), + command: WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_SYNC_EXPENSIFY_CARD_SETTLEMENTS, + parameters: {enabled: true}, + settingName: CONST.BUSINESS_CENTRAL_CONFIG.SYNC_EXPENSIFY_CARD_SETTLEMENTS, + value: true, + }, + { + name: 'updateBusinessCentralSettlementsBankAccount', + update: () => updateBusinessCentralSettlementsBankAccount(MOCK_POLICY_ID, 'bank-2', 'bank-1'), + command: WRITE_COMMANDS.UPDATE_BUSINESS_CENTRAL_SETTLEMENTS_BANK_ACCOUNT, + parameters: {settlementsBankAccountID: 'bank-2'}, + settingName: CONST.BUSINESS_CENTRAL_CONFIG.SETTLEMENTS_BANK_ACCOUNT_ID, + value: 'bank-2', + }, + ]; + + it.each(syncCases)('$name sends the request parameter the backend reads and optimistically updates the sync config', ({update, command, parameters, settingName, value}) => { + // Given a connected policy + // When an admin changes a payment sync setting + update(); + + // Then the command carries the parameter name Web-Expensify reads, and the sync config shows the new value as pending so the row updates instantly + expect(writeSpy).toHaveBeenCalledWith(command, {policyID: MOCK_POLICY_ID, ...parameters}, expect.anything()); + expect(getFirstWriteOnyxData()).toMatchObject({ + optimisticData: [ + { + key: POLICY_KEY, + value: { + connections: { + businessCentral: { + config: { + sync: {[settingName]: value}, + pendingFields: {[settingName]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {[settingName]: null}, + }, + }, + }, + }, + }, + ], + successData: [{key: POLICY_KEY, value: {connections: {businessCentral: {config: {pendingFields: {[settingName]: null}}}}}}], + }); + }); + + it('rolls a payment sync setting back to the old value and sets an error on failure', () => { + // Given a policy that pays reimbursements from bank-1 + // When an admin picks bank-2 + updateBusinessCentralReimbursementBankAccount(MOCK_POLICY_ID, 'bank-2', 'bank-1'); + + // Then on failure the sync config goes back to bank-1 and the row shows an error, so the admin sees the change was not saved + expect(getFirstWriteOnyxData()).toMatchObject({ + failureData: [ + { + key: POLICY_KEY, + value: { + connections: { + businessCentral: { + config: { + sync: {[CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSEMENT_BANK_ACCOUNT_ID]: 'bank-1'}, + pendingFields: {[CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSEMENT_BANK_ACCOUNT_ID]: null}, + errorFields: {[CONST.BUSINESS_CENTRAL_CONFIG.REIMBURSEMENT_BANK_ACCOUNT_ID]: ANY_VALUE}, + }, + }, + }, + }, + }, + ], + }); + }); + }); }); diff --git a/tests/navigation/WorkArrangementRouteTests.tsx b/tests/navigation/WorkArrangementRouteTests.tsx index 088301c918a4..688ec1153309 100644 --- a/tests/navigation/WorkArrangementRouteTests.tsx +++ b/tests/navigation/WorkArrangementRouteTests.tsx @@ -1,6 +1,8 @@ import {render} from '@testing-library/react-native'; import {SettingsModalStackNavigator} from '@libs/Navigation/AppNavigator/ModalStackNavigators'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; import {config} from '@libs/Navigation/linkingConfig/config'; import WORKSPACE_TO_RHP from '@libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP'; @@ -8,7 +10,7 @@ import WORKSPACE_TO_RHP from '@libs/Navigation/linkingConfig/RELATIONS/WORKSPACE import type {SettingsNavigatorParamList} from '@navigation/types'; import NAVIGATORS from '@src/NAVIGATORS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {createNavigationContainerRef, NavigationContainer} from '@react-navigation/native'; @@ -41,6 +43,24 @@ describe('Work arrangement route', () => { } } + function findInviteScreenPath(screens: unknown): string | undefined { + if (!isRecord(screens)) { + return undefined; + } + + for (const [screenName, configEntry] of Object.entries(screens)) { + if (screenName === SCREENS.WORKSPACE.INVITE_WORK_ARRANGEMENT && isRecord(configEntry) && typeof configEntry.path === 'string') { + return configEntry.path; + } + if (isRecord(configEntry) && 'screens' in configEntry) { + const path = findInviteScreenPath(configEntry.screens); + if (path) { + return path; + } + } + } + } + it('builds the member selector path from the policy and account IDs', () => { // Given a workspace policy and a member account const policyID = 'policy123'; @@ -90,4 +110,59 @@ describe('Work arrangement route', () => { }), ); }); + + it('registers the invite arrangement editor under the invite confirmation route', () => { + // Given the invite-specific work arrangement route + const policyID = 'policy123'; + const path = `workspaces/${policyID}/members/invite/invite-message/work-arrangement`; + const workflowInviteConfirmationPath = `workspaces/${policyID}/workflows/approvals/expenses-from/invite-message`; + const workflowArrangementPath = createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_INVITE_WORK_ARRANGEMENT.path, workflowInviteConfirmationPath); + const workflowAdaptedState = getAdaptedStateFromPath(workflowArrangementPath, undefined); + const cardInvitePath = ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD_INVITE_NEW_MEMBER.getRoute({policyID, feed: 'cdf#bank', cardID: 'card123'}); + const cardArrangementPath = createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_INVITE_WORK_ARRANGEMENT.path, cardInvitePath); + + // When the invite route is parsed + const settingsState = getStateFromPath(path) + .routes.find((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) + ?.state?.routes.find((route) => route.name === SCREENS.RIGHT_MODAL.SETTINGS)?.state; + const confirmationState = getStateFromPath(`workspaces/${policyID}/members/invite/invite-message`).routes.find((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR)?.state; + const confirmationSettingsState = confirmationState?.routes.find((route) => route.name === SCREENS.RIGHT_MODAL.SETTINGS)?.state; + const workflowSettingsState = getStateFromPath(workflowArrangementPath) + .routes.find((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) + ?.state?.routes.find((route) => route.name === SCREENS.RIGHT_MODAL.SETTINGS)?.state; + const cardSettingsState = getStateFromPath(cardArrangementPath) + .routes.find((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) + ?.state?.routes.find((route) => route.name === SCREENS.RIGHT_MODAL.SETTINGS)?.state; + + // Then it resolves to the invite editor and can return to the invite confirmation route + expect(path).toBe(`workspaces/${policyID}/members/invite/invite-message/work-arrangement`); + expect(findInviteScreenPath(config?.screens)).toBe(DYNAMIC_ROUTES.WORKSPACE_INVITE_WORK_ARRANGEMENT.path); + expect(workflowArrangementPath).toBe(`${workflowInviteConfirmationPath}/work-arrangement`); + expect(WORKSPACE_TO_RHP[SCREENS.WORKSPACE.MEMBERS]).not.toContain(SCREENS.WORKSPACE.INVITE_WORK_ARRANGEMENT); + expect(JSON.stringify(workflowAdaptedState)).toContain(SCREENS.WORKSPACE.DYNAMIC_WORKFLOWS_APPROVALS_EXPENSES_FROM); + expect(settingsState?.routes.at(-1)).toEqual( + expect.objectContaining({ + name: SCREENS.WORKSPACE.INVITE_WORK_ARRANGEMENT, + params: expect.objectContaining({policyID}), + }), + ); + expect(confirmationSettingsState?.routes.at(-1)).toEqual( + expect.objectContaining({ + name: SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_INVITE_MESSAGE, + params: {policyID}, + }), + ); + expect(workflowSettingsState?.routes.at(-1)).toEqual( + expect.objectContaining({ + name: SCREENS.WORKSPACE.INVITE_WORK_ARRANGEMENT, + params: expect.objectContaining({policyID}), + }), + ); + expect(cardSettingsState?.routes.at(-1)).toEqual( + expect.objectContaining({ + name: SCREENS.WORKSPACE.INVITE_WORK_ARRANGEMENT, + params: expect.objectContaining({policyID, cardID: 'card123', feed: 'cdf#bank'}), + }), + ); + }); }); diff --git a/tests/navigation/buildTabNavigatorNestedStateTests.ts b/tests/navigation/buildTabNavigatorNestedStateTests.ts index 2220c0f73ac8..ea0f8b8127dd 100644 --- a/tests/navigation/buildTabNavigatorNestedStateTests.ts +++ b/tests/navigation/buildTabNavigatorNestedStateTests.ts @@ -14,14 +14,14 @@ describe('buildTabNavigatorNestedState', () => { NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.INSIGHTS, - NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, NAVIGATORS.WORKSPACE_NAVIGATOR, + NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, ]); }); - it('sets index to the position of the selected tab (SETTINGS = 4)', () => { + it('sets index to the position of the selected tab (SETTINGS = 5)', () => { const result = buildTabNavigatorNestedState({name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR}); - expect(result.index).toBe(4); + expect(result.index).toBe(5); }); it('falls back to index 0 for an unknown tab name', () => { @@ -40,7 +40,7 @@ describe('buildTabNavigatorNestedState', () => { it('copies params from selectedTabRoute onto the matched route only', () => { const params = {screen: 'SomeScreen'}; const result = buildTabNavigatorNestedState({name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, params}); - expect(result.routes.at(4)?.params).toEqual(params); + expect(result.routes.at(5)?.params).toEqual(params); expect(result.routes.at(0)?.params).toBeUndefined(); expect(result.routes.at(1)?.params).toBeUndefined(); }); diff --git a/tests/navigation/collectDepositAccountDynamicRouteTest.ts b/tests/navigation/collectDepositAccountDynamicRouteTest.ts new file mode 100644 index 000000000000..0fbb59cb6c99 --- /dev/null +++ b/tests/navigation/collectDepositAccountDynamicRouteTest.ts @@ -0,0 +1,74 @@ +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import findAllMatchingDynamicSuffixes from '@libs/Navigation/helpers/dynamicRoutesUtils/findAllMatchingDynamicSuffixes'; +import getPathWithoutDynamicSuffix from '@libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix'; + +import {DYNAMIC_ROUTES} from '@src/ROUTES'; + +const PATTERN = DYNAMIC_ROUTES.COLLECT_DEPOSIT_ACCOUNT.path; + +describe('Collect deposit account dynamic route', () => { + it('keeps Home underneath when opened from Home', () => { + // Given a full path that opens the collect-deposit-account flow on top of the Home screen + const fullPath = 'home/collect-deposit-account/country'; + + // When the dynamic suffix matcher runs against the path + const matches = findAllMatchingDynamicSuffixes(fullPath); + const match = matches.find((m) => m.pattern === PATTERN); + + // Then the collect-deposit-account suffix matches and the back path is the Home base + expect(match).toBeDefined(); + if (!match) { + return; + } + const backPath = getPathWithoutDynamicSuffix(match.pathUsedForMatching, match.actualSuffix, match.pattern); + expect(backPath).toBe('home'); + }); + + it('keeps Wallet underneath when opened from Wallet', () => { + // Given a full path that opens the collect-deposit-account flow on top of the Wallet settings screen + const fullPath = 'settings/wallet/collect-deposit-account/country'; + + // When the dynamic suffix matcher runs against the path + const matches = findAllMatchingDynamicSuffixes(fullPath); + const match = matches.find((m) => m.pattern === PATTERN); + + // Then the collect-deposit-account suffix matches and the back path is the Wallet base + expect(match).toBeDefined(); + if (!match) { + return; + } + const backPath = getPathWithoutDynamicSuffix(match.pathUsedForMatching, match.actualSuffix, match.pattern); + expect(backPath).toBe('settings/wallet'); + }); + + it('builds next step route from entry base without duplicating suffix', () => { + // Given an entry base path for the Home screen and a target sub-page + const basePath = 'home'; + + // When the next step route is built from the entry base + const route = createDynamicRoute(DYNAMIC_ROUTES.COLLECT_DEPOSIT_ACCOUNT.getRoute('country'), basePath); + + // Then the suffix is appended once onto the entry base, not stacked onto the current URL + expect(route).toBe('home/collect-deposit-account/country'); + expect(route.match(/collect-deposit-account/g)?.length).toBe(1); + }); + + it('builds confirm step route with action', () => { + // Given an entry base path for the Wallet screen and an edit action on the confirm step + const basePath = 'settings/wallet'; + + // When the confirm-step route is built with the edit action + const route = createDynamicRoute(DYNAMIC_ROUTES.COLLECT_DEPOSIT_ACCOUNT.getRoute('confirm', 'edit'), basePath); + + // Then the sub-page and action are both appended to the entry base + expect(route).toBe('settings/wallet/collect-deposit-account/confirm/edit'); + }); + + it('returns bare suffix when no entry base is provided', () => { + // Given no entry base path is supplied to getRoute + // When getRoute is called with no sub-page and with a sub-page + // Then the bare suffix is returned, so the caller can append it onto whatever entry base they capture + expect(DYNAMIC_ROUTES.COLLECT_DEPOSIT_ACCOUNT.getRoute()).toBe('collect-deposit-account'); + expect(DYNAMIC_ROUTES.COLLECT_DEPOSIT_ACCOUNT.getRoute('country')).toBe('collect-deposit-account/country'); + }); +}); diff --git a/tests/navigation/getMatchingFullScreenRouteTests.ts b/tests/navigation/getMatchingFullScreenRouteTests.ts index d4fee769c7a6..9ec3af259537 100644 --- a/tests/navigation/getMatchingFullScreenRouteTests.ts +++ b/tests/navigation/getMatchingFullScreenRouteTests.ts @@ -1,5 +1,5 @@ import findFocusedRouteWithOnyxTabGuard from '@libs/Navigation/helpers/findFocusedRouteWithOnyxTabGuard'; -import {getMatchingFullScreenRoute} from '@libs/Navigation/helpers/getAdaptedStateFromPath'; +import getMatchingFullScreenRoute from '@libs/Navigation/helpers/getMatchingFullScreenRoute'; import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -13,10 +13,6 @@ jest.mock('@libs/Navigation/linkingConfig/config', () => ({ dynamicTabPatternToTabPaths: new Map(), })); -jest.mock('@libs/ReportUtils', () => ({ - getReportOrDraftReport: jest.fn(), -})); - jest.mock('@libs/Navigation/helpers/getStateFromPath', () => jest.fn()); jest.mock('@libs/Navigation/helpers/findFocusedRouteWithOnyxTabGuard', () => jest.fn()); diff --git a/tests/navigation/getMatchingNewRouteTest.ts b/tests/navigation/getMatchingNewRouteTest.ts index f5fac018fe28..dbbcd3608c25 100644 --- a/tests/navigation/getMatchingNewRouteTest.ts +++ b/tests/navigation/getMatchingNewRouteTest.ts @@ -167,8 +167,9 @@ describe('getBestMatchingPath', () => { it('redirects old settings tag routes to the new dynamic suffix shape', () => { expect(getMatchingNewRoute('/settings/p123/tags/10/edit')).toBe('/settings/p123/tags/settings-tags-edit/10'); - expect(getMatchingNewRoute('/settings/p123/tags/settings')).toBe('/settings/p123/tags/settings-tags-settings'); - expect(getMatchingNewRoute('/settings/p123/tags/settings/edit/10')).toBe('/settings/p123/tags/settings-tags-settings/settings-tags-edit/10'); + expect(getMatchingNewRoute('/settings/p123/tags/settings')).toBe('/settings/p123/tags'); + expect(getMatchingNewRoute('/settings/p123/tags/settings-tags-settings')).toBe('/settings/p123/tags'); + expect(getMatchingNewRoute('/settings/p123/tags/settings/edit/10')).toBe('/settings/p123/tags/settings-tags-edit/10'); expect(getMatchingNewRoute('/settings/p123/tags/tag-list/0/edit/0')).toBe('/settings/p123/tags/tag-list/0/settings-tags-edit/0'); expect(getMatchingNewRoute('/settings/p123/tags/new')).toBe('/settings/p123/tags/tag-new'); expect(getMatchingNewRoute('/settings/p123/tag/10/Meals')).toBe('/settings/p123/tags/tag-settings/10/Meals'); @@ -178,46 +179,54 @@ describe('getBestMatchingPath', () => { it('preserves query params when redirecting old settings tag routes', () => { expect(getMatchingNewRoute('/settings/p123/tags/10/edit?backTo=/home')).toBe('/settings/p123/tags/settings-tags-edit/10?backTo=/home'); - expect(getMatchingNewRoute('/settings/p123/tags/settings?backTo=/home')).toBe('/settings/p123/tags/settings-tags-settings?backTo=/home'); + expect(getMatchingNewRoute('/settings/p123/tags/settings?backTo=/home')).toBe('/settings/p123/tags?backTo=/home'); expect(getMatchingNewRoute('/settings/p123/tag/10/Meals?parentTagsFilter=Food')).toBe('/settings/p123/tags/tag-settings/10/Meals?parentTagsFilter=Food'); }); it('redirects old workspace tag routes to the new dynamic suffix shape', () => { - expect(getMatchingNewRoute('/workspaces/p123/tags/settings')).toBe('/workspaces/p123/tags/tags-settings'); + expect(getMatchingNewRoute('/workspaces/p123/tags/settings')).toBe('/workspaces/p123/tags'); + expect(getMatchingNewRoute('/workspaces/p123/tags/tags-settings')).toBe('/workspaces/p123/tags'); + // Nested sub-pages of the removed Settings page redirect to the list; the specific tag-edit redirect still wins by length. + expect(getMatchingNewRoute('/workspaces/p123/tags/tags-settings/workspace-edit-tags/10')).toBe('/workspaces/p123/tags'); + expect(getMatchingNewRoute('/workspaces/p123/tags/tags-settings/edit/10')).toBe('/workspaces/p123/tags/workspace-edit-tags/10'); expect(getMatchingNewRoute('/workspaces/p123/tags/new')).toBe('/workspaces/p123/tags/tag-create'); expect(getMatchingNewRoute('/workspaces/p123/tag-list/0')).toBe('/workspaces/p123/tags/workspace-tag-list/0'); expect(getMatchingNewRoute('/workspaces/p123/tags/import')).toBe('/workspaces/p123/tags/workspace-tags-import'); expect(getMatchingNewRoute('/workspaces/p123/tags/imported')).toBe('/workspaces/p123/tags/workspace-tags-imported'); - expect(getMatchingNewRoute('/workspaces/p123/tags/10/edit')).toBe('/workspaces/p123/tags/tags-settings/workspace-edit-tags/10'); + expect(getMatchingNewRoute('/workspaces/p123/tags/10/edit')).toBe('/workspaces/p123/tags/workspace-edit-tags/10'); expect(getMatchingNewRoute('/workspaces/p123/tag/10/Meals')).toBe('/workspaces/p123/tags/workspace-tag-settings/10/Meals'); expect(getMatchingNewRoute('/workspaces/p123/tag/10/Meals/edit')).toBe('/workspaces/p123/tags/workspace-tag-settings/10/Meals/workspace-tag-edit'); expect(getMatchingNewRoute('/workspaces/p123/tag/10/Meals/gl-code')).toBe('/workspaces/p123/tags/workspace-tag-settings/10/Meals/workspace-tag-gl-code'); expect(getMatchingNewRoute('/workspaces/p123/tag/10/Meals/approver')).toBe('/workspaces/p123/tags/workspace-tag-settings/10/Meals/workspace-tag-approver'); }); + it('redirects old category Settings deep links (including nested spend-category-selector) to the Categories list', () => { + expect(getMatchingNewRoute('/workspaces/p123/categories/settings')).toBe('/workspaces/p123/categories'); + expect(getMatchingNewRoute('/workspaces/p123/categories/categories-settings')).toBe('/workspaces/p123/categories'); + expect(getMatchingNewRoute('/workspaces/p123/categories/categories-settings/spend-category-selector/airlines')).toBe('/workspaces/p123/categories'); + expect(getMatchingNewRoute('/settings/p123/categories/manage-settings/spend-category-selector/airlines')).toBe('/settings/p123/categories'); + }); + it('preserves query params when redirecting old workspace tag routes', () => { - expect(getMatchingNewRoute('/workspaces/p123/tags/settings?backTo=/home')).toBe('/workspaces/p123/tags/tags-settings?backTo=/home'); + expect(getMatchingNewRoute('/workspaces/p123/tags/settings?backTo=/home')).toBe('/workspaces/p123/tags?backTo=/home'); expect(getMatchingNewRoute('/workspaces/p123/tags/new?backTo=/home')).toBe('/workspaces/p123/tags/tag-create?backTo=/home'); expect(getMatchingNewRoute('/workspaces/p123/tag-list/0?backTo=/home')).toBe('/workspaces/p123/tags/workspace-tag-list/0?backTo=/home'); expect(getMatchingNewRoute('/workspaces/p123/tags/import?backTo=/home')).toBe('/workspaces/p123/tags/workspace-tags-import?backTo=/home'); expect(getMatchingNewRoute('/workspaces/p123/tags/imported?backTo=/home')).toBe('/workspaces/p123/tags/workspace-tags-imported?backTo=/home'); - expect(getMatchingNewRoute('/workspaces/p123/tags/10/edit?backTo=/home')).toBe('/workspaces/p123/tags/tags-settings/workspace-edit-tags/10?backTo=/home'); + expect(getMatchingNewRoute('/workspaces/p123/tags/10/edit?backTo=/home')).toBe('/workspaces/p123/tags/workspace-edit-tags/10?backTo=/home'); expect(getMatchingNewRoute('/workspaces/p123/tag/10/Meals?parentTagsFilter=Food')).toBe('/workspaces/p123/tags/workspace-tag-settings/10/Meals?parentTagsFilter=Food'); }); it('does not redirect the new workspace tag dynamic routes', () => { - expect(getMatchingNewRoute('/workspaces/p123/tags/tags-settings')).toBe(undefined); expect(getMatchingNewRoute('/workspaces/p123/tags/tag-create')).toBe(undefined); expect(getMatchingNewRoute('/workspaces/p123/tags/workspace-tag-list/0')).toBe(undefined); expect(getMatchingNewRoute('/workspaces/p123/tags/workspace-tags-import')).toBe(undefined); expect(getMatchingNewRoute('/workspaces/p123/tags/workspace-tags-imported')).toBe(undefined); - expect(getMatchingNewRoute('/workspaces/p123/tags/tags-settings/workspace-edit-tags/10')).toBe(undefined); expect(getMatchingNewRoute('/workspaces/p123/tags/workspace-tag-settings/10/Meals')).toBe(undefined); }); it('does not redirect the new settings tag dynamic routes', () => { expect(getMatchingNewRoute('/settings/p123/tags/settings-tags-edit/10')).toBe(undefined); - expect(getMatchingNewRoute('/settings/p123/tags/settings-tags-settings')).toBe(undefined); expect(getMatchingNewRoute('/settings/p123/tags/tag-list/0/settings-tags-edit/0')).toBe(undefined); }); @@ -365,6 +374,28 @@ describe('getBestMatchingPath', () => { expect(getMatchingNewRoute('/home-page?backTo=r/123')).toBe('/home?backTo=r/123'); }); + it('redirects the old Wallet global reimbursements intro path to its dynamic route', () => { + // Given a link to the intro page saved before it became a dynamic route, keyed by bank account ID + const oldRoute = '/settings/wallet/123/global-reimbursements'; + + // When the old link is resolved + const newRoute = getMatchingNewRoute(oldRoute); + + // Then it lands on the dynamic suffix appended to Wallet, so back navigation still returns to Wallet + expect(newRoute).toBe('/settings/wallet/global-reimbursements/123'); + }); + + it('does not redirect the dynamic global reimbursements intro route', () => { + // Given the new dynamic intro path, whose segment order differs from the old pattern + const newRoute = '/settings/wallet/global-reimbursements/123'; + + // When it is resolved against the old routes + const matchedRoute = getMatchingNewRoute(newRoute); + + // Then nothing matches, so it is not rewritten in a loop + expect(matchedRoute).toBe(undefined); + }); + describe('redirects old enable-payments substeps to their new routes', () => { const enablePaymentsCases = [ {oldRoute: '/settings/wallet/enable-payments/plaid', expectedRoute: '/settings/wallet/enable-payments/add-bank-account/plaid'}, diff --git a/tests/navigation/replaceCompanyCardsRouteTests.ts b/tests/navigation/replaceCompanyCardsRouteTests.ts new file mode 100644 index 000000000000..c59b8191c4a9 --- /dev/null +++ b/tests/navigation/replaceCompanyCardsRouteTests.ts @@ -0,0 +1,29 @@ +import replaceCompanyCardsRoute from '@libs/Navigation/helpers/replaceCompanyCardsRoute'; + +describe('replaceCompanyCardsRoute', () => { + const feed = 'oauth.mockbank.com%2322709230'; + const cardID = '2406988247305323'; + const cardDetailsPath = `workspaces/ABC123/company-cards/company-card-details/${feed}/${cardID}`; + + it('strips the parametric company-card export suffix', () => { + expect(replaceCompanyCardsRoute(`${cardDetailsPath}/edit/export/${feed}/${cardID}`)).toBe(cardDetailsPath); + }); + + it('strips a bare /edit/export suffix', () => { + expect(replaceCompanyCardsRoute(`${cardDetailsPath}/edit/export`)).toBe(cardDetailsPath); + }); + + it('preserves query params after stripping the parametric export suffix', () => { + expect(replaceCompanyCardsRoute(`${cardDetailsPath}/edit/export/${feed}/${cardID}?foo=bar`)).toBe(`${cardDetailsPath}?foo=bar`); + }); + + it('returns the path unchanged when there is no export suffix', () => { + expect(replaceCompanyCardsRoute(cardDetailsPath)).toBe(cardDetailsPath); + }); + + it('does not strip an accounting export path that is not a company-card selector', () => { + const accountingExportPath = 'workspaces/ABC123/accounting/quickbooks-online/export'; + + expect(replaceCompanyCardsRoute(accountingExportPath)).toBe(accountingExportPath); + }); +}); diff --git a/tests/navigation/stripDynamicRouteSuffixFromPathTests.ts b/tests/navigation/stripDynamicRouteSuffixFromPathTests.ts new file mode 100644 index 000000000000..cb724b1bf479 --- /dev/null +++ b/tests/navigation/stripDynamicRouteSuffixFromPathTests.ts @@ -0,0 +1,27 @@ +import stripDynamicRouteSuffixFromPath from '@libs/Navigation/helpers/dynamicRoutesUtils/stripDynamicRouteSuffixFromPath'; + +import {DYNAMIC_ROUTES} from '@src/ROUTES'; + +describe('stripDynamicRouteSuffixFromPath', () => { + const suffix = DYNAMIC_ROUTES.VERIFY_ACCOUNT.path; + const parametricSuffix = DYNAMIC_ROUTES.WORKSPACE_COMPANY_CARD_EXPORT.path; + const feed = 'oauth.mockbank.com%2322709230'; + const cardID = '2406988247305323'; + const cardDetailsPath = `workspaces/ABC123/company-cards/company-card-details/${feed}/${cardID}`; + + it('strips a matching suffix', () => { + expect(stripDynamicRouteSuffixFromPath('settings/wallet/verify-account', suffix)).toBe('settings/wallet'); + }); + + it('strips a parametric suffix', () => { + expect(stripDynamicRouteSuffixFromPath(`${cardDetailsPath}/edit/export/${feed}/${cardID}`, parametricSuffix)).toBe(cardDetailsPath); + }); + + it('returns the path unchanged when the suffix does not match', () => { + expect(stripDynamicRouteSuffixFromPath('settings/wallet', suffix)).toBe('settings/wallet'); + }); + + it('strips a leading slash', () => { + expect(stripDynamicRouteSuffixFromPath('/settings/wallet', suffix)).toBe('settings/wallet'); + }); +}); diff --git a/tests/navigation/wiseGlobalReimbursementsIntroDynamicRouteTest.ts b/tests/navigation/wiseGlobalReimbursementsIntroDynamicRouteTest.ts new file mode 100644 index 000000000000..fbf1ab8cfc6a --- /dev/null +++ b/tests/navigation/wiseGlobalReimbursementsIntroDynamicRouteTest.ts @@ -0,0 +1,64 @@ +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import findAllMatchingDynamicSuffixes from '@libs/Navigation/helpers/dynamicRoutesUtils/findAllMatchingDynamicSuffixes'; +import getPathWithoutDynamicSuffix from '@libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix'; +import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; + +import CONST from '@src/CONST'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationState, PartialState} from '@react-navigation/native'; + +const SUFFIX = DYNAMIC_ROUTES.WISE_GLOBAL_REIMBURSEMENTS_INTRO.path; +const BANK_ACCOUNT_ID = 9053192; +const POLICY_ID = 'ABCDEF1234567890'; + +type FocusedRoute = {name?: string; params?: unknown}; + +function getFocusedRoute(state: PartialState): FocusedRoute { + let current: PartialState | undefined = state; + let route: FocusedRoute = {}; + + while (current?.routes) { + const nextRoute = current.routes.at(current.index ?? current.routes.length - 1); + route = {name: nextRoute?.name, params: nextRoute?.params}; + current = nextRoute?.state; + } + + return route; +} + +const ENTRY_POINTS = [ + ['Wallet', ROUTES.SETTINGS_WALLET], + ['Workspace Workflows > Payments', ROUTES.WORKSPACE_WORKFLOWS.getRoute(POLICY_ID, CONST.TAB.WORKFLOWS.PAYMENTS)], +] as const; + +describe('Wise global reimbursements intro dynamic route', () => { + it.each(ENTRY_POINTS)('opens the intro page on top of %s', (_label, basePath) => { + // Given the intro suffix appended to an entry point the banner or bank account menu lives on + const route = createDynamicRoute(DYNAMIC_ROUTES.WISE_GLOBAL_REIMBURSEMENTS_INTRO.getRoute(BANK_ACCOUNT_ID), basePath); + + // When the URL is parsed into navigation state + const focusedRoute = getFocusedRoute(getStateFromPath(route)); + + // Then the single dynamic intro screen is focused with the bank account from the suffix + expect(focusedRoute.name).toBe(SCREENS.SETTINGS.WALLET.DYNAMIC_WISE_GLOBAL_REIMBURSEMENTS_INTRO); + expect(focusedRoute.params).toMatchObject({bankAccountID: String(BANK_ACCOUNT_ID)}); + }); + + it.each(ENTRY_POINTS)('goes back to %s once the suffix is stripped', (_label, basePath) => { + // Given the intro page opened on top of an entry point + const route = createDynamicRoute(DYNAMIC_ROUTES.WISE_GLOBAL_REIMBURSEMENTS_INTRO.getRoute(BANK_ACCOUNT_ID), basePath); + + // When computing the back path from the current URL + const match = findAllMatchingDynamicSuffixes(route).find((m) => m.pattern === SUFFIX); + expect(match).toBeDefined(); + if (!match) { + return; + } + const backPath = getPathWithoutDynamicSuffix(match.pathUsedForMatching, match.actualSuffix, match.pattern); + + // Then going back lands on the page the user came from, including the selected Workflows tab + expect(backPath).toBe(basePath); + }); +}); diff --git a/tests/perf-test/GooglePlacesUtils.perf-test.ts b/tests/perf-local/GooglePlacesUtils.perf-local.ts similarity index 100% rename from tests/perf-test/GooglePlacesUtils.perf-test.ts rename to tests/perf-local/GooglePlacesUtils.perf-local.ts diff --git a/tests/perf-test/ModifiedExpenseMessage.perf-test.ts b/tests/perf-local/ModifiedExpenseMessage.perf-local.ts similarity index 100% rename from tests/perf-test/ModifiedExpenseMessage.perf-test.ts rename to tests/perf-local/ModifiedExpenseMessage.perf-local.ts diff --git a/tests/perf-test/OptionsListChatPreview.perf-test.ts b/tests/perf-local/OptionsListChatPreview.perf-local.ts similarity index 97% rename from tests/perf-test/OptionsListChatPreview.perf-test.ts rename to tests/perf-local/OptionsListChatPreview.perf-local.ts index c2e259002792..cd84a2ea7924 100644 --- a/tests/perf-test/OptionsListChatPreview.perf-test.ts +++ b/tests/perf-local/OptionsListChatPreview.perf-local.ts @@ -15,7 +15,7 @@ import type {TupleToUnion} from 'type-fest'; import Onyx from 'react-native-onyx'; import {measureFunction} from 'reassure'; -import {convertToDisplayString, translateLocal} from '../utils/TestHelper'; +import {convertToDisplayString, translateLocal, formatPhoneNumber} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; /** @@ -240,6 +240,7 @@ const buildOptionList = () => maxRecentReports: REPORTS_COUNT, transactionThreadIDs, lastActions, + formatPhoneNumber, }, undefined, ); @@ -311,7 +312,7 @@ describe('OptionsListChatPreview', () => { CURRENT_USER_EMAIL, undefined, getPreviewOptionsConfig(MAX_ELEMENTS), - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ), ); @@ -330,7 +331,7 @@ describe('OptionsListChatPreview', () => { CURRENT_USER_EMAIL, undefined, getPreviewOptionsConfig(WIDE_MAX_ELEMENTS), - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ), ); @@ -350,7 +351,7 @@ describe('OptionsListChatPreview', () => { CURRENT_USER_EMAIL, undefined, getPreviewOptionsConfig(MAX_ELEMENTS), - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); }); @@ -370,7 +371,7 @@ describe('OptionsListChatPreview', () => { CURRENT_USER_EMAIL, undefined, getPreviewOptionsConfig(WIDE_MAX_ELEMENTS), - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); }); diff --git a/tests/perf-test/OptionsListUtils.perf-test.ts b/tests/perf-local/OptionsListUtils.perf-local.ts similarity index 96% rename from tests/perf-test/OptionsListUtils.perf-test.ts rename to tests/perf-local/OptionsListUtils.perf-local.ts index c6b03fa48dfa..1333d3569c78 100644 --- a/tests/perf-test/OptionsListUtils.perf-test.ts +++ b/tests/perf-local/OptionsListUtils.perf-local.ts @@ -22,7 +22,7 @@ import createRandomOptionData from '../utils/collections/optionData'; import createPersonalDetails from '../utils/collections/personalDetails'; import {getRandomDate} from '../utils/collections/reportActions'; import {createRandomReport} from '../utils/collections/reports'; -import {convertToDisplayString, translateLocal} from '../utils/TestHelper'; +import {convertToDisplayString, translateLocal, formatPhoneNumber} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const REPORTS_COUNT = 5000; @@ -113,6 +113,7 @@ const options = createFilteredOptionList( conciergeReportID: undefined, isSearching: true, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + formatPhoneNumber, }, undefined, ); @@ -177,6 +178,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, getReportByID, rules: undefined, + formatPhoneNumber, }), ); }); @@ -193,7 +195,7 @@ describe('OptionsListUtils', () => { MOCK_CURRENT_USER_EMAIL, undefined, ValidOptionsConfig, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); await measureFunction(() => { @@ -201,6 +203,7 @@ describe('OptionsListUtils', () => { options: formattedOptions, searchInputValue: SEARCH_VALUE, countryCode: COUNTRY_CODE, + formatPhoneNumber, loginList, currentUserEmail: MOCK_CURRENT_USER_EMAIL, currentUserAccountID: MOCK_CURRENT_USER_ACCOUNT_ID, @@ -227,7 +230,7 @@ describe('OptionsListUtils', () => { MOCK_CURRENT_USER_EMAIL, undefined, ValidOptionsConfig, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); await measureFunction(() => { @@ -235,6 +238,7 @@ describe('OptionsListUtils', () => { options: formattedOptions, searchInputValue: '', countryCode: COUNTRY_CODE, + formatPhoneNumber, loginList, currentUserEmail: MOCK_CURRENT_USER_EMAIL, currentUserAccountID: MOCK_CURRENT_USER_ACCOUNT_ID, @@ -281,7 +285,7 @@ describe('OptionsListUtils', () => { includeUserToInvite: false, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ), ); @@ -335,6 +339,7 @@ describe('OptionsListUtils', () => { translateLocal, convertToDisplayString, undefined, + formatPhoneNumber, getReportByID, undefined, mockedPersonalDetails, @@ -361,6 +366,7 @@ describe('OptionsListUtils', () => { translateLocal, convertToDisplayString, undefined, + formatPhoneNumber, getReportByID, undefined, mockedPersonalDetails, @@ -387,6 +393,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, maxRecentReports: 500, isSearching: false, + formatPhoneNumber, }, undefined, ); @@ -409,6 +416,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, maxRecentReports: 500, isSearching: true, + formatPhoneNumber, }, undefined, ), @@ -432,6 +440,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -445,7 +454,7 @@ describe('OptionsListUtils', () => { MOCK_CURRENT_USER_EMAIL, undefined, ValidOptionsConfig, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -454,6 +463,7 @@ describe('OptionsListUtils', () => { options: formattedOptions, searchInputValue: 'Email Report Five', countryCode: COUNTRY_CODE, + formatPhoneNumber, loginList, currentUserEmail: MOCK_CURRENT_USER_EMAIL, currentUserAccountID: MOCK_CURRENT_USER_ACCOUNT_ID, @@ -485,6 +495,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, maxRecentReports: 500, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -507,6 +518,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, getReportByID, rules: undefined, + formatPhoneNumber, }), ); }); diff --git a/tests/perf-test/PersonalDetailOptionsListUtils.perf-test.ts b/tests/perf-local/PersonalDetailOptionsListUtils.perf-local.ts similarity index 100% rename from tests/perf-test/PersonalDetailOptionsListUtils.perf-test.ts rename to tests/perf-local/PersonalDetailOptionsListUtils.perf-local.ts diff --git a/tests/perf-test/PolicyUtils.perf-test.ts b/tests/perf-local/PolicyUtils.perf-local.ts similarity index 100% rename from tests/perf-test/PolicyUtils.perf-test.ts rename to tests/perf-local/PolicyUtils.perf-local.ts diff --git a/tests/perf-test/ReportActionsUtils.perf-test.ts b/tests/perf-local/ReportActionsUtils.perf-local.ts similarity index 100% rename from tests/perf-test/ReportActionsUtils.perf-test.ts rename to tests/perf-local/ReportActionsUtils.perf-local.ts diff --git a/tests/perf-test/ReportUtils.perf-test.ts b/tests/perf-local/ReportUtils.perf-local.ts similarity index 99% rename from tests/perf-test/ReportUtils.perf-test.ts rename to tests/perf-local/ReportUtils.perf-local.ts index bcafd512bebe..5ffd47e0f74f 100644 --- a/tests/perf-test/ReportUtils.perf-test.ts +++ b/tests/perf-local/ReportUtils.perf-local.ts @@ -96,7 +96,7 @@ describe('ReportUtils', () => { }); await waitForBatchedUpdates(); - await measureFunction(() => findLastAccessedReport(ignoreDomainRooms, undefined, openOnAdminRoom)); + await measureFunction(() => findLastAccessedReport(ignoreDomainRooms, undefined, false, openOnAdminRoom)); }); test('[ReportUtils] canDeleteReportAction on 1k reports and policies', async () => { @@ -105,7 +105,7 @@ describe('ReportUtils', () => { const reportAction = createMock({...createRandomReportAction(1), actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); await waitForBatchedUpdates(); - await measureFunction(() => canDeleteReportAction(reportAction, reportID, transaction, undefined, undefined, 1, undefined, undefined)); + await measureFunction(() => canDeleteReportAction(reportAction, reportID, transaction, undefined, undefined, 1, undefined, undefined, undefined)); }); test('[ReportUtils] getReportRecipientAccountID on 1k participants', async () => { diff --git a/tests/perf-test/SidebarUtils.perf-test.ts b/tests/perf-local/SidebarUtils.perf-local.ts similarity index 100% rename from tests/perf-test/SidebarUtils.perf-test.ts rename to tests/perf-local/SidebarUtils.perf-local.ts diff --git a/tests/perf-test/isReportMessageAttachment.perf-test.ts b/tests/perf-local/isReportMessageAttachment.perf-local.ts similarity index 100% rename from tests/perf-test/isReportMessageAttachment.perf-test.ts rename to tests/perf-local/isReportMessageAttachment.perf-local.ts diff --git a/tests/perf-local/useAdvancedSearchFilters.perf-local.ts b/tests/perf-local/useAdvancedSearchFilters.perf-local.ts new file mode 100644 index 000000000000..cc71b02b82f5 --- /dev/null +++ b/tests/perf-local/useAdvancedSearchFilters.perf-local.ts @@ -0,0 +1,60 @@ +import {isPolicyFeatureEnabled} from '@libs/PolicyUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +import {measureFunction} from 'reassure'; + +import createCollection from '../utils/collections/createCollection'; +import createRandomPolicy from '../utils/collections/policies'; + +const LARGE_POLICY_COUNT = 50000; + +describe('useAdvancedSearchFilters', () => { + test('policy-derived computation', async () => { + const policies = createCollection( + (_, index) => `${ONYXKEYS.COLLECTION.POLICY}${index}`, + (index) => createRandomPolicy(index), + LARGE_POLICY_COUNT, + ); + + await measureFunction(() => { + // Optimized: single pass over all policies with early-exit flags + let areCategoriesEnabled = false; + let areTagsEnabled = false; + let areTaxEnabled = false; + let isAttendeeTrackingEnabled = false; + let hasReportFields = false; + let hasAnyTaxRates = false; + let hasNonPersonalPolicyCategories = false; + + for (const policy of Object.values(policies ?? {})) { + if (!policy) { + continue; + } + if (!hasNonPersonalPolicyCategories && policy.type !== CONST.POLICY.TYPE.PERSONAL) { + hasNonPersonalPolicyCategories = true; + } + if (!areCategoriesEnabled) { + areCategoriesEnabled = isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED); + } + if (!areTagsEnabled) { + areTagsEnabled = isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_TAGS_ENABLED); + } + if (!areTaxEnabled) { + areTaxEnabled = isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED); + } + if (!isAttendeeTrackingEnabled) { + isAttendeeTrackingEnabled = isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.IS_ATTENDEE_TRACKING_ENABLED); + } + if (!hasReportFields) { + hasReportFields = Object.values(policy.fieldList ?? {}).some((val) => val.type !== CONST.POLICY.DEFAULT_FIELD_LIST_TYPE); + } + if (!hasAnyTaxRates && areTaxEnabled) { + hasAnyTaxRates = Object.keys(policy.taxRates?.taxes ?? {}).length > 0; + } + } + }); + }); +}); diff --git a/tests/perf-local/useFilterFormValues.perf-local.ts b/tests/perf-local/useFilterFormValues.perf-local.ts new file mode 100644 index 000000000000..e2dab8fcd666 --- /dev/null +++ b/tests/perf-local/useFilterFormValues.perf-local.ts @@ -0,0 +1,162 @@ +import {typeOptionsPoliciesSelector} from '@components/Search/FilterComponents/TypeSelector'; + +import {advancedSearchPoliciesSelector} from '@hooks/useAdvancedSearchFilters'; +import {exportedToPoliciesSelector} from '@hooks/useExportedToFilterOptions'; +import {policiesSelector, policyCategoriesSelector} from '@hooks/useFilterFormValues'; + +import {getAllTaxRates} from '@libs/PolicyUtils'; +import {buildFilterFormValuesFromQuery} from '@libs/SearchQueryUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, PolicyCategories, Report} from '@src/types/onyx'; + +import {measureFunction} from 'reassure'; + +import {buildSearchQueryJSONOrThrow, CATEGORY_COUNT, POLICY_COUNT, REPORT_COUNT} from '../perf-test/FilterFormValuesPerfTestUtils'; +import createCollection from '../utils/collections/createCollection'; +import createRandomPolicy from '../utils/collections/policies'; +import createMock from '../utils/createMock'; + +describe('useFilterFormValues', () => { + describe('selector execution', () => { + test('policiesSelector with 500 policies', async () => { + const policies = createCollection( + (_, index) => `${ONYXKEYS.COLLECTION.POLICY}${index}`, + (index) => ({ + ...createRandomPolicy(index), + taxRates: { + name: 'Tax', + defaultExternalID: '', + defaultValue: '10%', + foreignTaxDefault: '', + taxes: {tax1: {name: 'VAT', value: '20%', code: 'vat', modifiedName: 'VAT', isDisabled: false}}, + }, + employeeList: Object.fromEntries(Array.from({length: 100}, (_unused, i) => [`user${i}@test.com`, {email: `user${i}@test.com`, role: 'user'}])), + }), + POLICY_COUNT, + ); + + await measureFunction(() => policiesSelector(policies)); + }); + + test('policyCategoriesSelector with 500 policy category collections', async () => { + const categories = createCollection( + (_, index) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${index}`, + () => + createMock( + Object.fromEntries( + Array.from({length: 40}, (_unused, i) => [ + `Category${i}`, + {name: `Category${i}`, enabled: true, unencodedName: `Category${i}`, areCommentsRequired: false, externalID: `${i}`, origin: 'abc'}, + ]), + ), + ), + CATEGORY_COUNT, + ); + + await measureFunction(() => policyCategoriesSelector(categories)); + }); + + test('exportedToPoliciesSelector with 500 policies', async () => { + const policies = createCollection( + (_, index) => `${ONYXKEYS.COLLECTION.POLICY}${index}`, + (index) => + createMock({ + ...createRandomPolicy(index), + connections: { + quickbooksOnline: { + config: {realmId: `realm${index}`, companyName: `Company ${index}`}, + lastSync: {isConnected: true}, + }, + }, + exportLayouts: Object.fromEntries(Array.from({length: 5}, (_unused, i) => [`template${i}`, {name: `Template ${i}`}])), + employeeList: Object.fromEntries(Array.from({length: 100}, (_unused, i) => [`user${i}@test.com`, {email: `user${i}@test.com`, role: 'user'}])), + taxRates: {name: 'Tax', defaultExternalID: '', defaultValue: '10%', foreignTaxDefault: '', taxes: {}}, + }), + POLICY_COUNT, + ); + + await measureFunction(() => exportedToPoliciesSelector(policies)); + }); + + test('typeOptionsPoliciesSelector with 500 policies', async () => { + const policies = createCollection( + (_, index) => `${ONYXKEYS.COLLECTION.POLICY}${index}`, + (index) => + createMock({ + ...createRandomPolicy(index), + connections: { + quickbooksOnline: { + config: {realmId: `realm${index}`}, + lastSync: {isConnected: true}, + }, + }, + taxRates: {name: 'Tax', defaultExternalID: '', defaultValue: '10%', foreignTaxDefault: '', taxes: {}}, + fieldList: Object.fromEntries(Array.from({length: 10}, (_unused, i) => [`field${i}`, {name: `Field ${i}`, type: 'text'}])), + employeeList: Object.fromEntries(Array.from({length: 100}, (_unused, i) => [`user${i}@test.com`, {email: `user${i}@test.com`, role: 'user'}])), + }), + POLICY_COUNT, + ); + + await measureFunction(() => typeOptionsPoliciesSelector(policies)); + }); + + test('advancedSearchPoliciesSelector with 500 policies', async () => { + const policies = createCollection( + (_, index) => `${ONYXKEYS.COLLECTION.POLICY}${index}`, + (index) => + createMock({ + ...createRandomPolicy(index), + connections: { + quickbooksOnline: { + config: {realmId: `realm${index}`, companyName: `Company ${index}`}, + lastSync: {isConnected: true}, + }, + }, + exportLayouts: Object.fromEntries(Array.from({length: 5}, (_unused, i) => [`template${i}`, {name: `Template ${i}`}])), + rules: {approvalRules: Array.from({length: 10}, (_unused, i) => ({id: `rule${i}`, name: `Rule ${i}`}))}, + employeeList: Object.fromEntries(Array.from({length: 100}, (_unused, i) => [`user${i}@test.com`, {email: `user${i}@test.com`, role: 'user'}])), + taxRates: {name: 'Tax', defaultExternalID: '', defaultValue: '10%', foreignTaxDefault: '', taxes: {}}, + fieldList: Object.fromEntries(Array.from({length: 10}, (_unused, i) => [`field${i}`, {name: `Field ${i}`, type: 'text'}])), + }), + POLICY_COUNT, + ); + + await measureFunction(() => advancedSearchPoliciesSelector(policies)); + }); + }); + + describe('buildFilterFormValuesFromQuery execution', () => { + test('buildFilterFormValuesFromQuery with 500 policies, reports, categories', async () => { + const queryJSON = buildSearchQueryJSONOrThrow('type:expense status:all category:Category0 tag:Tag0'); + + const policies = createCollection( + (_, index) => `${ONYXKEYS.COLLECTION.POLICY}${index}`, + (index) => ({ + ...createRandomPolicy(index), + taxRates: { + name: 'Tax', + defaultExternalID: '', + defaultValue: '10%', + foreignTaxDefault: '', + taxes: {tax1: {name: 'VAT', value: '20%', code: 'vat', modifiedName: 'VAT', isDisabled: false}}, + }, + }), + POLICY_COUNT, + ); + const reports = createCollection( + (_, index) => `${ONYXKEYS.COLLECTION.REPORT}${index}`, + (index) => createMock({reportID: `${index}`, reportName: `Report ${index}`}), + REPORT_COUNT, + ); + const categories = createCollection( + (_, index) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${index}`, + () => createMock(Object.fromEntries(Array.from({length: 40}, (_unused, i) => [`Category${i}`, {name: `Category${i}`, enabled: true}]))), + CATEGORY_COUNT, + ); + const taxRates = getAllTaxRates(policies); + + await measureFunction(() => buildFilterFormValuesFromQuery(queryJSON, categories, {}, {}, {}, reports, taxRates)); + }); + }); +}); diff --git a/tests/perf-test/FilterFormValuesPerfTestUtils.ts b/tests/perf-test/FilterFormValuesPerfTestUtils.ts new file mode 100644 index 000000000000..b646445a9e9e --- /dev/null +++ b/tests/perf-test/FilterFormValuesPerfTestUtils.ts @@ -0,0 +1,17 @@ +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + +import type {SearchQueryJSON} from '@src/components/Search/types'; + +const POLICY_COUNT = 500; +const REPORT_COUNT = 500; +const CATEGORY_COUNT = 500; + +function buildSearchQueryJSONOrThrow(query: string): SearchQueryJSON { + const result = buildSearchQueryJSON(query); + if (!result) { + throw new Error(`Failed to parse query string: ${query}`); + } + return result; +} + +export {POLICY_COUNT, REPORT_COUNT, CATEGORY_COUNT, buildSearchQueryJSONOrThrow}; diff --git a/tests/perf-test/SidebarLinksRowWrites.perf-test.tsx b/tests/perf-test/SidebarLinksRowWrites.perf-test.tsx new file mode 100644 index 000000000000..ca738b3ef2e5 --- /dev/null +++ b/tests/perf-test/SidebarLinksRowWrites.perf-test.tsx @@ -0,0 +1,123 @@ +import {screen} from '@testing-library/react-native'; + +import {setHasRadio} from '@libs/NetworkState'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportCollectionDataSet} from '@src/types/onyx/Report'; + +import Onyx from 'react-native-onyx'; +import {measureRenders} from 'reassure'; + +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; + +jest.mock('@libs/Permissions'); +jest.mock('../../src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + isActiveRoute: jest.fn(), + getTopmostReportId: jest.fn(), + getActiveRoute: jest.fn(), + getTopmostReportActionId: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + isDisplayedInModal: jest.fn(() => false), + getActiveRouteWithoutParams: jest.fn(() => ''), +})); +jest.mock('../../src/libs/Navigation/navigationRef', () => ({ + getState: () => ({ + routes: [{name: 'Report'}], + }), + getRootState: () => ({ + routes: [], + }), + addListener: () => () => {}, + isReady: () => true, +})); + +jest.mock('@react-navigation/native'); + +const REPORT_COUNT = 500; +const WRITES_PER_SCENARIO = 10; +const UNRELATED_POLICY_ID = 'UNRELATED_POLICY'; +const UNRELATED_POLICY_KEY = `${ONYXKEYS.COLLECTION.POLICY}${UNRELATED_POLICY_ID}` as const; + +const reports: ReportCollectionDataSet = Object.fromEntries( + Array.from({length: REPORT_COUNT}, () => { + const report = {...LHNTestUtils.getFakeReport([1, 2], 1, true), lastMessageText: 'hey'}; + return [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report]; + }), +); +const firstReportID = Object.values(reports).at(0)?.reportID; + +describe('SidebarLinks on single-key writes', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + // Required lazily so LHNTestUtils registers its @react-navigation/native mock before the engine's imports load it + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + require('@userActions/OnyxDerived').default(); + }); + + beforeEach(async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + wrapOnyxWithWaitForBatchedUpdates(Onyx); + setHasRadio(true); + await TestHelper.signInWithTestUser(1, 'email1@test.com', undefined, undefined, 'One'); + await waitForBatchedUpdates(); + + // Given 500 reports in the LHN and a policy that none of them belongs to + await Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, + [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS], + [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, + [UNRELATED_POLICY_KEY]: {id: UNRELATED_POLICY_ID, name: 'Unrelated', type: CONST.POLICY.TYPE.TEAM}, + ...reports, + }); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + test('[SidebarLinks] should re-render when one report is written', async () => { + // Reassure runs the scenario several times against one seeded Onyx, so every run writes a value that doesn't exist yet + let run = 0; + const scenario = async () => { + await screen.findByTestId('lhn-options-list'); + + // When a single report gets a field update that does not change the LHN order + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${firstReportID}`, {lastReadTime: `2026-01-01 00:${String(run % 60).padStart(2, '0')}:${String(index).padStart(2, '0')}.000`}); + await waitForBatchedUpdates(); + } + run++; + }; + + // Then reassure measures what a write to one report costs the LHN + await measureRenders(, {scenario}); + }); + + test('[SidebarLinks] should re-render when an unrelated policy is written', async () => { + let run = 0; + const scenario = async () => { + await screen.findByTestId('lhn-options-list'); + + // When a policy that no LHN report belongs to is renamed + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(UNRELATED_POLICY_KEY, {name: `Unrelated ${run}-${index}`}); + await waitForBatchedUpdates(); + } + run++; + }; + + // Then reassure measures what the unrelated policy writes cost the LHN + await measureRenders(, {scenario}); + }); +}); diff --git a/tests/perf-test/SidebarLinksWritePath.perf-test.tsx b/tests/perf-test/SidebarLinksWritePath.perf-test.tsx new file mode 100644 index 000000000000..b306086c67b8 --- /dev/null +++ b/tests/perf-test/SidebarLinksWritePath.perf-test.tsx @@ -0,0 +1,165 @@ +import {render, screen} from '@testing-library/react-native'; + +import {setHasRadio} from '@libs/NetworkState'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportCollectionDataSet} from '@src/types/onyx/Report'; + +import Onyx from 'react-native-onyx'; +import {measureAsyncFunction, measureRenders} from 'reassure'; + +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; + +jest.mock('@libs/Permissions'); +jest.mock('../../src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + isActiveRoute: jest.fn(), + getTopmostReportId: jest.fn(), + getActiveRoute: jest.fn(), + getTopmostReportActionId: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + isDisplayedInModal: jest.fn(() => false), + getActiveRouteWithoutParams: jest.fn(() => ''), +})); +jest.mock('../../src/libs/Navigation/navigationRef', () => ({ + getState: () => ({ + routes: [{name: 'Report'}], + }), + getRootState: () => ({ + routes: [], + }), + addListener: () => () => {}, + isReady: () => true, +})); + +jest.mock('@react-navigation/native'); + +const WRITES_PER_SCENARIO = 10; +const OFFSCREEN_ROOM_ID = '999999101'; +const OFFSCREEN_ROOM_KEY = `${ONYXKEYS.COLLECTION.REPORT}${OFFSCREEN_ROOM_ID}` as const; +const OUTSIDE_ACCOUNT_ID = 999999; + +function buildReports(count: number): ReportCollectionDataSet { + return Object.fromEntries( + Array.from({length: count}, (_, index) => { + const report = { + ...LHNTestUtils.getFakeReport([1, 2], 1, true), + lastMessageText: 'hey', + lastVisibleActionCreated: `2025-01-01 00:00:${String(index % 60).padStart(2, '0')}.${String(index % 1000).padStart(3, '0')}`, + }; + return [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report]; + }), + ); +} + +describe.each([500, 5000])('SidebarLinks write path with %i reports', (reportCount) => { + const reports = buildReports(reportCount); + const firstReportKey = `${ONYXKEYS.COLLECTION.REPORT}${Object.values(reports).at(59)?.reportID}` as const; + + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + // Required lazily so LHNTestUtils registers its @react-navigation/native mock before the engine's imports load it + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + require('@userActions/OnyxDerived').default(); + }); + + beforeEach(async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + wrapOnyxWithWaitForBatchedUpdates(Onyx); + setHasRadio(true); + await TestHelper.signInWithTestUser(1, 'email1@test.com', undefined, undefined, 'One'); + await waitForBatchedUpdates(); + + // Given many reports sorted by recent activity and a room older than all of them, so it sits below the rendered window + await Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, + [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS], + [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, + [OFFSCREEN_ROOM_KEY]: { + ...LHNTestUtils.getFakeReport([1, 2], 1, false), + reportID: OFFSCREEN_ROOM_ID, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, + reportName: '#offscreen', + lastVisibleActionCreated: '2020-01-01 00:00:00.000', + }, + ...reports, + }); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + test('should re-render when a report below the rendered window is renamed', async () => { + let run = 0; + const scenario = async () => { + await screen.findByTestId('lhn-options-list'); + + // When a room no visible row shows gets a new name + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(OFFSCREEN_ROOM_KEY, {reportName: `#offscreen-${run}-${index}`}); + await waitForBatchedUpdates(); + } + run++; + }; + + // Then reassure measures what the rename costs the visible rows + await measureRenders(, {scenario}); + }); + + test('should take wall time to write one visible report', async () => { + // Given a mounted LHN, so the measurement covers subscribers, selectors, comparisons and renders together + render(); + await screen.findByTestId('lhn-options-list'); + let run = 0; + + // When one visible report gets a field update that does not change the order + await measureAsyncFunction(async () => { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(firstReportKey, {lastMessageText: `hey ${run}-${index}`}); + await waitForBatchedUpdates(); + } + run++; + }); + }); + + test('should take wall time to rename a report below the rendered window', async () => { + render(); + await screen.findByTestId('lhn-options-list'); + let run = 0; + + // When a room no visible row shows gets a new name + await measureAsyncFunction(async () => { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(OFFSCREEN_ROOM_KEY, {reportName: `#offscreen-${run}-${index}`}); + await waitForBatchedUpdates(); + } + run++; + }); + }); + + test('should take wall time to write personal details of a user outside the list', async () => { + render(); + await screen.findByTestId('lhn-options-list'); + let run = 0; + + // When a user who is in no LHN report changes their display name + await measureAsyncFunction(async () => { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[OUTSIDE_ACCOUNT_ID]: {accountID: OUTSIDE_ACCOUNT_ID, displayName: `Outside ${run}-${index}`}}); + await waitForBatchedUpdates(); + } + run++; + }); + }); +}); diff --git a/tests/perf-test/useAdvancedSearchFilters.perf-test.tsx b/tests/perf-test/useAdvancedSearchFilters.perf-test.tsx index dbbea6894703..e2393711fc58 100644 --- a/tests/perf-test/useAdvancedSearchFilters.perf-test.tsx +++ b/tests/perf-test/useAdvancedSearchFilters.perf-test.tsx @@ -2,10 +2,7 @@ import {LocaleContextProvider} from '@components/LocaleContextProvider'; import useAdvancedSearchFilters from '@hooks/useAdvancedSearchFilters'; -import {isPolicyFeatureEnabled} from '@libs/PolicyUtils'; - import ComposeProviders from '@src/components/ComposeProviders'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; @@ -14,7 +11,7 @@ import type * as NativeNavigation from '@react-navigation/native'; import React from 'react'; import {View} from 'react-native'; import Onyx from 'react-native-onyx'; -import {measureFunction, measureRenders} from 'reassure'; +import {measureRenders} from 'reassure'; import createCollection from '../utils/collections/createCollection'; import createRandomPolicy from '../utils/collections/policies'; @@ -22,7 +19,6 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; const POLICY_COUNT = 5000; -const LARGE_POLICY_COUNT = 50000; jest.mock('@src/libs/Log'); @@ -96,52 +92,6 @@ beforeEach(() => { afterEach(() => Onyx.clear()); describe('useAdvancedSearchFilters', () => { - test('policy-derived computation', async () => { - const policies = createCollection( - (_, index) => `${ONYXKEYS.COLLECTION.POLICY}${index}`, - (index) => createRandomPolicy(index), - LARGE_POLICY_COUNT, - ); - - await measureFunction(() => { - // Optimized: single pass over all policies with early-exit flags - let areCategoriesEnabled = false; - let areTagsEnabled = false; - let areTaxEnabled = false; - let isAttendeeTrackingEnabled = false; - let hasReportFields = false; - let hasAnyTaxRates = false; - let hasNonPersonalPolicyCategories = false; - - for (const policy of Object.values(policies ?? {})) { - if (!policy) { - continue; - } - if (!hasNonPersonalPolicyCategories && policy.type !== CONST.POLICY.TYPE.PERSONAL) { - hasNonPersonalPolicyCategories = true; - } - if (!areCategoriesEnabled) { - areCategoriesEnabled = isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED); - } - if (!areTagsEnabled) { - areTagsEnabled = isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_TAGS_ENABLED); - } - if (!areTaxEnabled) { - areTaxEnabled = isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED); - } - if (!isAttendeeTrackingEnabled) { - isAttendeeTrackingEnabled = isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.IS_ATTENDEE_TRACKING_ENABLED); - } - if (!hasReportFields) { - hasReportFields = Object.values(policy.fieldList ?? {}).some((val) => val.type !== CONST.POLICY.DEFAULT_FIELD_LIST_TYPE); - } - if (!hasAnyTaxRates && areTaxEnabled) { - hasAnyTaxRates = Object.keys(policy.taxRates?.taxes ?? {}).length > 0; - } - } - }); - }); - test('hook initial render with policies', async () => { const policies = createCollection( (_, index) => `${ONYXKEYS.COLLECTION.POLICY}${index}`, diff --git a/tests/perf-test/useFilterFormValues.perf-test.tsx b/tests/perf-test/useFilterFormValues.perf-test.tsx index 0810e34f6931..164c45fed0cf 100644 --- a/tests/perf-test/useFilterFormValues.perf-test.tsx +++ b/tests/perf-test/useFilterFormValues.perf-test.tsx @@ -1,13 +1,8 @@ -import {typeOptionsPoliciesSelector} from '@components/Search/FilterComponents/TypeSelector'; - -import {advancedSearchPoliciesSelector} from '@hooks/useAdvancedSearchFilters'; -import {exportedToPoliciesSelector} from '@hooks/useExportedToFilterOptions'; import {policiesSelector, policyCategoriesSelector} from '@hooks/useFilterFormValues'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import {buildFilterFormValuesFromQuery, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {buildFilterFormValuesFromQuery} from '@libs/SearchQueryUtils'; -import type {SearchQueryJSON} from '@src/components/Search/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, Report} from '@src/types/onyx'; @@ -15,25 +10,14 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import Onyx, {useOnyx} from 'react-native-onyx'; -import {measureFunction, measureRenders} from 'reassure'; +import {measureRenders} from 'reassure'; import createCollection from '../utils/collections/createCollection'; import createRandomPolicy from '../utils/collections/policies'; import createMock from '../utils/createMock'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; - -function buildSearchQueryJSONOrThrow(query: string): SearchQueryJSON { - const result = buildSearchQueryJSON(query); - if (!result) { - throw new Error(`Failed to parse query string: ${query}`); - } - return result; -} - -const POLICY_COUNT = 500; -const REPORT_COUNT = 500; -const CATEGORY_COUNT = 500; +import {buildSearchQueryJSONOrThrow, CATEGORY_COUNT, POLICY_COUNT, REPORT_COUNT} from './FilterFormValuesPerfTestUtils'; beforeAll(() => Onyx.init({keys: ONYXKEYS})); @@ -44,148 +28,6 @@ beforeEach(() => { afterEach(() => Onyx.clear()); describe('useFilterFormValues', () => { - describe('selector execution', () => { - test('policiesSelector with 500 policies', async () => { - const policies = createCollection( - (_, index) => `${ONYXKEYS.COLLECTION.POLICY}${index}`, - (index) => ({ - ...createRandomPolicy(index), - taxRates: { - name: 'Tax', - defaultExternalID: '', - defaultValue: '10%', - foreignTaxDefault: '', - taxes: {tax1: {name: 'VAT', value: '20%', code: 'vat', modifiedName: 'VAT', isDisabled: false}}, - }, - employeeList: Object.fromEntries(Array.from({length: 100}, (_unused, i) => [`user${i}@test.com`, {email: `user${i}@test.com`, role: 'user'}])), - }), - POLICY_COUNT, - ); - - await measureFunction(() => policiesSelector(policies)); - }); - - test('policyCategoriesSelector with 500 policy category collections', async () => { - const categories = createCollection( - (_, index) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${index}`, - () => - createMock( - Object.fromEntries( - Array.from({length: 40}, (_unused, i) => [ - `Category${i}`, - {name: `Category${i}`, enabled: true, unencodedName: `Category${i}`, areCommentsRequired: false, externalID: `${i}`, origin: 'abc'}, - ]), - ), - ), - CATEGORY_COUNT, - ); - - await measureFunction(() => policyCategoriesSelector(categories)); - }); - - test('exportedToPoliciesSelector with 500 policies', async () => { - const policies = createCollection( - (_, index) => `${ONYXKEYS.COLLECTION.POLICY}${index}`, - (index) => - createMock({ - ...createRandomPolicy(index), - connections: { - quickbooksOnline: { - config: {realmId: `realm${index}`, companyName: `Company ${index}`}, - lastSync: {isConnected: true}, - }, - }, - exportLayouts: Object.fromEntries(Array.from({length: 5}, (_unused, i) => [`template${i}`, {name: `Template ${i}`}])), - employeeList: Object.fromEntries(Array.from({length: 100}, (_unused, i) => [`user${i}@test.com`, {email: `user${i}@test.com`, role: 'user'}])), - taxRates: {name: 'Tax', defaultExternalID: '', defaultValue: '10%', foreignTaxDefault: '', taxes: {}}, - }), - POLICY_COUNT, - ); - - await measureFunction(() => exportedToPoliciesSelector(policies)); - }); - - test('typeOptionsPoliciesSelector with 500 policies', async () => { - const policies = createCollection( - (_, index) => `${ONYXKEYS.COLLECTION.POLICY}${index}`, - (index) => - createMock({ - ...createRandomPolicy(index), - connections: { - quickbooksOnline: { - config: {realmId: `realm${index}`}, - lastSync: {isConnected: true}, - }, - }, - taxRates: {name: 'Tax', defaultExternalID: '', defaultValue: '10%', foreignTaxDefault: '', taxes: {}}, - fieldList: Object.fromEntries(Array.from({length: 10}, (_unused, i) => [`field${i}`, {name: `Field ${i}`, type: 'text'}])), - employeeList: Object.fromEntries(Array.from({length: 100}, (_unused, i) => [`user${i}@test.com`, {email: `user${i}@test.com`, role: 'user'}])), - }), - POLICY_COUNT, - ); - - await measureFunction(() => typeOptionsPoliciesSelector(policies)); - }); - - test('advancedSearchPoliciesSelector with 500 policies', async () => { - const policies = createCollection( - (_, index) => `${ONYXKEYS.COLLECTION.POLICY}${index}`, - (index) => - createMock({ - ...createRandomPolicy(index), - connections: { - quickbooksOnline: { - config: {realmId: `realm${index}`, companyName: `Company ${index}`}, - lastSync: {isConnected: true}, - }, - }, - exportLayouts: Object.fromEntries(Array.from({length: 5}, (_unused, i) => [`template${i}`, {name: `Template ${i}`}])), - rules: {approvalRules: Array.from({length: 10}, (_unused, i) => ({id: `rule${i}`, name: `Rule ${i}`}))}, - employeeList: Object.fromEntries(Array.from({length: 100}, (_unused, i) => [`user${i}@test.com`, {email: `user${i}@test.com`, role: 'user'}])), - taxRates: {name: 'Tax', defaultExternalID: '', defaultValue: '10%', foreignTaxDefault: '', taxes: {}}, - fieldList: Object.fromEntries(Array.from({length: 10}, (_unused, i) => [`field${i}`, {name: `Field ${i}`, type: 'text'}])), - }), - POLICY_COUNT, - ); - - await measureFunction(() => advancedSearchPoliciesSelector(policies)); - }); - }); - - describe('buildFilterFormValuesFromQuery execution', () => { - test('buildFilterFormValuesFromQuery with 500 policies, reports, categories', async () => { - const queryJSON = buildSearchQueryJSONOrThrow('type:expense status:all category:Category0 tag:Tag0'); - - const policies = createCollection( - (_, index) => `${ONYXKEYS.COLLECTION.POLICY}${index}`, - (index) => ({ - ...createRandomPolicy(index), - taxRates: { - name: 'Tax', - defaultExternalID: '', - defaultValue: '10%', - foreignTaxDefault: '', - taxes: {tax1: {name: 'VAT', value: '20%', code: 'vat', modifiedName: 'VAT', isDisabled: false}}, - }, - }), - POLICY_COUNT, - ); - const reports = createCollection( - (_, index) => `${ONYXKEYS.COLLECTION.REPORT}${index}`, - (index) => createMock({reportID: `${index}`, reportName: `Report ${index}`}), - REPORT_COUNT, - ); - const categories = createCollection( - (_, index) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${index}`, - () => createMock(Object.fromEntries(Array.from({length: 40}, (_unused, i) => [`Category${i}`, {name: `Category${i}`, enabled: true}]))), - CATEGORY_COUNT, - ); - const taxRates = getAllTaxRates(policies); - - await measureFunction(() => buildFilterFormValuesFromQuery(queryJSON, categories, {}, {}, {}, reports, taxRates)); - }); - }); - describe('hook render duration with unrelated Onyx changes', () => { test('with selectors - render duration when policies change', async () => { const queryJSON = buildSearchQueryJSONOrThrow('type:expense status:all category:Category0 tag:Tag0'); diff --git a/tests/tooling/getDeployPullRequestList.test.ts b/tests/tooling/getDeployPullRequestList.test.ts new file mode 100644 index 000000000000..2cb87bbc4a83 --- /dev/null +++ b/tests/tooling/getDeployPullRequestList.test.ts @@ -0,0 +1,55 @@ +/// + +import {afterEach, describe, expect, it, jest} from 'bun:test'; + +import * as core from '@actions/core'; + +import run from '../../.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList'; +import GithubUtils from '../../.github/libs/GithubUtils'; + +const getInputSpy = jest.spyOn(core, 'getInput').mockImplementation((name: string) => { + if (name === 'TAG') { + return '1.0.0'; + } + return 'false'; +}); +const setFailedSpy = jest.spyOn(core, 'setFailed').mockImplementation(() => {}); +const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); +GithubUtils.initOctokitWithToken('test-token'); +const testOctokit = GithubUtils.internalOctokit; +if (!testOctokit) { + throw new Error('GithubUtils did not initialize Octokit'); +} +const paginateSpy = jest.spyOn(testOctokit, 'paginate'); + +const rejectionCases = [ + {name: 'Error', rejection: new Error('release lookup failed'), expectedConsoleValue: 'release lookup failed'}, + {name: 'string', rejection: 'release lookup failed', expectedConsoleValue: 'release lookup failed'}, + {name: 'null', rejection: null, expectedConsoleValue: 'null'}, + {name: 'plain object', rejection: {reason: 'release lookup failed'}, expectedConsoleValue: '[object Object]'}, +]; + +afterEach(() => { + getInputSpy.mockClear(); + setFailedSpy.mockClear(); + consoleErrorSpy.mockClear(); + paginateSpy.mockReset(); +}); + +describe('getDeployPullRequestList', () => { + for (const {name, rejection, expectedConsoleValue} of rejectionCases) { + it(`reports a rejected ${name} without a secondary failure`, async () => { + // Given release lookup can reject with an Error or a non-Error value. + paginateSpy.mockRejectedValueOnce(rejection); + + // When deployment-list generation encounters that rejection. + await expect(run()).resolves.toBeUndefined(); + + // Then reporting preserves Error identity and safely normalizes every other shape. + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith(expectedConsoleValue); + expect(setFailedSpy).toHaveBeenCalledTimes(1); + expect(setFailedSpy).toHaveBeenCalledWith(rejection instanceof Error ? rejection : expectedConsoleValue); + }); + } +}); diff --git a/tests/tooling/reassurePerfTrigger.test.ts b/tests/tooling/reassurePerfTrigger.test.ts new file mode 100644 index 000000000000..5fc4f995693b --- /dev/null +++ b/tests/tooling/reassurePerfTrigger.test.ts @@ -0,0 +1,72 @@ +import {describe, expect, it} from 'bun:test'; + +import fs from 'fs'; + +/** + * The workflow's ordered `paths` list lets a later pattern re-include what an earlier `!exclude` + * dropped (https://github.com/orgs/community/discussions/60317), so this test replays that order. + */ + +const PERF_WORKFLOW = '.github/workflows/reassurePerformanceTests.yml'; + +// eslint-disable-next-line @typescript-eslint/naming-convention -- `pull_request` is GitHub's YAML key +type Workflow = {on: {pull_request: {paths: string[]}}}; + +function readWorkflow(path: string): Workflow { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Bun.YAML.parse returns unknown + return Bun.YAML.parse(fs.readFileSync(path, 'utf8')) as Workflow; +} + +// Mirrors GitHub's filter patterns: `**` crosses `/`, a lone `*` and `?` do not. +function globToRegExp(glob: string): RegExp { + const escaped = glob.replaceAll(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll(/\*\*|\*|\?/g, (token) => { + if (token === '**') { + return '.*'; + } + return token === '*' ? '[^/]*' : '[^/]'; + }); + return new RegExp(`^${escaped}$`); +} + +// Mirrors GitHub: patterns apply in order, last match (positive or negated) wins. +function pathTriggersRun(patterns: string[], filePath: string): boolean { + let included = false; + for (const pattern of patterns) { + const isNegated = pattern.startsWith('!'); + const glob = isNegated ? pattern.slice(1) : pattern; + if (globToRegExp(glob).test(filePath)) { + included = !isNegated; + } + } + return included; +} + +const paths = readWorkflow(PERF_WORKFLOW).on.pull_request.paths; + +describe('Reassure perf trigger paths', () => { + // A wrongly skipped path lets a regression merge unmeasured, so cover each re-include and Jest input + // next to an exclusion. + it.each([ + ['src/x.ts', true], + ['tests/unit/A.test.ts', false], + ['tests/perf-test/A.perf-test.tsx', true], + ['tests/utils/TestHelper.ts', true], + ['tests/__mocks__/x.ts', true], + ['tests/unit/currencyList.json', true], + ['config/babel/oxcJestTransformer.js', true], + ['config/proxyConfig.ts', true], + ['scripts/x.sh', false], + ['src/x.sh', false], + ['README.md', false], + ['.github/workflows/reassurePerformanceTests.yml', true], + ['.github/workflows/seedJestPerfCache.yml', true], + ['.github/actions/javascript/validateReassureOutput/index.js', true], + ['.github/actions/composite/setupNode/action.yml', true], + ])('%s triggers the check: %p', (filePath, expectedToRun) => { + // Given the perf workflow's ordered `paths` list read straight from the committed YAML + // When a PR touches only this one path + // Then whether the check is created must match what the measurement can actually observe, + // not an incidental side effect of exclusion order + expect(pathTriggersRun(paths, filePath)).toBe(expectedToRun); + }); +}); diff --git a/tests/tooling/validateReassureOutput.test.ts b/tests/tooling/validateReassureOutput.test.ts new file mode 100644 index 000000000000..853ef0bf934c --- /dev/null +++ b/tests/tooling/validateReassureOutput.test.ts @@ -0,0 +1,93 @@ +import type {Mock} from 'bun:test'; +import {afterEach, beforeEach, describe, expect, it, jest} from 'bun:test'; + +import run from '@github/actions/javascript/validateReassureOutput/validateReassureOutput'; + +import type {CompareResult, MeasureEntry} from '@callstack/reassure-compare'; + +import * as core from '@actions/core'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +type Entry = Pick; + +type CountChangedEntry = Pick & {baseline: Entry; current: Entry}; + +function buildOutput(countChanged: CountChangedEntry[] | undefined) { + return JSON.stringify({countChanged}); +} + +describe('validateReassureOutput', () => { + let setFailed: Mock; + let originalCwd: string; + let tempDir: string; + + // The action reads `.reassure/output.json` relative to the working directory, so run it from a temp directory. + const writeOutput = (content: string) => { + fs.mkdirSync(path.join(tempDir, '.reassure'), {recursive: true}); + fs.writeFileSync(path.join(tempDir, '.reassure', 'output.json'), content); + }; + + beforeEach(() => { + originalCwd = process.cwd(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-reassure-output-')); + process.chdir(tempDir); + + // Real ESM module namespace exports are read-only live bindings, so spy on them instead of reassigning. + jest.spyOn(core, 'getInput').mockImplementation((name: string) => (name === 'COUNT_DEVIATION' ? '0' : '')); + setFailed = jest.spyOn(core, 'setFailed').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + process.chdir(originalCwd); + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('passes when the render count drops, even if the duration grows by 500%', () => { + // Given a scenario whose render count went down by 1 and whose mean duration grew by 500% + // This is the only shape the removed duration check could fire on, because a duration-only change never enters countChanged + writeOutput(buildOutput([{name: 'Scenario', baseline: {meanCount: 5, meanDuration: 10}, current: {meanCount: 4, meanDuration: 60}}])); + + // When the output is validated + run(); + + // Then the run is not failed, because durations are reported but not gated + expect(setFailed).not.toHaveBeenCalled(); + }); + + it('fails when the render count grows by more than the allowed deviation', () => { + // Given a scenario whose render count went up by 1 and the allowed deviation is 0 + writeOutput(buildOutput([{name: 'Scenario', baseline: {meanCount: 4, meanDuration: 10}, current: {meanCount: 5, meanDuration: 10}}])); + + // When the output is validated + run(); + + // Then the run is failed on the render count + expect(setFailed).toHaveBeenCalledTimes(1); + expect(setFailed.mock.calls.at(0)?.at(0)).toContain('Render count difference exceeded'); + }); + + it('passes when countChanged is missing', () => { + // Given an output without countChanged + writeOutput(buildOutput(undefined)); + + // When the output is validated + run(); + + // Then the run is not failed + expect(setFailed).not.toHaveBeenCalled(); + }); + + it('passes when countChanged is empty', () => { + // Given an output with an empty countChanged + writeOutput(buildOutput([])); + + // When the output is validated + run(); + + // Then the run is not failed + expect(setFailed).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/AddAgentRulePageTest.tsx b/tests/ui/AddAgentRulePageTest.tsx new file mode 100644 index 000000000000..f5da3577c6fa --- /dev/null +++ b/tests/ui/AddAgentRulePageTest.tsx @@ -0,0 +1,340 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import usePolicy from '@hooks/usePolicy'; + +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import AddAgentRulePage from '@pages/workspace/rules/AgentRules/AddAgentRulePage'; + +import {addPolicyAgentRule} from '@userActions/Policy/Rules'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({Feed: 'Feed', Pencil: 'Pencil', Info: 'Info'})), +})); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string, ...params: string[]) => [key, ...params].join(' '), + })), +); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/usePolicy', () => jest.fn()); +jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: jest.fn(() => Promise.resolve()), closeModal: jest.fn()}))); +jest.mock('@hooks/useDiscardChangesConfirmation', () => jest.fn(() => ({suppressDiscardPrompt: jest.fn()}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn( + () => + new Proxy( + {}, + { + get: () => ({}), + }, + ), + ), +); +jest.mock('@libs/Navigation/Navigation', () => ({ + goBack: jest.fn(), + navigate: jest.fn(), + dismissModal: jest.fn(), +})); +jest.mock('@libs/actions/Tab', () => ({setSelectedTab: jest.fn()})); +jest.mock('@userActions/Policy/Rules', () => ({ + addPolicyAgentRule: jest.fn(), + getAgentRuleSuggestions: jest.fn(), +})); +jest.mock( + '@pages/workspace/AccessOrNotFoundWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/ScreenWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/CollapsibleHeaderOnKeyboard', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/SidePanel/SidePanelButton', () => () => null); +jest.mock('@components/TabSelector/TabSelectorBase', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable} = jest.requireActual<{Pressable: React.ComponentType<{accessibilityLabel?: string; onPress?: () => void}>}>('react-native'); + return ({tabs, onTabPress}: {tabs: Array<{key: string; title: string}>; onTabPress: (key: string) => void}) => + ReactModule.createElement( + ReactModule.Fragment, + null, + tabs.map((tab) => ReactModule.createElement(Pressable, {key: tab.key, accessibilityLabel: tab.title, onPress: () => onTabPress(tab.key)})), + ); +}); +jest.mock( + '@components/TabSelector/TabSelectorContext', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable} = jest.requireActual<{Pressable: React.ComponentType<{accessibilityLabel?: string; onPress?: () => void}>}>('react-native'); + return ({onBackButtonPress}: {onBackButtonPress?: () => void}) => ReactModule.createElement(Pressable, {accessibilityLabel: 'Back', onPress: onBackButtonPress}); +}); +jest.mock('@components/Banner', () => { + const ReactModule = jest.requireActual('react'); + const {Text} = jest.requireActual<{Text: React.ComponentType<{children?: React.ReactNode}>}>('react-native'); + return ({text}: {text?: string}) => ReactModule.createElement(Text, null, text); +}); +jest.mock('@pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable} = jest.requireActual<{Pressable: React.ComponentType<{accessibilityLabel?: string; onPress?: () => void}>}>('react-native'); + return ({onSelectSuggestion}: {onSelectSuggestion: (suggestion: {id: string; title: string; prompt: string}) => void}) => + ReactModule.createElement(Pressable, { + accessibilityLabel: 'PickSuggestion', + onPress: () => onSelectSuggestion({id: 'suggestion1', title: 'Hold expenses without a receipt', prompt: 'Hold any expense over $75 without a receipt'}), + }); +}); +jest.mock('@pages/workspace/rules/AgentRules/AddAgentRuleWriteTab', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual<{ + Pressable: React.ComponentType<{accessibilityLabel?: string; onPress?: () => void; children?: React.ReactNode}>; + Text: React.ComponentType<{children?: React.ReactNode}>; + }>('react-native'); + return ({onSave}: {onSave: (values: {prompt: string}) => void}) => + ReactModule.createElement(Pressable, {accessibilityLabel: 'WriteTab', onPress: () => onSave({prompt: 'Hold gift card expenses'})}, ReactModule.createElement(Text, null, 'WriteTab')); +}); + +type AddAgentRulePageProps = PlatformStackScreenProps; + +const POLICY_ID = 'policy1'; +const GENERATION_ID = '7654321'; +const PROMPT = 'Hold gift card expenses'; +const FALLBACK_BANNER = 'workspace.rules.agentRules.fallbackBanner'; +const EXPLANATION = 'Agent rules cannot stop a card charge. This holds the expense after it posts.'; + +const mockedUsePolicy = jest.mocked(usePolicy); + +function renderAddAgentRulePage(generationID?: string) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only screen props. The page reads only route.params. + const props = { + route: {key: 'Rules_Agent_New', name: SCREENS.WORKSPACE.RULES_AGENT_NEW, params: {policyID: POLICY_ID, generationID}}, + navigation: {}, + } as unknown as AddAgentRulePageProps; + return render(); +} + +async function setAgentRuleAnswer(generationID: string) { + await act(async () => { + await Onyx.set(ONYXKEYS.GENERATED_RULE, {generationID, state: CONST.GENERATED_RULE.STATE.AGENT_RULE, agentRulePrompt: PROMPT, explanation: EXPLANATION}); + }); +} + +async function setAgentRuleDraft(prompt: string) { + await act(async () => { + await Onyx.set(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT, {prompt}); + }); +} + +describe('AddAgentRulePage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedUsePolicy.mockReturnValue({ + ...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), + id: POLICY_ID, + rules: {agentRules: {existingRule: {ruleID: 'existingRule', prompt: 'Existing', created: '2026-10-01 00:00:00'}}}, + }); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + it('opens on the Suggestions tab when there is no draft prompt', async () => { + // Given no draft, because the admin opened the page from the Agents tab + + // When the page renders + renderAddAgentRulePage(); + await waitForBatchedUpdatesWithAct(); + + // Then the Write tab stays closed, so the admin starts from the suggested rules + expect(screen.queryByText('WriteTab')).toBeNull(); + }); + + it('opens on the Write tab when a draft prompt exists at mount', async () => { + // Given a draft prompt, because the describe flow wrote it before it opened this page + await setAgentRuleDraft(PROMPT); + + // When the page renders + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + + // Then the admin sees the drafted prompt and does not have to look for it + expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + }); + + it('opens on the Write tab when the draft prompt arrives after the first render', async () => { + // Given the page renders before the draft loads + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryByText('WriteTab')).toBeNull(); + + // When the draft prompt loads + await setAgentRuleDraft(PROMPT); + await waitForBatchedUpdatesWithAct(); + + // Then the page still switches to Write, because the first render must not decide the tab for good + expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + }); + + it('shows the fallback banner when the page opens with a generationID', async () => { + // Given a draft that came from a description that was not a standard rule + await setAgentRuleDraft(PROMPT); + + // When the page renders with the generationID of that description + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + + // Then the banner tells the admin that agent rules act after the fact + expect(screen.getByText(FALLBACK_BANNER)).toBeOnTheScreen(); + }); + + it('shows the explanation of the agent rule answer in the fallback banner', async () => { + // Given Concierge answered the description with an agent rule that differs from what the admin asked for + await setAgentRuleDraft(PROMPT); + await setAgentRuleAnswer(GENERATION_ID); + + // When the page renders with the generationID of that answer + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + + // Then the banner says how the agent rule differs, so the admin reviews the reworded rule before saving + expect(screen.getByText(`workspace.rules.agentRules.fallbackBannerWithExplanation ${EXPLANATION}`)).toBeOnTheScreen(); + }); + + it('ignores the explanation of an answer to another description', async () => { + // Given the stored answer belongs to another description + await setAgentRuleDraft(PROMPT); + await setAgentRuleAnswer('1234567'); + + // When the page renders with the generationID of this description + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + + // Then the banner has no explanation, because that explanation describes another rule + expect(screen.getByText(FALLBACK_BANNER)).toBeOnTheScreen(); + }); + + it('does not show the fallback banner without a generationID', async () => { + // Given a draft that the admin picked from the suggestions, not one from the describe flow + await setAgentRuleDraft(PROMPT); + + // When the page renders without a generationID + renderAddAgentRulePage(); + await waitForBatchedUpdatesWithAct(); + + // Then the Write tab opens without the banner, because no description was turned into this rule + expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + expect(screen.queryByText(FALLBACK_BANNER)).toBeNull(); + }); + + it('goes back to the previous screen from the Write tab when the page opens with a generationID', async () => { + // Given the describe flow opened the page on the Write tab + await setAgentRuleDraft(PROMPT); + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + + // When the admin presses Back + fireEvent.press(screen.getByLabelText('Back')); + + // Then the page closes and the admin returns to the previous screen, because they did not come from the Suggestions tab + expect(Navigation.goBack).toHaveBeenCalledTimes(1); + expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + }); + + it('switches from the Write tab to Suggestions on Back without a generationID', async () => { + // Given the Write tab is open for a draft that did not come from the describe flow + await setAgentRuleDraft(PROMPT); + renderAddAgentRulePage(); + await waitForBatchedUpdatesWithAct(); + + // When the admin presses Back + fireEvent.press(screen.getByLabelText('Back')); + + // Then the page returns to Suggestions and stays open, because the admin can pick another suggestion + expect(Navigation.goBack).not.toHaveBeenCalled(); + expect(screen.queryByText('WriteTab')).toBeNull(); + }); + + it('drops the fallback banner and log after the admin picks a suggestion', async () => { + // Given the describe flow opened the page with a generationID and the fallback banner + const logSpy = jest.spyOn(Log, 'info'); + await setAgentRuleDraft(PROMPT); + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText(FALLBACK_BANNER)).toBeOnTheScreen(); + + // When the admin goes to Suggestions, picks a suggestion, and saves it + fireEvent.press(screen.getByLabelText('workspace.rules.agentRules.suggestionsTab')); + fireEvent.press(screen.getByLabelText('PickSuggestion')); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + expect(screen.queryByText(FALLBACK_BANNER)).toBeNull(); + fireEvent.press(screen.getByLabelText('WriteTab')); + + // Then the save is not counted as a fallback rule, because the prompt came from the suggestion + expect(addPolicyAgentRule).toHaveBeenCalledTimes(1); + expect(logSpy).not.toHaveBeenCalledWith('[GenerateRule] Saved agent rule from fallback', expect.anything(), expect.anything()); + logSpy.mockRestore(); + }); + + it('switches from the Write tab to Suggestions on Back after the admin picks a suggestion in the fallback flow', async () => { + // Given the describe flow opened the page with a generationID and the admin picked a suggestion + await setAgentRuleDraft(PROMPT); + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByLabelText('workspace.rules.agentRules.suggestionsTab')); + fireEvent.press(screen.getByLabelText('PickSuggestion')); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + + // When the admin presses Back + fireEvent.press(screen.getByLabelText('Back')); + + // Then the page returns to Suggestions and stays open, because picking a suggestion leaves the fallback flow + expect(Navigation.goBack).not.toHaveBeenCalled(); + expect(screen.queryByText('WriteTab')).toBeNull(); + }); + + it('logs the generationID with the saved agent rule when the page opens with a generationID', async () => { + // Given the describe flow opened the page with a generationID + const logSpy = jest.spyOn(Log, 'info'); + await setAgentRuleDraft(PROMPT); + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + + // When the admin saves the rule + fireEvent.press(screen.getByLabelText('WriteTab')); + + // Then the log joins the saved rule to the offer on the generationID + const agentRuleID = jest.mocked(addPolicyAgentRule).mock.calls.at(0)?.at(1); + expect(addPolicyAgentRule).toHaveBeenCalledWith(POLICY_ID, expect.any(String), PROMPT); + expect(logSpy).toHaveBeenCalledWith('[GenerateRule] Saved agent rule from fallback', false, {policyID: POLICY_ID, generationID: GENERATION_ID, agentRuleID}); + logSpy.mockRestore(); + }); +}); diff --git a/tests/ui/AncestorReportActionItemTest.tsx b/tests/ui/AncestorReportActionItemTest.tsx new file mode 100644 index 000000000000..b37313a2db48 --- /dev/null +++ b/tests/ui/AncestorReportActionItemTest.tsx @@ -0,0 +1,125 @@ +import {render} 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 AncestorReportActionItem from '@pages/inbox/report/AncestorReportActionItem'; +import type ReportActionItem from '@pages/inbox/report/ReportActionItem'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction} from '@src/types/onyx'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@pages/inbox/report/ReportActionItem', () => jest.fn(() => null)); +jest.mock('@pages/inbox/report/ThreadDivider', () => jest.fn(() => null)); + +const mockReportActionItem = jest.requireMock]>>('@pages/inbox/report/ReportActionItem'); + +const PARENT_REPORT_ID = '200001'; +const AGENT_ACCOUNT_ID = 99001; +const CURRENT_USER_ACCOUNT_ID = 12345; + +const parentReport: Report = { + reportID: PARENT_REPORT_ID, + type: CONST.REPORT.TYPE.CHAT, +}; + +function createComment(reportActionID: string, actorAccountID: number, created: string): ReportAction { + return { + reportActionID, + reportID: PARENT_REPORT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID, + created, + message: [{type: 'COMMENT', html: 'hi', text: 'hi'}], + originalMessage: {html: 'hi', whisperedTo: []}, + shouldShow: true, + }; +} + +const conciergeComment = createComment('100', CONST.ACCOUNT_ID.CONCIERGE, '2026-09-01 00:00:00.000'); +const olderAgentComment = createComment('200', AGENT_ACCOUNT_ID, '2026-09-02 00:00:00.000'); +const newerAgentComment = createComment('300', AGENT_ACCOUNT_ID, '2026-09-03 00:00:00.000'); + +async function renderParentAction(reportAction: ReportAction, currentUserAccountID = CURRENT_USER_ACCOUNT_ID) { + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + return mockReportActionItem.mock.calls.at(-1)?.at(0)?.isLatestConciergeFeedbackAction; +} + +describe('AncestorReportActionItem feedback prompt', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockReportActionItem.mockClear(); + await Onyx.clear(); + await Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[AGENT_ACCOUNT_ID]: {accountID: AGENT_ACCOUNT_ID, isCustomAgent: true}}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT_ID}` as const]: { + [conciergeComment.reportActionID]: conciergeComment, + [olderAgentComment.reportActionID]: olderAgentComment, + [newerAgentComment.reportActionID]: newerAgentComment, + }, + }); + await waitForBatchedUpdatesWithAct(); + }); + + it("shows the prompt on a thread whose parent is the agent's newest comment", async () => { + // Given a parent report where a custom agent wrote twice after Concierge + // When a thread on the agent's newest comment opens + // Then the parent message shows the prompt, as it does under the same comment in the parent report + expect(await renderParentAction(newerAgentComment)).toBe(true); + }); + + it("keeps Concierge's prompt when an agent wrote after Concierge", async () => { + // Given the same parent report + // When a thread on Concierge's comment opens + // Then the newer agent comments do not take the prompt away from Concierge + expect(await renderParentAction(conciergeComment)).toBe(true); + }); + + it('shows no prompt on a thread whose parent is an older comment of the agent', async () => { + // Given the same parent report + // When a thread on the agent's older comment opens + // Then only the agent's newest comment can be rated + expect(await renderParentAction(olderAgentComment)).toBe(false); + }); + + it('shows no prompt to the agent itself when the agent account is open through Copilot', async () => { + // Given the same parent report, opened while signed in as the agent + // When a thread on the agent's newest comment opens + // Then the agent is not asked to rate its own reply + expect(await renderParentAction(newerAgentComment, AGENT_ACCOUNT_ID)).toBe(false); + }); +}); diff --git a/tests/ui/BankAccountPurposeCountrySelectionTest.tsx b/tests/ui/BankAccountPurposeCountrySelectionTest.tsx index 856ee175bf34..6a30fd476d62 100644 --- a/tests/ui/BankAccountPurposeCountrySelectionTest.tsx +++ b/tests/ui/BankAccountPurposeCountrySelectionTest.tsx @@ -3,7 +3,7 @@ import {act, render} from '@testing-library/react-native'; import CountrySelection from '@pages/settings/Wallet/BankAccountPurposePage/substeps/CountrySelection'; import CountrySelectionList from '@pages/settings/Wallet/CountrySelectionList'; -import {clearReimbursementAccount, clearReimbursementAccountDraft, navigateToBankAccountRoute, updateReimbursementAccountDraft} from '@userActions/ReimbursementAccount'; +import {navigateToBankAccountRoute, prepareStandaloneBankAccountSetup} from '@userActions/ReimbursementAccount'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -29,20 +29,16 @@ jest.mock('@hooks/useThemeStyles', () => })), ); jest.mock('@userActions/ReimbursementAccount', () => ({ - clearReimbursementAccount: jest.fn(), - clearReimbursementAccountDraft: jest.fn(), + prepareStandaloneBankAccountSetup: jest.fn(), navigateToBankAccountRoute: jest.fn(), - updateReimbursementAccountDraft: jest.fn(), })); const Stack = createStackNavigator(); describe('BankAccountPurpose CountrySelection', () => { const mockedCountrySelectionList = jest.mocked(CountrySelectionList); - const mockedClearReimbursementAccount = jest.mocked(clearReimbursementAccount); - const mockedClearReimbursementAccountDraft = jest.mocked(clearReimbursementAccountDraft); + const mockedPrepareStandaloneBankAccountSetup = jest.mocked(prepareStandaloneBankAccountSetup); const mockedNavigateToBankAccountRoute = jest.mocked(navigateToBankAccountRoute); - const mockedUpdateReimbursementAccountDraft = jest.mocked(updateReimbursementAccountDraft); let mockMountCount = 0; let mockUnmountCount = 0; @@ -52,10 +48,8 @@ describe('BankAccountPurpose CountrySelection', () => { mockMountCount = 0; mockUnmountCount = 0; mockedCountrySelectionList.mockClear(); - mockedClearReimbursementAccount.mockClear(); - mockedClearReimbursementAccountDraft.mockClear(); + mockedPrepareStandaloneBankAccountSetup.mockClear(); mockedNavigateToBankAccountRoute.mockClear(); - mockedUpdateReimbursementAccountDraft.mockClear(); mockedCountrySelectionList.mockImplementation(() => { React.useEffect(() => { mockMountCount += 1; @@ -70,6 +64,7 @@ describe('BankAccountPurpose CountrySelection', () => { }); it('keeps the child list mounted while persisting the selected country and navigating', async () => { + // Given the country list mounted within the bank account setup screen. render( @@ -85,6 +80,7 @@ describe('BankAccountPurpose CountrySelection', () => { expect(mockMountCount).toBe(1); expect(mockUnmountCount).toBe(0); + // When the user chooses Lithuania and confirms bank account setup. act(() => { initialProps?.onCountrySelected('LT'); }); @@ -100,9 +96,8 @@ describe('BankAccountPurpose CountrySelection', () => { jest.runOnlyPendingTimers(); }); - expect(mockedClearReimbursementAccount).toHaveBeenCalled(); - expect(mockedClearReimbursementAccountDraft).toHaveBeenCalled(); - expect(mockedUpdateReimbursementAccountDraft).toHaveBeenCalledWith({country: 'LT', currency: CONST.BBA_COUNTRY_CURRENCY_MAP.LT}); + // Then setup preserves the selected country and the list stays mounted while navigating. + expect(mockedPrepareStandaloneBankAccountSetup).toHaveBeenCalledWith(CONST.BBA_COUNTRY_CURRENCY_MAP.LT, 'LT'); expect(mockedNavigateToBankAccountRoute).toHaveBeenCalledWith({backTo: ROUTES.SETTINGS_BANK_ACCOUNT_PURPOSE}); expect(mockMountCount).toBe(1); expect(mockUnmountCount).toBe(0); diff --git a/tests/ui/BaseSidebarScreenTest.tsx b/tests/ui/BaseSidebarScreenTest.tsx index 1daf35d456c8..eaf55a117f57 100644 --- a/tests/ui/BaseSidebarScreenTest.tsx +++ b/tests/ui/BaseSidebarScreenTest.tsx @@ -43,7 +43,6 @@ jest.mock('@components/ScreenWrapper', () => { }); jest.mock('@components/Navigation/TopBarWithLoadingBar', () => () => null); -jest.mock('@components/Navigation/TabBarBottomContent', () => () => null); jest.mock('@pages/inbox/sidebar/InboxTabSelector', () => () => null); jest.mock('@components/OptionsListSkeletonView', () => { const ReactModule = jest.requireActual('react'); diff --git a/tests/ui/CategoryPickerTest.tsx b/tests/ui/CategoryPickerTest.tsx index 0c6bd8ec6f7b..350f65b8d12f 100644 --- a/tests/ui/CategoryPickerTest.tsx +++ b/tests/ui/CategoryPickerTest.tsx @@ -5,6 +5,7 @@ import SelectionListWithSections from '@components/SelectionList/SelectionListWi import useOnyx from '@hooks/useOnyx'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PolicyCategories} from '@src/types/onyx'; @@ -23,6 +24,8 @@ jest.mock('@components/SelectionList/SelectionListWithSections', () => jest.fn(( jest.mock('@hooks/useOnyx', () => jest.fn()); jest.mock('@hooks/useAutoFocusInput', () => jest.fn(() => ({inputCallbackRef: jest.fn()}))); jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +// The hook sends GetPolicyCategories on mount. Under Jest, a real request reaches Expo's fetch polyfill, which throws "Request is not defined" and crashes the worker. +jest.mock('@hooks/useLoadPolicyCategories', () => jest.fn(() => ({isLoadingPolicyCategories: false}))); jest.mock('@hooks/useThemeStyles', () => jest.fn(() => ({}))); // Returns [searchValue, debouncedSearchValue, setter]. Lets a test stand the picker up mid-search. let mockDebouncedSearchValue = ''; @@ -107,6 +110,24 @@ describe('CategoryPicker', () => { expect(findRow('Benefits')?.alternateText).toBeUndefined(); }); + it('shows GL codes when the caller forces them on, even if the policy has the GL-code flags disabled', () => { + // Given a policy that hides GL codes in the regular category picker + mockedUseOnyxWithoutSnapshots.mockReturnValue([false, {status: 'loaded'}]); + + // When the picker is opened from the Category GL code cell, which forces GL codes on because the code is what the user is picking + render( + , + ); + + // Then each category still shows its GL code underneath + expect(findRow('Advertising')?.alternateText).toBe('12'); + expect(findRow('Benefits')?.alternateText).toBe('13'); + }); + describe('reported rendered row count', () => { it('counts one row per category when none of them are nested', () => { // Given three flat categories @@ -211,4 +232,52 @@ describe('CategoryPicker', () => { expect(collectionKeys).not.toContain(key); } }); + + describe('search input threshold', () => { + const EIGHT_CATEGORIES = Object.fromEntries( + Array.from({length: CONST.INLINE_EDIT_PICKER_SEARCH_INPUT_THRESHOLD}, (_, index) => { + const name = `Category ${index}`; + return [name, makeCategory(name, `${index}`)]; + }), + ); + + beforeEach(() => { + mockedUseOnyxWithoutSnapshots.mockReturnValue([false, {status: 'loaded'}]); + mockedUseOnyx.mockImplementation((key) => { + if (key === `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${POLICY_ID}`) { + return [EIGHT_CATEGORIES, {status: 'loaded'}]; + } + return [undefined, {status: 'loaded'}]; + }); + }); + + it('hides the search input on the full-page picker until there are 12 categories', () => { + // Given a workspace with 8 enabled categories, fewer than the full-page threshold of 12 + // When the picker renders with the default threshold, as the full-page pickers do + render( + , + ); + + // Then the search input stays hidden + expect(jest.mocked(SelectionListWithSections).mock.lastCall?.[0].shouldShowTextInput).toBe(false); + }); + + it('shows the search input on the inline picker once there are 8 categories', () => { + // Given the same 8 enabled categories + // When the picker renders with the lower threshold the inline expense-table popover passes + render( + , + ); + + // Then the search input is shown + expect(jest.mocked(SelectionListWithSections).mock.lastCall?.[0].shouldShowTextInput).toBe(true); + }); + }); }); diff --git a/tests/ui/ClearReportActionErrorsUITest.tsx b/tests/ui/ClearReportActionErrorsUITest.tsx index 8c20d01abae6..0120769d359c 100644 --- a/tests/ui/ClearReportActionErrorsUITest.tsx +++ b/tests/ui/ClearReportActionErrorsUITest.tsx @@ -6,6 +6,8 @@ import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import ScreenWrapper from '@components/ScreenWrapper'; +import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; + import * as ClearReportActionErrorsActions from '@libs/actions/ClearReportActionErrors'; import {setHasRadio} from '@libs/NetworkState'; import {getIOUActionForReportID} from '@libs/ReportActionsUtils'; @@ -35,6 +37,10 @@ const REPORT_ID = '12345'; const REPORT_ACTION_ID = '99999'; const CHILD_REPORT_ID = '67890'; const CHILD_REPORT_ACTION_ID = '88888'; +const PARENT_REPORT_ACTION_ID = '55555'; +const GRANDPARENT_REPORT_ID = '44444'; +const GRANDPARENT_REPORT_ACTION_ID = '33333'; +const UNRELATED_REPORT_ID = '22222'; const SPLIT_CHILD_TRANSACTION_ID = '77777'; const SPLIT_CONTAINER_TRANSACTION_ID = '66666'; @@ -86,20 +92,31 @@ describe('ClearReportActionErrors UI', () => { await waitForBatchedUpdatesWithAct(); }); + function ReportActionItemWithHierarchy({action, report}: {action: ReportAction; report: Report}) { + const reportsParentHierarchy = useReportsParentHierarchy(report.reportID); + return ( + + ); + } + function renderReportActionItem(action: ReportAction, report: Report) { return render( - @@ -248,6 +265,66 @@ describe('ClearReportActionErrors UI', () => { expect(parentReportActions?.[REPORT_ACTION_ID]?.errors).toBeUndefined(); expect(childReportActions?.[CHILD_REPORT_ACTION_ID]?.errors).toEqual({}); }); + + it('should clear errors on every ancestor action when a thread action error is dismissed', async () => { + // Given a thread -> parent -> grandparent chain where each level's linking action has the same error key, + // plus an unrelated report, so the component must resolve the whole ancestor chain from Onyx + const sharedError = {[DEFAULT_ERROR_TIMESTAMP]: 'Shared error message'}; + const threadAction = getFakeReportAction(Number(CHILD_REPORT_ACTION_ID), {actorAccountID: ACTOR_ACCOUNT_ID, errors: sharedError}); + const parentAction = getFakeReportAction(Number(PARENT_REPORT_ACTION_ID), {actorAccountID: ACTOR_ACCOUNT_ID, childReportID: CHILD_REPORT_ID, errors: sharedError}); + const grandparentAction = getFakeReportAction(Number(GRANDPARENT_REPORT_ACTION_ID), {actorAccountID: ACTOR_ACCOUNT_ID, childReportID: REPORT_ID, errors: sharedError}); + + const grandparentReport = createMockReport({reportID: GRANDPARENT_REPORT_ID, ownerAccountID: ACTOR_ACCOUNT_ID}); + const parentReport = createMockReport({ + reportID: REPORT_ID, + parentReportID: GRANDPARENT_REPORT_ID, + parentReportActionID: GRANDPARENT_REPORT_ACTION_ID, + ownerAccountID: ACTOR_ACCOUNT_ID, + }); + const threadReport = createMockReport({ + reportID: CHILD_REPORT_ID, + parentReportID: REPORT_ID, + parentReportActionID: PARENT_REPORT_ACTION_ID, + ownerAccountID: ACTOR_ACCOUNT_ID, + }); + const unrelatedReport = createMockReport({reportID: UNRELATED_REPORT_ID, ownerAccountID: ACTOR_ACCOUNT_ID}); + const spy = jest.spyOn(ClearReportActionErrorsActions, 'clearAllRelatedReportActionErrors'); + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${GRANDPARENT_REPORT_ID}`, grandparentReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, parentReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${CHILD_REPORT_ID}`, threadReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${UNRELATED_REPORT_ID}`, unrelatedReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${GRANDPARENT_REPORT_ID}`, {[GRANDPARENT_REPORT_ACTION_ID]: grandparentAction}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[PARENT_REPORT_ACTION_ID]: parentAction}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CHILD_REPORT_ID}`, {[CHILD_REPORT_ACTION_ID]: threadAction}); + }); + await waitForBatchedUpdatesWithAct(); + + renderReportActionItem(threadAction, threadReport); + await waitForBatchedUpdatesWithAct(); + + // When the user dismisses the error on the thread action + fireEvent.press(screen.getByLabelText('Dismiss')); + await waitForBatchedUpdatesWithAct(); + + // Then the ancestor links read by the hook reach the action, so the walk does not depend on the module-level fallback + const suppliedReports = spy.mock.calls.at(0)?.at(0)?.reports; + expect(suppliedReports?.[`${ONYXKEYS.COLLECTION.REPORT}${CHILD_REPORT_ID}`]).toMatchObject({parentReportID: REPORT_ID, parentReportActionID: PARENT_REPORT_ACTION_ID}); + expect(suppliedReports?.[`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`]).toMatchObject({ + parentReportID: GRANDPARENT_REPORT_ID, + parentReportActionID: GRANDPARENT_REPORT_ACTION_ID, + }); + spy.mockRestore(); + + // And the matching error is cleared at every level of the chain + const threadReportActions = await getReportActionsFromOnyx(CHILD_REPORT_ID); + const parentReportActions = await getReportActionsFromOnyx(REPORT_ID); + const grandparentReportActions = await getReportActionsFromOnyx(GRANDPARENT_REPORT_ID); + expect(threadReportActions?.[CHILD_REPORT_ACTION_ID]?.errors).toBeUndefined(); + expect(parentReportActions?.[PARENT_REPORT_ACTION_ID]?.errors).toEqual({}); + expect(grandparentReportActions?.[GRANDPARENT_REPORT_ACTION_ID]?.errors).toEqual({}); + }); }); describe('Split container error propagation', () => { diff --git a/tests/ui/ComposerImperativeRefTest.tsx b/tests/ui/ComposerImperativeRefTest.tsx new file mode 100644 index 000000000000..91ebfa402ff5 --- /dev/null +++ b/tests/ui/ComposerImperativeRefTest.tsx @@ -0,0 +1,699 @@ +/** Executes Composer platform refs, animated clearing and focus-command consumers. */ +import {act, render, renderHook} from '@testing-library/react-native'; + +import type * as ImplementationModule from '@components/Composer/implementation/index'; +import type * as ImplementationNativeModule from '@components/Composer/implementation/index.native'; +import NativeComposer from '@components/Composer/implementation/index.native'; +import type {ComposerProps, ComposerRef} from '@components/Composer/types'; +import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; + +import type * as UseBlurOnKeyboardHideModule from '@hooks/useBlurOnKeyboardHide'; +import type * as UseHtmlPasteModule from '@hooks/useHtmlPaste/index.ts'; +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; + +import {composerFocusKeepFocusOn} from '@libs/actions/InputFocus/index.web'; +import {getBrowser, isMobileChrome} from '@libs/Browser'; +import type * as ComponentUtilsModule from '@libs/ComponentUtils/index'; +import type * as ComponentUtilsNativeModule from '@libs/ComponentUtils/index.native'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import preventFirstResponder from '@libs/preventTextInputFocusOnFirstResponderOnce/index.ios'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; + +import type * as ComposerContextModule from '@pages/inbox/report/ReportActionCompose/ComposerContext'; +import ComposerWithSuggestions from '@pages/inbox/report/ReportActionCompose/ComposerWithSuggestions'; +import type {ComposerWithSuggestionsRef} from '@pages/inbox/report/ReportActionCompose/ComposerWithSuggestions'; +import updateNativeTextInputValue from '@pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios'; +import type * as UseComposerSuggestionsModule from '@pages/inbox/report/ReportActionCompose/useComposerSuggestions'; +import type * as UseEditComposerToggleModule from '@pages/inbox/report/ReportActionCompose/useEditComposerToggle'; +import type * as ReportActionEditMessageContextModule from '@pages/inbox/report/ReportActionEditMessageContext'; +import type * as UseDebouncedSaveDraftModule from '@pages/inbox/report/useDebouncedSaveDraft'; +import type * as UseDebouncedSaveReportActionDraftModule from '@pages/inbox/report/useDebouncedSaveReportActionDraft'; + +import type * as ReportModule from '@userActions/Report'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Modal} from '@src/types/onyx'; + +import type * as MarkdownTextInputWebModule from '@expensify/react-native-live-markdown/lib/typescript/src/MarkdownTextInput.web'; +import type * as ReactNavigationModule from '@react-navigation/native'; +import type {TextInput, TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native'; +import type * as UseAnimatedRefModule from 'react-native-reanimated/lib/typescript/hook/useAnimatedRef'; + +import React, {act as actDOM} from 'react'; +import {createRoot} from 'react-dom/client'; +import Onyx from 'react-native-onyx'; +import {dispatchCommand, useAnimatedRef} from 'react-native-reanimated'; + +import createMock from '../utils/createMock'; + +let mockShouldMountComposer = true; +let mockShouldUseNativeComposer = false; +let mockMarkdownInput: TextInput | (HTMLDivElement & Pick) | null = null; +const WebComposer = jest.requireActual('@components/Composer/implementation/index.tsx').default; +const {forceClearInput: clearWebInput} = jest.requireActual('@libs/ComponentUtils/index.ts'); +const mockResetScroll = jest.fn< + ReturnType['resetScrollPosition']>, + Parameters['resetScrollPosition']> +>(); +const mockSuggestionsRef = {current: null}; +const {forceClearInput: clearNativeInput} = jest.requireActual('@libs/ComponentUtils/index.native.ts'); + +jest.mock('@components/Composer', () => { + const ReactModule = jest.requireActual('react'); + const Web = jest.requireActual('@components/Composer/implementation/index.tsx').default; + const Native = jest.requireActual('@components/Composer/implementation/index.native').default; + return (props: ComposerProps) => (mockShouldMountComposer ? ReactModule.createElement(mockShouldUseNativeComposer ? Native : Web, {...props, key: props.placeholder}) : null); +}); +jest.mock('@components/RNMarkdownTextInput', () => { + const ReactModule = jest.requireActual('react'); + return jest.fn((props: Parameters[0]) => ReactModule.createElement('MarkdownInput', {ref: props.ref})); +}); +jest.mock('@expensify/react-native-live-markdown/lib/commonjs/web/MarkdownTextInput.css', () => ({})); +jest.mock('@components/OnyxListItemProvider', () => ({useSession: () => undefined})); +jest.mock('@components/WideRHPContextProvider', () => ({useWideRHPState: () => ({superWideRHPRouteKeys: [], wideRHPRouteKeys: []})})); +jest.mock('@hooks/useTheme', () => () => ({})); +jest.mock('@hooks/useThemeStyles', () => () => ({})); +jest.mock('@hooks/useStyleUtils', () => () => ({ + getComposeTextAreaPadding: () => ({}), + getComposerMaxHeightStyle: () => ({}), + getContainerComposeStyles: () => ({paddingVertical: 0}), +})); +jest.mock('@hooks/useMarkdownStyle', () => () => ({})); +jest.mock('@hooks/useIsScrollBarVisible', () => () => false); +jest.mock('@hooks/useBlurOnKeyboardHide', () => jest.fn, Parameters>()); +jest.mock('@hooks/useIsInLandscapeMode', () => jest.fn(() => false)); +jest.mock('@hooks/useOnyx', () => () => [undefined]); +jest.mock('@hooks/useHtmlPaste', () => jest.requireActual('@hooks/useHtmlPaste/index.ts')); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({accountID: 1})); +jest.mock('@hooks/useIsInSidePanel', () => () => false); +jest.mock('@hooks/useKeyboardState', () => () => ({isKeyboardShown: false})); +jest.mock('@hooks/useLocalize', () => () => ({preferredLocale: 'en'})); +jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); +jest.mock('@hooks/useReportIDToNameMap', () => () => ({})); +jest.mock('@hooks/useResponsiveLayout', () => () => ({shouldUseNarrowLayout: false})); +jest.mock('@hooks/useSidePanelState', () => () => ({isSidePanelHiddenOrLargeScreen: true, shouldHideSidePanel: false})); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: () => true, + useRoute: () => ({name: 'Report', key: 'report'}), + useNavigation: () => ({addListener: () => () => {}}), +})); +jest.mock('@libs/Browser', () => ({isMobileSafari: () => false, isSafari: () => false, isMobileChrome: jest.fn(() => false), getBrowser: jest.fn(() => 'chrome')})); +jest.mock('@libs/ComponentUtils', () => jest.requireActual('@libs/ComponentUtils/index.ts')); +jest.mock('@libs/Navigation/TransitionTracker', () => ({runAfterTransitions: jest.fn(() => ({cancel: jest.fn()}))})); +jest.mock('@libs/canFocusInputOnScreenFocus', () => () => false); +jest.mock('@libs/willBlurTextInputOnTapOutside', () => () => false); +jest.mock('@pages/Share/getFileSize', () => () => Promise.resolve(7)); +jest.mock('@pages/inbox/report/ReportActionEditMessageContext', () => ({ + useReportActionActiveEditActions: () => ({ + setEditingMessage: jest.fn< + ReturnType['setEditingMessage']>, + Parameters['setEditingMessage']> + >(), + setCurrentEditMessageSelection: jest.fn< + ReturnType['setCurrentEditMessageSelection']>, + Parameters['setCurrentEditMessageSelection']> + >(), + }), +})); +jest.mock('@pages/inbox/report/useDebouncedSaveDraft', () => () => ({ + saveDraft: jest.fn['saveDraft']>, Parameters['saveDraft']>>(), + isSavePending: {current: false}, +})); +jest.mock('@pages/inbox/report/useDebouncedSaveReportActionDraft', () => () => ({ + saveDraft: jest.fn< + ReturnType['saveDraft']>, + Parameters['saveDraft']> + >(), + isSavePending: {current: false}, + cancelSaveDraft: jest.fn(), +})); +jest.mock('@pages/inbox/report/useDraftMessageVideoAttributeCache', () => () => {}); +jest.mock('@pages/inbox/report/useShouldEditInComposer', () => () => false); +jest.mock('@pages/inbox/report/ReportActionCompose/ComposerContext', () => ({ + useComposerText: () => '', + useComposerActions: () => ({ + setText: jest.fn< + ReturnType['setText']>, + Parameters['setText']> + >(), + }), + useComposerEditState: () => ({editingState: 'off', effectiveDraft: null}), +})); +jest.mock('@pages/inbox/report/ReportActionCompose/useComposerSuggestions', () => () => ({ + measureParentContainerAndReportCursor: jest.fn< + ReturnType['measureParentContainerAndReportCursor']>, + Parameters['measureParentContainerAndReportCursor']> + >(), + onSaveScrollAndHideSuggestionMenu: jest.fn< + ReturnType['onSaveScrollAndHideSuggestionMenu']>, + Parameters['onSaveScrollAndHideSuggestionMenu']> + >(), + resetScrollPosition: mockResetScroll, + raiseIsScrollLayoutTriggered: jest.fn< + ReturnType['raiseIsScrollLayoutTriggered']>, + Parameters['raiseIsScrollLayoutTriggered']> + >(), +})); +jest.mock('@pages/inbox/report/ReportActionCompose/useEditComposerToggle', () => + jest.fn, Parameters>(), +); +jest.mock('@pages/inbox/report/ReportActionCompose/useLastEditableAction', () => () => undefined); +jest.mock('@pages/inbox/report/ReportActionCompose/Suggestions', () => () => null); +jest.mock('@pages/inbox/report/ReportActionCompose/SilentCommentUpdater', () => () => null); +jest.mock('@userActions/Report', () => ({ + broadcastUserIsTyping: jest.fn, Parameters>(), + saveReportActionDraft: jest.fn, Parameters>(), + saveReportDraftComment: jest.fn, Parameters>(), +})); +jest.mock('@userActions/Modal', () => ({areAllModalsHidden: () => true})); + +function markdownProps() { + const props = jest.mocked(RNMarkdownTextInput).mock.calls.at(-1)?.[0]; + if (!props) { + throw new Error('Expected actual Composer to render markdown input'); + } + return props; +} + +function webMarkdownInput(value?: string) { + // Live-markdown augments its actual div with clear/isFocused and may omit value before parsing. + const div = Object.assign(document.createElement('div'), { + clear: jest.fn(() => { + div.textContent = ''; + }), + isFocused: () => document.activeElement === div, + }); + div.setAttribute('contenteditable', 'true'); + div.tabIndex = 0; + document.body.appendChild(div); + if (value !== undefined) { + Object.assign(div, {value}); + } + mockMarkdownInput = div; + return div; +} + +function forwardingProps(ref: React.Ref): Parameters[0] { + return { + reportID: '1', + onFocus: jest.fn[0]['onFocus']>, Parameters[0]['onFocus']>>(), + onBlur: jest.fn[0]['onBlur']>, Parameters[0]['onBlur']>>(), + onValueChange: jest.fn[0]['onValueChange']>, Parameters[0]['onValueChange']>>(), + isComposerFullSize: false, + setIsFullComposerAvailable: jest.fn< + ReturnType[0]['setIsFullComposerAvailable']>, + Parameters[0]['setIsFullComposerAvailable']> + >(), + isMenuVisible: false, + inputPlaceholder: '', + onPasteFile: jest.fn[0]['onPasteFile']>, Parameters[0]['onPasteFile']>>(), + onEnterKeyPress: jest.fn< + ReturnType[0]['onEnterKeyPress']>, + Parameters[0]['onEnterKeyPress']> + >(), + measureParentContainer: jest.fn< + ReturnType[0]['measureParentContainer']>, + Parameters[0]['measureParentContainer']> + >(), + suggestionsRef: mockSuggestionsRef, + isNextModalWillOpenRef: {current: false}, + isGroupPolicyReport: false, + ref, + }; +} + +describe('Composer imperative refs', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockShouldMountComposer = true; + mockShouldUseNativeComposer = false; + jest.mocked(useIsInLandscapeMode).mockReturnValue(false); + jest.mocked(useAnimatedRef).mockImplementation(jest.requireActual('react-native-reanimated/src/hook/useAnimatedRef.ts').useAnimatedRef); + jest.mocked(isMobileChrome).mockReturnValue(false); + jest.mocked(getBrowser).mockReturnValue(CONST.BROWSER.CHROME); + jest.spyOn(Onyx, 'set').mockResolvedValue(); + }); + afterEach(() => { + jest.restoreAllMocks(); + document.body.replaceChildren(); + ReportActionComposeFocusManager.composerRef.current = null; + ReportActionComposeFocusManager.sidePanelComposerRef.current = null; + }); + + it('observes the installed live-markdown div with value omitted before parsing', () => { + // Given the installed web producer receives no value, matching an initially uncontrolled composer. + const LiveMarkdown = jest.requireActual('@expensify/react-native-live-markdown/lib/commonjs/MarkdownTextInput.web.js').default; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const ref = React.createRef>(); + const parser = jest.fn(() => []); + + // When the actual library attaches its div and runs its initial effects. + actDOM(() => + root.render( + , + ), + ); + const input = host.querySelector('div'); + const commands = ref.current; + if (!input || !commands) { + throw new Error('Expected live-markdown host and commands'); + } + + // Then DOM identity and common commands exist while the producer leaves value absent. + expect(commands).toBe(input); + expect('value' in input).toBe(false); + expect(parser).not.toHaveBeenCalled(); + input.textContent = 'unparsed'; + actDOM(() => commands.clear()); + expect(input.textContent).toBe(''); + expect('value' in input).toBe(false); + actDOM(() => root.unmount()); + }); + + it.each(['hello', undefined])('keeps web commands, absent native capabilities and clear order for value %s', (value) => { + // Given an actual markdown div with either parsed text or the producer's omitted value. + const input = webMarkdownInput(value); + const onSelectionChange = jest.fn>, Parameters>>(); + const onClear = jest.fn>, Parameters>>(); + const ref = React.createRef(); + render( + , + {createNodeMock: () => mockMarkdownInput}, + ); + const handle = ref.current; + if (!handle) { + throw new Error('Expected web Composer ref'); + } + const blur = jest.spyOn(input, 'blur'); + const focus = jest.spyOn(input, 'focus'); + input.scrollTop = 23; + + // When common commands and the custom clear sequence execute. + act(() => { + handle.focus(); + handle.blur(); + handle.clear(); + }); + + // Then clear precedes selection reset and a missing input value is normalized to an empty string. + expect(focus).toHaveBeenCalledTimes(1); + expect(blur).toHaveBeenCalledTimes(1); + expect(handle.isFocused()).toBe(false); + expect(handle.setSelection).toBeUndefined(); + expect(handle.setNativeProps).toBeUndefined(); + expect(handle.preventFocusOnFirstResponderOnce).toBeUndefined(); + expect('scrollTop' in handle && handle.scrollTop).toBe(23); + expect(input.clear).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenCalledWith({nativeEvent: {selection: {start: 0, end: 0}}}); + expect(onClear).toHaveBeenCalledWith(value ?? ''); + const earlierCallOrder1 = input.clear.mock.invocationCallOrder.at(0); + const laterCallOrder1 = onSelectionChange.mock.invocationCallOrder.at(0); + if (earlierCallOrder1 === undefined || laterCallOrder1 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder1).toBeLessThan(laterCallOrder1); + const earlierCallOrder2 = onSelectionChange.mock.invocationCallOrder.at(0); + const laterCallOrder2 = onClear.mock.invocationCallOrder.at(0); + if (earlierCallOrder2 === undefined || laterCallOrder2 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder2).toBeLessThan(laterCallOrder2); + expect(markdownProps().selection).toEqual({start: 0, end: 0}); + }); + + it('preserves raw native identity, top-level commands and non-null callback timing', () => { + // Given the native markdown input owns the real input commands at its top level. + const inputClear = jest.fn, Parameters>(); + const inputSetNativeProps = jest.fn, Parameters>(); + const inputPreventFocusOnFirstResponderOnce = jest.fn, Parameters>(); + const input = createMock({ + clear: inputClear, + blur: jest.fn, Parameters>(), + focus: jest.fn, Parameters>(), + isFocused: () => false, + setSelection: jest.fn, Parameters>(), + setNativeProps: inputSetNativeProps, + preventFocusOnFirstResponderOnce: inputPreventFocusOnFirstResponderOnce, + }); + mockMarkdownInput = input; + const callback = jest.fn(); + const view = render(, {createNodeMock: () => mockMarkdownInput}); + const ref = {current: input}; + + // When native selection/update/first-responder operations and clear use that ref. + input.clear(); + preventFirstResponder(ref); + updateNativeTextInputValue({composerRef: ref, shouldForceNativeValueUpdate: true, text: 'draft'}); + updateNativeTextInputValue({composerRef: ref, shouldForceNativeValueUpdate: false, text: 'ignored'}); + view.unmount(); + + // Then the callback exposes the same object and detachment does not add a null notification. + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(input); + expect(inputClear).toHaveBeenCalledTimes(1); + expect(inputPreventFocusOnFirstResponderOnce).toHaveBeenCalledTimes(1); + expect(inputSetNativeProps).toHaveBeenCalledTimes(1); + expect(inputSetNativeProps).toHaveBeenCalledWith({text: 'draft'}); + }); + + it('keeps the landscape proxy focus suppression and object-ref behavior', () => { + // Given native landscape mode suppresses programmatic focus through its existing proxy. + const inputFocus = jest.fn, Parameters>(); + const inputClear = jest.fn, Parameters>(); + const input = createMock({ + focus: inputFocus, + clear: inputClear, + isFocused: () => false, + }); + mockMarkdownInput = input; + jest.mocked(useIsInLandscapeMode).mockReturnValue(true); + const callback = jest.fn(); + render( + , + {createNodeMock: () => mockMarkdownInput}, + ); + const proxy = callback.mock.calls.at(0)?.[0]; + if (!proxy) { + throw new Error('Expected landscape proxy'); + } + + // When consumers request focus and clear, and a separate object ref is supplied. + proxy.focus(); + proxy.clear(); + const objectRef = React.createRef(); + render(, {createNodeMock: () => mockMarkdownInput}); + + // Then only focus is suppressed and the original object-ref behavior remains intact. + expect(proxy).not.toBe(input); + expect(inputFocus).not.toHaveBeenCalled(); + expect(inputClear).toHaveBeenCalledTimes(1); + expect(markdownProps().autoFocus).toBe(false); + expect(objectRef.current).toBeNull(); + }); + + it('keeps the outer commands tied to the current input and safely handles an absent input', () => { + // Given the outer handle exposes only the commands its consumers use. + mockShouldUseNativeComposer = true; + const firstBlur = jest.fn, Parameters>(); + const firstFocus = jest.fn, Parameters>(); + const firstSetSelection = jest.fn, Parameters>(); + const firstIsFocused = jest.fn, Parameters>(() => false); + const first = createMock({blur: firstBlur, focus: firstFocus, isFocused: firstIsFocused, setSelection: firstSetSelection}); + const secondBlur = jest.fn, Parameters>(); + const second = createMock({blur: secondBlur, isFocused: () => false}); + mockMarkdownInput = first; + const ref = React.createRef(); + const props = forwardingProps(ref); + const view = render(, {createNodeMock: () => mockMarkdownInput}); + const handle = ref.current; + if (!handle) { + throw new Error('Expected forwarding interface'); + } + expect(Object.keys(handle)).toEqual(['focus', 'replaceSelectionWithText', 'getCurrentText', 'clearWorklet', 'resetHeight', 'blur', 'isFocused']); + handle.focus(false, {start: 2, end: 4}); + handle.blur(); + expect(firstFocus).toHaveBeenCalledTimes(1); + expect(firstSetSelection).toHaveBeenCalledWith(2, 4); + expect(firstBlur).toHaveBeenCalledTimes(1); + firstIsFocused.mockReturnValue(true); + expect(handle.isFocused()).toBe(true); + expect(ReportActionComposeFocusManager.composerRef.current).toBe(first); + + // When the input remounts, the retained handle uses the replacement input. + mockMarkdownInput = second; + view.rerender( + , + ); + handle.blur(); + + // Then commands observe the current input and missing inputs are safe. + expect(ReportActionComposeFocusManager.composerRef.current).toBe(second); + expect(secondBlur).toHaveBeenCalledTimes(1); + expect(firstBlur).toHaveBeenCalledTimes(1); + expect(handle.isFocused()).toBe(false); + mockShouldMountComposer = false; + view.unmount(); + const absentRef = React.createRef(); + render(); + const absent = absentRef.current; + if (!absent) { + throw new Error('Expected absent-input forwarding interface'); + } + expect(() => absent.blur()).not.toThrow(); + expect(absent.isFocused()).toBe(false); + expect(() => absent.focus()).not.toThrow(); + expect(Object.keys(absent)).toEqual(Object.keys(handle)); + }); + + it('preserves native clear values and MIME-derived attachment filenames', async () => { + // Given clipboard files without extensions rely on their MIME type, while plain text is ignored. + mockMarkdownInput = createMock({isFocused: () => false}); + const onClear = jest.fn>, Parameters>>(); + const onPasteFile = jest.fn>, Parameters>>(); + render( + , + {createNodeMock: () => mockMarkdownInput}, + ); + const props = markdownProps(); + const paste = props.onPaste; + const clear = props.onClear; + if (!paste || !clear) { + throw new Error('Expected native clear and paste callbacks'); + } + + // When native notifications pass through Composer without a ref wrapper. + await act(async () => { + clear(createMock[0]>({nativeEvent: {text: 'draft'}})); + paste(createMock[0]>({nativeEvent: {items: [{type: 'text/plain', data: 'hello'}]}})); + paste(createMock[0]>({nativeEvent: {items: [{type: 'image/png', data: 'file:///photo'}]}})); + await Promise.resolve(); + }); + + // Then text absence is not coerced and the attachment keeps its MIME-derived extension and observed size. + expect(onClear).toHaveBeenCalledWith('draft'); + expect(onPasteFile).toHaveBeenCalledTimes(1); + expect(onPasteFile).toHaveBeenCalledWith({uri: 'file:///photo', name: 'photo.png', type: 'image/png', size: 7}); + }); + + it.each([ + {browser: CONST.BROWSER.CHROME, isComposing: true, keyCode: 13, mobile: false, calls: 0}, + {browser: CONST.BROWSER.SAFARI, isComposing: false, keyCode: 229, mobile: false, calls: 0}, + {browser: CONST.BROWSER.CHROME, isComposing: true, keyCode: 13, mobile: true, calls: 1}, + {browser: CONST.BROWSER.CHROME, isComposing: false, keyCode: 13, mobile: false, calls: 1}, + ])('preserves nativeEvent IME handling for $browser/$keyCode/mobile=$mobile', ({browser, isComposing, keyCode, mobile, calls}) => { + // Given the input owns a real native KeyboardEvent in its React Native notification. + webMarkdownInput('text'); + jest.mocked(getBrowser).mockReturnValue(browser); + jest.mocked(isMobileChrome).mockReturnValue(mobile); + const onKeyPress = jest.fn>, Parameters>>(); + render(, {createNodeMock: () => mockMarkdownInput}); + const event = createMock({nativeEvent: new KeyboardEvent('keydown', {key: 'Enter', isComposing, keyCode})}); + + // When Enter is delivered through the actual web Composer handler and unchanged IME helper. + act(() => markdownProps().onKeyPress?.(event)); + + // Then IME confirmation is suppressed except for the helper's mobile Chrome behavior. + expect(onKeyPress).toHaveBeenCalledTimes(calls); + if (calls) { + expect(onKeyPress).toHaveBeenCalledWith(event); + } + }); + + it('retains paste ownership for another input and accepts attachments from the composer itself', () => { + // Given the active composer and another real input share the document paste listener. + const input = webMarkdownInput('text'); + input.focus(); + const onPasteFile = jest.fn>, Parameters>>(); + render( + true} + onPasteFile={onPasteFile} + />, + {createNodeMock: () => mockMarkdownInput}, + ); + const other = document.createElement('textarea'); + document.body.appendChild(other); + const file = new File(['photo'], 'photo.png', {type: 'image/png'}); + const otherEvent = new Event('paste', {bubbles: true, cancelable: true}); + Object.defineProperty(otherEvent, 'clipboardData', {value: {files: [file], types: [], getData: () => ''}}); + const ownEvent = new Event('paste', {bubbles: true, cancelable: true}); + Object.defineProperty(ownEvent, 'clipboardData', {value: {files: [file], types: [], getData: () => ''}}); + + // When the paste target belongs first to the other input and then to the raw markdown div. + act(() => other.dispatchEvent(otherEvent)); + expect(onPasteFile).not.toHaveBeenCalled(); + expect(otherEvent.defaultPrevented).toBe(false); + act(() => input.dispatchEvent(ownEvent)); + + // Then ownership preserves other input paste while handling the composer's attachment once. + expect(ownEvent.defaultPrevented).toBe(true); + expect(onPasteFile).toHaveBeenCalledWith([file]); + }); + + it('retains caret coordinates on real selection notifications', () => { + // Given layout belongs to the raw markdown div and the current browser range. + const input = webMarkdownInput('text'); + input.textContent = 'text'; + input.scrollTop = 8; + const range = document.createRange(); + range.selectNodeContents(input); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + const rectsDescriptor = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects'); + Object.defineProperty(Range.prototype, 'getClientRects', { + configurable: true, + value: () => { + const rects = createMock({length: 1}); + Object.defineProperty(rects, 0, {value: createMock({left: 20, top: 40, height: 10}), enumerable: true, configurable: true, writable: true}); + return rects; + }, + }); + jest.spyOn(input, 'getBoundingClientRect').mockReturnValue(createMock({left: 5, top: 10})); + const changed = jest.fn>, Parameters>>(); + render( + , + {createNodeMock: () => mockMarkdownInput}, + ); + const event = createMock({nativeEvent: {selection: {start: 1, end: 2}}}); + + // When the actual Composer adds relative caret geometry to a real notification. + act(() => markdownProps().onSelectionChange?.(event)); + + // Then selection indices and geometry both reach consumers. + expect(changed).toHaveBeenCalledWith(expect.objectContaining({nativeEvent: {selection: {start: 1, end: 2, positionX: 15 - CONST.SPACE_CHARACTER_WIDTH, positionY: 33}}})); + if (rectsDescriptor) { + Object.defineProperty(Range.prototype, 'getClientRects', rectsDescriptor); + } else { + Reflect.deleteProperty(Range.prototype, 'getClientRects'); + } + }); + + it('clears the current animated web handle after replacement and retains native dispatch', () => { + // Given Reanimated stores the actual public Composer object in its current field. + const first = webMarkdownInput('first'); + const ref = React.createRef(); + const props = forwardingProps(ref); + const view = render(, {createNodeMock: () => mockMarkdownInput}); + const retainedHandle = ref.current; + if (!retainedHandle) { + throw new Error('Expected retained forwarding interface'); + } + const firstHandle = ReportActionComposeFocusManager.composerRef.current; + expect(firstHandle).toBe(ReportActionComposeFocusManager.composerRef.current); + expect(firstHandle).not.toBeNull(); + const second = webMarkdownInput('second'); + + // When the real child is reattached before the existing clearWorklet executes. + view.rerender( + , + ); + const handle = ref.current; + if (!handle) { + throw new Error('Expected forwarding interface'); + } + const focus = jest.spyOn(second, 'focus'); + act(() => retainedHandle.focus()); + expect(focus).toHaveBeenCalledTimes(1); + act(() => handle.clearWorklet()); + const {result} = renderHook(() => jest.requireActual('react-native-reanimated/src/hook/useAnimatedRef.ts').useAnimatedRef()); + act(() => result.current(ReportActionComposeFocusManager.composerRef.current)); + clearNativeInput(result.current); + + // Then current-ref clearing uses the replacement and native clearing still dispatches the same ref. + expect(first.clear).not.toHaveBeenCalled(); + expect(second.clear).toHaveBeenCalledTimes(1); + expect(ReportActionComposeFocusManager.composerRef.current).not.toBe(firstHandle); + expect(dispatchCommand).toHaveBeenCalledWith(result.current, 'clear'); + expect(mockResetScroll).toHaveBeenCalledTimes(1); + result.current.current = null; + expect(() => clearWebInput(result.current)).not.toThrow(); + }); +}); + +const hiddenModal = {} satisfies Modal; + +describe('composerFocusKeepFocusOn actual focus commands', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Onyx, 'set').mockResolvedValue(); + jest.spyOn(ReportActionComposeFocusManager, 'isFocused').mockReturnValue(false); + // The focused branch intentionally saves null without an immediate invocation when Onyx is already focused. + composerFocusKeepFocusOn(null, true, hiddenModal, true); + }); + afterEach(() => jest.restoreAllMocks()); + + it('updates Onyx before immediate focus and safely skips a missing ref', () => { + // Given the helper consumes only a focus command, not a DOM element. + const ref = {focus: jest.fn, Parameters>()} satisfies NonNullable[0]>; + + // When initial focus executes and a missing ref reaches the same immediate branch. + composerFocusKeepFocusOn(ref, true, hiddenModal, false); + expect(() => composerFocusKeepFocusOn(null, true, hiddenModal, false)).not.toThrow(); + + // Then both calls update Onyx first, while only the real command object focuses. + expect(Onyx.set).toHaveBeenNthCalledWith(1, ONYXKEYS.INPUT_FOCUSED, true); + expect(Onyx.set).toHaveBeenNthCalledWith(2, ONYXKEYS.INPUT_FOCUSED, true); + expect(ref.focus).toHaveBeenCalledTimes(1); + const earlierCallOrder3 = jest.mocked(Onyx.set).mock.invocationCallOrder.at(0); + const laterCallOrder3 = ref.focus.mock.invocationCallOrder.at(0); + if (earlierCallOrder3 === undefined || laterCallOrder3 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder3).toBeLessThan(laterCallOrder3); + }); + + it('defers focus against the latest saved command object and respects modal/manager gates', () => { + // Given a saved composer loses focus while all modals are hidden. + const first = {focus: jest.fn, Parameters>()}; + const second = {focus: jest.fn, Parameters>()}; + composerFocusKeepFocusOn(first, true, hiddenModal, true); + composerFocusKeepFocusOn(null, false, {isVisible: true}, false); + composerFocusKeepFocusOn(null, false, {willAlertModalBecomeVisible: true}, false); + expect(TransitionTracker.runAfterTransitions).not.toHaveBeenCalled(); + + // When restoration is queued and another composer replaces the saved ref before transitions finish. + composerFocusKeepFocusOn(null, false, hiddenModal, false); + composerFocusKeepFocusOn(second, true, hiddenModal, true); + const deferred = jest.mocked(TransitionTracker.runAfterTransitions).mock.calls.at(0)?.[0]; + if (!deferred?.callback) { + throw new Error('Expected deferred focus'); + } + deferred.callback(); + + // Then the latest object focuses. An already focused manager clears the saved object and prevents later restoration. + expect(first.focus).not.toHaveBeenCalled(); + expect(second.focus).toHaveBeenCalledTimes(1); + jest.mocked(ReportActionComposeFocusManager.isFocused).mockReturnValue(true); + composerFocusKeepFocusOn(null, false, hiddenModal, false); + jest.mocked(ReportActionComposeFocusManager.isFocused).mockReturnValue(false); + composerFocusKeepFocusOn(null, false, hiddenModal, false); + expect(TransitionTracker.runAfterTransitions).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/ui/ConciergePageTest.tsx b/tests/ui/ConciergePageTest.tsx new file mode 100644 index 000000000000..57ac582b4d2f --- /dev/null +++ b/tests/ui/ConciergePageTest.tsx @@ -0,0 +1,134 @@ +import {act, render} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import {navigateToConciergeChat} from '@libs/actions/Report'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; + +import ConciergePage from '@pages/ConciergePage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@libs/actions/Report', () => ({ + navigateToConciergeChat: jest.fn(() => Promise.resolve()), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getActiveRoute: jest.fn(() => ''), + getActiveRouteWithoutParams: jest.fn(() => ''), +})); + +const mockNavigateToConciergeChat = jest.mocked(navigateToConciergeChat); + +const ACCOUNT_ID = 12345; +const CONCIERGE_REPORT_ID = '98765'; + +type ConciergeStackParamList = { + [SCREENS.CONCIERGE]: undefined; +}; + +const Stack = createPlatformStackNavigator(); + +function renderPage() { + return render( + + + + + + + + + , + ); +} + +async function seedSignedInAccount(hasCompletedGuidedSetupFlow: boolean) { + await Onyx.set(ONYXKEYS.SESSION, {accountID: ACCOUNT_ID, authToken: 'token'}); + await Onyx.set(ONYXKEYS.IS_LOADING_REPORT_DATA, false); + await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, CONCIERGE_REPORT_ID); + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow}); +} + +describe('ConciergePage', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.clearAllMocks(); + }); + + it('opens the Concierge chat for an account that has finished onboarding', async () => { + // Given a signed-in account whose report data is loaded and onboarding is done + await act(async () => { + await seedSignedInAccount(true); + }); + + // When the page mounts + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then it redirects to the chat, which is its whole job + expect(mockNavigateToConciergeChat).toHaveBeenCalledWith(expect.objectContaining({conciergeReportID: CONCIERGE_REPORT_ID, shouldDismissModal: true})); + }); + + it('waits for onboarding to finish before opening the Concierge chat', async () => { + // Given a new account that lands on this page while onboarding is still open on top of it + await act(async () => { + await seedSignedInAccount(false); + }); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then it does not redirect, because the redirect dismisses the top modal and would close onboarding + expect(mockNavigateToConciergeChat).not.toHaveBeenCalled(); + + // When onboarding completes + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the chat opens + expect(mockNavigateToConciergeChat).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['a workspace member', ONYXKEYS.HAS_NON_PERSONAL_POLICY, true], + ['an invited user', ONYXKEYS.NVP_INTRO_SELECTED, {inviteType: CONST.ONBOARDING_INVITE_TYPES.WORKSPACE}], + ] as const)('opens the Concierge chat for %s who never finished onboarding', async (_, key, value) => { + // Given an account the app never sends to onboarding, so its onboarding flag stays false for good + await act(async () => { + await seedSignedInAccount(false); + await Onyx.set(key, value); + }); + + // When the page mounts + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then it redirects instead of waiting for an onboarding that will never open + expect(mockNavigateToConciergeChat).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/ui/DescribeRuleModalTest.tsx b/tests/ui/DescribeRuleModalTest.tsx new file mode 100644 index 000000000000..da2ad7202fec --- /dev/null +++ b/tests/ui/DescribeRuleModalTest.tsx @@ -0,0 +1,312 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import * as API from '@libs/API'; +import type {GenerateRuleParams} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import Log from '@libs/Log'; + +import DescribeRuleModal from '@pages/workspace/rules/DescribeRuleModal'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {GeneratedRule} from '@src/types/onyx'; +import type {GeneratedRuleImpossibleReason, GeneratedRuleState, GeneratedRuleType} from '@src/types/onyx/GeneratedRule'; + +import type * as ReactNavigationNative from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import {translateLocal} from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +let mockPrompt = ''; + +jest.mock('@react-navigation/native', () => { + const actualNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualNavigation, + useFocusEffect: (callback: () => void) => { + callback(); + }, + }; +}); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); +jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({isSmallScreenWidth: false, shouldUseNarrowLayout: false}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn( + () => + new Proxy( + {}, + { + get: () => ({}), + }, + ), + ), +); +jest.mock('@components/Text', () => { + const ReactModule = jest.requireActual('react'); + const {Text} = jest.requireActual<{Text: React.ComponentType<{children?: React.ReactNode}>}>('react-native'); + return ({children}: {children: React.ReactNode}) => ReactModule.createElement(Text, null, children); +}); +jest.mock( + '@components/Modal', + () => + ({isVisible, children}: {isVisible: boolean; children: React.ReactNode}) => + isVisible ? children : null, +); +jest.mock('@components/Button', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual<{ + Pressable: React.ComponentType<{onPress?: () => void; children?: React.ReactNode}>; + Text: React.ComponentType<{children?: React.ReactNode}>; + }>('react-native'); + const MockButton = ({onPress, children}: {onPress: () => void; children: React.ReactNode}) => ReactModule.createElement(Pressable, {onPress}, children); + MockButton.Text = ({children}: {children: React.ReactNode}) => ReactModule.createElement(Text, null, children); + return MockButton; +}); +jest.mock('@components/Form/InputWrapper', () => () => null); +jest.mock('@components/Form/FormProvider', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual<{ + Pressable: React.ComponentType<{accessibilityLabel?: string; onPress?: () => void; children?: React.ReactNode}>; + Text: React.ComponentType<{children?: React.ReactNode}>; + }>('react-native'); + return ({children, onSubmit, submitButtonText}: {children: React.ReactNode; onSubmit: (values: {prompt: string}) => void; submitButtonText: string}) => + ReactModule.createElement( + ReactModule.Fragment, + null, + children, + ReactModule.createElement( + Pressable, + {accessibilityLabel: submitButtonText, onPress: () => onSubmit({prompt: mockPrompt})}, + ReactModule.createElement(Text, null, submitButtonText), + ), + ); +}); + +const POLICY_ID = 'policy1'; + +const IMPOSSIBLE_ANSWER = { + generationID: '', + state: CONST.GENERATED_RULE.STATE.IMPOSSIBLE, + reason: CONST.GENERATED_RULE.IMPOSSIBLE_REASON.NO_CAPABILITY, + explanation: 'Rules cannot send text messages.', + suggestedPrompt: 'Comment on the report when it is submitted', +} satisfies GeneratedRule; + +type WriteSpy = jest.SpiedFunction; + +function isGenerateRuleParams(parameters: unknown): parameters is GenerateRuleParams { + return typeof parameters === 'object' && parameters !== null && 'generationID' in parameters && 'prompt' in parameters; +} + +async function renderFlagForReviewModal(onRuleGenerated: jest.Mock, onClose: jest.Mock) { + render( + , + ); + await waitForBatchedUpdatesWithAct(); +} + +/** Submits the prompt through the modal and returns the generationID of the GenerateRule request */ +function describeRule(writeSpy: WriteSpy, prompt: string): string { + mockPrompt = prompt; + fireEvent.press(screen.getByLabelText('common.next')); + const parameters = writeSpy.mock.calls.find(([command]) => command === WRITE_COMMANDS.GENERATE_RULE)?.at(1); + if (!isGenerateRuleParams(parameters)) { + throw new Error('The describe modal did not request GenerateRule'); + } + return parameters.generationID; +} + +/** Returns the parameters of every GenerateRule request, oldest first */ +function getGenerateRuleRequests(writeSpy: WriteSpy): GenerateRuleParams[] { + return writeSpy.mock.calls + .filter(([command]) => command === WRITE_COMMANDS.GENERATE_RULE) + .map(([, parameters]) => parameters) + .filter(isGenerateRuleParams); +} + +async function answerGeneration(answer: GeneratedRule) { + await act(async () => { + await Onyx.set(ONYXKEYS.GENERATED_RULE, answer); + }); + await waitForBatchedUpdatesWithAct(); +} + +async function getPromptErrors() { + const form = await getOnyxValue(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); + return Object.values(form?.errors ?? {}); +} + +describe('DescribeRuleModal', () => { + let writeSpy: WriteSpy; + + beforeEach(() => { + jest.clearAllMocks(); + mockPrompt = ''; + writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + }); + + afterEach(async () => { + writeSpy.mockRestore(); + await act(async () => { + await Onyx.clear(); + }); + }); + + it('gives the generated values to the editor and closes when the answer has the rule type of the editor', async () => { + // Given the flag for review editor asked for a rule + const onRuleGenerated = jest.fn(); + const onClose = jest.fn(); + await renderFlagForReviewModal(onRuleGenerated, onClose); + const generationID = describeRule(writeSpy, 'Flag expenses over $500'); + + // When Concierge answers with a flag for review rule + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.RULE, + ruleType: CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW, + rule: {maxExpenseAmount: '500'}, + }); + + // Then the editor gets the values and the modal closes, because the editor can show them + expect(onRuleGenerated).toHaveBeenCalledWith({maxExpenseAmount: '500'}, CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW); + expect(onClose).toHaveBeenCalled(); + }); + + it.each([ + {description: 'another rule type', generatedRuleType: CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND}, + {description: 'no rule type', generatedRuleType: undefined}, + ])('shows the unsupported error and stays open when the answer has $description', async ({generatedRuleType}: {generatedRuleType: GeneratedRuleType | undefined}) => { + // Given the flag for review editor asked for a rule + const onRuleGenerated = jest.fn(); + const onClose = jest.fn(); + await renderFlagForReviewModal(onRuleGenerated, onClose); + const generationID = describeRule(writeSpy, 'Block retail purchases on all cards'); + + // When Concierge answers with a rule that is not a flag for review rule + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.RULE, + ruleType: generatedRuleType, + rule: {merchantNames: ['Walmart']}, + }); + + // Then the editor does not get the values, because they would replace its form with the fields of another rule type + expect(onRuleGenerated).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unsupported')]); + expect(await getOnyxValue(ONYXKEYS.GENERATED_RULE)).toBeUndefined(); + }); + + it('shows the error of an answer that is not a rule', async () => { + // Given the flag for review editor asked for a rule + const onRuleGenerated = jest.fn(); + const onClose = jest.fn(); + await renderFlagForReviewModal(onRuleGenerated, onClose); + const generationID = describeRule(writeSpy, 'asdf qwerty zxcv'); + + // When Concierge answers that the description is unintelligible + await answerGeneration({generationID, state: CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE}); + + // Then the modal shows the unintelligible error, because the rule type check applies only to a rule answer + expect(onRuleGenerated).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unintelligible')]); + }); + + it.each([CONST.GENERATED_RULE.STATE.UNSUPPORTED, CONST.GENERATED_RULE.STATE.AGENT_RULE])( + 'shows the unsupported error for an answer of %s when the caller gives no agent rule fallback', + async (state: GeneratedRuleState) => { + // Given the flag for review editor asked for a rule, and the editor does not offer the agent rule fallback + const onRuleGenerated = jest.fn(); + const onClose = jest.fn(); + await renderFlagForReviewModal(onRuleGenerated, onClose); + const generationID = describeRule(writeSpy, 'Block gift cards'); + + // When Concierge answers that no standard rule can do it + await answerGeneration({generationID, state, agentRulePrompt: 'Hold gift card expenses'}); + + // Then the modal shows the unsupported error, because only a caller with a fallback turns this answer into an agent rule + expect(onRuleGenerated).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unsupported')]); + }, + ); + + it('shows why the description cannot be done and keeps the editor form when the answer is impossible', async () => { + // Given the flag for review editor asked for a rule + const onRuleGenerated = jest.fn(); + const onClose = jest.fn(); + await renderFlagForReviewModal(onRuleGenerated, onClose); + const generationID = describeRule(writeSpy, 'Text the approver when a report is submitted'); + + // When Concierge answers that neither a rule nor an agent rule can do it + await answerGeneration({...IMPOSSIBLE_ANSWER, generationID}); + + // Then the modal stays open with the reason, the explanation and the suggestion, and nothing reaches the editor form + expect(onRuleGenerated).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByText('workspace.rules.newRule.impossible.noCapability')).toBeOnTheScreen(); + expect(screen.getByText(IMPOSSIBLE_ANSWER.explanation)).toBeOnTheScreen(); + expect(screen.getByText(IMPOSSIBLE_ANSWER.suggestedPrompt)).toBeOnTheScreen(); + + // And the form stops loading without an error, because the answer is shown in the form + const form = await getOnyxValue(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); + expect(form?.isLoading).toBe(false); + expect(await getPromptErrors()).toEqual([]); + }); + + it('describes the suggested rule when the admin taps Try this instead', async () => { + // Given Concierge answered that the description cannot be done and suggested another one + const logSpy = jest.spyOn(Log, 'info'); + await renderFlagForReviewModal(jest.fn(), jest.fn()); + const generationID = describeRule(writeSpy, 'Text the approver when a report is submitted'); + await answerGeneration({...IMPOSSIBLE_ANSWER, generationID}); + + // When the admin taps Try this instead + fireEvent.press(screen.getByText('workspace.rules.newRule.impossible.tryThisInstead')); + await waitForBatchedUpdatesWithAct(); + + // Then Concierge gets the suggested description for the same editor, so the admin does not retype it + const lastRequest = getGenerateRuleRequests(writeSpy).at(-1); + expect(lastRequest?.prompt).toBe(IMPOSSIBLE_ANSWER.suggestedPrompt); + expect(lastRequest?.ruleType).toBe(CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW); + expect(logSpy).toHaveBeenCalledWith('[GenerateRule] Tried the suggested description', false, {policyID: POLICY_ID, generationID, reason: IMPOSSIBLE_ANSWER.reason}); + + // And the refusal goes away, because it belongs to the earlier description + expect(screen.queryByText(IMPOSSIBLE_ANSWER.explanation)).toBeNull(); + logSpy.mockRestore(); + }); + + it('shows the general title and no suggestion when the reason is unknown and nothing is suggested', async () => { + // Given the flag for review editor asked for a rule + await renderFlagForReviewModal(jest.fn(), jest.fn()); + const generationID = describeRule(writeSpy, 'Text the approver when a report is submitted'); + + // When Concierge answers with a reason that this app version does not know, and with no suggestion + await answerGeneration({ + ...IMPOSSIBLE_ANSWER, + generationID, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- simulates an answer from a server that knows more reasons than this app version + reason: 'futureReason' as GeneratedRuleImpossibleReason, + suggestedPrompt: '', + }); + + // Then the modal shows the general title and no Try this instead button, because there is nothing to try + expect(screen.getByText('workspace.rules.newRule.impossible.noCapability')).toBeOnTheScreen(); + expect(screen.queryByText('workspace.rules.newRule.impossible.tryThisInstead')).toBeNull(); + }); +}); diff --git a/tests/ui/DistanceRequestStartPageTest.tsx b/tests/ui/DistanceRequestStartPageTest.tsx index 5067fc7285ac..4932e78be3f6 100644 --- a/tests/ui/DistanceRequestStartPageTest.tsx +++ b/tests/ui/DistanceRequestStartPageTest.tsx @@ -4,6 +4,7 @@ import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersona import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type * as OnyxTabNavigatorModule from '@libs/Navigation/OnyxTabNavigator'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; @@ -73,12 +74,16 @@ jest.mock('@components/ScreenWrapper', () => { jest.mock('@libs/Navigation/OnyxTabNavigator', () => { const ReactActual = jest.requireActual('react'); - const actual = jest.requireActual<{default: React.ComponentType>}>('@libs/Navigation/OnyxTabNavigator'); - function MockOnyxTabNavigator(props: Record) { - mockOnyxTabNavigatorProps.current = props; - return ReactActual.createElement(actual.default, props); - } - return {...actual, __esModule: true, default: MockOnyxTabNavigator}; + const actual = jest.requireActual('@libs/Navigation/OnyxTabNavigator'); + const createNavigator: typeof actual.createOnyxTabNavigator = (names) => { + const pair = actual.createOnyxTabNavigator(names); + function MockOnyxTabNavigator(props: Parameters[0]) { + mockOnyxTabNavigatorProps.current = props; + return ReactActual.createElement(pair.OnyxTabNavigator, props); + } + return {...pair, OnyxTabNavigator: MockOnyxTabNavigator}; + }; + return {...actual, createOnyxTabNavigator: createNavigator}; }); const REPORT_ID = '1'; @@ -109,7 +114,11 @@ async function setUpOnyx({selectedTab, lastDistanceExpenseType}: {selectedTab?: }); } -async function renderPage(defaultSelectedTab: SelectedTabRequest, iouType: IOUType = CONST.IOU.TYPE.TRACK, action: IOUAction = CONST.IOU.ACTION.CREATE) { +async function renderPage( + defaultSelectedTab: Parameters[0]['defaultSelectedTab'], + iouType: IOUType = CONST.IOU.TYPE.TRACK, + action: IOUAction = CONST.IOU.ACTION.CREATE, +) { render( diff --git a/tests/ui/DomainMemberDetailsPageTest.tsx b/tests/ui/DomainMemberDetailsPageTest.tsx new file mode 100644 index 000000000000..ed9c0b76d1ab --- /dev/null +++ b/tests/ui/DomainMemberDetailsPageTest.tsx @@ -0,0 +1,104 @@ +import {render} from '@testing-library/react-native'; + +import VacationDelegateMenuItem from '@components/VacationDelegateMenuItem'; + +import {clearDraftValues} from '@libs/actions/FormActions'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import DomainMemberDetailsPage from '@pages/domain/Members/DomainMemberDetailsPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +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; + +let mockDomain: Record | undefined; + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + dismissModal: jest.fn(), +})); + +jest.mock('@libs/actions/FormActions', () => ({ + clearDraftValues: jest.fn(), +})); + +// Renders only the page's own rows, so the test can reach the vacation delegate row without the member header and its data +jest.mock('@pages/domain/BaseDomainMemberDetailsComponent', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/OfflineWithFeedback', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/VacationDelegateMenuItem', () => jest.fn(() => null)); +jest.mock('@components/MenuItem/presets/MenuItemField', () => jest.fn(() => null)); +jest.mock('@components/MenuItem/presets/MenuItemAction', () => jest.fn(() => null)); +jest.mock('@components/DecisionModal', () => jest.fn(() => null)); +jest.mock('@components/MenuItem', () => ({__esModule: true, default: new Proxy({}, {get: () => () => null})})); +jest.mock('@components/Button', () => ({__esModule: true, default: new Proxy({}, {get: () => () => null})})); + +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: jest.fn(() => ({}))})); +jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: jest.fn()}))); +jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({isSmallScreenWidth: false}))); +jest.mock('@hooks/usePersonalDetails', () => ({usePersonalDetail: jest.fn(() => [{accountID: 2, login: 'member@example.com'}])})); +// Every domain read goes through a selector, so each one is applied to the same mock domain record +jest.mock('@hooks/useOnyx', () => jest.fn((key: string, options?: {selector?: (value: unknown) => unknown}) => [options?.selector ? options.selector(mockDomain) : undefined])); + +type DomainMemberDetailsPageProps = PlatformStackScreenProps; + +const route: DomainMemberDetailsPageProps['route'] = { + key: 'domain-member-details', + name: SCREENS.DOMAIN.MEMBER_DETAILS, + 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 DomainMemberDetailsPageProps['navigation']; + +function pressVacationDelegateRow() { + render( + , + ); + jest.mocked(VacationDelegateMenuItem).mock.lastCall?.[0].onPress?.(); +} + +describe('DomainMemberDetailsPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDomain = undefined; + }); + + it('opens the vacation delegate form when the member has no delegate', () => { + // Given a member with no vacation delegate + // When the admin presses the member's vacation delegate row + pressVacationDelegateRow(); + + // Then the form opens, not the member picker, so the clear after date is set in the same place as the delegate + expect(clearDraftValues).toHaveBeenCalledWith(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(DOMAIN_ACCOUNT_ID, MEMBER_ACCOUNT_ID)); + }); + + it('opens the vacation delegate form when the member already has a delegate', () => { + // Given a member with a saved vacation delegate + mockDomain = {[`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${MEMBER_ACCOUNT_ID}`]: {creator: 'admin@example.com', delegate: 'delegate@example.com'}}; + + // When the admin presses the member's vacation delegate row + pressVacationDelegateRow(); + + // Then the same form opens with a fresh draft, so the saved delegate, date and time are shown rather than a stale pick + expect(clearDraftValues).toHaveBeenCalledWith(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(DOMAIN_ACCOUNT_ID, MEMBER_ACCOUNT_ID)); + }); +}); 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/ui/DynamicWorkspaceSettlementAccountPageTest.tsx b/tests/ui/DynamicWorkspaceSettlementAccountPageTest.tsx new file mode 100644 index 000000000000..d92a88141675 --- /dev/null +++ b/tests/ui/DynamicWorkspaceSettlementAccountPageTest.tsx @@ -0,0 +1,338 @@ +import {act, cleanup, fireEvent, render, screen} from '@testing-library/react-native'; + +import BankOfAmerica from '@assets/images/bank-icons/bank-of-america.svg'; +import Chase from '@assets/images/bank-icons/chase.svg'; +import GenericBank from '@assets/images/bank-icons/generic-bank-account.svg'; + +import ComposeProviders from '@components/ComposeProviders'; +import HTMLEngineProvider from '@components/HTMLEngineProvider'; +import Icon from '@components/Icon'; +import type * as WebBankIconsModule from '@components/Icon/BankIcons/index'; +import type * as NativeBankIconsModule from '@components/Icon/BankIcons/index.native'; +import type * as BankIconsUtilsModule from '@components/Icon/BankIconsUtils'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import RenderHTML from '@components/RenderHTML'; +import type {SelectionListProps} from '@components/SelectionList/types'; +import type {BankAccountListItem} from '@components/SettlementAccountSelector'; + +import {getRouteParamForConnection} from '@libs/AccountingUtils'; +import type * as PolicyConnectionsModule from '@libs/actions/PolicyConnections'; +import {openPolicyAccountingPage} from '@libs/actions/PolicyConnections'; + +import Navigation from '@navigation/Navigation'; + +import DynamicWorkspaceSettlementAccountPage from '@pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage'; +import type {WorkspaceSettlementAccountPageProps} from '@pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage'; + +import type {ThemeStyles} from '@styles/index'; +import variables from '@styles/variables'; + +import type * as CardActionsModule from '@userActions/Card'; +import {updateSettlementAccount} from '@userActions/Card'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type BankAccount from '@src/types/onyx/BankAccount'; +import type {BankAccountList} from '@src/types/onyx/BankAccount'; +import type ExpensifyCardSettings from '@src/types/onyx/ExpensifyCardSettings'; +import type Policy from '@src/types/onyx/Policy'; +import type {PolicyConnectionName} from '@src/types/onyx/Policy'; + +import type * as NativeNavigation from '@react-navigation/native'; +import type ReactNative from 'react-native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createMock from '../utils/createMock'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: () => true, + useNavigationState: () => false, + useRoute: () => ({key: 'settlement', name: 'Workspace_Dynamic_ExpensifyCard_Settings_Account', params: {policyID: 'policy123'}}), + usePreventRemove: () => {}, +})); +jest.mock('@src/hooks/useResponsiveLayout'); +jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({__esModule: true, default: () => ({didScreenTransitionEnd: true})})); +jest.mock('@hooks/useWorkspaceAccountID', () => ({__esModule: true, default: () => 424242})); +jest.mock( + '@pages/workspace/AccessOrNotFoundWrapper', + () => + ({children}: React.PropsWithChildren) => + children, +); +jest.mock('@userActions/Card', () => ({ + ...jest.requireActual('@userActions/Card'), + updateSettlementAccount: jest.fn, Parameters>(), +})); +jest.mock('@libs/actions/PolicyConnections', () => ({ + ...jest.requireActual('@libs/actions/PolicyConnections'), + openPolicyAccountingPage: jest.fn, Parameters>(), +})); + +// List virtualization is below SettlementAccountSelector. The actual row icon, header and selection callback still execute. +jest.mock('@components/SelectionList', () => { + const RN = jest.requireActual('react-native'); + return ({data, onSelectRow, customListHeaderContent, initiallyFocusedItemKey}: SelectionListProps) => ( + + {customListHeaderContent} + {data.map((item) => ( + onSelectRow(item)} + > + {item.leftElement} + {item.text} + {item.alternateText} + + ))} + + ); +}); + +const getNativeBankIcon = jest.requireActual('../../src/components/Icon/BankIcons/index.native.ts').default; + +const policyID = 'policy123'; +const fundID = 424242; +const cardSettingsKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${fundID}` as const; +const programKey = CONST.COUNTRY.US; +const policy = createMock({id: policyID, name: 'Card workspace', role: CONST.POLICY.ROLE.ADMIN, outputCurrency: CONST.CURRENCY.USD}); +const cardSettings: ExpensifyCardSettings = { + [programKey]: {domainName: 'example.com', paymentBankAccountID: 11, paymentBankAccountAddressName: 'Unknown Credit Union', paymentBankAccountNumber: '12340011'}, +}; +const eligibleChase = createMock({ + methodID: 22, + title: 'Chase business', + bankCurrency: CONST.CURRENCY.USD, + bankCountry: CONST.COUNTRY.US, + accountData: {type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, allowDebit: true, state: CONST.BANK_ACCOUNT.STATE.OPEN, addressName: 'CHASE Bank', accountNumber: '12340022', bankAccountID: 22}, +}); +const eligibleUnknown = createMock({ + methodID: 33, + title: 'Credit union business', + bankCurrency: CONST.CURRENCY.USD, + bankCountry: CONST.COUNTRY.US, + accountData: {type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, allowDebit: true, state: CONST.BANK_ACCOUNT.STATE.OPEN, addressName: 'Unknown Credit Union', accountNumber: '12340033'}, +}); +const ineligible = createMock({ + methodID: 44, + title: 'Pending account', + bankCurrency: CONST.CURRENCY.USD, + bankCountry: CONST.COUNTRY.US, + accountData: {type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, allowDebit: true, state: CONST.BANK_ACCOUNT.STATE.SETUP, addressName: 'Chase Bank'}, +}); +const route: WorkspaceSettlementAccountPageProps['route'] = {key: 'settlement', name: SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_SETTINGS_ACCOUNT, params: {policyID}}; + +function renderSettlementPage() { + return render( + + ({})} + /> + , + ); +} + +describe('DynamicWorkspaceSettlementAccountPage', () => { + beforeEach(async () => { + Onyx.init({keys: ONYXKEYS}); + jest.mocked(updateSettlementAccount).mockClear(); + jest.mocked(openPolicyAccountingPage).mockClear(); + jest.spyOn(Navigation, 'goBack').mockImplementation(() => {}); + await act(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.set(cardSettingsKey, cardSettings); + }); + await waitForBatchedUpdatesWithAct(); + }); + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + it('uses eligible accounts ahead of the fallback and emits account IDs', async () => { + // Given real backend account fields, only debit-enabled business accounts past setup are eligible. + const accounts: BankAccountList = {[`22`]: eligibleChase, [`33`]: eligibleUnknown, [`44`]: ineligible}; + await act(async () => { + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, accounts); + }); + await waitForBatchedUpdatesWithAct(); + const page = renderSettlementPage(); + await waitForBatchedUpdatesWithAct(); + + // When the actual page composes list rows, institution names must reach the real icon matcher unchanged. + expect(screen.getByText('Chase business')).toBeOnTheScreen(); + expect(screen.getByText('Credit union business')).toBeOnTheScreen(); + expect(screen.queryByText('Pending account')).toBeNull(); + expect(screen.queryByTestId('settlement-account-11')).toBeNull(); + expect(screen.getByText('Account ending in 0022')).toBeOnTheScreen(); + expect(page.UNSAFE_getAllByType(Icon).some((node) => node.props.src === Chase)).toBe(true); + expect(page.UNSAFE_getAllByType(Icon).some((node) => node.props.src === GenericBank)).toBe(true); + fireEvent.press(screen.getByTestId('settlement-account-22')); + fireEvent.press(screen.getByTestId('settlement-account-33')); + + // Then accountData.bankAccountID and methodID fallback both retain their exact action arguments. + expect(updateSettlementAccount).toHaveBeenCalledTimes(2); + expect(updateSettlementAccount).toHaveBeenNthCalledWith(1, 'example.com', fundID, policyID, programKey, 22, 11, false); + expect(updateSettlementAccount).toHaveBeenNthCalledWith(2, 'example.com', fundID, policyID, programKey, 33, 11, false); + expect(Navigation.goBack).toHaveBeenCalledTimes(2); + }); + + it.each(['Unknown Credit Union', 'BankOfAmerica', ''])('renders the selected fallback for institution "%s" without eligible accounts', async (bankName) => { + // Given no eligible account, nested card settings are the source of the selected fallback and its last four digits. + await act(async () => { + await Onyx.set(cardSettingsKey, {[programKey]: {...cardSettings[programKey], paymentBankAccountAddressName: bankName}}); + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, {[`44`]: ineligible}); + }); + await waitForBatchedUpdatesWithAct(); + const page = renderSettlementPage(); + await waitForBatchedUpdatesWithAct(); + + // When the fallback row is selected, its existing account ID and selection state must survive arbitrary names. + const row = screen.getByTestId('settlement-account-11'); + expect(row.props.accessibilityState).toEqual({selected: true}); + expect(screen.getByText('Account ending in 0011')).toBeOnTheScreen(); + expect(screen.getByTestId('settlement-focused-11')).toBeOnTheScreen(); + const expectedIcon = bankName === 'BankOfAmerica' ? BankOfAmerica : GenericBank; + expect(page.UNSAFE_getAllByType(Icon).some((node) => node.props.src === expectedIcon)).toBe(true); + fireEvent.press(row); + + // Then the real selector delivers the selected ID rather than a display name or list index. + expect(updateSettlementAccount).toHaveBeenCalledTimes(1); + expect(updateSettlementAccount).toHaveBeenNthCalledWith(1, 'example.com', fundID, policyID, programKey, 11, 11, false); + }); + + it.each([ + {connection: undefined, enabled: true, active: true, visible: false}, + {connection: CONST.POLICY.CONNECTIONS.NAME.QBO, enabled: false, active: true, visible: false}, + {connection: CONST.POLICY.CONNECTIONS.NAME.QBO, enabled: true, active: false, visible: false}, + // Exact Online is only listed for its waitlist, so it is not a connection a policy can reconcile against. + ...Object.values(CONST.POLICY.CONNECTIONS.NAME) + .filter((connection): connection is PolicyConnectionName => connection !== CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE) + .map((connection) => ({connection, enabled: true, active: true, visible: !!getRouteParamForConnection(connection)})), + ] satisfies Array<{connection: PolicyConnectionName | undefined; enabled: boolean; active: boolean; visible: boolean}>)( + 'preserves reconciliation link visibility for $connection, enabled=$enabled, active=$active', + async ({connection, enabled, active, visible}) => { + // Given independent connection and reconciliation state, no single flag is enough to authorize a link. + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, active ? {...policy, connections: {quickbooksOnline: {}}} : policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.EXPENSIFY_CARD_USE_CONTINUOUS_RECONCILIATION}${fundID}`, enabled); + if (connection) { + await Onyx.set(`${ONYXKEYS.COLLECTION.EXPENSIFY_CARD_CONTINUOUS_RECONCILIATION_CONNECTION}${fundID}`, connection); + } + }); + await waitForBatchedUpdatesWithAct(); + + // When the actual page uses the real accounting mapping, absent or disabled connections remain hidden. + const page = renderSettlementPage(); + await waitForBatchedUpdatesWithAct(); + const html = page.UNSAFE_queryAllByType(RenderHTML); + + // Then known mappings retain the route, fund and masked account while other states render no reconciliation HTML. + expect(html).toHaveLength(visible ? 1 : 0); + if (visible && connection) { + const routeParam = getRouteParamForConnection(connection); + expect(routeParam).toBeDefined(); + if (!routeParam) { + throw new Error('Expected a mapped connection'); + } + expect(html.at(0)?.props.html).toContain( + `${ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, routeParam)}/${DYNAMIC_ROUTES.WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS.path}?fundID=${fundID}`, + ); + expect(html.at(0)?.props.html).toContain(`${CONST.MASKED_PAN_PREFIX}0011`); + } + }, + ); +}); + +describe.each(['web', 'native'])('real %s bank icon names', (platform) => { + it.each([ + {bankName: 'CHASE Bank', key: 'CHASE', cachedComponent: Chase}, + {bankName: 'BankOfAmerica', key: 'BANK_OF_AMERICA', cachedComponent: BankOfAmerica}, + {bankName: 'Unknown Credit Union', key: '', cachedComponent: GenericBank}, + {bankName: '', key: '', cachedComponent: GenericBank}, + ] as const)('preserves matching and fallback for $bankName', ({bankName, key, cachedComponent}) => { + // Given setup-owned SVG functions, web extraction needs a distinct real transformed component at their default property. + const realChase = jest.requireActual('@assets/images/bank-icons/chase.svg'); + const realBankOfAmerica = jest.requireActual('@assets/images/bank-icons/bank-of-america.svg'); + for (const component of [Chase, BankOfAmerica, GenericBank, realChase, realBankOfAmerica]) { + expect(component).toBeDefined(); + expect(component).not.toBeNull(); + } + expect(Chase).not.toBe(BankOfAmerica); + expect(GenericBank).not.toBe(Chase); + expect(GenericBank).not.toBe(BankOfAmerica); + expect(realChase).not.toBe(Chase); + expect(realBankOfAmerica).not.toBe(BankOfAmerica); + expect(realChase).not.toBe(realBankOfAmerica); + const chaseDefault = Object.getOwnPropertyDescriptor(Chase, 'default'); + const americaDefault = Object.getOwnPropertyDescriptor(BankOfAmerica, 'default'); + expect(chaseDefault).toBeUndefined(); + expect(americaDefault).toBeUndefined(); + expect(Object.isExtensible(Chase)).toBe(true); + expect(Object.isExtensible(BankOfAmerica)).toBe(true); + const styles = createMock({bankIconContainer: {}, cardIcon: {}}); + const getWebBankIcon = jest.requireActual('../../src/components/Icon/BankIcons/index.ts').default; + const {getBankIconAsset, getBankNameKey} = jest.requireActual('@components/Icon/BankIconsUtils'); + try { + if (platform === 'web') { + const chaseNamespace = Object.assign(Chase, {default: realChase}); + const americaNamespace = Object.assign(BankOfAmerica, {default: realBankOfAmerica}); + expect(chaseNamespace.default).toBe(realChase); + expect(americaNamespace.default).toBe(realBankOfAmerica); + } + + // When the actual matcher, loader and platform consumer run, neither the loader nor the result is stubbed. + expect(getBankNameKey(bankName.toLowerCase())).toBe(key); + const result = (platform === 'web' ? getWebBankIcon : getNativeBankIcon)({bankName, styles}); + const webComponent = key === 'CHASE' ? realChase : realBankOfAmerica; + + // Then distinct nonempty platform oracles expose omitted extraction, wrong module bindings and missing fallbacks. + expect(result.icon).toBeDefined(); + expect(result.icon).not.toBeNull(); + if (key) { + const asset = getBankIconAsset(key, false); + expect(asset).toBe(cachedComponent); + if (platform === 'web') { + expect(result.icon).toBe(webComponent); + expect(result.icon).not.toBe(asset); + if (asset === null || (typeof asset !== 'object' && typeof asset !== 'function') || !('default' in asset)) { + throw new Error('Expected the web SVG namespace default'); + } + expect(asset.default).toBe(webComponent); + } else { + expect(result.icon).toBe(cachedComponent); + expect(result.icon).not.toBe(webComponent); + expect(Object.getOwnPropertyDescriptor(cachedComponent, 'default')).toBeUndefined(); + } + } else { + expect(result.icon).toBe(GenericBank); + } + expect(result.iconSize).toBe(variables.iconSizeExtraLarge); + expect(result.iconStyles).toEqual([styles.bankIconContainer]); + } finally { + if (chaseDefault) { + Object.defineProperty(Chase, 'default', chaseDefault); + } else { + expect(Reflect.deleteProperty(Chase, 'default')).toBe(true); + } + if (americaDefault) { + Object.defineProperty(BankOfAmerica, 'default', americaDefault); + } else { + expect(Reflect.deleteProperty(BankOfAmerica, 'default')).toBe(true); + } + expect(Object.getOwnPropertyDescriptor(Chase, 'default')).toEqual(chaseDefault); + expect(Object.getOwnPropertyDescriptor(BankOfAmerica, 'default')).toEqual(americaDefault); + } + }); +}); diff --git a/tests/ui/EditReportFieldDropdownTest.tsx b/tests/ui/EditReportFieldDropdownTest.tsx new file mode 100644 index 000000000000..9bc104cc7955 --- /dev/null +++ b/tests/ui/EditReportFieldDropdownTest.tsx @@ -0,0 +1,131 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import EditReportFieldDropdown from '@pages/EditReportFieldDropdown'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import type * as NativeNavigation from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@react-navigation/native', () => ({ + ...((): typeof NativeNavigation => jest.requireActual('@react-navigation/native'))(), + useNavigation: jest.fn(() => ({navigate: jest.fn(), addListener: jest.fn(() => jest.fn())})), + useIsFocused: jest.fn(() => true), + useRoute: jest.fn(() => ({key: '', name: ''})), + // The dropdown is rendered outside a navigator here, so the focus effect that resets the pinned selection is a noop. + useFocusEffect: jest.fn(), +})); + +const fieldKey = 'expensify_field_dropdown'; +const fieldOptions = ['Alpha', 'Bravo', 'Charlie']; + +const renderDropdown = async (props: Partial> = {}) => { + const rendered = render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + return rendered; +}; + +describe('EditReportFieldDropdown', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await act(async () => { + // "Bravo" was used recently, so the "Recent" section has something to render when it is enabled. + await Onyx.merge(ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, {[fieldKey]: ['Bravo']}); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + it('shows the search input by default', async () => { + // Given a dropdown rendered without any opt-out + await renderDropdown(); + + // When the list is displayed + // Then the search input is there, because the editor page has always been searchable and the new flags must + // not quietly change what an existing caller gets + expect(screen.getByTestId('selection-list-text-input')).toBeOnTheScreen(); + }); + + it('hides the search input when the caller opts out', async () => { + // Given a caller that opts out of the search input, which the inline field does for a short option list + await renderDropdown({shouldShowTextInput: false}); + + // When the list is displayed + // Then the search input is gone, so the popover does not reserve height for a box that is quicker to scan + // past than to type into + expect(screen.queryByTestId('selection-list-text-input')).not.toBeOnTheScreen(); + }); + + it('lists a recently used option twice by default, once in the "Recent" section and once in the full list', async () => { + // Given "Bravo" recorded as recently used and a dropdown rendered without any opt-out + await renderDropdown(); + + // When the list is displayed + // Then "Bravo" appears in both the "Recent" section and the full list, which is the editor page's existing + // behavior and must survive the flags being added + expect(screen.getAllByTestId('base-list-item-Bravo')).toHaveLength(2); + }); + + it('lists a recently used option only once when the caller opts out of the "Recent" section', async () => { + // Given "Bravo" recorded as recently used and a caller that opts out of the "Recent" section + await renderDropdown({shouldShowRecentlyUsedOptions: false}); + + // When the list is displayed + // Then "Bravo" appears once. A short list shown in a small popover is worse for having the same few options + // repeated at the top than it is for making the user read all of them + expect(screen.getAllByTestId('base-list-item-Bravo')).toHaveLength(1); + }); + + it('submits the option that was picked', async () => { + // Given a dropdown whose field currently holds "Alpha" + const onSubmit = jest.fn(); + await renderDropdown({onSubmit}); + + // When a different option is pressed + fireEvent.press(screen.getByTestId('base-list-item-Charlie')); + await waitForBatchedUpdatesWithAct(); + + // Then it is submitted under the field's own key, which is what both the editor page and the inline field + // rely on to know which field the value belongs to + expect(onSubmit).toHaveBeenCalledWith({[fieldKey]: 'Charlie'}); + }); + + it('submits the current value rather than an empty string when the option already selected is picked again', async () => { + // Given a dropdown whose field currently holds "Alpha" + const onSubmit = jest.fn(); + await renderDropdown({onSubmit}); + + // When that same option is pressed again + fireEvent.press(screen.getByTestId('base-list-item-Alpha')); + await waitForBatchedUpdatesWithAct(); + + // Then the current value is submitted, not an empty string. Callers skip a no-op save by comparing this + // against the stored value, so an empty string would be read as the user having cleared the field + expect(onSubmit).toHaveBeenCalledWith({[fieldKey]: 'Alpha'}); + }); +}); diff --git a/tests/ui/EmojiPickerMenuComponentTest.tsx b/tests/ui/EmojiPickerMenuComponentTest.tsx new file mode 100644 index 000000000000..c0ab15a16a04 --- /dev/null +++ b/tests/ui/EmojiPickerMenuComponentTest.tsx @@ -0,0 +1,669 @@ +import {act, cleanup, fireEvent, render, screen} from '@testing-library/react-native'; + +import emojis, {importEmojiLocale} from '@assets/emojis'; +import type {Emoji} from '@assets/emojis/types'; + +import ComposeProviders from '@components/ComposeProviders'; +import type * as WebEmojiMenuModule from '@components/EmojiPicker/EmojiPickerMenu/index'; +import type * as NativeEmojiMenuModule from '@components/EmojiPicker/EmojiPickerMenu/index.native'; +import type EmojiPickerMenuProps from '@components/EmojiPicker/EmojiPickerMenu/types'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import {buildEmojisTrie} from '@libs/EmojiTrie'; +import {getHeaderEmojis, mergeEmojisWithFrequentlyUsedEmojis, processFrequentlyUsedEmojis} from '@libs/EmojiUtils'; +import type {EmojiPickerListItem} from '@libs/EmojiUtils'; + +import CONST from '@src/CONST'; +import en from '@src/languages/en'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type {FlashListProps, FlashListRef, RenderTarget} from '@shopify/flash-list'; +import type {ComponentRef} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; +import type ReactNative from 'react-native'; +import type * as KeyCommand from 'react-native-key-command'; + +import {PortalProvider} from '@gorhom/portal'; +import React from 'react'; +import {AccessibilityInfo, View} from 'react-native'; +import Onyx from 'react-native-onyx'; + +import type * as CreateMockModule from '../utils/createMock'; + +import createMock from '../utils/createMock'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const mockKeyRegistrations: Array<{key: Parameters[0]; callback: Parameters[1]}> = []; + +// Keep real command registrations and subscribers. Native delivery carries the registered trigger, not the DOM key name. +jest.mock('react-native-key-command', () => { + const addListener: typeof KeyCommand.addListener = (key, callback) => { + const registration = {key, callback}; + mockKeyRegistrations.push(registration); + return () => { + const index = mockKeyRegistrations.indexOf(registration); + if (index >= 0) { + mockKeyRegistrations.splice(index, 1); + } + }; + }; + return {constants: {}, addListener, registerKeyCommands: () => {}, unregisterKeyCommands: () => {}}; +}); + +const mockScrollToOffset = jest.fn['scrollToOffset']>>(); +let mockEmojiListProps: FlashListProps; +let mockHiddenHeaders = new Set(); +let mockRenderTarget: RenderTarget = 'Cell'; +let mockVisibleEmojiCode = '👋'; +let refreshEmojiWindow: (() => void) | undefined; +const mockHeaderLabels = en.emojiPicker.headers; +let mockSelectedHeaderGeneration = 0; +const mockFocusDescriptors: Array<{host: ComponentRef; descriptor: PropertyDescriptor | undefined}> = []; +const originalFocusDescriptor = Object.getOwnPropertyDescriptor(View.prototype, 'focus'); + +// The window is controlled below FlashList so both real menus keep their own data, rendering and focus behavior. +jest.mock('@shopify/flash-list', () => { + const ReactLocal = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + const createMockLocal = jest.requireActual('../utils/createMock').default; + const FlashList = ReactLocal.forwardRef, FlashListProps>((props, ref) => { + const [, setWindowRevision] = ReactLocal.useState(0); + refreshEmojiWindow = () => setWindowRevision((revision) => revision + 1); + mockEmojiListProps = props; + const data = props.data; + const renderItem = props.renderItem; + expect(data).toBeDefined(); + expect(renderItem).toBeDefined(); + if (!data || !renderItem) { + throw new Error('Expected actual emoji list data and renderer'); + } + ReactLocal.useImperativeHandle(ref, () => createMockLocal>({scrollToOffset: mockScrollToOffset})); + return ( + + {data.map((item, index) => { + if ('header' in item ? mockHiddenHeaders.has(item.code) : item.code !== mockVisibleEmojiCode && index !== 0 && !('spacer' in item)) { + return null; + } + return ( + 'header' in entry)?.code}-${item.code}-${'header' in item && item.code === 'animalsAndNature' ? mockSelectedHeaderGeneration : 0}`} + testID={`emoji-row-${index}`} + > + {renderItem({item, index, target: mockRenderTarget})} + + ); + })} + + ); + }); + return {FlashList}; +}); + +const WebEmojiPickerMenu = jest.requireActual('../../src/components/EmojiPicker/EmojiPickerMenu/index.tsx').default; +const NativeEmojiPickerMenu = jest.requireActual('../../src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx').default; +const platformCases = [ + {platform: 'web', Menu: WebEmojiPickerMenu}, + {platform: 'native', Menu: NativeEmojiPickerMenu}, +]; +const wave = emojis.find((item): item is Emoji => 'name' in item && item.name === 'wave'); +const smile = emojis.find((item): item is Emoji => 'name' in item && item.name === 'smile'); +const headerLayout = createMock({nativeEvent: {layout: {x: 0, y: 0, width: 100, height: 20}}}); + +function renderEmojiMenu(Menu: typeof WebEmojiPickerMenu, onEmojiSelected: EmojiPickerMenuProps['onEmojiSelected']) { + return render( + + + + + , + ); +} + +// The renderer erases instance. Constructor identity narrows only that actual host, without relabeling its props. +function getEmojiHeaderHost(index: number, platform: string) { + const item = mockEmojiListProps.data?.at(index); + expect(item).toBeDefined(); + if (!item || !('header' in item)) { + throw new Error('Expected a real emoji header item'); + } + const heading = screen.getByTestId(`emoji-row-${index}`).find((node) => node.type === View && (node.props.role === CONST.ROLE.HEADING || node.props.accessibilityRole === 'header')); + if (platform === 'native') { + expect(heading.props.accessibilityRole).toBe('header'); + expect(heading.props.accessibilityLabel).toBe(mockHeaderLabels[item.code]); + } else { + expect(heading.props.role).toBe(CONST.ROLE.HEADING); + expect(heading.props.tabIndex).toBe(-1); + expect(heading.findAll((node) => node.props.children === mockHeaderLabels[item.code]).length).toBeGreaterThan(0); + } + const instance: unknown = heading.instance; + expect(instance).toBeInstanceOf(View); + if (!(instance instanceof View)) { + throw new Error('Expected the rendered native View instance'); + } + return {heading, host: instance}; +} + +beforeAll(async () => { + await importEmojiLocale(CONST.LOCALES.EN); + buildEmojisTrie(CONST.LOCALES.EN); +}); + +describe.each(platformCases)('actual $platform EmojiPickerMenu', ({platform, Menu}) => { + beforeEach(async () => { + jest.useFakeTimers(); + Onyx.init({keys: ONYXKEYS}); + await act(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + await Onyx.set(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, 1); + await Onyx.set(ONYXKEYS.FREQUENTLY_USED_EMOJIS, []); + }); + await waitForBatchedUpdatesWithAct(); + mockHiddenHeaders = new Set(); + mockRenderTarget = 'Cell'; + mockVisibleEmojiCode = '👋'; + mockScrollToOffset.mockClear(); + mockSelectedHeaderGeneration = 0; + refreshEmojiWindow = undefined; + }); + + afterEach(() => { + cleanup(); + for (const {host, descriptor} of mockFocusDescriptors) { + if (descriptor) { + Object.defineProperty(host, 'focus', descriptor); + } else { + expect(Reflect.deleteProperty(host, 'focus')).toBe(true); + } + expect(Object.getOwnPropertyDescriptor(host, 'focus')).toEqual(descriptor); + } + mockFocusDescriptors.length = 0; + expect(Object.getOwnPropertyDescriptor(View.prototype, 'focus')).toEqual(originalFocusDescriptor); + refreshEmojiWindow = undefined; + jest.clearAllTimers(); + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it.each(['Cell', 'StickyHeader'] satisfies RenderTarget[])('renders every catalog header and no spacers as %s', async (target) => { + // Given the canonical catalog without recent history, every non-recent category must be reachable. + mockRenderTarget = target; + const onEmojiSelected = jest.fn>(); + renderEmojiMenu(Menu, onEmojiSelected); + await waitForBatchedUpdatesWithAct(); + const data = mockEmojiListProps.data; + expect(data).toBeDefined(); + if (!data || !mockEmojiListProps.renderItem) { + throw new Error('Expected real FlashList data and renderer'); + } + if (platform === 'native') { + expect(mockEmojiListProps.extraData).toEqual([data, 1]); + } + const headers = getHeaderEmojis([...data]); + expect(headers).toHaveLength(8); + expect(headers.map(({code}) => code)).toEqual(emojis.flatMap((item) => ('header' in item ? [item.code] : []))); + + // When FlashList renders real menu items, header text must use the owner's catalog key. + for (const {index} of headers) { + const item = data.at(index); + expect(item).toBeDefined(); + if (!item || !('header' in item)) { + throw new Error('Expected a catalog header'); + } + const row = screen.getByTestId(`emoji-row-${index}`); + const header = row.find((node) => node.type === View && (node.props.role === CONST.ROLE.HEADING || node.props.accessibilityRole === 'header')); + expect(screen.getAllByText(mockHeaderLabels[item.code]).length).toBeGreaterThan(0); + // Then each platform retains its own accessibility contract instead of borrowing the other renderer. + if (platform === 'native') { + expect(header.props.accessibilityRole).toBe('header'); + expect(header.props.accessibilityLabel).toBe(mockHeaderLabels[item.code]); + } else { + expect(header.props.role).toBe(CONST.ROLE.HEADING); + expect(header.props.tabIndex).toBe(-1); + } + } + expect(screen.queryByText(en.emojiPicker.headers.frequentlyUsed)).toBeNull(); + const spacers = data.flatMap((item, index) => ('spacer' in item ? [{item, index}] : [])); + expect(spacers.length).toBeGreaterThan(0); + for (const {item, index} of spacers) { + expect(mockEmojiListProps.renderItem({item, index, target})).toBeNull(); + } + expect(mockEmojiListProps.stickyHeaderIndices).toEqual(headers.map(({index}) => index)); + expect(onEmojiSelected).not.toHaveBeenCalled(); + }); + + it('restores recent data and exact sticky indices after real search', async () => { + // Given one persisted catalog emoji, recent history changes the renderer's section positions. + expect(smile).toBeDefined(); + if (!smile) { + throw new Error('Expected smile in the production catalog'); + } + const recent = [{code: smile.code, name: smile.name, count: 1, lastUpdatedAt: 1}]; + await act(async () => { + await Onyx.set(ONYXKEYS.FREQUENTLY_USED_EMOJIS, recent); + }); + await waitForBatchedUpdatesWithAct(); + renderEmojiMenu(Menu, jest.fn>()); + await waitForBatchedUpdatesWithAct(); + const initialData = mergeEmojisWithFrequentlyUsedEmojis(emojis, processFrequentlyUsedEmojis(recent)); + const initialIndices = getHeaderEmojis(initialData).map(({index}) => index); + expect(mockEmojiListProps.data).toEqual(initialData); + expect(getHeaderEmojis(initialData)).toHaveLength(9); + expect(screen.getByText(en.emojiPicker.headers.frequentlyUsed)).toBeOnTheScreen(); + + // When a real search is entered, neither header rows nor sticky indices belong in the results. + fireEvent.changeText(screen.getByPlaceholderText(en.common.search), 'smile'); + await act(async () => jest.advanceTimersByTime(350)); + expect(mockEmojiListProps.data?.length).toBeGreaterThan(0); + expect(mockEmojiListProps.data?.some((item) => 'name' in item && item.name === smile.name)).toBe(true); + expect(mockEmojiListProps.data?.some((item) => 'header' in item)).toBe(false); + expect(mockEmojiListProps.stickyHeaderIndices).toEqual([]); + expect(screen.queryByRole('button', {name: en.emojiPicker.headers.smileysAndEmotion})).toBeNull(); + fireEvent.changeText(screen.getByPlaceholderText(en.common.search), ''); + await act(async () => jest.advanceTimersByTime(350)); + + // Then clearing search restores the full producer output and scroll origin, including the recent section. + expect(mockEmojiListProps.data).toEqual(initialData); + expect(mockEmojiListProps.stickyHeaderIndices).toEqual(initialIndices); + expect(screen.getByText(en.emojiPicker.headers.frequentlyUsed)).toBeOnTheScreen(); + expect(mockScrollToOffset).toHaveBeenLastCalledWith({offset: 0, animated: false}); + }); + + it.each([ + {name: 'wave', preference: 1}, + {name: 'wave', preference: -1}, + {name: 'smile', preference: 1}, + ])('emits the real $name item with preference $preference', async ({name, preference}) => { + // Given catalog data and a persisted preference, the renderer owns selection of the emitted variant. + const emoji = name === 'wave' ? wave : smile; + expect(emoji).toBeDefined(); + if (!emoji) { + throw new Error('Expected the selection fixture in the catalog'); + } + mockVisibleEmojiCode = emoji.code; + await act(async () => { + await Onyx.set(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, preference); + }); + await waitForBatchedUpdatesWithAct(); + const onEmojiSelected = jest.fn>(); + renderEmojiMenu(Menu, onEmojiSelected); + await waitForBatchedUpdatesWithAct(); + const expectedCode = preference !== -1 ? (emoji.types?.at(preference) ?? emoji.code) : emoji.code; + + // When the actual menu item is pressed, the callback must receive the rendered item and selected code once. + fireEvent.press(screen.getByText(expectedCode)); + + // Then a dropped, duplicated or incorrectly indexed selection fails independently of header rendering. + expect(onEmojiSelected).toHaveBeenCalledTimes(1); + expect(onEmojiSelected).toHaveBeenCalledWith(expectedCode, emoji, preference); + }); + + it.each(['layout', 'timer'])('focuses only the remounted selected header through %s', async (path) => { + // Given an absent category and an independent mounted host, retries must wait for the chosen header. + mockHiddenHeaders.add('animalsAndNature'); + const delivery = jest.spyOn(AccessibilityInfo, 'sendAccessibilityEvent').mockImplementation(() => {}); + delivery.mockClear(); + renderEmojiMenu(Menu, jest.fn>()); + await waitForBatchedUpdatesWithAct(); + const natureIndex = mockEmojiListProps.data?.findIndex((item) => 'header' in item && item.code === 'animalsAndNature'); + expect(natureIndex).toBeGreaterThan(0); + if (natureIndex === undefined || natureIndex < 0) { + throw new Error('Expected the actual nature header index'); + } + const {host: unrelatedHost} = getEmojiHeaderHost(0, platform); + const unrelatedFocus = jest.fn, Parameters>(); + mockFocusDescriptors.push({host: unrelatedHost, descriptor: Object.getOwnPropertyDescriptor(unrelatedHost, 'focus')}); + unrelatedHost.focus = unrelatedFocus; + expect(screen.queryByTestId(`emoji-row-${natureIndex}`)).toBeNull(); + fireEvent.press(screen.getByRole('button', {name: en.emojiPicker.headers.animalsAndNature})); + if (platform === 'web') { + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION)); + } + expect(delivery).not.toHaveBeenCalled(); + expect(unrelatedFocus).not.toHaveBeenCalled(); + expect(mockScrollToOffset).toHaveBeenCalledWith({offset: Math.floor(natureIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT, animated: true}); + + // When only the chosen delivery lane runs, removing layout or the delayed retry leaves its own oracle unsatisfied. + mockHiddenHeaders.delete('animalsAndNature'); + expect(refreshEmojiWindow).toBeDefined(); + act(() => refreshEmojiWindow?.()); + const {heading, host} = getEmojiHeaderHost(natureIndex, platform); + const focus = jest.fn, Parameters>(); + mockFocusDescriptors.push({host, descriptor: Object.getOwnPropertyDescriptor(host, 'focus')}); + host.focus = focus; + expect(focus).not.toBe(unrelatedFocus); + expect(getEmojiHeaderHost(0, platform).host).toBe(unrelatedHost); + if (path === 'layout') { + fireEvent(heading, 'layout', headerLayout); + } else { + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION)); + } + + // Then only the actual selected host receives one delivery, with its normal method receiver. + if (platform === 'native') { + expect(delivery).toHaveBeenCalledTimes(1); + expect(delivery).toHaveBeenCalledWith(host, 'focus'); + } else { + expect(focus).toHaveBeenCalledTimes(1); + expect(focus.mock.contexts.at(0)).toBe(host); + } + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION * 3)); + if (platform === 'native') { + expect(delivery).toHaveBeenCalledTimes(1); + } else { + expect(focus).toHaveBeenCalledTimes(1); + } + expect(unrelatedFocus).not.toHaveBeenCalled(); + expect(delivery).not.toHaveBeenCalledWith(unrelatedHost, 'focus'); + }); + + it('exhausts absent-header retries without later stale delivery', async () => { + // Given an absent category, another mounted header must never become its substitute. + mockHiddenHeaders.add('animalsAndNature'); + const delivery = jest.spyOn(AccessibilityInfo, 'sendAccessibilityEvent').mockImplementation(() => {}); + delivery.mockClear(); + renderEmojiMenu(Menu, jest.fn>()); + await waitForBatchedUpdatesWithAct(); + const natureIndex = mockEmojiListProps.data?.findIndex((item) => 'header' in item && item.code === 'animalsAndNature'); + expect(natureIndex).toBeGreaterThan(0); + if (natureIndex === undefined || natureIndex < 0) { + throw new Error('Expected the nature header'); + } + const {host: unrelatedHost} = getEmojiHeaderHost(0, platform); + const unrelatedFocus = jest.fn, Parameters>(); + mockFocusDescriptors.push({host: unrelatedHost, descriptor: Object.getOwnPropertyDescriptor(unrelatedHost, 'focus')}); + unrelatedHost.focus = unrelatedFocus; + const schedule = jest.spyOn(globalThis, 'setTimeout'); + + // When every bounded attempt finds no host, scheduling stops before it returns. + fireEvent.press(screen.getByRole('button', {name: en.emojiPicker.headers.animalsAndNature})); + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION * 4)); + expect(schedule.mock.calls.filter(([, delay]) => delay === CONST.ANIMATED_TRANSITION)).toHaveLength(platform === 'web' ? 3 : 2); + expect(screen.queryByTestId(`emoji-row-${natureIndex}`)).toBeNull(); + expect(delivery).not.toHaveBeenCalled(); + expect(unrelatedFocus).not.toHaveBeenCalled(); + schedule.mockClear(); + mockHiddenHeaders.delete('animalsAndNature'); + expect(refreshEmojiWindow).toBeDefined(); + act(() => refreshEmojiWindow?.()); + const {host} = getEmojiHeaderHost(natureIndex, platform); + const focus = jest.fn, Parameters>(); + mockFocusDescriptors.push({host, descriptor: Object.getOwnPropertyDescriptor(host, 'focus')}); + host.focus = focus; + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION * 4)); + + // Then a later host receives no stale delivery and the retry count remains bounded. + expect(schedule.mock.calls.filter(([, delay]) => delay === CONST.ANIMATED_TRANSITION)).toHaveLength(0); + expect(focus).not.toHaveBeenCalled(); + expect(delivery).not.toHaveBeenCalled(); + expect(unrelatedFocus).not.toHaveBeenCalled(); + }); + + it('cancels stale category delivery when another category is selected', async () => { + // Given an absent selected category, its queued retry must lose authority on a category switch. + mockHiddenHeaders.add('animalsAndNature'); + const delivery = jest.spyOn(AccessibilityInfo, 'sendAccessibilityEvent').mockImplementation(() => {}); + delivery.mockClear(); + renderEmojiMenu(Menu, jest.fn>()); + await waitForBatchedUpdatesWithAct(); + const natureIndex = mockEmojiListProps.data?.findIndex((item) => 'header' in item && item.code === 'animalsAndNature'); + const foodIndex = mockEmojiListProps.data?.findIndex((item) => 'header' in item && item.code === 'foodAndDrink'); + expect(natureIndex).toBeGreaterThan(0); + expect(foodIndex).toBeGreaterThan(0); + if (natureIndex === undefined || foodIndex === undefined || natureIndex < 0 || foodIndex < 0) { + throw new Error('Expected both real category indices'); + } + const {host: foodHost} = getEmojiHeaderHost(foodIndex, platform); + const foodFocus = jest.fn, Parameters>(); + mockFocusDescriptors.push({host: foodHost, descriptor: Object.getOwnPropertyDescriptor(foodHost, 'focus')}); + foodHost.focus = foodFocus; + fireEvent.press(screen.getByRole('button', {name: en.emojiPicker.headers.animalsAndNature})); + if (platform === 'web') { + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION)); + } + + // When the real food shortcut becomes current, remounting the stale header cannot restore its earlier request. + fireEvent.press(screen.getByRole('button', {name: en.emojiPicker.headers.foodAndDrink})); + mockHiddenHeaders.delete('animalsAndNature'); + expect(refreshEmojiWindow).toBeDefined(); + act(() => refreshEmojiWindow?.()); + const {host: staleHost} = getEmojiHeaderHost(natureIndex, platform); + const staleFocus = jest.fn, Parameters>(); + mockFocusDescriptors.push({host: staleHost, descriptor: Object.getOwnPropertyDescriptor(staleHost, 'focus')}); + staleHost.focus = staleFocus; + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION * 4)); + + // Then only the current category receives one delivery and the old ref remains unused. + expect(staleFocus).not.toHaveBeenCalled(); + if (platform === 'native') { + expect(delivery).toHaveBeenCalledTimes(1); + expect(delivery).toHaveBeenCalledWith(foodHost, 'focus'); + expect(delivery).not.toHaveBeenCalledWith(staleHost, 'focus'); + } else { + expect(foodFocus).toHaveBeenCalledTimes(1); + expect(foodFocus.mock.contexts.at(0)).toBe(foodHost); + } + }); + + if (platform === 'native') { + it('retries the current remounted category at native momentum end', async () => { + // Given a native category outside the window, momentum end must use its newly attached ref. + mockHiddenHeaders.add('animalsAndNature'); + const delivery = jest.spyOn(AccessibilityInfo, 'sendAccessibilityEvent').mockImplementation(() => {}); + delivery.mockClear(); + renderEmojiMenu(Menu, jest.fn>()); + await waitForBatchedUpdatesWithAct(); + const natureIndex = mockEmojiListProps.data?.findIndex((item) => 'header' in item && item.code === 'animalsAndNature'); + expect(natureIndex).toBeGreaterThan(0); + if (natureIndex === undefined || natureIndex < 0) { + throw new Error('Expected the native nature header'); + } + const {host: unrelatedHost} = getEmojiHeaderHost(0, platform); + fireEvent.press(screen.getByRole('button', {name: en.emojiPicker.headers.animalsAndNature})); + expect(delivery).not.toHaveBeenCalled(); + mockHiddenHeaders.delete('animalsAndNature'); + expect(refreshEmojiWindow).toBeDefined(); + act(() => refreshEmojiWindow?.()); + const {host} = getEmojiHeaderHost(natureIndex, platform); + const momentumEnd = mockEmojiListProps.onMomentumScrollEnd; + expect(momentumEnd).toBeDefined(); + if (!momentumEnd) { + throw new Error('Expected the actual native momentum handler'); + } + + // When the real momentum callback runs before its retry, the selected host receives accessibility focus. + act(() => momentumEnd(createMock[0]>({}))); + + // Then one delivery and no duplicate distinguish momentum handling from the timer lane. + expect(delivery).toHaveBeenCalledTimes(1); + expect(delivery).toHaveBeenCalledWith(host, 'focus'); + expect(delivery).not.toHaveBeenCalledWith(unrelatedHost, 'focus'); + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION * 3)); + expect(delivery).toHaveBeenCalledTimes(1); + }); + } +}); + +describe('actual web keyboard and header focus', () => { + beforeEach(async () => { + jest.useFakeTimers(); + await act(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + await Onyx.set(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, -1); + await Onyx.set(ONYXKEYS.FREQUENTLY_USED_EMOJIS, []); + }); + await waitForBatchedUpdatesWithAct(); + mockHiddenHeaders = new Set(); + mockRenderTarget = 'Cell'; + mockVisibleEmojiCode = '👋'; + mockScrollToOffset.mockClear(); + mockSelectedHeaderGeneration = 0; + refreshEmojiWindow = undefined; + }); + afterEach(() => { + cleanup(); + for (const {host, descriptor} of mockFocusDescriptors) { + if (descriptor) { + Object.defineProperty(host, 'focus', descriptor); + } else { + expect(Reflect.deleteProperty(host, 'focus')).toBe(true); + } + expect(Object.getOwnPropertyDescriptor(host, 'focus')).toEqual(descriptor); + } + mockFocusDescriptors.length = 0; + expect(Object.getOwnPropertyDescriptor(View.prototype, 'focus')).toEqual(originalFocusDescriptor); + refreshEmojiWindow = undefined; + jest.clearAllTimers(); + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('selects the first real search result while suppressing the Enter event', async () => { + // Given real nonempty search output, keyboard selection must use that output instead of catalog headers. + const onEmojiSelected = jest.fn>(); + renderEmojiMenu(WebEmojiPickerMenu, onEmojiSelected); + await waitForBatchedUpdatesWithAct(); + fireEvent.changeText(screen.getByPlaceholderText(en.common.search), 'smile'); + act(() => jest.advanceTimersByTime(200)); + const first = mockEmojiListProps.data?.[0]; + expect(first).toBeDefined(); + if (!first || !('name' in first)) { + throw new Error('Expected a real search result'); + } + const enter = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}); + const preventDefault = jest.spyOn(enter, 'preventDefault'); + const stopPropagation = jest.spyOn(enter, 'stopPropagation'); + const registration = mockKeyRegistrations.find(({key}) => key.input === CONST.KEYBOARD_SHORTCUTS.ENTER.trigger.DEFAULT.input); + expect(registration).toBeDefined(); + if (!registration) { + throw new Error('Expected the real Enter registration'); + } + + // When search highlights the first result, Enter cannot leak to the composer or child pressable. + act(() => { + document.dispatchEvent(enter); + registration.callback(registration.key, enter); + }); + + // Then the actual selected result and both event barriers are observed. + expect(onEmojiSelected).toHaveBeenCalledTimes(1); + expect(onEmojiSelected).toHaveBeenCalledWith(first.code, first, -1); + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it('moves through two separately committed arrows to the distinct second search result', async () => { + // Given multiple real search results, first-result highlighting cannot prove arrow movement. + const onEmojiSelected = jest.fn>(); + renderEmojiMenu(WebEmojiPickerMenu, onEmojiSelected); + await waitForBatchedUpdatesWithAct(); + fireEvent.changeText(screen.getByPlaceholderText(en.common.search), 'smile'); + await act(async () => jest.advanceTimersByTime(200)); + const data = mockEmojiListProps.data; + expect(data?.length).toBeGreaterThanOrEqual(2); + expect(data?.some((item) => 'header' in item || 'spacer' in item)).toBe(false); + expect(mockEmojiListProps.stickyHeaderIndices).toEqual([]); + const first = data?.[0]; + const second = data?.[1]; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + if (!first || !second || !('name' in first) || !('name' in second)) { + throw new Error('Expected two actual search emojis'); + } + expect(second).not.toBe(first); + expect(second.code).not.toBe(first.code); + mockVisibleEmojiCode = second.code; + expect(refreshEmojiWindow).toBeDefined(); + act(() => refreshEmojiWindow?.()); + fireEvent(screen.getByPlaceholderText(en.common.search), 'blur'); + await act(async () => jest.advanceTimersByTime(0)); + const arrowRegistration = mockKeyRegistrations.find(({key}) => key.input === CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT.trigger.DEFAULT.input); + const enterRegistration = mockKeyRegistrations.find(({key}) => key.input === CONST.KEYBOARD_SHORTCUTS.ENTER.trigger.DEFAULT.input); + expect(arrowRegistration).toBeDefined(); + expect(enterRegistration).toBeDefined(); + if (!arrowRegistration || !enterRegistration) { + throw new Error('Expected real arrow and Enter registrations'); + } + + // When each arrow commits independently, the real hook advances from -1 through 0 to 1 before Enter. + const firstArrow = new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true, cancelable: true}); + await act(async () => { + document.dispatchEvent(firstArrow); + arrowRegistration.callback(arrowRegistration.key, firstArrow); + }); + const secondArrow = new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true, cancelable: true}); + await act(async () => { + document.dispatchEvent(secondArrow); + arrowRegistration.callback(arrowRegistration.key, secondArrow); + }); + const enter = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}); + const preventDefault = jest.spyOn(enter, 'preventDefault'); + const stopPropagation = jest.spyOn(enter, 'stopPropagation'); + await act(async () => { + document.dispatchEvent(enter); + enterRegistration.callback(enterRegistration.key, enter); + }); + + // Then missing either movement selects the wrong item and fails this independent second-item oracle. + expect(mockEmojiListProps.extraData).toEqual([1, -1]); + expect(onEmojiSelected).toHaveBeenCalledTimes(1); + expect(onEmojiSelected).toHaveBeenCalledWith(second.code, second, -1); + expect(onEmojiSelected).not.toHaveBeenCalledWith(first.code, first, -1); + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it('skips the real first header and all seven spacers before selecting grinning', async () => { + // Given an unfiltered real catalog, both header and spacer barriers stand between -1 and the first emoji. + const onEmojiSelected = jest.fn>(); + renderEmojiMenu(WebEmojiPickerMenu, onEmojiSelected); + await waitForBatchedUpdatesWithAct(); + const data = mockEmojiListProps.data; + expect(data?.[0]).toEqual(expect.objectContaining({header: true, code: 'smileysAndEmotion'})); + expect(data?.slice(1, 8)).toHaveLength(7); + expect(data?.slice(1, 8).every((item) => 'spacer' in item)).toBe(true); + const grinning = data?.[8]; + expect(grinning).toBeDefined(); + if (!grinning || !('name' in grinning)) { + throw new Error('Expected the real grinning item after the padded header'); + } + expect(grinning.name).toBe('grinning'); + expect(grinning.code).toBe('😀'); + expect(mockEmojiListProps.stickyHeaderIndices).toContain(0); + expect(screen.getByPlaceholderText(en.common.search).props.value).toBe(''); + mockVisibleEmojiCode = grinning.code; + expect(refreshEmojiWindow).toBeDefined(); + act(() => refreshEmojiWindow?.()); + fireEvent(screen.getByPlaceholderText(en.common.search), 'blur'); + await act(async () => jest.advanceTimersByTime(0)); + const arrowRegistration = mockKeyRegistrations.find(({key}) => key.input === CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT.trigger.DEFAULT.input); + const enterRegistration = mockKeyRegistrations.find(({key}) => key.input === CONST.KEYBOARD_SHORTCUTS.ENTER.trigger.DEFAULT.input); + expect(arrowRegistration).toBeDefined(); + expect(enterRegistration).toBeDefined(); + if (!arrowRegistration || !enterRegistration) { + throw new Error('Expected real arrow and Enter registrations'); + } + + // When one registered arrow commits, traversal crosses index 0 and spacers 1 through 7 to index 8. + const arrow = new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true, cancelable: true}); + await act(async () => { + document.dispatchEvent(arrow); + arrowRegistration.callback(arrowRegistration.key, arrow); + }); + const enter = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}); + const preventDefault = jest.spyOn(enter, 'preventDefault'); + const stopPropagation = jest.spyOn(enter, 'stopPropagation'); + await act(async () => { + document.dispatchEvent(enter); + enterRegistration.callback(enterRegistration.key, enter); + }); + + // Then a disabled-index or traversal regression yields a wrong item or no selection, with both Enter barriers retained. + expect(onEmojiSelected).toHaveBeenCalledTimes(1); + expect(onEmojiSelected).toHaveBeenCalledWith(grinning.code, grinning, -1); + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/ExpenseReportRulesSectionTest.tsx b/tests/ui/ExpenseReportRulesSectionTest.tsx index dccb4dcd0e85..1e562c31e0f5 100644 --- a/tests/ui/ExpenseReportRulesSectionTest.tsx +++ b/tests/ui/ExpenseReportRulesSectionTest.tsx @@ -53,6 +53,7 @@ jest.mock('@hooks/useThemeStyles', () => jest.mock('@libs/Navigation/Navigation', () => ({navigate: jest.fn()})); jest.mock('@libs/PolicyUtils', () => ({ + arePaymentsEnabled: jest.fn(() => true), getWorkflowApprovalsUnavailable: jest.fn(() => false), isAutoPayApprovedReportsAvailable: jest.fn( (policy?: {areWorkflowsEnabled?: boolean; reimbursementChoice?: string; achAccount?: {bankAccountID?: number}}) => @@ -74,9 +75,9 @@ const mockedToggleSettingOptionRow = jest.mocked(ToggleSettingOptionRow); const POLICY_ID = 'POLICY_1'; -// optionItems order: 0=preventSelfApproval, 1=autoApproveCompliantReports, 2=autoPayApprovedReports -const AUTO_APPROVE_TOGGLE_INDEX = 1; -const AUTO_PAY_TOGGLE_INDEX = 2; +// optionItems order: 0=preventSelfApproval, 1=preventPayoutNonReimbursableReports, 2=autoApproveCompliantReports, 3=autoPayApprovedReports +const AUTO_APPROVE_TOGGLE_INDEX = 2; +const AUTO_PAY_TOGGLE_INDEX = 3; const AUTO_PAY_UPGRADE_ROUTE = ROUTES.WORKSPACE_UPGRADE.getRoute(POLICY_ID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.autoPayApprovedReports.alias, ROUTES.WORKSPACE_WORKFLOWS.getRoute(POLICY_ID)); diff --git a/tests/ui/GLCodeCellTest.tsx b/tests/ui/GLCodeCellTest.tsx new file mode 100644 index 000000000000..74102b00c30a --- /dev/null +++ b/tests/ui/GLCodeCellTest.tsx @@ -0,0 +1,188 @@ +import {render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import CategoryGLCodeCell from '@components/TransactionItemRow/DataCells/CategoryGLCodeCell'; +import TagGLCodeCell from '@components/TransactionItemRow/DataCells/TagGLCodeCell'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PolicyCategories, PolicyTagLists, Transaction} from '@src/types/onyx'; + +import type ReactNative from 'react-native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomTransaction from '../utils/collections/transaction'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.mock('@libs/Navigation/Navigation'); + +// Forces the cell into the "wide/editable" layout branch, which jsdom's default viewport doesn't naturally satisfy. +jest.mock('@hooks/useResponsiveLayoutOnWideRHP', () => ({ + __esModule: true, + default: () => ({isLargeScreenWidth: true, shouldUseNarrowLayout: false, isInNarrowPaneModal: false}), +})); + +// EditableCell only renders the edit-pencil button while hovered, which jsdom can't simulate reliably. +jest.mock('@components/Hoverable', () => ({ + __esModule: true, + default: ({children}: {children: ((isHovered: boolean) => React.ReactNode) | React.ReactNode}) => (typeof children === 'function' ? children(true) : children), +})); + +// Mirrors TotalCellTest.tsx so the presence of the pencil button can be asserted by testID. +jest.mock('@components/Pressable/PressableWithFeedback', () => { + const {TouchableOpacity} = jest.requireActual('react-native'); + function mockPressableWithFeedback({ + children, + onPress, + accessibilityLabel, + }: { + children: React.ReactNode | ((state: {hovered: boolean; pressed: boolean}) => React.ReactNode); + onPress: () => void; + accessibilityLabel?: string; + }) { + const content = typeof children === 'function' ? children({hovered: false, pressed: false}) : children; + return ( + + {content} + + ); + } + return {__esModule: true, default: mockPressableWithFeedback}; +}); + +const POLICY_ID = 'glCodePolicy'; + +const transaction: Transaction = { + ...createRandomTransaction(1), + category: 'Travel', + tag: 'Engineering', +}; + +const categoriesWithoutGLCode: PolicyCategories = { + Travel: {name: 'Travel', enabled: true}, +}; + +const categoriesWithGLCode: PolicyCategories = { + // eslint-disable-next-line @typescript-eslint/naming-convention + Travel: {name: 'Travel', enabled: true, 'GL Code': '6000'}, +}; + +const buildTagLists = (glCode?: string): PolicyTagLists => ({ + Department: { + name: 'Department', + required: false, + orderWeight: 0, + // eslint-disable-next-line @typescript-eslint/naming-convention + tags: {Engineering: {name: 'Engineering', enabled: true, 'GL Code': glCode}}, + }, +}); + +const renderCategoryGLCodeCell = (policyCategories: PolicyCategories) => + render( + + + , + ); + +const renderTagGLCodeCell = (policyTagLists: PolicyTagLists) => + render( + + + , + ); + +describe('GL code cells', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.DEFAULT); + await waitForBatchedUpdates(); + }); + + describe('CategoryGLCodeCell', () => { + it('is editable before the full category list has loaded, even when no loaded category has a GL code', async () => { + // Given a lazy-loaded workspace whose categories have never been fetched, so the local list may be partial + renderCategoryGLCodeCell(categoriesWithoutGLCode); + await waitForBatchedUpdates(); + + // Then the cell stays editable so opening the picker can fetch the full list + expect(await screen.findByTestId('mock-edit-button')).toBeOnTheScreen(); + }); + + it('is read-only once the full category list has loaded without any GL codes', async () => { + // Given the full category list has loaded and no category has a GL code + await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_CATEGORIES_LOADING_STATE}${POLICY_ID}`, {hasOnceLoaded: true, isLoading: false}); + renderCategoryGLCodeCell(categoriesWithoutGLCode); + await waitForBatchedUpdates(); + + // Then there is nothing to pick, so the cell is read-only + expect(screen.queryByTestId('mock-edit-button')).not.toBeOnTheScreen(); + }); + + it('is editable once the full category list has loaded with a GL code', async () => { + // Given the full category list has loaded and a category has a GL code + await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_CATEGORIES_LOADING_STATE}${POLICY_ID}`, {hasOnceLoaded: true, isLoading: false}); + renderCategoryGLCodeCell(categoriesWithGLCode); + await waitForBatchedUpdates(); + + // Then the cell is editable + expect(await screen.findByTestId('mock-edit-button')).toBeOnTheScreen(); + }); + }); + + describe('TagGLCodeCell', () => { + it('is editable before the full tag list has loaded, even when no loaded tag has a GL code', async () => { + // Given a lazy-loaded workspace whose tags have never been fetched, so the local list may be partial + renderTagGLCodeCell(buildTagLists()); + await waitForBatchedUpdates(); + + // Then the cell stays editable so opening the picker can fetch the full list + expect(await screen.findByTestId('mock-edit-button')).toBeOnTheScreen(); + }); + + it('is read-only once the full tag list has loaded without any GL codes', async () => { + // Given the full tag list has loaded and no tag has a GL code + await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_TAGS_LOADING_STATE}${POLICY_ID}`, {hasOnceLoaded: true, isLoading: false}); + renderTagGLCodeCell(buildTagLists()); + await waitForBatchedUpdates(); + + // Then there is nothing to pick, so the cell is read-only + expect(screen.queryByTestId('mock-edit-button')).not.toBeOnTheScreen(); + }); + + it('is editable once the full tag list has loaded with a GL code', async () => { + // Given the full tag list has loaded and a tag has a GL code + await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_TAGS_LOADING_STATE}${POLICY_ID}`, {hasOnceLoaded: true, isLoading: false}); + renderTagGLCodeCell(buildTagLists('4000')); + await waitForBatchedUpdates(); + + // Then the cell is editable + expect(await screen.findByTestId('mock-edit-button')).toBeOnTheScreen(); + }); + }); +}); diff --git a/tests/ui/IOURequestStartPageManualTabTest.tsx b/tests/ui/IOURequestStartPageManualTabTest.tsx index 41372ebb5502..ae2fc96d238e 100644 --- a/tests/ui/IOURequestStartPageManualTabTest.tsx +++ b/tests/ui/IOURequestStartPageManualTabTest.tsx @@ -3,6 +3,8 @@ import {act, render, screen} from '@testing-library/react-native'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type * as OnyxTabNavigatorModule from '@libs/Navigation/OnyxTabNavigator'; + import IOURequestStartPage from '@pages/iou/request/IOURequestStartPage'; import type {IOURequestType, IOUType} from '@src/CONST'; @@ -15,6 +17,8 @@ import {NavigationContainer} from '@react-navigation/native'; import React from 'react'; import Onyx from 'react-native-onyx'; +import type * as CreateMockModule from '../utils/createMock'; + import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; const REPORT_ID = '1'; @@ -31,6 +35,8 @@ jest.mock('@rnmapbox/maps', () => ({ setAccessToken: jest.fn(), })); +jest.mock('react-native-tab-view', () => ({TabView: () => null, TabBar: () => null, SceneMap: jest.fn()})); + jest.mock('react-native-vision-camera', () => ({ useCameraDevice: jest.fn(), })); @@ -45,13 +51,23 @@ jest.mock('@react-navigation/native', () => ({ // Render every tab screen inline so the manual tab content is mounted without driving the real tab navigator. jest.mock('@libs/Navigation/OnyxTabNavigator', () => { const ReactModule = jest.requireActual('react'); + const actual = jest.requireActual('@libs/Navigation/OnyxTabNavigator'); + const createMock = jest.requireActual('../utils/createMock').default; + const createNavigator = (names: Parameters>[0]) => { + const pair = actual.createOnyxTabNavigator(names); + const OnyxTabNavigator = ({children}: Parameters[0]) => ReactModule.createElement(ReactModule.Fragment, null, children); + // Production Screen is a null marker. This mock deliberately renders its children for page-content cases. + const Screen = (props: Parameters>[0]) => { + const child = props.children; + const content = typeof child === 'function' ? child(createMock[0]>({})) : child; + return ReactModule.createElement(ReactModule.Fragment, null, content); + }; + return {...pair, OnyxTabNavigator, TopTab: {...pair.TopTab, Screen}}; + }; return { - __esModule: true, - default: ({children}: {children: React.ReactNode}) => ReactModule.createElement(ReactModule.Fragment, null, children), - TopTab: { - Screen: ({children}: {children: () => React.ReactNode}) => ReactModule.createElement(ReactModule.Fragment, null, typeof children === 'function' ? children() : children), - }, - TabScreenWithFocusTrapWrapper: ({children}: {children: React.ReactNode}) => ReactModule.createElement(ReactModule.Fragment, null, children), + ...actual, + createOnyxTabNavigator: createNavigator, + TabScreenWithFocusTrapWrapper: ({children}: Parameters[0]) => ReactModule.createElement(ReactModule.Fragment, null, children), }; }); diff --git a/tests/ui/IOURequestStepDistanceRateTest.tsx b/tests/ui/IOURequestStepDistanceRateTest.tsx index 1b12830b6d7c..41b7dfcbfb3d 100644 --- a/tests/ui/IOURequestStepDistanceRateTest.tsx +++ b/tests/ui/IOURequestStepDistanceRateTest.tsx @@ -126,6 +126,10 @@ function buildRates(count: number) { } function buildPolicy(rateCount: number): Policy { + return buildPolicyWithRates(buildRates(rateCount)); +} + +function buildPolicyWithRates(rates: Record): Policy { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only policy stub return { id: POLICY_ID, @@ -141,7 +145,7 @@ function buildPolicy(rateCount: number): Policy { defaultCategory: '', enabled: true, name: 'Distance', - rates: buildRates(rateCount), + rates, }, }, } as unknown as Policy; @@ -246,4 +250,49 @@ describe('IOURequestStepDistanceRate', () => { // Below the threshold moveInitialSelectionToTop is a no-op, so natural alphabetical order is kept. expect(order.at(0)).toBe(`${CONST.BASE_LIST_ITEM_TEST_ID}rate00`); }); + + it('shows dated rate names without their date suffix, sorted by the cleaned name', async () => { + // Given an ended and a current government band that share a base name, a rate that sorts before them, + // and a custom dated rate that a user named with a date on purpose + const baseRate = {attributes: {}, currency: CONST.CURRENCY.USD, enabled: true, rate: 100, subRates: []}; + const governmentAttributes = {governmentRate: {sourceRateID: 'GB_2026-09-01', rate: 100, startDate: '2026-09-01'}}; + await act(async () => { + await Onyx.set( + `${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, + buildPolicyWithRates({ + endedBand: { + ...baseRate, + customUnitRateID: 'endedBand', + name: 'Cars - 6/1/26 - 8/31/26', + startDate: '2026-06-01', + endDate: '2026-08-31', + attributes: governmentAttributes, + }, + currentBand: {...baseRate, customUnitRateID: 'currentBand', name: 'Cars - From 9/1/26', startDate: '2026-09-01', attributes: governmentAttributes}, + bicycle: {...baseRate, customUnitRateID: 'bicycle', name: 'Bicycle'}, + summer: {...baseRate, customUnitRateID: 'summer', name: 'Summer - 6/1/26 - 8/31/26', startDate: '2026-06-01', endDate: '2026-08-31'}, + }), + ); + }); + + // When the rate picker opens + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then both government bands show the cleaned name, because their date range is already shown as the alternate text + expect(screen.getAllByText('Cars')).toHaveLength(2); + expect(screen.queryByText('Cars - 6/1/26 - 8/31/26')).toBeNull(); + expect(screen.queryByText('Cars - From 9/1/26')).toBeNull(); + + // And the custom dated rate keeps its full name, because only government rates get the date suffix from the backend + expect(screen.getByText('Summer - 6/1/26 - 8/31/26')).toBeOnTheScreen(); + + // And the rates sort by cleaned name, with the current band above the ended band + expect(rowOrder()).toEqual([ + `${CONST.BASE_LIST_ITEM_TEST_ID}bicycle`, + `${CONST.BASE_LIST_ITEM_TEST_ID}currentBand`, + `${CONST.BASE_LIST_ITEM_TEST_ID}endedBand`, + `${CONST.BASE_LIST_ITEM_TEST_ID}summer`, + ]); + }); }); diff --git a/tests/ui/ImportedPerDiemPageTest.tsx b/tests/ui/ImportedPerDiemPageTest.tsx new file mode 100644 index 000000000000..654c0ffb8d42 --- /dev/null +++ b/tests/ui/ImportedPerDiemPageTest.tsx @@ -0,0 +1,149 @@ +import {act, render} from '@testing-library/react-native'; + +import {importPerDiemRates} from '@libs/actions/Policy/PerDiem'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import ImportedPerDiemPage from '@pages/workspace/perDiem/ImportedPerDiemPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Policy} from '@src/types/onyx'; +import type ImportedSpreadsheet from '@src/types/onyx/ImportedSpreadsheet'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +type MockImportSpreadsheetColumnsProps = { + importFunction: () => Promise; + errors?: Errors | null; +}; + +const mockImportSpreadsheetColumns = jest.fn(() => null); + +jest.mock('@libs/actions/Policy/PerDiem', () => ({ + ...jest.requireActual>('@libs/actions/Policy/PerDiem'), + importPerDiemRates: jest.fn(), +})); + +jest.mock('@components/HeaderWithBackButton', () => () => null); +jest.mock('@components/ImportSpreadsheetColumns', () => (props: MockImportSpreadsheetColumnsProps) => mockImportSpreadsheetColumns(props)); +jest.mock( + '@components/ScreenWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@hooks/useCloseImportPage', () => ({ + __esModule: true, + default: () => ({setIsClosing: jest.fn()}), +})); +jest.mock('@hooks/useImportSpreadsheetConfirmModal', () => ({ + __esModule: true, + default: () => jest.fn(), +})); + +const POLICY_ID = '1'; +const PER_DIEM_CUSTOM_UNIT_ID = 'perDiemUnit123'; + +const SPREADSHEET: ImportedSpreadsheet = { + data: [ + ['Destination', 'Germany'], + ['Subrate', 'Full day'], + ['Currency', 'EUR'], + ['Amount', '28'], + ], + columns: Object.fromEntries([CONST.CSV_IMPORT_COLUMNS.DESTINATION, CONST.CSV_IMPORT_COLUMNS.SUBRATE, CONST.CSV_IMPORT_COLUMNS.CURRENCY, CONST.CSV_IMPORT_COLUMNS.AMOUNT].entries()), + containsHeader: true, + isImportingMultiLevelTags: false, + isImportingIndependentMultiLevelTags: false, + isGLAdjacent: false, +}; + +const mockImportPerDiemRates = jest.mocked(importPerDiemRates); + +type ImportedPerDiemPageScreenProps = PlatformStackScreenProps; + +const route: ImportedPerDiemPageScreenProps['route'] = { + key: 'workspace-per-diem-imported', + name: SCREENS.WORKSPACE.PER_DIEM_IMPORTED, + params: {policyID: POLICY_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 ImportedPerDiemPageScreenProps['navigation']; + +function getLastColumnsProps(): MockImportSpreadsheetColumnsProps | undefined { + return mockImportSpreadsheetColumns.mock.lastCall?.at(0); +} + +async function renderAndImport(policy: Policy) { + await Onyx.set(ONYXKEYS.IMPORTED_SPREADSHEET, SPREADSHEET); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await waitForBatchedUpdatesWithAct(); + + render( + , + ); + await waitForBatchedUpdatesWithAct(); + + await act(async () => { + await getLastColumnsProps()?.importFunction(); + }); + await waitForBatchedUpdatesWithAct(); +} + +describe('ImportedPerDiemPage', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + + it('shows an error and skips the import when the workspace has no per diem custom unit', async () => { + // Given a workspace with per diem turned on but no Per Diem International unit, which some copied workspaces ended up with + const policy: Policy = {...createRandomPolicy(Number(POLICY_ID), CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: true, customUnits: {}}; + + // When the user maps every column and presses Import + await renderAndImport(policy); + + // Then the user sees why nothing was imported, instead of the import silently doing nothing + expect(getLastColumnsProps()?.errors).toHaveProperty('missingPerDiemUnit'); + expect(mockImportPerDiemRates).not.toHaveBeenCalled(); + }); + + it('imports the rates into the per diem custom unit when it exists', async () => { + // Given a workspace that has its Per Diem International unit + const policy: Policy = { + ...createRandomPolicy(Number(POLICY_ID), CONST.POLICY.TYPE.CORPORATE), + arePerDiemRatesEnabled: true, + customUnits: { + [PER_DIEM_CUSTOM_UNIT_ID]: { + customUnitID: PER_DIEM_CUSTOM_UNIT_ID, + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + enabled: true, + rates: {}, + }, + }, + }; + + // When the user maps every column and presses Import + await renderAndImport(policy); + + // Then the rates are sent to that unit and the missing unit error stays hidden + expect(mockImportPerDiemRates).toHaveBeenCalledWith(POLICY_ID, PER_DIEM_CUSTOM_UNIT_ID, expect.any(Array), 1); + expect(getLastColumnsProps()?.errors).not.toHaveProperty('missingPerDiemUnit'); + }); +}); diff --git a/tests/ui/InitialSettingsPageTest.tsx b/tests/ui/InitialSettingsPageTest.tsx index a5cef1a0dfff..a0b384770bfd 100644 --- a/tests/ui/InitialSettingsPageTest.tsx +++ b/tests/ui/InitialSettingsPageTest.tsx @@ -67,6 +67,8 @@ jest.mock('@userActions/App', () => ({ jest.mock('@libs/Navigation/helpers/useIsSidebarRouteActive', () => jest.fn(() => false)); +jest.mock('@hooks/useHasTabBeenShown', () => jest.fn(() => true)); + jest.mock('@hooks/useSubscriptionPlan', () => jest.fn(() => null)); jest.mock('@components/AccountSwitcher', () => { @@ -85,14 +87,6 @@ jest.mock('@components/AccountSwitcherSkeletonView', () => { return MockAccountSwitcherSkeletonView; }); -jest.mock('@components/Navigation/TabBarBottomContent', () => { - function MockTabBarBottomContent() { - return null; - } - MockTabBarBottomContent.displayName = 'TabBarBottomContent'; - return MockTabBarBottomContent; -}); - jest.mock('@components/Navigation/TopBarWithLoadingBar', () => { function MockTopBarWithLoadingBar() { return null; @@ -253,6 +247,7 @@ describe('InitialSettingsPage - agent account', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, { id: '1', name: 'Boulder Development', + areDistanceRatesEnabled: true, commuterExclusions: {method: 'homeAndOffice'}, }); }); @@ -330,7 +325,7 @@ describe('InitialSettingsPage - agent account', () => { errorFields: {revoke: {error: 'Unable to revoke device'}}, }, }); - await Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, { + await Onyx.set(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, { nameErrors: {error: 'Agent name error'}, }); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); diff --git a/tests/ui/InsightsChartWidgetTest.tsx b/tests/ui/InsightsChartWidgetTest.tsx new file mode 100644 index 000000000000..dca56c665aea --- /dev/null +++ b/tests/ui/InsightsChartWidgetTest.tsx @@ -0,0 +1,122 @@ +import {render} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {CurrencyListContextProvider} from '@components/CurrencyListContextProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type {GroupedItem} from '@components/Search/types'; +import WidgetHeaderMenu from '@components/WidgetHeaderMenu'; + +import localFileDownload from '@libs/localFileDownload'; +import {INSIGHTS_CHART_STATE} from '@libs/resolveInsightsChartData'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + +import InsightsChartWidget from '@pages/Insights/charts/InsightsChartWidget'; +import INSIGHTS_DASHBOARD_SPECS from '@pages/Insights/dashboardSpecs'; +import type {InsightsFilters} from '@pages/Insights/insightsFilters'; +import useInsightsChartData from '@pages/Insights/useInsightsChartData'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import currencyList from '../unit/currencyList.json'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@components/WidgetHeaderMenu', () => jest.fn(() => null)); +jest.mock('@components/Search/SearchChartView', () => jest.fn(() => null)); +jest.mock('@libs/localFileDownload', () => jest.fn()); +jest.mock('@libs/Navigation/Navigation'); +jest.mock('@pages/Insights/useInsightsChartData', () => ({__esModule: true, default: jest.fn()})); + +const CHART = INSIGHTS_DASHBOARD_SPECS[CONST.INSIGHTS.DASHBOARD.SPEND].headlineChart; +const QUERY = buildSearchQueryJSON('type:expense group-by:month date>=2026-01-01 date<=2026-03-31 group-currency:USD'); +const FILTERS: InsightsFilters = {date: {from: '2026-01-01', to: '2026-03-31'}, policyIDs: [], groupBy: CONST.SEARCH.GROUP_BY.MONTH, groupCurrency: CONST.CURRENCY.USD}; +const DATA: GroupedItem[] = [ + { + groupedBy: CONST.SEARCH.GROUP_BY.MONTH, + year: 2026, + month: 2, + keyForList: 'group_february', + count: 2, + total: 12345, + currency: CONST.CURRENCY.USD, + transactions: [], + formattedMonth: 'February 2026', + shortFormattedMonth: 'Feb', + sortKey: 202602, + }, +]; + +function renderWidget() { + return render( + + + , + ); +} + +describe('Insights chart CSV action', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.set(ONYXKEYS.CURRENCY_LIST, currencyList); + await waitForBatchedUpdatesWithAct(); + jest.mocked(useInsightsChartData).mockReturnValue({data: DATA, state: INSIGHTS_CHART_STATE.READY, comparison: undefined}); + }); + + afterEach(() => Onyx.clear()); + + it('offers a local CSV below View on Spend and exports only the current period', async () => { + // Given a ready chart compared against a different set of totals + jest.mocked(useInsightsChartData).mockReturnValue({ + data: DATA, + state: INSIGHTS_CHART_STATE.READY, + comparison: {data: DATA.map((item) => ({...item, total: 99999})), label: 'Average', color: '#000000'}, + }); + + // When the user selects Download CSV + renderWidget(); + await waitForBatchedUpdatesWithAct(); + const menuItems = jest.mocked(WidgetHeaderMenu).mock.lastCall?.[0].menuItems; + expect(menuItems?.map((item) => item.text)).toEqual(['View on Spend', 'Download CSV']); + menuItems?.at(1)?.onSelected?.(); + + // Then the existing local download helper receives only grouped current-period values and zero-filled gaps + expect(localFileDownload).toHaveBeenCalledWith( + `Expensify-${CHART.graphKey}.csv`, + '\uFEFFDate,Expenses,Total,Currency\r\n2026-01-01,0,0.00,USD\r\n2026-02-01,2,123.45,USD\r\n2026-03-01,0,0.00,USD', + expect.any(Function), + ); + }); + + it.each([INSIGHTS_CHART_STATE.LOADING, INSIGHTS_CHART_STATE.EMPTY, INSIGHTS_CHART_STATE.ERROR, INSIGHTS_CHART_STATE.OFFLINE])( + 'hides Download CSV while the chart is %s', + async (state) => { + // Given chart data that is not ready to export + jest.mocked(useInsightsChartData).mockReturnValue({data: [], state, comparison: undefined}); + + // When its header is rendered + renderWidget(); + await waitForBatchedUpdatesWithAct(); + const menuItems = jest.mocked(WidgetHeaderMenu).mock.lastCall?.[0].menuItems ?? []; + + // Then no stale or incomplete data can be downloaded, while View on Spend remains available during loading + expect(menuItems.map((item) => item.text)).toEqual(state === INSIGHTS_CHART_STATE.LOADING ? ['View on Spend'] : []); + expect(localFileDownload).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/tests/ui/InsightsControlsTest.tsx b/tests/ui/InsightsControlsTest.tsx index e0d32e742b8d..4ea963010791 100644 --- a/tests/ui/InsightsControlsTest.tsx +++ b/tests/ui/InsightsControlsTest.tsx @@ -10,6 +10,7 @@ import InsightsGroupCurrencyControl from '@pages/Insights/controls/InsightsGroup import InsightsPageControls from '@pages/Insights/controls/InsightsPageControls'; import InsightsWorkspaceControl from '@pages/Insights/controls/InsightsWorkspaceControl'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; +import {getInsightsGroupByOptions} from '@pages/Insights/insightsGroupByUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -122,6 +123,7 @@ describe('Insights controls', () => { renderWithProviders( , ); @@ -142,6 +144,7 @@ describe('Insights controls', () => { renderWithProviders( , ); @@ -155,6 +158,47 @@ describe('Insights controls', () => { expect(onChange).toHaveBeenCalledWith(CONST.SEARCH.GROUP_BY.MONTH); }); + it('only offers the groupings that fit the date range', async () => { + // Given the headline chart grouped by week over this month, which is too short for Month, Quarter and Year + const date: InsightsFilters['date'] = {preset: CONST.SEARCH.DATE_PRESETS.THIS_MONTH}; + renderWithProviders( + , + ); + + // When the user opens the control + await openPill(/: Week$/); + + // Then the groupings that would plot a single point are hidden + expect(screen.getByText(/^(Day|search\.filters\.groupBy\.day)$/)).toBeOnTheScreen(); + expect(screen.queryByText(/^(Month|search\.filters\.groupBy\.month)$/)).toBeNull(); + expect(screen.queryByText(/^(Quarter|search\.filters\.groupBy\.quarter)$/)).toBeNull(); + }); + + it('resets the grouping to month even when the date range rules month out', async () => { + // Given the headline chart grouped by day over this month, which doesn't offer Month + const onChange = jest.fn(); + const date: InsightsFilters['date'] = {preset: CONST.SEARCH.DATE_PRESETS.THIS_MONTH}; + renderWithProviders( + , + ); + + // When the user resets the control + await openPill(/: Day$/); + fireEvent.press(screen.getByText(RESET)); + await waitForBatchedUpdatesWithAct(); + + // Then month is saved, so it comes back once the date range fits it + expect(onChange).toHaveBeenCalledWith(CONST.SEARCH.GROUP_BY.MONTH); + }); + it('resets Workspace to every workspace', async () => { // Given a dashboard narrowed to one workspace const onChange = jest.fn(); diff --git a/tests/ui/MerchantFilterContentTest.tsx b/tests/ui/MerchantFilterContentTest.tsx new file mode 100644 index 000000000000..be459e4789fb --- /dev/null +++ b/tests/ui/MerchantFilterContentTest.tsx @@ -0,0 +1,136 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import MerchantFilterContent from '@components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent'; +import SearchAdvancedFiltersContent from '@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent'; + +import MerchantFilterContentPageWrapper from '@pages/Search/SearchAdvancedFiltersContentPage/MerchantFilterContentPageWrapper'; + +import CONST from '@src/CONST'; +import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; +import type {MerchantMatchType} from '@src/types/form/SearchAdvancedFiltersForm'; + +import {NavigationContainer} from '@react-navigation/native'; + +jest.mock('@hooks/useAutoFocusInput', () => () => ({inputCallbackRef: jest.fn()})); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); + +function renderMerchantFilter({value = 'I', isNegated = false, merchantOperator}: {value?: string; isNegated?: boolean; merchantOperator?: MerchantMatchType} = {}) { + const onChange = jest.fn(); + render( + + + , + ); + return {onChange}; +} + +describe('MerchantFilterContent', () => { + it('keeps the merchant input fixed above the match type options', () => { + // Given a positive Merchant filter + const {onChange} = renderMerchantFilter({merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}); + const merchantInput = screen.getByLabelText('common.merchant'); + + // When the user edits the value and switches to Equal to + fireEvent.changeText(merchantInput, 'Ibis'); + fireEvent.press(screen.getByText('search.filters.merchant.equalTo')); + + // Then the same input keeps the edited value, so switching the match type does not reset what the user typed + expect(screen.getByLabelText('common.merchant')).toBe(merchantInput); + expect(screen.getByDisplayValue('Ibis')).toBeOnTheScreen(); + + // When the user confirms + fireEvent.press(screen.getByText('common.confirm')); + + // Then the exact operator is submitted with the edited value + expect(onChange).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: 'Ibis', + [FILTER_KEYS.MERCHANT_NOT]: undefined, + [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }); + }); + + it.each([CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, undefined])('submits Contains when the stored operator is %s', (merchantOperator) => { + // Given a Merchant filter stored as Contains, or with no operator from an older query + const {onChange} = renderMerchantFilter({merchantOperator}); + + // When the user confirms without changing the match type + fireEvent.press(screen.getByText('common.confirm')); + + // Then Contains is submitted, because it is the default Merchant match type + expect(onChange).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: 'I', + [FILTER_KEYS.MERCHANT_NOT]: undefined, + [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + }); + }); + + it('routes the Merchant filter through the advanced filters content with its stored operator', () => { + // Given the advanced filters page renders a stored exact Merchant filter with an Apply label + const onChange = jest.fn(); + render( + + null, Amount: () => null, Date: () => null, ReportField: () => null, List: () => null}} + onChange={onChange} + /> + , + ); + + // When the user applies the filter + fireEvent.press(screen.getByText('common.apply')); + + // Then the stored exact operator is kept, so the Merchant content received it from the form values + expect(onChange).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: 'I', + [FILTER_KEYS.MERCHANT_NOT]: undefined, + [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }); + }); + + it('retains the value when changing a stored negative filter back to positive', () => { + // Given a stored negated Merchant filter + const {onChange} = renderMerchantFilter({value: 'Prime', isNegated: true}); + + // When the user switches it to positive and picks Contains + fireEvent.press(screen.getByText('search.filters.filterType.is.positive')); + fireEvent.press(screen.getByText('search.filters.merchant.contains')); + fireEvent.press(screen.getByText('common.confirm')); + + // Then the negated value moves to the positive filter instead of being cleared + expect(onChange).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: 'Prime', + [FILTER_KEYS.MERCHANT_NOT]: undefined, + [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + }); + }); + + it('hides the match type and submits no operator when the filter is negated', () => { + // Given a positive Merchant filter + const {onChange} = renderMerchantFilter({merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}); + + // When the user negates it + fireEvent.press(screen.getByText('search.filters.filterType.is.negative')); + + // Then the match type is hidden, because a negated Merchant filter always uses the not-equal operator + expect(screen.queryByText('search.filters.merchant.matchType')).not.toBeOnTheScreen(); + + // When the user confirms + fireEvent.press(screen.getByText('common.confirm')); + + // Then only the negated value is submitted + expect(onChange).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: undefined, + [FILTER_KEYS.MERCHANT_NOT]: 'I', + [FILTER_KEYS.MERCHANT_OPERATOR]: undefined, + }); + }); +}); diff --git a/tests/ui/MerchantFilterPopupTest.tsx b/tests/ui/MerchantFilterPopupTest.tsx new file mode 100644 index 000000000000..ed9e48bf23dc --- /dev/null +++ b/tests/ui/MerchantFilterPopupTest.tsx @@ -0,0 +1,117 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import MerchantFilterPopup from '@components/Search/FilterDropdowns/MerchantFilterPopup'; + +import CONST from '@src/CONST'; +import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; + +import {NavigationContainer} from '@react-navigation/native'; + +jest.mock('@hooks/useAutoFocusInput', () => () => ({inputCallbackRef: jest.fn()})); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useResponsiveLayout', () => () => ({isSmallScreenWidth: false})); + +describe('MerchantFilterPopup', () => { + it('applies the Merchant filter and closes the popup', () => { + // Given a Merchant chip popup with a stored Contains filter + const closeOverlay = jest.fn(); + const updateFilterForm = jest.fn(); + render( + + + , + ); + + // When the user applies the filter + fireEvent.press(screen.getByText('common.apply')); + + // Then the popup submits the Merchant values and closes, because the chip applies changes immediately + expect(updateFilterForm).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: 'I', + [FILTER_KEYS.MERCHANT_NOT]: undefined, + [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + }); + expect(closeOverlay).toHaveBeenCalledTimes(1); + }); + + it('renders the input and match type options in the popup', () => { + // Given a Merchant chip popup with a stored filter + render( + + + , + ); + + // Then the input and both match types render inside the popup, because the popover sizes to its content and must not collapse them + expect(screen.getByDisplayValue('I')).toBeOnTheScreen(); + expect(screen.getByText('search.filters.merchant.contains')).toBeOnTheScreen(); + expect(screen.getByText('search.filters.merchant.equalTo')).toBeOnTheScreen(); + }); + + it('applies the selected match type', () => { + // Given a Merchant chip popup with a stored Contains filter + const updateFilterForm = jest.fn(); + render( + + + , + ); + + // When the user selects Equal to and applies the filter + fireEvent.press(screen.getByText('search.filters.merchant.equalTo')); + fireEvent.press(screen.getByText('common.apply')); + + // Then the popup submits the Equal to match type + expect(updateFilterForm).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: 'I', + [FILTER_KEYS.MERCHANT_NOT]: undefined, + [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }); + }); + + it('hides the match type and clears it when the filter is negated', () => { + // Given a Merchant chip popup with a stored Equal to filter + const updateFilterForm = jest.fn(); + render( + + + , + ); + + // When the user negates the filter and applies it + fireEvent.press(screen.getByText('search.filters.filterType.is.negative')); + fireEvent.press(screen.getByText('common.apply')); + + // Then the match type is hidden and cleared, because a negated Merchant filter always uses the not-equal operator + expect(screen.queryByText('search.filters.merchant.equalTo')).not.toBeOnTheScreen(); + expect(updateFilterForm).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: undefined, + [FILTER_KEYS.MERCHANT_NOT]: 'I', + [FILTER_KEYS.MERCHANT_OPERATOR]: undefined, + }); + }); +}); diff --git a/tests/ui/MoneyReportViewTest.tsx b/tests/ui/MoneyReportViewTest.tsx index 5d525c985783..9d6701d586f3 100644 --- a/tests/ui/MoneyReportViewTest.tsx +++ b/tests/ui/MoneyReportViewTest.tsx @@ -1,14 +1,20 @@ -import {act, render, screen, waitFor} from '@testing-library/react-native'; +/** Exercises report breakdown visibility and exact pending contributions through the real report rows. */ +import {act, render, screen, waitFor, within} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import MoneyReportView from '@components/ReportActionItem/MoneyReportView'; +import {getReportFieldKey} from '@libs/ReportUtils'; + import initOnyxDerivedValues from '@userActions/OnyxDerived'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type * as NativeNavigation from '@react-navigation/native'; @@ -19,6 +25,25 @@ import * as LHNTestUtils from '../utils/LHNTestUtils'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +// Pinned to a wide layout so the column-count assertion below exercises the `shouldUseSingleColumn` prop rather than +// the narrow-layout fallback, which would collapse the fields to one per row on its own. +jest.mock('@hooks/useResponsiveLayoutOnWideRHP', () => ({ + __esModule: true, + default: () => ({ + shouldUseNarrowLayout: false, + shouldUseNarrowLayoutIgnoringWideRHP: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isWideRHPDisplayedOnWideLayout: true, + isSuperWideRHPDisplayedOnWideLayout: false, + }), +})); + +jest.mock('@components/OfflineWithFeedback', () => { + const actual = jest.requireActual<{default: typeof OfflineWithFeedback}>('@components/OfflineWithFeedback'); + return {...actual, __esModule: true, default: jest.fn, [OfflineWithFeedbackProps]>(actual.default)}; +}); + jest.mock('@hooks/useLocalize', () => jest.fn(() => ({ translate: jest.fn((key: string) => key), @@ -98,7 +123,7 @@ const seedReportAndTransactions = async (transactions: OnyxTypes.Transaction[], await waitForBatchedUpdatesWithAct(); }; -const renderMoneyReportView = (report: OnyxTypes.Report, policy: OnyxTypes.Policy | undefined = undefined, isCombinedReport = false) => +const renderMoneyReportView = (report: OnyxTypes.Report, policy: OnyxTypes.Policy | undefined = undefined, isCombinedReport = false, pendingAction?: PendingAction) => render( , ); @@ -173,6 +199,27 @@ describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => { }); }); + it('shows all four exact breakdown labels together for multiple taxed and billable expenses', async () => { + // Given multiple expenses with company spend, billable amount and tax enabled + const policy = buildTaxPolicy(); + const transactions = [buildTransaction('t1', 5000, true, true, 500), buildTransaction('t2', 3000, false, true, 300)]; + const report = buildExpenseReport({total: -8000, unheldTotal: -8000, nonReimbursableTotal: -3000, unheldNonReimbursableTotal: -3000}); + await seedReportAndTransactions(transactions, report); + + // When the actual report renders its breakdown + renderMoneyReportView(report, policy); + await waitForBatchedUpdatesWithAct(); + + // Then all four labels remain in their existing order + const labels = screen.getAllByText(/^(cardTransactions\.(outOfPocket|companySpend)|common\.(billable|tax))$/); + expect(labels.map((label) => label.children.filter((child) => typeof child === 'string').join(''))).toEqual([ + 'cardTransactions.outOfPocket', + 'cardTransactions.companySpend', + 'common.billable', + 'common.tax', + ]); + }); + it('hides every report-level row for a single billable expense (the amount lives on the expense field)', async () => { const transactions = [buildTransaction('t1', 5000, false, true)]; await seedReportAndTransactions(transactions, {nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000}); @@ -277,7 +324,7 @@ describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => { }); describe('MoneyReportView report fields visibility', () => { - const customFieldKey = `expensify_${'field_test'}` as const; + const customFieldKey = getReportFieldKey('field_test'); const buildTitleField = (): OnyxTypes.PolicyReportField => ({ fieldID: CONST.REPORT_FIELD_TITLE_FIELD_ID, @@ -341,6 +388,38 @@ describe('MoneyReportView report fields visibility', () => { }); }); + const buildFourFieldReport = () => { + const customFields = Array.from({length: 4}, (_unused, index) => ({ + ...buildCustomTextField(), + fieldID: `field_test${index}`, + name: `Test${index}`, + orderWeight: index + 2, + })); + const fieldList: Record = {[CONST.REPORT_FIELD_TITLE_FIELD_ID]: buildTitleField()}; + for (const field of customFields) { + fieldList[`expensify_${field.fieldID}`] = field; + } + return {policy: buildReportFieldsPolicy(fieldList), report: buildExpenseReport({fieldList})}; + }; + + it('edits the report fields inline, one per row on a wide layout', async () => { + // Given a one-expense report with 4 custom fields on a wide layout + const {policy, report} = buildFourFieldReport(); + await seedReportFieldsPolicy(policy, report); + + // When this view renders them + renderMoneyReportView(report, policy); + await waitForBatchedUpdatesWithAct(); + + // Then each field is an input on its own row. Four fields would fill two rows of three if the grid were in use, + // so four rows proves this view asks for a single column: it renders inside a container too narrow for three + // fields side by side even though the window itself is wide + await waitFor(() => { + expect(screen.getAllByTestId('reportFieldsRow')).toHaveLength(4); + }); + expect(screen.getByLabelText('Test0')).toBeOnTheScreen(); + }); + it('keeps a custom report field visible for a non-admin submitter after the report is approved (single-expense combined view)', async () => { const fieldList = { [CONST.REPORT_FIELD_TITLE_FIELD_ID]: buildTitleField(), @@ -359,7 +438,7 @@ describe('MoneyReportView report fields visibility', () => { // The custom field (rendered read-only after approval) must still show for the submitter. await waitFor(() => { - expect(screen.getByText('Test')).toBeOnTheScreen(); + expect(screen.getByLabelText('Test')).toBeOnTheScreen(); }); }); @@ -381,4 +460,96 @@ describe('MoneyReportView report fields visibility', () => { expect(screen.queryByText('title')).not.toBeOnTheScreen(); }); }); + + it.each([undefined, null, CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE])( + 'preserves the exact custom-field contribution %s without taking a title or another field contribution', + async (contribution) => { + // Given keys produced by getReportFieldKey and the pending shape written by updateReportField + const secondFieldKey = getReportFieldKey('field_second'); + const fieldList = { + [CONST.REPORT_FIELD_TITLE_FIELD_ID]: buildTitleField(), + [customFieldKey]: buildCustomTextField(), + [secondFieldKey]: {...buildCustomTextField(), fieldID: 'field_second', name: 'Second', orderWeight: 3}, + }; + const policy = buildReportFieldsPolicy(fieldList); + const report = buildExpenseReport({ + fieldList, + pendingFields: {[getReportFieldKey(CONST.REPORT_FIELD_TITLE_FIELD_ID)]: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }); + // Custom pending keys are written by updateReportField even though the Report model lists fixed keys only. + const pendingFields = {...report.pendingFields, [customFieldKey]: contribution, [secondFieldKey]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; + const reportWithPending = {...report, pendingFields}; + await seedReportFieldsPolicy(policy, report); + await act(async () => { + await Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline: true}); + }); + jest.mocked(OfflineWithFeedback).mockClear(); + + // When typed direct props retain explicit undefined and null that an Onyx merge would delete + renderMoneyReportView(reportWithPending, policy); + await waitForBatchedUpdatesWithAct(); + + // Then both intended rows exist and the real feedback component receives their distinct exact values + expect(screen.getByLabelText('Test')).toBeOnTheScreen(); + expect(screen.getByLabelText('Second')).toBeOnTheScreen(); + const feedback = screen.UNSAFE_getAllByType(OfflineWithFeedback); + expect(feedback).toHaveLength(2); + const customFeedback = feedback.find((row) => within(row).queryByLabelText('Test')); + const secondFeedback = feedback.find((row) => within(row).queryByLabelText('Second')); + expect(customFeedback).toBeDefined(); + expect(secondFeedback).toBeDefined(); + if (!customFeedback || !secondFeedback) { + throw new Error('Expected feedback around both custom report fields'); + } + const customObservation = jest.mocked(OfflineWithFeedback).mock.calls.findLast(([props]) => props === customFeedback.props)?.[0]; + const secondObservation = jest.mocked(OfflineWithFeedback).mock.calls.findLast(([props]) => props === secondFeedback.props)?.[0]; + expect(customObservation).toBeDefined(); + expect(secondObservation).toBeDefined(); + expect(customObservation?.pendingAction).toBe(contribution); + expect(secondObservation?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + }, + ); + + it.each(['missing-container', 'missing-key', 'overall-action'] as const)('retains undefined feedback for %s', async (scenario) => { + // Given two custom rows and title pending data that must not leak into either row + const secondFieldKey = getReportFieldKey('field_second'); + const fieldList = { + [CONST.REPORT_FIELD_TITLE_FIELD_ID]: buildTitleField(), + [customFieldKey]: buildCustomTextField(), + [secondFieldKey]: {...buildCustomTextField(), fieldID: 'field_second', name: 'Second', orderWeight: 3}, + }; + const policy = buildReportFieldsPolicy(fieldList); + const report = buildExpenseReport({fieldList}); + const pendingFields = + scenario === 'missing-container' + ? undefined + : { + ...report.pendingFields, + [getReportFieldKey(CONST.REPORT_FIELD_TITLE_FIELD_ID)]: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ...(scenario === 'overall-action' ? {[customFieldKey]: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, [secondFieldKey]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE} : {}), + }; + const reportWithPending = {...report, pendingFields}; + await seedReportFieldsPolicy(policy, reportWithPending); + jest.mocked(OfflineWithFeedback).mockClear(); + + // When the real component receives missing contributions or an overall pending action + renderMoneyReportView(reportWithPending, policy, false, scenario === 'overall-action' ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : undefined); + await waitForBatchedUpdatesWithAct(); + + // Then the intended rows still exist without duplicate or unrelated feedback + expect(screen.getByLabelText('Test')).toBeOnTheScreen(); + expect(screen.getByLabelText('Second')).toBeOnTheScreen(); + const feedback = screen.UNSAFE_getAllByType(OfflineWithFeedback); + expect(feedback).toHaveLength(2); + for (const name of ['Test', 'Second']) { + const fieldFeedback = feedback.find((row) => within(row).queryByLabelText(name)); + expect(fieldFeedback).toBeDefined(); + if (!fieldFeedback) { + throw new Error(`Expected feedback around ${name}`); + } + const observation = jest.mocked(OfflineWithFeedback).mock.calls.findLast(([props]) => props === fieldFeedback.props)?.[0]; + expect(observation).toBeDefined(); + expect(observation?.pendingAction).toBeUndefined(); + } + }); }); diff --git a/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx b/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx index 5deba87d9330..4977a91fdb09 100644 --- a/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx +++ b/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx @@ -1,7 +1,9 @@ import {act, fireEvent, render, screen} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; +import type FeatureTrainingComponent from '@components/FeatureTraining'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import {ModalProvider} from '@components/Modal/Global/ModalContext'; import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -11,11 +13,15 @@ import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import type * as InFlightRequests from '@hooks/useInFlightRequests'; import useNetwork from '@hooks/useNetwork'; +import Navigation from '@libs/Navigation/Navigation'; + import {ActionListContextProvider} from '@pages/inbox/ActionListContext'; import type * as ReportActionIndexContexts from '@pages/inbox/report/ReportActionIndexContext'; +import type ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {Policy, Report, ReportAction, Session, Transaction} from '@src/types/onyx'; @@ -84,6 +90,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ getActiveRouteWithoutParams: jest.fn(() => ''), isNavigationReady: jest.fn(() => Promise.resolve()), getDeepestFocusedScreen: jest.fn(() => undefined), + isTopmostRouteModalScreen: jest.fn(() => false), setParams: jest.fn(), })); @@ -130,15 +137,16 @@ jest.mock('@components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState return () => ; }); -jest.mock('@components/HoldOrRejectEducationalModal', () => { +// The content mock skips the illustrations but keeps the real confirm button, so the flow test still renders the real +// FeatureTrainingModal tree on the global modal stack +jest.mock('@components/HoldOrRejectEducationalModalContent', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const {View} = require('react-native'); - return (props: {onConfirm: () => void}) => ( + const {default: FeatureTraining} = jest.requireActual<{default: typeof FeatureTrainingComponent}>('@components/FeatureTraining'); + const {translateLocal: translate} = jest.requireActual('../utils/TestHelper'); + return () => ( - + {translate('common.buttonConfirm')} ); }); @@ -285,7 +293,7 @@ const mockCommentReportAction: ReportAction = { const renderComponent = () => { return render( - + @@ -373,6 +381,34 @@ describe('MoneyRequestReportActionsList - Reject Educational Modal', () => { expect(mockOriginalRejectOnSelected).not.toHaveBeenCalled(); }); + it('should open bulk reject once the reject educational modal is confirmed', async () => { + // Given a user who has not dismissed the reject explanation yet + await act(async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION]: false, + [`${ONYXKEYS.COLLECTION.REPORT}${FAKE_REPORT_ID}` as const]: mockReport, + [`${ONYXKEYS.COLLECTION.POLICY}${FAKE_POLICY_ID}` as const]: mockPolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${FAKE_TRANSACTION_ID}` as const]: mockTransaction, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${FAKE_REPORT_ID}` as const]: {[mockReportAction.reportActionID]: mockReportAction}, + [`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${FAKE_REPORT_ID}` as const]: {isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}, + [ONYXKEYS.SESSION]: {accountID: FAKE_ACCOUNT_ID, email: FAKE_EMAIL} as Session, + }); + }); + + renderComponent(); + await waitForBatchedUpdatesWithAct(); + + // When they pick reject, which opens the educational modal on the global modal stack, and then confirm it + fireEvent.press(screen.getByTestId(`dropdown-option-${CONST.REPORT.SECONDARY_ACTIONS.REJECT}`)); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByText(TestHelper.translateLocal('common.buttonConfirm'))); + await waitForBatchedUpdatesWithAct(); + + // Then the modal leaves the stack and the bulk reject flow opens + expect(screen.queryByTestId('HoldOrRejectEducationalModal')).toBeNull(); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_MONEY_REQUEST_REPORT_REJECT_TRANSACTIONS.getRoute({reportID: FAKE_REPORT_ID})); + }); + it('should NOT show reject educational modal when explanation HAS been dismissed', async () => { await act(async () => { await Onyx.multiSet({ @@ -452,6 +488,61 @@ describe('MoneyRequestReportActionsList - Reject Educational Modal', () => { expect(screen.queryByTestId('MockInitialReportActionsSkeleton')).toBeNull(); }); + describe('feedback prompt under custom agent replies', () => { + const AGENT_ACCOUNT_ID = 99001; + const mockReportActionsListItemRenderer = jest.requireMock]>>( + '@pages/inbox/report/ReportActionsListItemRenderer', + ); + const agentReply: ReportAction = { + ...mockCommentReportAction, + reportActionID: 'AGENT_REPLY_001', + created: '2025-01-03 00:00:00', + actorAccountID: AGENT_ACCOUNT_ID, + message: [{type: 'COMMENT', html: 'Everything looks in order.', text: 'Everything looks in order.'}], + person: [{type: 'TEXT', style: 'strong', text: 'Receipt Checker'}], + }; + + const renderWithAgentReply = async (currentUserAccountID: number) => { + await act(async () => { + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}${FAKE_REPORT_ID}` as const]: mockReport, + [`${ONYXKEYS.COLLECTION.POLICY}${FAKE_POLICY_ID}` as const]: mockPolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${FAKE_TRANSACTION_ID}` as const]: mockTransaction, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${FAKE_REPORT_ID}` as const]: { + [mockReportAction.reportActionID]: mockReportAction, + [agentReply.reportActionID]: agentReply, + }, + [`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${FAKE_REPORT_ID}` as const]: {isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[AGENT_ACCOUNT_ID]: {accountID: AGENT_ACCOUNT_ID, isCustomAgent: true}}, + [ONYXKEYS.SESSION]: {accountID: currentUserAccountID, email: FAKE_EMAIL} as Session, + }); + }); + + renderComponent(); + await waitForBatchedUpdatesWithAct(); + + return mockReportActionsListItemRenderer.mock.calls.findLast(([props]) => props.reportAction.reportActionID === agentReply.reportActionID)?.at(0); + }; + + it('marks the newest reply of a custom agent as the feedback target', async () => { + // Given an expense report where a custom agent replied last + // When the report opens for the expense owner + const agentReplyProps = await renderWithAgentReply(FAKE_ACCOUNT_ID); + + // Then the agent's reply shows the feedback prompt, the same as a Concierge reply would + expect(agentReplyProps?.isLatestConciergeFeedbackAction).toBe(true); + }); + + it('does not ask the agent to rate its own reply when the agent account is open through Copilot', async () => { + // Given the same report, opened while signed in as the agent + // When the list renders + const agentReplyProps = await renderWithAgentReply(AGENT_ACCOUNT_ID); + + // Then the agent's own reply shows no prompt, because an author cannot give feedback to itself + expect(agentReplyProps?.isLatestConciergeFeedbackAction).toBe(false); + }); + }); + it('shows cached empty behavior for an offline queued report load', async () => { mockUseNetwork.mockReturnValue({isOffline: true}); mockUseIsReportLoadPending.mockReturnValue(true); diff --git a/tests/ui/MoneyRequestReportTransactionItemRejectErrorTest.tsx b/tests/ui/MoneyRequestReportTransactionItemRejectErrorTest.tsx index a8de9d6ddda3..bd507d4ea0b7 100644 --- a/tests/ui/MoneyRequestReportTransactionItemRejectErrorTest.tsx +++ b/tests/ui/MoneyRequestReportTransactionItemRejectErrorTest.tsx @@ -6,6 +6,10 @@ import MoneyRequestReportTransactionItem from '@components/MoneyRequestReportVie import OnyxListItemProvider from '@components/OnyxListItemProvider'; import ScreenWrapper from '@components/ScreenWrapper'; +import type * as CopyableTextRowPress from '@hooks/useCopyableTextRowPress'; + +import canUseTouchScreen from '@libs/DeviceCapabilities/canUseTouchScreen'; + import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -22,6 +26,15 @@ import {translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; jest.mock('@react-navigation/native'); +jest.mock('@libs/DeviceCapabilities/canUseTouchScreen', () => jest.fn()); + +const mockIsPressStartOnCopyableText = jest.fn(); +jest.mock('@hooks/useCopyableTextRowPress', () => { + const actual = jest.requireActual('@hooks/useCopyableTextRowPress'); + return {...actual, __esModule: true, default: actual.default, isPressStartOnCopyableText: (event: unknown) => mockIsPressStartOnCopyableText(event)}; +}); + +const mockedCanUseTouchScreen = jest.mocked(canUseTouchScreen); const REPORT_ID = '12345'; const TRANSACTION_ID = '67890'; @@ -62,6 +75,11 @@ describe('MoneyRequestReportTransactionItem - reject errors', () => { return waitForBatchedUpdatesWithAct(); }); + beforeEach(() => { + mockedCanUseTouchScreen.mockReturnValue(false); + mockIsPressStartOnCopyableText.mockReturnValue(false); + }); + afterEach(async () => { await act(async () => { await Onyx.clear(); @@ -70,7 +88,7 @@ describe('MoneyRequestReportTransactionItem - reject errors', () => { await waitForBatchedUpdatesWithAct(); }); - async function renderTransactionItem(transaction: Transaction, handleOnPress = jest.fn()) { + async function renderTransactionItem(transaction: Transaction, handleOnPress = jest.fn(), handleLongPress = jest.fn()) { await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); @@ -89,7 +107,7 @@ describe('MoneyRequestReportTransactionItem - reject errors', () => { isSelectionModeEnabled={false} toggleTransaction={jest.fn()} handleOnPress={handleOnPress} - handleLongPress={jest.fn()} + handleLongPress={handleLongPress} isSelected={false} dateColumnSize={CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} postedColumnSize={CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} @@ -105,7 +123,7 @@ describe('MoneyRequestReportTransactionItem - reject errors', () => { ); await waitForBatchedUpdatesWithAct(); - return {handleOnPress}; + return {handleLongPress, handleOnPress}; } it('should show the translated copy instead of the English message the server sent', async () => { @@ -147,6 +165,24 @@ describe('MoneyRequestReportTransactionItem - reject errors', () => { expect(handleOnPress).toHaveBeenCalledWith(TRANSACTION_ID); }); + it('should open row actions when a long press starts in blank space inside a copyable cell', async () => { + // Given a touch device and a copyable cell whose element extends beyond its text + mockedCanUseTouchScreen.mockReturnValue(true); + mockIsPressStartOnCopyableText.mockReturnValue(false); + const copyableCell = document.createElement('span'); + copyableCell.setAttribute(`data-${CONST.COPYABLE_TEXT_ELEMENT}`, 'true'); + const {handleLongPress} = await renderTransactionItem(buildTransaction()); + const row = screen.getByLabelText('View details'); + + // When the user long-presses the cell element outside the text glyphs + fireEvent(row, 'touchStart', {target: copyableCell}); + fireEvent(row, 'pressIn', {target: copyableCell}); + fireEvent(row, 'longPress'); + + // Then the row action remains available instead of treating the blank area as selectable text + expect(handleLongPress).toHaveBeenCalledWith(TRANSACTION_ID); + }); + it('should clear the reject error when it is dismissed', async () => { // Given: An expense left behind by a reject the server refused await renderTransactionItem(buildTransactionWithRejectError()); diff --git a/tests/ui/MoneyRequestViewReportFieldsTest.tsx b/tests/ui/MoneyRequestViewReportFieldsTest.tsx new file mode 100644 index 000000000000..27d512563a15 --- /dev/null +++ b/tests/ui/MoneyRequestViewReportFieldsTest.tsx @@ -0,0 +1,601 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import MoneyRequestViewReportFields from '@components/MoneyRequestReportView/MoneyRequestViewReportFields'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; + +import {deleteReportField, updateReportField} from '@libs/actions/Report'; + +import initOnyxDerivedValues from '@userActions/OnyxDerived'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type * as NativeNavigation from '@react-navigation/native'; + +import React from 'react'; +import {StyleSheet} from 'react-native'; +import Onyx from 'react-native-onyx'; + +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +let mockShouldUseNarrowLayout = false; + +jest.mock('@hooks/useResponsiveLayoutOnWideRHP', () => ({ + __esModule: true, + default: () => ({ + shouldUseNarrowLayout: mockShouldUseNarrowLayout, + shouldUseNarrowLayoutIgnoringWideRHP: mockShouldUseNarrowLayout, + isSmallScreenWidth: mockShouldUseNarrowLayout, + isInNarrowPaneModal: false, + isWideRHPDisplayedOnWideLayout: !mockShouldUseNarrowLayout, + isSuperWideRHPDisplayedOnWideLayout: false, + }), +})); + +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: jest.fn((key: string) => key), + numberFormat: jest.fn((num: number) => num.toString()), + toLocaleDigit: jest.fn((digit: string) => digit), + localeCompare: jest.fn((first: string, second: string) => first.localeCompare(second)), + })), +); + +jest.mock('@react-navigation/native', () => ({ + ...((): typeof NativeNavigation => jest.requireActual('@react-navigation/native'))(), + useNavigation: jest.fn(() => ({navigate: jest.fn(), addListener: jest.fn(() => jest.fn())})), + useIsFocused: jest.fn(() => true), + useRoute: jest.fn(() => ({key: '', name: '', params: {reportID: '1'}})), + // The option list is rendered outside a navigator here, so there is no focus cycle to refresh its snapshot on. + useFocusEffect: jest.fn(), +})); + +// The list field's dropdown is built on FilterPopupButton, which reads focus straight from @react-navigation/core. +jest.mock('@react-navigation/core', () => ({ + ...jest.requireActual>('@react-navigation/core'), + useIsFocused: jest.fn(() => true), +})); + +jest.mock('@libs/actions/Report', () => ({ + ...jest.requireActual>('@libs/actions/Report'), + updateReportField: jest.fn(), + deleteReportField: jest.fn(), +})); + +const mockShowConfirmModal = jest.fn(() => Promise.resolve({action: 'CONFIRM'})); + +jest.mock('@hooks/useConfirmModal', () => ({ + __esModule: true, + default: () => ({showConfirmModal: mockShowConfirmModal}), +})); + +TestHelper.setupGlobalFetchMock(); + +const policyID = 'policy_report_fields_grid'; +const reportID = 'report_report_fields_grid'; +const accountID = 1; + +const buildTextField = (index: number): OnyxTypes.PolicyReportField => ({ + name: `Field${index}`, + fieldID: `field${index}`, + defaultValue: '', + value: `Value${index}`, + orderWeight: index, + type: CONST.REPORT_FIELD_TYPES.TEXT, + deletable: true, + values: [], + keys: [], + externalIDs: [], + disabledOptions: [], + target: CONST.REPORT_FIELD_TARGETS.EXPENSE, + isTax: false, +}); + +// An empty value is what `getFieldViolation` turns into a " is required" violation. +const buildEmptyField = (): OnyxTypes.PolicyReportField => ({ + ...buildTextField(1), + name: 'EmptyField', + fieldID: 'emptyField', + value: '', +}); + +const buildListField = (): OnyxTypes.PolicyReportField => ({ + ...buildTextField(1), + name: 'ListField', + fieldID: 'listField', + type: CONST.REPORT_FIELD_TYPES.LIST, + value: 'Option1', + values: ['Option1', 'Option2'], + disabledOptions: [false, false], +}); + +// A field the admin deleted from the workspace. The report keeps its own copy, so the value it already held is not lost. +const buildDeletedField = (): OnyxTypes.PolicyReportField => ({ + ...buildTextField(1), + name: 'DeletedField', + fieldID: 'deletedField', + value: 'Old value', +}); + +/** Every flattened style currently on screen, so a test can assert that some node does (or does not) carry one. */ +const getRenderedStyles = (): unknown[] => { + const renderedStyles: unknown[] = []; + for (const node of screen.UNSAFE_root.findAll(() => true)) { + renderedStyles.push(StyleSheet.flatten(node.props.style)); + } + return renderedStyles; +}; + +// `getTextInputIconContainerStyles` gives every icon container a full-height centred box, and `pr0` then strips the +// right half of its padding. That one-sided padding identifies the caret container. +const caretContainer = {height: '100%', paddingHorizontal: 11, paddingRight: 0}; + +const buildFieldList = (fieldCount: number): Record => { + const fieldList: Record = {}; + for (let index = 1; index <= fieldCount; index++) { + const field = buildTextField(index); + fieldList[`expensify_${field.fieldID}`] = field; + } + return fieldList; +}; + +const buildPolicy = (fieldCount: number, extraFields: OnyxTypes.PolicyReportField[] = []): OnyxTypes.Policy => { + const fieldList = buildFieldList(fieldCount); + for (const field of extraFields) { + fieldList[`expensify_${field.fieldID}`] = field; + } + + return { + ...LHNTestUtils.getFakePolicy(policyID, 'Policy'), + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + outputCurrency: CONST.CURRENCY.USD, + areReportFieldsEnabled: true, + fieldList, + }; +}; + +const buildReport = (reportOnlyFields: OnyxTypes.PolicyReportField[] = []): OnyxTypes.Report => ({ + ...LHNTestUtils.getFakeReport([accountID, 2]), + fieldList: Object.fromEntries(reportOnlyFields.map((field) => [`expensify_${field.fieldID}`, field])), + reportID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + ownerAccountID: accountID, + currency: CONST.CURRENCY.USD, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: 0, +}); + +const renderReportFields = async (fieldCount: number, extraFields: OnyxTypes.PolicyReportField[] = [], reportOnlyFields: OnyxTypes.PolicyReportField[] = []) => { + const policy = buildPolicy(fieldCount, extraFields); + const report = buildReport(reportOnlyFields); + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + }); + await waitForBatchedUpdatesWithAct(); + + const rendered = render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + return rendered; +}; + +describe('MoneyRequestViewReportFields', () => { + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); + initOnyxDerivedValues(); + // Field violation copy comes from `translateLocal`, not the mocked `useLocalize`, so the locale has to be real. + IntlStore.load('en'); + await waitForBatchedUpdatesWithAct(); + }); + + beforeEach(async () => { + mockShouldUseNarrowLayout = false; + jest.clearAllMocks(); + await act(async () => { + await TestHelper.signInWithTestUser(accountID); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + it('shows up to three fields per row on a wide layout', async () => { + // Given a report with 7 fields, a count that does not divide evenly into rows of three, because a partly + // filled last row is the case that would regress if the chunking were off by one + await renderReportFields(7); + + // When the fields are laid out on a wide layout + // Then they occupy 3 rows, two full rows of three plus one holding the remaining field, which is what packing + // the fields three to a row rather than stacking them produces + expect(screen.getAllByTestId('reportFieldsRow')).toHaveLength(3); + }); + + it('shows one field per row on a narrow layout', async () => { + // Given a narrow layout, where three fields side by side would each be too cramped to read + mockShouldUseNarrowLayout = true; + + // When 4 fields are laid out + await renderReportFields(4); + + // Then each one gets its own row, so the column count drops to 1 rather than staying at 3 + expect(screen.getAllByTestId('reportFieldsRow')).toHaveLength(4); + }); + + it('renders every field as an input holding its value', async () => { + // Given a report with 2 fields that already hold values + await renderReportFields(2); + + // When each field is looked up by its label + // Then it is an input carrying the saved value, because the fields are edited in place now instead of being + // rows that show the value and navigate to the editor page to change it + expect(screen.getByLabelText('Field1')).toHaveProp('value', 'Value1'); + expect(screen.getByLabelText('Field2')).toHaveProp('value', 'Value2'); + }); + + it('saves a value typed into a field without leaving the report', async () => { + // Given a report with 2 fields + await renderReportFields(2); + + // When a new value is typed into the first field and the field is left + const input = screen.getByLabelText('Field1'); + fireEvent.changeText(input, 'Updated value'); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then the value is saved from the report itself, with the old value passed along so the optimistic update can + // be rolled back. This is the whole point of inline editing: no navigation to the editor page to change a value + expect(updateReportField).toHaveBeenCalledTimes(1); + expect(jest.mocked(updateReportField).mock.calls.at(0)?.at(0)).toMatchObject({ + reportField: {fieldID: 'field1', value: 'Updated value'}, + previousReportField: {fieldID: 'field1', value: 'Value1'}, + }); + }); + + it('saves once when Enter is pressed and the field then loses focus', async () => { + // Given a report with 2 fields + await renderReportFields(2); + + // When a new value is typed and submitted with Enter, which on iOS and Android also blurs the input + const input = screen.getByLabelText('Field1'); + fireEvent.changeText(input, 'Updated value'); + fireEvent(input, 'submitEditing'); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then the value is saved only once, because each save posts its own system message to the report + expect(updateReportField).toHaveBeenCalledTimes(1); + expect(jest.mocked(updateReportField).mock.calls.at(0)?.at(0)).toMatchObject({ + reportField: {fieldID: 'field1', value: 'Updated value'}, + }); + }); + + it('saves an empty value when a text field is cleared', async () => { + // Given a report with 2 text fields that already hold values + await renderReportFields(2); + + // When the first field is emptied and left + const input = screen.getByLabelText('Field1'); + fireEvent.changeText(input, ''); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then the empty value is saved, because OldDot lets a text field be cleared and the report history then shows + // `removed Field1 (previously "Value1")` + expect(updateReportField).toHaveBeenCalledTimes(1); + expect(jest.mocked(updateReportField).mock.calls.at(0)?.at(0)).toMatchObject({ + reportField: {fieldID: 'field1', value: ''}, + previousReportField: {fieldID: 'field1', value: 'Value1'}, + }); + }); + + it('saves an empty value rather than whitespace when a text field is left holding only spaces', async () => { + // Given a report with 2 text fields that already hold values + await renderReportFields(2); + + // When the first field is replaced with spaces and left + const input = screen.getByLabelText('Field1'); + fireEvent.changeText(input, ' '); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then an empty value is saved, so the report never stores a value that looks empty but is not + expect(updateReportField).toHaveBeenCalledTimes(1); + expect(jest.mocked(updateReportField).mock.calls.at(0)?.at(0)).toMatchObject({ + reportField: {fieldID: 'field1', value: ''}, + }); + }); + + it('does not save when the option a list field already holds is picked again', async () => { + // Given a list field already set to Option1 + await renderReportFields(1, [buildListField()]); + + // When the dropdown is opened and that same option is picked + fireEvent.press(screen.getByLabelText('ListField')); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('base-list-item-Option1')); + await waitForBatchedUpdatesWithAct(); + + // Then no update is sent, because a re-pick is not an edit and saving it would put a pointless entry in the + // report history and mark the field as recently used for a value the user never actually changed + expect(updateReportField).not.toHaveBeenCalled(); + }); + + it('does not save when the value is left unchanged', async () => { + // Given a report with 2 fields that already hold values + await renderReportFields(2); + + // When a field is focused and left without typing, which happens constantly as the user tabs through a row + const input = screen.getByLabelText('Field1'); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then nothing is saved, because blur is the save trigger now and merely passing through a field must not + // cost a write + expect(updateReportField).not.toHaveBeenCalled(); + }); + + it('edits the raw value of a text field whose default refers to another field', async () => { + // Given a text field with no value of its own whose default refers to Field1, so the report would display it + // computed as "Value1 code" + await renderReportFields(1, [{...buildTextField(2), name: 'LinkedField', fieldID: 'linkedField', defaultValue: '{field:Field1} code', value: null}]); + + // When the field is inspected + // Then the input holds the raw default, the same as the editor page, so editing it never saves the computed + // text over the reference and the field keeps following Field1 + expect(screen.getByLabelText('LinkedField')).toHaveProp('value', '{field:Field1} code'); + }); + + it('resets the displayed text when an edit is skipped because it only changed the whitespace', async () => { + // Given a report with 2 fields that already hold values + await renderReportFields(2); + + // When the value is retyped with surrounding whitespace and the field is left + const input = screen.getByLabelText('Field1'); + fireEvent.changeText(input, ' Value1 '); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then no update is sent, because the value is unchanged once trimmed + expect(updateReportField).not.toHaveBeenCalled(); + + // Then the field shows the saved value again rather than the whitespace the user typed. The saved value never + // changed, so nothing else would ever correct the display and the text would silently snap back on remount + expect(screen.getByLabelText('Field1')).toHaveProp('value', 'Value1'); + }); + + it('keeps the fields of a row aligned at the top so an error grows the row downwards', async () => { + // Given a full row of 3 fields + await renderReportFields(3); + + // When the row is laid out + // Then its fields are aligned to the top, so one field growing taller to show a validation error pushes its + // own content down instead of re-centering the two fields beside it + expect(screen.getAllByTestId('reportFieldsRow').at(0)).toHaveStyle({alignItems: 'flex-start'}); + }); + + it('shows a field violation as soon as the report opens', async () => { + // Given a required field that is already empty when the report opens, so its violation is true from the start + await renderReportFields(1, [buildEmptyField()]); + + // When the report is shown, before the user has touched the field + // Then the error is already visible, like the report field rows before inline editing, so a report with empty + // required fields tells the user what still needs filling in + expect(screen.getByText('EmptyField is required')).toBeOnTheScreen(); + }); + + it('renders a list field as a collapsed combobox rather than a row that opens a page', async () => { + // Given a report holding a list field set to Option1 + await renderReportFields(1, [buildListField()]); + + // When the field is inspected before it has been opened + const listInput = screen.getByLabelText('ListField'); + + // Then it exposes itself to assistive tech as a closed combobox showing the current value, which is what a + // dropdown that opens in place must look like, unlike the old row that announced itself as a link to a page + expect(listInput).toHaveProp('role', CONST.ROLE.COMBOBOX); + expect(listInput).toHaveProp('accessibilityState', {expanded: false}); + expect(listInput).toHaveProp('value', 'Option1'); + + // Then no option row is mounted yet, because building the option list for every list field on a report full of + // them would be paid for up front even though most dropdowns are never opened + expect(screen.queryByText('Option2')).toBeNull(); + }); + + it('hides the text cursor on a list field but keeps it on a text field', async () => { + // Given a report holding both a text field and a list field + await renderReportFields(1, [buildListField()]); + + // When the two inputs are compared + // Then the list field hides the cursor, because its value is picked from the dropdown rather than typed, and + // `disableKeyboard` alone only suppresses the software keyboard and would still leave a cursor blinking in a + // field the user cannot type into. + expect(screen.getByLabelText('ListField')).toHaveProp('caretHidden', true); + + // Then the text field is untouched and still shows its cursor. + expect(screen.getByLabelText('Field1')).not.toHaveProp('caretHidden', true); + }); + + it('opens the option list in place when a list field is pressed', async () => { + // Given a report holding a list field + await renderReportFields(1, [buildListField()]); + + // When the field is pressed + fireEvent.press(screen.getByLabelText('ListField')); + await waitForBatchedUpdatesWithAct(); + + // Then the options appear over the report and the combobox reports itself as expanded, which is what + // replacing the navigation to the editor page with an in-place dropdown has to produce + expect(screen.getByLabelText('ListField')).toHaveProp('accessibilityState', {expanded: true}); + expect(screen.getByText('Option2')).toBeOnTheScreen(); + }); + + it('keeps the option list open when one tap reaches the list field as both a press and a press out', async () => { + // Given a report holding a list field + await renderReportFields(1, [buildListField()]); + const listField = screen.getByLabelText('ListField'); + const tapEvent = {nativeEvent: {}}; + + // When a single tap reaches the field twice with the same event, the way the Android input reports it + // through both `onPressOut` and `onPress` + fireEvent(listField, 'pressOut', tapEvent); + fireEvent.press(listField, tapEvent); + await waitForBatchedUpdatesWithAct(); + + // Then the list stays open. The dropdown trigger toggles, so if the repeated report were not ignored the + // second call would close the list the first one opened, which is the list auto-closing on Android + expect(listField).toHaveProp('accessibilityState', {expanded: true}); + expect(screen.getByText('Option2')).toBeOnTheScreen(); + }); + + it('keeps the caret in the same place when the option list opens', async () => { + // Given a closed list field whose caret sits in a container padded on one side only + await renderReportFields(1, [buildListField()]); + + expect(getRenderedStyles()).toEqual(expect.arrayContaining([expect.objectContaining(caretContainer)])); + + // When the list is opened and the caret flips to point up + fireEvent.press(screen.getByLabelText('ListField')); + await waitForBatchedUpdatesWithAct(); + + // Then the caret is mirrored on the vertical axis rather than rotated. `rotate(180deg)` would carry the + // asymmetric padding around with it and slide the caret 11px left the instant the list opens, so the caret + // would visibly jump sideways every time the user opened a dropdown + const expandedStyles = getRenderedStyles(); + expect(expandedStyles).toEqual(expect.arrayContaining([expect.objectContaining({...caretContainer, transform: [{scaleY: -1}]})])); + expect(expandedStyles).not.toEqual(expect.arrayContaining([expect.objectContaining({transform: [{rotate: '180deg'}]})])); + }); + + it('does not pin the label of text and list fields in the raised position', async () => { + // Given a report holding both a text field and a list field + await renderReportFields(1, [buildListField()]); + + // When the inputs are inspected + const raisedLabels: unknown[] = []; + for (const node of screen.UNSAFE_root.findAll((candidate) => candidate.props.forceActiveLabel === true)) { + raisedLabels.push(node.props.label); + } + + // Then neither label is pinned, so an empty field shows its label inside the input like a placeholder and + // reads as empty instead of looking like a filled field + expect(raisedLabels).not.toEqual(expect.arrayContaining(['Field1'])); + expect(raisedLabels).not.toEqual(expect.arrayContaining(['ListField'])); + }); + + it('does not carry an unsaved edit into the next report', async () => { + // Given a report whose field holds an edit the user has typed but not saved yet + const {rerender} = await renderReportFields(1); + fireEvent.changeText(screen.getByLabelText('Field1'), 'Unsaved edit'); + await waitForBatchedUpdatesWithAct(); + + // When the next report arrow swaps in another report on the same workspace without remounting the screen + const policy = buildPolicy(1); + const nextReport = {...buildReport(), reportID: 'next_report_report_fields_grid'}; + rerender( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the field shows the next report's value, because the edit belonged to the previous report and the + // field key alone is the same on every report of the workspace + expect(screen.getByLabelText('Field1')).toHaveProp('value', 'Value1'); + }); + + it('sizes the option popover like the Spend dropdowns instead of matching the field width', async () => { + // Given a report holding a list field, which in the grid can be as narrow as a third of a row + await renderReportFields(1, [buildListField()]); + + // When its option list is opened + fireEvent.press(screen.getByLabelText('ListField')); + await waitForBatchedUpdatesWithAct(); + + // Then the popover takes the standard dropdown width rather than the width of the field it is anchored to, + // because a popover that narrow left the option labels cramped and truncated + const popover = screen.UNSAFE_getByType(PopoverWithMeasuredContent); + expect(popover.props.popoverDimensions).toEqual(expect.objectContaining({width: CONST.POPOVER_DROPDOWN_WIDTH})); + }); + + it('shows a delete button only on a field that was deleted from the workspace', async () => { + // Given a report holding one field that is still on the workspace and one the admin has since deleted. Both + // are `deletable`, so the button has to be decided by the field being gone from the workspace + await renderReportFields(1, [], [buildDeletedField()]); + + // When the fields are rendered + // Then the deleted field still shows its value and carries a delete button, because the editor page with its + // Delete option is no longer reachable and this is the only way to clear the stale value off the report + expect(screen.getByLabelText('DeletedField')).toHaveProp('value', 'Old value'); + expect(screen.getByLabelText('workspace.reportFields.delete, DeletedField')).toBeOnTheScreen(); + + // And the field that is still on the workspace has no delete button, since removing it would only bring it back + expect(screen.queryByLabelText('workspace.reportFields.delete, Field1')).not.toBeOnTheScreen(); + }); + + it('shows a delete button on a deleted date field even when its copy on the report is not deletable', async () => { + // Given a report holding a date field the admin has since deleted from the workspace, whose copy on the report + // is flagged as not deletable + await renderReportFields( + 1, + [], + [{...buildDeletedField(), name: 'DeletedDateField', fieldID: 'deletedDateField', type: CONST.REPORT_FIELD_TYPES.DATE, value: '2026-10-01', deletable: false}], + ); + + // When the fields are rendered + // Then it still carries a delete button, because the field is gone from the workspace and there is no other + // way to clear its stale value off the report + expect(screen.getByLabelText('workspace.reportFields.delete, DeletedDateField')).toBeOnTheScreen(); + }); + + it('deletes the field from the report once the delete is confirmed', async () => { + // Given a report holding a field that was deleted from the workspace + await renderReportFields(1, [], [buildDeletedField()]); + + // When its delete button is pressed and the confirmation is accepted + fireEvent.press(screen.getByLabelText('workspace.reportFields.delete, DeletedField')); + await waitForBatchedUpdatesWithAct(); + + // Then the user is asked first, as on the editor page, and the field is removed from this report + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + expect(deleteReportField).toHaveBeenCalledTimes(1); + expect(deleteReportField).toHaveBeenCalledWith(reportID, expect.objectContaining({fieldID: 'deletedField'})); + }); + + it('keeps the field when the delete is cancelled', async () => { + // Given a report holding a field that was deleted from the workspace + await renderReportFields(1, [], [buildDeletedField()]); + + // When its delete button is pressed but the confirmation is dismissed + mockShowConfirmModal.mockImplementationOnce(() => Promise.resolve({action: 'CLOSE'})); + fireEvent.press(screen.getByLabelText('workspace.reportFields.delete, DeletedField')); + await waitForBatchedUpdatesWithAct(); + + // Then nothing is deleted, so a stray tap on the button cannot wipe the value + expect(deleteReportField).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index e6d7c202320a..f61b9fb1e8a8 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -5,6 +5,8 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import MoneyRequestView from '@components/ReportActionItem/MoneyRequestView'; import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; +import useThemeStyles from '@hooks/useThemeStyles'; + import initOnyxDerivedValues from '@userActions/OnyxDerived'; import CONST from '@src/CONST'; @@ -12,6 +14,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; import type * as NativeNavigation from '@react-navigation/native'; +import type {StyleProp, TextStyle} from 'react-native'; import type {PartialDeep} from 'type-fest'; import escapeRegExp from 'lodash/escapeRegExp'; @@ -23,6 +26,46 @@ import * as LHNTestUtils from '../utils/LHNTestUtils'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +type MockMenuItemWithTopDescriptionProps = { + description?: string; + title?: string; + interactive?: boolean; + descriptionTextStyle?: StyleProp; + errorText?: string; + brickRoadIndicator?: string; +}; + +const mockMenuItemWithTopDescription = jest.fn(({description, title, interactive, errorText, brickRoadIndicator}: MockMenuItemWithTopDescriptionProps) => { + const RN = jest.requireActual>>('react-native'); + + return ( + <> + + {interactive ? 'editable' : 'readonly'} + + {title !== undefined && ( + + {title} + + )} + {errorText !== undefined && ( + + {errorText} + + )} + {brickRoadIndicator !== undefined && ( + + {brickRoadIndicator} + + )} + + ); +}); + +jest.mock('@expensify/react-native-hybrid-app', () => ({ + isHybridApp: jest.fn(() => false), +})); + jest.mock('@hooks/useLocalize', () => jest.fn(() => ({ translate: jest.fn((key: string) => key), @@ -58,19 +101,7 @@ jest.mock('@pages/inbox/report/AnimatedEmptyStateBackground', () => { // Title lives in a sibling element so existing toHaveTextContent('editable'|'readonly') assertions on // the menu-item testID stay strict-equal — they don't pick up the title text. jest.mock('@components/MenuItemWithTopDescription', () => { - const RN = jest.requireActual>>('react-native'); - return ({description, title, interactive}: {description?: string; title?: string; interactive?: boolean}) => ( - <> - - {interactive ? 'editable' : 'readonly'} - - {title !== undefined && ( - - {title} - - )} - - ); + return (props: MockMenuItemWithTopDescriptionProps) => mockMenuItemWithTopDescription(props); }); // Mock the legacy MenuItem (used for some fields like billable), but keep the real compound parts the migrated rows render with @@ -107,11 +138,23 @@ const parentReportActionID = 'parent_action_mrv'; const transactionID = 'txn_mrv_test'; const SCREEN_WRAPPER_STATUS = {didScreenTransitionEnd: true, shouldUseNarrowLayoutOnWideRHP: false, isSafeAreaTopPaddingApplied: true, isSafeAreaBottomPaddingApplied: true}; +let renderedThemeStyles: ReturnType | undefined; + +function ThemeStylesObserver() { + const styles = useThemeStyles(); + + React.useEffect(() => { + renderedThemeStyles = styles; + }, [styles]); + + return null; +} const renderMoneyRequestView = (threadReport: ReturnType, policy?: PartialDeep, readonly = false) => render( + { await act(async () => { await Onyx.clear(); }); + mockMenuItemWithTopDescription.mockClear(); }); const setupTestData = async (isSettledReport = false) => { @@ -254,6 +298,69 @@ describe('MoneyRequestView edit fields', () => { }); }); + it('preserves word wrapping when expense detail labels are made non-selectable', async () => { + // Given an expense with all labels affected by the non-selectable text styling + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + const longTagName = 'LongTagNameWithoutSpaces'; + + await setupTestData(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { + [longTagName]: {name: longTagName, orderWeight: 0, required: false, tags: {Berlin: {name: 'Berlin', enabled: true}}}, + }); + }); + + // When the expense details are rendered + renderMoneyRequestView(threadReport, {areTagsEnabled: true}); + await waitForBatchedUpdatesWithAct(); + + // Then each affected description keeps both breakWord and userSelectNone + await waitFor(() => { + const renderedRows = mockMenuItemWithTopDescription.mock.calls.map(([props]) => props); + const affectedRows = [ + renderedRows.find(({description}) => description === longTagName), + renderedRows.find(({description}) => description?.startsWith('iou.amount')), + renderedRows.find(({description}) => description === 'common.description'), + renderedRows.find(({description}) => description === 'common.merchant'), + renderedRows.find(({description}) => description === 'common.report'), + ]; + + expect(affectedRows).not.toContain(undefined); + for (const row of affectedRows) { + expect(row?.descriptionTextStyle).toEqual([renderedThemeStyles?.breakWord, renderedThemeStyles?.userSelectNone]); + } + }); + }); + + it('marks selectable toggle labels for multi-field copying', async () => { + // Given an expense that displays the Reimbursable and Billable toggles + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + + await setupTestData(); + + // When the expense details are rendered + renderMoneyRequestView(threadReport); + await waitForBatchedUpdatesWithAct(); + + // Then both selectable labels carry the marker used by the multi-field selection scraper + await waitFor(() => { + const toggleLabels = [screen.getByText(/reimbursable$/i, {includeHiddenElements: true}), screen.getByText('common.billable', {includeHiddenElements: true})]; + + for (const toggleLabel of toggleLabels) { + expect(toggleLabel).toHaveProp('dataSet', expect.objectContaining({[CONST.COPYABLE_TEXT_ELEMENT]: true})); + expect(toggleLabel).not.toHaveProp('dataSet', expect.objectContaining({[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true})); + } + }); + }); + it('hides the Category and Tag rows when the categories and tags belong to a different policy', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), @@ -482,13 +589,14 @@ describe('MoneyRequestView edit fields', () => { }); }); - it('should NOT append "Non-reimbursable" to the Amount description when the parent report has multiple expenses', async () => { + it('should append "Non-reimbursable" to the Amount description when the parent report has multiple expenses', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, parentReportActionID, }; + // Given a non-reimbursable expense on a report that has another expense await setupTestData(); await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {reimbursable: false}); @@ -505,25 +613,27 @@ describe('MoneyRequestView edit fields', () => { }); await waitForBatchedUpdatesWithAct(); + // When the expense details are rendered renderMoneyRequestView(threadReport); await waitForBatchedUpdatesWithAct(); + // Then the Amount description flags it as non-reimbursable, the same as in a single-expense report await waitFor(() => { - expect(screen.getByTestId(/^menu-item-iou\.amount/)).toBeOnTheScreen(); - expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).not.toBeOnTheScreen(); + expect(screen.getByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).toBeOnTheScreen(); }); }); - it('should append "Non-reimbursable" immediately when the only other expense is pending deletion', async () => { + it('should append "Reimbursable" to the Amount description when the parent report has multiple expenses', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, parentReportActionID, }; + // Given a reimbursable expense on a report that has another expense await setupTestData(); await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {reimbursable: false}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {reimbursable: true}); await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}_sibling`, { transactionID: `${transactionID}_sibling`, reportID: expenseReportID, @@ -533,20 +643,22 @@ describe('MoneyRequestView edit fields', () => { merchant: 'Sibling', comment: {}, reimbursable: false, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, }); }); await waitForBatchedUpdatesWithAct(); + // When the expense details are rendered renderMoneyRequestView(threadReport); await waitForBatchedUpdatesWithAct(); + // Then the Amount description flags it as reimbursable, the same as in a single-expense report await waitFor(() => { - expect(screen.getByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).toBeOnTheScreen(); + expect(screen.getByTestId(/^menu-item-iou\.amount.*iou\.reimbursable/i)).toBeOnTheScreen(); + expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).not.toBeOnTheScreen(); }); }); - it('should NOT append "Non-reimbursable" while offline because the pending-deleted expense is still rendered', async () => { + it('should append "Non-reimbursable" immediately when the only other expense is pending deletion', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, @@ -567,7 +679,6 @@ describe('MoneyRequestView edit fields', () => { reimbursable: false, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, }); - await Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline: true}); }); await waitForBatchedUpdatesWithAct(); @@ -575,8 +686,7 @@ describe('MoneyRequestView edit fields', () => { await waitForBatchedUpdatesWithAct(); await waitFor(() => { - expect(screen.getByTestId(/^menu-item-iou\.amount/)).toBeOnTheScreen(); - expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).not.toBeOnTheScreen(); + expect(screen.getByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).toBeOnTheScreen(); }); }); @@ -729,7 +839,7 @@ describe('MoneyRequestView edit fields', () => { }); }); - it('shows the supplier row on Xero without the vendorMatching beta because Xero (R3) is generally available', async () => { + it('shows the vendor row on Xero without the vendorMatching beta because Xero (R3) is generally available', async () => { // Given a non-reimbursable expense whose vendor is one of the synced Xero contacts const threadReport = { ...LHNTestUtils.getFakeReport(), @@ -757,11 +867,10 @@ describe('MoneyRequestView edit fields', () => { }); await waitForBatchedUpdatesWithAct(); - // Then the row is shown with the Supplier label and the contact name because Xero does not depend on the beta + // Then the row is shown with the Vendor label used for every integration and the contact name, because Xero does not depend on the beta await waitFor(() => { - expect(screen.getByLabelText(fieldLabel('common.supplier'))).toHaveTextContent(/Acme Xero/); + expect(screen.getByLabelText(fieldLabel('common.vendor'))).toHaveTextContent(/Acme Xero/); }); - expect(screen.queryByTestId('menu-item-common.vendor')).not.toBeOnTheScreen(); }); it.each([ @@ -857,7 +966,6 @@ describe('MoneyRequestView edit fields', () => { // Then no vendor row is shown because Business Central still depends on the beta await waitFor(() => { - expect(screen.queryByLabelText(fieldLabel('common.supplier'))).not.toBeOnTheScreen(); expect(screen.queryByLabelText(fieldLabel('common.vendor'))).not.toBeOnTheScreen(); }); }); @@ -1003,6 +1111,55 @@ describe('MoneyRequestView edit fields', () => { }); }); + it('shows the vendor-field error when assigned vendor is disabled in policyVendors', async () => { + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + + const disabledVendorID = 'disabled-vendor-id'; + + await setupTestData(); + await act(async () => { + await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.VENDOR_MATCHING]); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, { + [disabledVendorID]: { + externalID: disabledVendorID, + name: 'Disabled Vendor', + enabled: false, + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + reimbursable: false, + comment: {vendor: {externalID: disabledVendorID, wasManuallySet: false}}, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [ + { + name: CONST.VIOLATIONS.INACTIVE_VENDOR, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + }, + ]); + }); + await waitForBatchedUpdatesWithAct(); + + renderMoneyRequestView(threadReport, { + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: [{id: disabledVendorID, name: 'Disabled Vendor', currency: 'USD', email: 'vendor@example.com'}]}, + }, + }, + }); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('violations.inactiveVendor.'); + expect(screen.getByTestId('menu-item-brick-road-indicator')).toBeOnTheScreen(); + }); + }); + describe('commuter exclusion in the Distance field', () => { const selfDMReportID = 'self_dm_mrv_123'; // `translate` is mocked to return the key, so the commuter description is the plain distance label plus the "Original" key diff --git a/tests/ui/NewReportWorkspaceSelectionPageTest.tsx b/tests/ui/NewReportWorkspaceSelectionPageTest.tsx index ab06c1c2c008..efd44302d38f 100644 --- a/tests/ui/NewReportWorkspaceSelectionPageTest.tsx +++ b/tests/ui/NewReportWorkspaceSelectionPageTest.tsx @@ -25,6 +25,7 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct' jest.mock('@libs/actions/Report', () => ({ createNewReport: jest.fn(() => ({reportID: 'new-report-id'})), + openRecipientPicker: jest.fn(), })); const mockOpenCreateReportConfirmation = jest.fn(); diff --git a/tests/ui/NumericBigNumberPadTest.tsx b/tests/ui/NumericBigNumberPadTest.tsx new file mode 100644 index 000000000000..75dbcd0a0b75 --- /dev/null +++ b/tests/ui/NumericBigNumberPadTest.tsx @@ -0,0 +1,620 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import NumericInput from '@components/NumericInput'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; + +import type * as DeviceCapabilities from '@libs/DeviceCapabilities'; + +import type * as NativeNavigation from '@react-navigation/native'; + +import React from 'react'; + +import findAncestorWithStyle from '../utils/findAncestorWithStyle'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@libs/DeviceCapabilities', () => ({ + ...jest.requireActual('@libs/DeviceCapabilities'), + canUseTouchScreen: () => true, +})); + +const mockUseIsInLandscapeMode = jest.fn(() => false); +jest.mock('@hooks/useIsInLandscapeMode', () => () => mockUseIsInLandscapeMode()); + +const BOTTOM_SAFE_AREA_INSET = 21; +jest.mock('@hooks/useSafeAreaInsets', () => ({ + __esModule: true, + default: () => ({top: 0, right: 0, bottom: BOTTOM_SAFE_AREA_INSET, left: 0}), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: jest.fn(() => true), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + })), +})); + +const INPUT_TEST_ID = 'numeric-text-input'; +const ROOT_TEST_ID = 'layout'; +const PAD_TEST_ID = 'layout-pad'; + +function renderWithProviders(children: React.ReactNode) { + return render({children}); +} + +type NumericInputProps = React.ComponentProps; + +describe('NumericInput number pad', () => { + const onInputChange = jest.fn(); + + // The root renders the number pad itself on touch screens + const renderInputWithPad = (inputProps: Partial = {}, inputRef?: React.Ref) => + renderWithProviders( + + + , + ); + + afterEach(() => { + jest.clearAllMocks(); + mockUseIsInLandscapeMode.mockReturnValue(false); + }); + + it('renders the number pad buttons when touch screen is available', async () => { + // Given a device with touch screen support and an input rendered with BigNumberPad + renderInputWithPad(); + await waitForBatchedUpdatesWithAct(); + + // When checking the rendered elements + // Then all digit buttons, decimal separator, and backspace button are displayed on screen + for (let i = 0; i <= 9; i++) { + expect(screen.getByTestId(`button_${i}`)).toBeOnTheScreen(); + } + expect(screen.getByTestId('button_.')).toBeOnTheScreen(); + expect(screen.getByTestId('button_<')).toBeOnTheScreen(); + }); + + it('appends pressed digits to the input value', async () => { + // Given an input with initial value '1' + renderInputWithPad({value: '1'}); + await waitForBatchedUpdatesWithAct(); + + // When the digit '2' is pressed on the number pad + fireEvent.press(screen.getByTestId('button_2')); + await waitForBatchedUpdatesWithAct(); + + // Then '12' is reported and displayed + expect(onInputChange).toHaveBeenLastCalledWith('12'); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('12'); + }); + + it('adds a leading zero when the decimal separator is pressed on an empty input', async () => { + // Given an empty input accepting decimals + renderInputWithPad({value: ''}); + await waitForBatchedUpdatesWithAct(); + + // When the decimal separator '.' is pressed on the number pad + fireEvent.press(screen.getByTestId('button_.')); + await waitForBatchedUpdatesWithAct(); + + // Then '0.' is reported and displayed with a leading zero + expect(onInputChange).toHaveBeenLastCalledWith('0.'); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('0.'); + }); + + it('inserts a digit at the current caret position', async () => { + // Given an input with value '1234' and the caret positioned after '12' + renderInputWithPad({value: '1234'}); + await waitForBatchedUpdatesWithAct(); + + const input = screen.getByTestId(INPUT_TEST_ID); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 2, end: 2}}}); + await waitForBatchedUpdatesWithAct(); + + // When the digit '9' is pressed on the number pad + fireEvent.press(screen.getByTestId('button_9')); + await waitForBatchedUpdatesWithAct(); + + // Then '9' is inserted at the caret position resulting in '12934', with the caret right after it + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('12934'); + expect(screen.getByTestId(INPUT_TEST_ID).props.selection).toEqual({start: 3, end: 3}); + expect(onInputChange).toHaveBeenLastCalledWith('12934'); + }); + + it('inserts consecutive digits in a row mid-string', async () => { + // Given an input with value '1234' and the caret positioned after '12' + renderInputWithPad({value: '1234'}); + await waitForBatchedUpdatesWithAct(); + + const input = screen.getByTestId(INPUT_TEST_ID); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 2, end: 2}}}); + await waitForBatchedUpdatesWithAct(); + + // When digits '9' and '8' are pressed in a row on the number pad + fireEvent.press(screen.getByTestId('button_9')); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByTestId('button_8')); + await waitForBatchedUpdatesWithAct(); + + // Then both digits are inserted consecutively, resulting in '129834' + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('129834'); + expect(onInputChange).toHaveBeenLastCalledWith('129834'); + }); + + it('replaces the selected range when a digit is pressed', async () => { + // Given an input with value '1234' with characters '23' selected + renderInputWithPad({value: '1234'}); + await waitForBatchedUpdatesWithAct(); + + const input = screen.getByTestId(INPUT_TEST_ID); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 1, end: 3}}}); + await waitForBatchedUpdatesWithAct(); + + // When the digit '9' is pressed on the number pad + fireEvent.press(screen.getByTestId('button_9')); + await waitForBatchedUpdatesWithAct(); + + // Then the selected range is replaced by '9' resulting in '194' + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('194'); + expect(onInputChange).toHaveBeenLastCalledWith('194'); + }); + + it('deletes the character before the caret when backspace is pressed', async () => { + // Given an input with value '12' with the caret at the end + renderInputWithPad({value: '12'}); + await waitForBatchedUpdatesWithAct(); + + // When the backspace button is pressed on the number pad + fireEvent.press(screen.getByTestId('button_<')); + await waitForBatchedUpdatesWithAct(); + + // Then the last character '2' is deleted, leaving '1' + expect(onInputChange).toHaveBeenLastCalledWith('1'); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('1'); + }); + + it('deletes the selected range when backspace is pressed', async () => { + // Given an input with value '1234' with characters '23' selected + renderInputWithPad({value: '1234'}); + await waitForBatchedUpdatesWithAct(); + + const input = screen.getByTestId(INPUT_TEST_ID); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 1, end: 3}}}); + await waitForBatchedUpdatesWithAct(); + + // When the backspace button is pressed on the number pad + fireEvent.press(screen.getByTestId('button_<')); + await waitForBatchedUpdatesWithAct(); + + // Then the selected range is deleted, leaving '14' with the caret where the range started + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('14'); + expect(screen.getByTestId(INPUT_TEST_ID).props.selection).toEqual({start: 1, end: 1}); + expect(onInputChange).toHaveBeenLastCalledWith('14'); + }); + + it('does nothing when backspace is pressed with caret at position 0 for a non-empty value', async () => { + // Given an input with value '123' with the caret at the beginning (offset 0) + renderInputWithPad({value: '123'}); + await waitForBatchedUpdatesWithAct(); + + const input = screen.getByTestId(INPUT_TEST_ID); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 0, end: 0}}}); + await waitForBatchedUpdatesWithAct(); + + // When the backspace button is pressed on the number pad + fireEvent.press(screen.getByTestId('button_<')); + await waitForBatchedUpdatesWithAct(); + + // Then no change is reported and the value remains '123' + expect(onInputChange).not.toHaveBeenCalled(); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('123'); + }); + + it('clears the minus sign when backspace is pressed on an empty negative input', async () => { + // Given a negative input with an empty magnitude ('-') + renderInputWithPad({value: '-', allowNegative: true}); + await waitForBatchedUpdatesWithAct(); + + // When the backspace button is pressed on the number pad + fireEvent.press(screen.getByTestId('button_<')); + await waitForBatchedUpdatesWithAct(); + + // Then the minus sign is cleared and an empty value is reported + expect(screen.queryByText('-')).toBeNull(); + expect(onInputChange).toHaveBeenLastCalledWith(''); + }); + + it('clears the minus sign when backspace is pressed at offset 0 on a negative value', async () => { + // Given a negative input with value '-12' and the caret positioned at the beginning + renderInputWithPad({value: '-12', allowNegative: true}); + await waitForBatchedUpdatesWithAct(); + + const input = screen.getByTestId(INPUT_TEST_ID); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 0, end: 0}}}); + await waitForBatchedUpdatesWithAct(); + + // When the backspace button is pressed on the number pad + fireEvent.press(screen.getByTestId('button_<')); + await waitForBatchedUpdatesWithAct(); + + // Then the minus sign is cleared and '12' is reported and displayed + expect(screen.queryByText('-')).toBeNull(); + expect(onInputChange).toHaveBeenLastCalledWith('12'); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('12'); + }); + + it('appends pressed digits to a negative input while preserving the sign', async () => { + // Given a negative input with value '-1' + renderInputWithPad({value: '-1', allowNegative: true}); + await waitForBatchedUpdatesWithAct(); + + // When the digit '2' is pressed on the number pad + fireEvent.press(screen.getByTestId('button_2')); + await waitForBatchedUpdatesWithAct(); + + // Then '-12' is reported with the negative sign preserved + expect(onInputChange).toHaveBeenLastCalledWith('-12'); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('12'); + expect(screen.getByText('-')).toBeOnTheScreen(); + }); + + it('does nothing when backspace is pressed on an empty positive input', async () => { + // Given an empty positive input + renderInputWithPad({value: ''}); + await waitForBatchedUpdatesWithAct(); + + // When the backspace button is pressed on the number pad + fireEvent.press(screen.getByTestId('button_<')); + await waitForBatchedUpdatesWithAct(); + + // Then no change is reported and the input remains empty with the caret at the start + expect(onInputChange).not.toHaveBeenCalled(); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue(''); + expect(screen.getByTestId(INPUT_TEST_ID).props.selection).toEqual({start: 0, end: 0}); + }); + + it('rejects input that violates validation', async () => { + // Given an input that does not accept decimals (decimals: 0) and displays '1' + renderInputWithPad({value: '1', decimals: 0}); + await waitForBatchedUpdatesWithAct(); + + // When the decimal separator '.' is pressed on the number pad + fireEvent.press(screen.getByTestId('button_.')); + await waitForBatchedUpdatesWithAct(); + + // Then the decimal point is rejected and the value remains '1' + expect(onInputChange).not.toHaveBeenCalled(); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('1'); + }); + + it('does not buffer rejected inputs and correctly deletes the last character on backspace', async () => { + // Given an input with maxLength 4 and decimals 0 + renderInputWithPad({value: '', maxLength: 4, decimals: 0}); + await waitForBatchedUpdatesWithAct(); + + // When filling the input up to maxLength with digits 1, 2, 3, 4 + fireEvent.press(screen.getByTestId('button_1')); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByTestId('button_2')); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByTestId('button_3')); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByTestId('button_4')); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('1234'); + + // And pressing extra digits beyond maxLength (5, 6, 7, 8, 9) + fireEvent.press(screen.getByTestId('button_5')); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByTestId('button_6')); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByTestId('button_7')); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByTestId('button_8')); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByTestId('button_9')); + await waitForBatchedUpdatesWithAct(); + + // Then the display value remains '1234' + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('1234'); + + // When backspace is pressed once on the number pad + fireEvent.press(screen.getByTestId('button_<')); + await waitForBatchedUpdatesWithAct(); + + // Then the last valid character '4' is deleted immediately, leaving '123' + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('123'); + expect(onInputChange).toHaveBeenLastCalledWith('123'); + }); + + it('adds the leading zero inside the magnitude when the decimal separator is pressed on an empty negative input', async () => { + // Given a negative input whose magnitude is still empty ('-') + renderInputWithPad({value: '-', allowNegative: true}); + await waitForBatchedUpdatesWithAct(); + + // When the decimal separator '.' is pressed on the number pad + fireEvent.press(screen.getByTestId('button_.')); + await waitForBatchedUpdatesWithAct(); + + // Then the zero goes after the sign, because the pad edits only the magnitude + expect(onInputChange).toHaveBeenLastCalledWith('-0.'); + expect(screen.getByText('-')).toBeOnTheScreen(); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('0.'); + }); + + it('rejects a digit that would exceed maxLength', async () => { + // Given an input limited to two integer digits and displaying '12' + renderInputWithPad({value: '12', maxLength: 2}); + await waitForBatchedUpdatesWithAct(); + + // When the digit '3' is pressed on the number pad + fireEvent.press(screen.getByTestId('button_3')); + await waitForBatchedUpdatesWithAct(); + + // Then the press is rejected like typing, and the value remains '12' + expect(onInputChange).not.toHaveBeenCalled(); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('12'); + }); + + it('keeps the caret position for a forward-delete keypress followed by a pad deletion', async () => { + // Given an input with value '1234' and the caret positioned after '12' + renderInputWithPad({value: '1234'}); + await waitForBatchedUpdatesWithAct(); + + const input = screen.getByTestId(INPUT_TEST_ID); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 2, end: 2}}}); + await waitForBatchedUpdatesWithAct(); + + // When the character after the caret is forward-deleted with the keyboard + fireEvent(input, 'keyPress', {nativeEvent: {key: 'Delete', ctrlKey: false}}); + fireEvent.changeText(input, '124'); + await waitForBatchedUpdatesWithAct(); + + // And the backspace button is pressed on the number pad + fireEvent.press(screen.getByTestId('button_<')); + await waitForBatchedUpdatesWithAct(); + + // Then the forward delete left the caret in place, so the pad deletes the character before it, leaving '14' + expect(input).toHaveDisplayValue('14'); + expect(input.props.selection).toEqual({start: 1, end: 1}); + expect(onInputChange).toHaveBeenLastCalledWith('14'); + }); + + it('ignores selection changes while backspace is long pressed', async () => { + // Given an input with value '1234' and the caret positioned after '12' + renderInputWithPad({value: '1234'}); + await waitForBatchedUpdatesWithAct(); + + const input = screen.getByTestId(INPUT_TEST_ID); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 2, end: 2}}}); + await waitForBatchedUpdatesWithAct(); + expect(input.props.selection).toEqual({start: 2, end: 2}); + + // Fake timers keep the repeating deletion from firing, so only the selection guard is exercised + jest.useFakeTimers({doNotFake: ['nextTick']}); + try { + const backspaceButton = screen.getByTestId('button_<'); + + // When the backspace button is long pressed and native reports a selection change + fireEvent(backspaceButton, 'longPress'); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 0, end: 0}}}); + + // Then the selection is unchanged, because the held key keeps deleting from the caret it started at + expect(input.props.selection).toEqual({start: 2, end: 2}); + expect(input).toHaveDisplayValue('1234'); + + fireEvent(backspaceButton, 'pressOut'); + } finally { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + } + }); + + it('stops the backspace repeat when the layout remounts mid long press', async () => { + // Given an input in portrait with value '1234' and a held backspace long press + const tree = ( + + + + + + ); + const {rerender} = render(tree); + await waitForBatchedUpdatesWithAct(); + + jest.useFakeTimers({doNotFake: ['nextTick']}); + try { + fireEvent(screen.getByTestId('button_<'), 'longPress'); + + // When rotating to landscape swaps the layout root, remounting the pad before the button is released + mockUseIsInLandscapeMode.mockReturnValue(true); + rerender(tree); + + // And the user types a digit and the old repeat interval would have ticked several times + fireEvent.press(screen.getByTestId('button_9')); + act(() => { + jest.advanceTimersByTime(500); + }); + + // Then the unmounted pad no longer deletes, so the typed digit is not overwritten by stale backspaces + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('12349'); + } finally { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + } + }); + + it('focuses the input when a keypad number is pressed', async () => { + // Given an input rendered with BigNumberPad and a ref tracking focus + const inputRef = React.createRef(); + renderInputWithPad({value: '1'}, inputRef); + await waitForBatchedUpdatesWithAct(); + + const inputElement = inputRef.current; + if (!inputElement) { + throw new Error('Numeric input ref was not assigned'); + } + const focusSpy = jest.spyOn(inputElement, 'focus'); + + // When a number button is pressed on the number pad + fireEvent.press(screen.getByTestId('button_2')); + await waitForBatchedUpdatesWithAct(); + + // Then the input is focused + expect(focusSpy).toHaveBeenCalledTimes(1); + focusSpy.mockRestore(); + }); + + it('deletes several characters continuously when backspace is held down', async () => { + jest.useFakeTimers(); + try { + // Given an input with value '12345' and a ref tracking focus + const inputRef = React.createRef(); + renderInputWithPad({value: '12345'}, inputRef); + await waitForBatchedUpdatesWithAct(); + + const inputElement = inputRef.current; + if (!inputElement) { + throw new Error('Numeric input ref was not assigned'); + } + const focusSpy = jest.spyOn(inputElement, 'focus'); + + const backspaceButton = screen.getByTestId('button_<'); + + // When the user starts long-pressing the backspace button + fireEvent(backspaceButton, 'longPress'); + await waitForBatchedUpdatesWithAct(); + + // And timers advance for several deletion ticks + act(() => { + jest.advanceTimersByTime(300); + }); + await waitForBatchedUpdatesWithAct(); + + // Then characters are deleted continuously, leaving '12' + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('12'); + expect(onInputChange).toHaveBeenLastCalledWith('12'); + + // When the user releases the backspace button + focusSpy.mockClear(); + fireEvent(backspaceButton, 'pressOut'); + await waitForBatchedUpdatesWithAct(); + + // And timers advance further + act(() => { + jest.advanceTimersByTime(300); + }); + await waitForBatchedUpdatesWithAct(); + + // Then deletion stops and the input is refocused + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('12'); + expect(focusSpy).toHaveBeenCalledTimes(1); + focusSpy.mockRestore(); + } finally { + jest.useRealTimers(); + } + }); + + it('keeps the first caret move after a backspace long press', async () => { + // Given '123' with the caret at the end + renderInputWithPad({value: '123'}); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByTestId(INPUT_TEST_ID); + const backspace = screen.getByTestId('button_<'); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 3, end: 3}}}); + await waitForBatchedUpdatesWithAct(); + + // When backspace repeats once, its native caret echo arrives while held, and the key is released + jest.useFakeTimers({doNotFake: ['nextTick']}); + try { + fireEvent(backspace, 'longPress'); + act(() => { + jest.advanceTimersByTime(100); + }); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 2, end: 2}}}); + fireEvent(backspace, 'pressOut'); + } finally { + jest.useRealTimers(); + } + await waitForBatchedUpdatesWithAct(); + + // And the user moves the caret to the start and types 9 + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 0, end: 0}}}); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByTestId('button_9')); + await waitForBatchedUpdatesWithAct(); + + // Then 9 lands at the start, because the caret move is not swallowed as a stale echo + expect(input).toHaveDisplayValue('912'); + }); + + it('deletes continuously when parent controls value', async () => { + // Given a parent controlling the value with '12345' + jest.useFakeTimers(); + try { + function Controlled() { + const [val, setVal] = React.useState('12345'); + return ( + + + + ); + } + renderWithProviders(); + await waitForBatchedUpdatesWithAct(); + + const backspaceButton = screen.getByTestId('button_<'); + + // When backspace is long-pressed for 300ms + fireEvent(backspaceButton, 'longPress'); + await waitForBatchedUpdatesWithAct(); + + act(() => { + jest.advanceTimersByTime(300); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the value is repeatedly deleted down to '12' + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('12'); + + fireEvent(backspaceButton, 'pressOut'); + } finally { + jest.useRealTimers(); + } + }); + + it('fills the width of its column', async () => { + // Given the number pad rendered in the layout's pad column, which centers its content + renderInputWithPad(); + await waitForBatchedUpdatesWithAct(); + + // When looking for the closest full-width view around a key + const fullWidthView = findAncestorWithStyle(screen.getByTestId('button_1'), 'width', '100%'); + + // Then the pad itself spans the column, because a centered pad would shrink to its content and stack the key columns onto each other + expect(fullWidthView).toBeDefined(); + expect(fullWidthView).not.toBe(screen.getByTestId(PAD_TEST_ID)); + expect(screen.getByTestId(PAD_TEST_ID)).toContainElement(fullWidthView ?? null); + }); +}); diff --git a/tests/ui/NumericBigNumberPadWithoutTouchScreenTest.tsx b/tests/ui/NumericBigNumberPadWithoutTouchScreenTest.tsx new file mode 100644 index 000000000000..426c62c0526c --- /dev/null +++ b/tests/ui/NumericBigNumberPadWithoutTouchScreenTest.tsx @@ -0,0 +1,48 @@ +import {render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import NumericInput from '@components/NumericInput'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import type * as DeviceCapabilities from '@libs/DeviceCapabilities'; + +import type * as NativeNavigation from '@react-navigation/native'; + +import React from 'react'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@libs/DeviceCapabilities', () => ({ + ...jest.requireActual('@libs/DeviceCapabilities'), + canUseTouchScreen: () => false, +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: jest.fn(() => true), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + })), +})); + +const INPUT_TEST_ID = 'numeric-text-input'; + +describe('NumericInput number pad without touch screen', () => { + it('does not render when touch screen is not available', async () => { + // Given a device without touch screen support + render( + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // When checking the rendered output + // Then the number pad buttons are not rendered + expect(screen.queryByTestId('button_1')).toBeNull(); + }); +}); diff --git a/tests/ui/NumericButtonTest.tsx b/tests/ui/NumericButtonTest.tsx index 4bd1dd19ebed..1adbb97786f8 100644 --- a/tests/ui/NumericButtonTest.tsx +++ b/tests/ui/NumericButtonTest.tsx @@ -200,9 +200,8 @@ describe('Numeric buttons', () => { value="12" onInputChange={onInputChange} allowNegative + actions={} > - - , ); diff --git a/tests/ui/NumericInputLayoutTabletTest.tsx b/tests/ui/NumericInputLayoutTabletTest.tsx new file mode 100644 index 000000000000..5bf259f4ade9 --- /dev/null +++ b/tests/ui/NumericInputLayoutTabletTest.tsx @@ -0,0 +1,61 @@ +import {screen} from '@testing-library/react-native'; + +import type * as DeviceCapabilities from '@libs/DeviceCapabilities'; + +import type * as NativeNavigation from '@react-navigation/native'; +import type DeviceInfoModule from 'react-native-device-info'; + +import {StyleSheet} from 'react-native'; + +import {BODY_TEST_ID, FOOTER_TEST_ID, ROOT_TEST_ID, renderLayout} from '../utils/NumericInputLayoutTestUtils'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const TABLET_LANDSCAPE = {windowWidth: 1366, windowHeight: 1024}; + +const mockWindowDimensions = jest.fn(() => TABLET_LANDSCAPE); + +jest.mock('@hooks/useWindowDimensions', () => () => mockWindowDimensions()); + +// The tablet check of the native landscape helper (isInLandscapeMode/index.native.ts) runs once when it is imported, so it lives in its own test file +jest.mock('react-native-device-info', () => { + const mockDeviceInfo = jest.requireActual('react-native-device-info/jest/react-native-device-info-mock'); + return { + __esModule: true, + default: {...mockDeviceInfo, isTablet: () => true}, + }; +}); + +jest.mock('@libs/DeviceCapabilities', () => ({ + ...jest.requireActual('@libs/DeviceCapabilities'), + canUseTouchScreen: () => true, +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: jest.fn(() => true), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + })), +})); + +describe('NumericInput layout on tablets', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('keeps a single scrollable column on a tablet in landscape', async () => { + // Given a tablet whose window is wider than it is tall, which only phones split into two columns + mockWindowDimensions.mockReturnValue(TABLET_LANDSCAPE); + + // When the layout renders + renderLayout(); + await waitForBatchedUpdatesWithAct(); + + // Then the root is the scroll view holding the footer, and the body stays a plain column + const root = screen.getByTestId(ROOT_TEST_ID); + expect(StyleSheet.flatten(root.props.contentContainerStyle)).toMatchObject({flexGrow: 1}); + expect(root).toContainElement(screen.getByTestId(FOOTER_TEST_ID)); + expect(screen.getByTestId(BODY_TEST_ID).props.contentContainerStyle).toBeUndefined(); + }); +}); diff --git a/tests/ui/NumericInputLayoutTest.tsx b/tests/ui/NumericInputLayoutTest.tsx new file mode 100644 index 000000000000..2d4757c91fd4 --- /dev/null +++ b/tests/ui/NumericInputLayoutTest.tsx @@ -0,0 +1,469 @@ +import {fireEvent, renderHook, screen} from '@testing-library/react-native'; + +import Button from '@components/Button'; +import NumericInput from '@components/NumericInput'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; + +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; + +import type * as DeviceCapabilities from '@libs/DeviceCapabilities'; + +import type * as NativeNavigation from '@react-navigation/native'; + +import React from 'react'; +import {StyleSheet} from 'react-native'; + +import findAncestorWithStyle from '../utils/findAncestorWithStyle'; +import { + AMOUNT_TEST_ID, + BODY_TEST_ID, + FLIP_TEST_ID, + FOOTER_TEST_ID, + INPUT_TEST_ID, + MAIN_TEST_ID, + PAD_TEST_ID, + ROOT_TEST_ID, + getLayoutStyles, + renderWithProviders, +} from '../utils/NumericInputLayoutTestUtils'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const PORTRAIT_PHONE = {windowWidth: 390, windowHeight: 844}; +const LANDSCAPE_PHONE = {windowWidth: 844, windowHeight: 390}; +const NO_SAFE_AREA_INSETS = {top: 0, right: 0, bottom: 0, left: 0}; +const HOME_INDICATOR_INSETS = {...NO_SAFE_AREA_INSETS, bottom: 34}; + +// The real useIsInLandscapeMode runs on top of these dimensions. Jest resolves its native helper (isInLandscapeMode/index.native.ts), +// where the device info mock reports a phone (not a tablet), so the web helper based on window.screen is not covered here +const mockWindowDimensions = jest.fn(() => PORTRAIT_PHONE); + +jest.mock('@hooks/useWindowDimensions', () => () => mockWindowDimensions()); + +// No inset by default; tests that check where the bottom safe area goes give the screen a home indicator +const mockSafeAreaInsets = jest.fn(() => NO_SAFE_AREA_INSETS); + +jest.mock('@hooks/useSafeAreaInsets', () => ({ + __esModule: true, + default: () => mockSafeAreaInsets(), +})); + +jest.mock('@libs/DeviceCapabilities', () => ({ + ...jest.requireActual('@libs/DeviceCapabilities'), + canUseTouchScreen: () => true, +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: jest.fn(() => true), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + })), +})); + +type LayoutOptions = { + /** Whether the layout renders a footer below the body */ + hasFooter?: boolean; +}; + +function renderLayout(inputRef?: React.Ref, options?: LayoutOptions) { + return renderWithProviders(getLayout(inputRef, options)); +} + +function getLayout(inputRef?: React.Ref, {hasFooter = true}: LayoutOptions = {}) { + return ( + // The decimals keep the value as given: sanitizing it on mount would move the caret to the end manually and swallow the + // next native selection event as its echo, so the highlighted range the refocus tests set would never apply + Save : undefined} + > + + % + + ); +} + +/** Bottom safe area padding the layout parts add, read through the same hook. Call it before rendering the layout, since it updates `screen`. */ +function getSafeAreaPaddingBottom() { + return renderHook(() => useSafeAreaPaddings(true)).result.current.paddingBottom; +} + +function isFocusableInput(input: BaseTextInputRef): input is BaseTextInputRef & {isFocused: () => boolean} { + return 'isFocused' in input && typeof input.isFocused === 'function'; +} + +function createMouseDownEvent(target: Node | undefined) { + return {nativeEvent: {target}, preventDefault: jest.fn(), isDefaultPrevented: () => false}; +} + +describe('NumericInput layout', () => { + beforeEach(() => { + mockWindowDimensions.mockReturnValue(PORTRAIT_PHONE); + mockSafeAreaInsets.mockReturnValue(NO_SAFE_AREA_INSETS); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders a single scrollable column with the footer inside the scroll view outside phone landscape', async () => { + // Given a phone in portrait (tablets in landscape are covered by NumericInputLayoutTabletTest) + mockWindowDimensions.mockReturnValue(PORTRAIT_PHONE); + + // When the layout renders + renderLayout(); + await waitForBatchedUpdatesWithAct(); + + // Then the root is the scroll view holding the body and footer, and the body is a plain column + const root = screen.getByTestId(ROOT_TEST_ID); + expect(StyleSheet.flatten(root.props.contentContainerStyle)).toMatchObject({flexGrow: 1}); + expect(root).toContainElement(screen.getByTestId(FOOTER_TEST_ID)); + expect(screen.getByTestId(BODY_TEST_ID).props.contentContainerStyle).toBeUndefined(); + }); + + it('renders two columns in a scrollable row with the footer pinned outside it on phones in landscape', async () => { + // Given a phone in landscape, the only case that splits the screen into two columns + mockWindowDimensions.mockReturnValue(LANDSCAPE_PHONE); + const styles = getLayoutStyles(); + + // When the layout renders + renderLayout(); + await waitForBatchedUpdatesWithAct(); + + // Then the body is a scroll view laying the main column and the pad side by side + const body = screen.getByTestId(BODY_TEST_ID); + expect(StyleSheet.flatten(body.props.contentContainerStyle)).toMatchObject({flexDirection: 'row'}); + expect(body).toContainElement(screen.getByTestId(MAIN_TEST_ID)); + expect(body).toContainElement(screen.getByTestId(PAD_TEST_ID)); + + // Then the pad takes the rest of the row next to the fixed-width main column and is centred vertically in it + expect(screen.getByTestId(PAD_TEST_ID)).toHaveStyle({...styles.flex1, ...styles.justifyContentCenter}); + + // Then the footer stays outside the scrolling body, so it remains visible + expect(body).not.toContainElement(screen.getByTestId(FOOTER_TEST_ID)); + expect(screen.getByTestId(ROOT_TEST_ID).props.contentContainerStyle).toBeUndefined(); + + // Then the main column is centred in the row at its content height rather than stretched to the row's height, so the + // growing amount container has no free space to push the actions and error down to the bottom of the screen + expect(screen.getByTestId(MAIN_TEST_ID)).toHaveStyle({...styles.alignSelfCenter, ...styles.numberWithSymbolFormInputContainerLandscape}); + }); + + it('switches between one and two columns when the phone rotates', async () => { + // Given the layout rendered on a phone in portrait, as a single scrollable column + const {rerender} = renderLayout(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByTestId(ROOT_TEST_ID)).toContainElement(screen.getByTestId(FOOTER_TEST_ID)); + + // When the phone rotates to landscape + mockWindowDimensions.mockReturnValue(LANDSCAPE_PHONE); + rerender(getLayout()); + await waitForBatchedUpdatesWithAct(); + + // Then the body becomes the scrollable row and the footer is pinned outside it + const body = screen.getByTestId(BODY_TEST_ID); + expect(StyleSheet.flatten(body.props.contentContainerStyle)).toMatchObject({flexDirection: 'row'}); + expect(body).not.toContainElement(screen.getByTestId(FOOTER_TEST_ID)); + + // When the phone rotates back to portrait + mockWindowDimensions.mockReturnValue(PORTRAIT_PHONE); + rerender(getLayout()); + await waitForBatchedUpdatesWithAct(); + + // Then the root is the scroll view again, holding the footer + expect(StyleSheet.flatten(screen.getByTestId(ROOT_TEST_ID).props.contentContainerStyle)).toMatchObject({flexGrow: 1}); + expect(screen.getByTestId(BODY_TEST_ID).props.contentContainerStyle).toBeUndefined(); + }); + + it('leaves the bottom spacing to the screen edge when a footer follows the pad in a single column', async () => { + // Given a phone in portrait, where the pad and the footer stack at the bottom of the column + mockWindowDimensions.mockReturnValue(PORTRAIT_PHONE); + const styles = getLayoutStyles(); + + // When the layout renders a footer below the pad + renderLayout(undefined, {hasFooter: true}); + await waitForBatchedUpdatesWithAct(); + + // Then the footer keeps a gap from the keys right above it and the scroll view pads the bottom + expect(StyleSheet.flatten(screen.getByTestId(FOOTER_TEST_ID).props.style)).toMatchObject({...styles.alignItemsCenter, ...styles.mt5}); + expect(StyleSheet.flatten(screen.getByTestId(ROOT_TEST_ID).props.contentContainerStyle)).toMatchObject(styles.pb5); + }); + + it('adds the bottom safe area to the scroll view in a single column, not to the footer inside it', async () => { + // Given a phone in portrait with a home indicator, where the footer scrolls with the column + mockWindowDimensions.mockReturnValue(PORTRAIT_PHONE); + mockSafeAreaInsets.mockReturnValue(HOME_INDICATOR_INSETS); + const styles = getLayoutStyles(); + const safeAreaPaddingBottom = getSafeAreaPaddingBottom(); + + // When the layout renders a footer + renderLayout(undefined, {hasFooter: true}); + await waitForBatchedUpdatesWithAct(); + + // Then the scroll view clears the home indicator below its spacing, and the footer adds nothing on top, so the safe area is not doubled + expect(safeAreaPaddingBottom).toBeGreaterThan(0); + expect(StyleSheet.flatten(screen.getByTestId(ROOT_TEST_ID).props.contentContainerStyle)).toMatchObject({paddingBottom: styles.pb5.paddingBottom + safeAreaPaddingBottom}); + expect(StyleSheet.flatten(screen.getByTestId(FOOTER_TEST_ID).props.style)).not.toHaveProperty('paddingBottom'); + }); + + it('adds the bottom safe area to the pinned footer in two columns', async () => { + // Given a phone in landscape with a home indicator, where the footer is pinned below the scrollable columns + mockWindowDimensions.mockReturnValue(LANDSCAPE_PHONE); + mockSafeAreaInsets.mockReturnValue(HOME_INDICATOR_INSETS); + const styles = getLayoutStyles(); + const safeAreaPaddingBottom = getSafeAreaPaddingBottom(); + + // When the layout renders a footer + renderLayout(undefined, {hasFooter: true}); + await waitForBatchedUpdatesWithAct(); + + // Then the footer clears the home indicator itself, since it is the last element above the screen edge, and the body above it adds nothing + expect(safeAreaPaddingBottom).toBeGreaterThan(0); + expect(StyleSheet.flatten(screen.getByTestId(FOOTER_TEST_ID).props.style)).toMatchObject({paddingBottom: styles.pb5.paddingBottom + safeAreaPaddingBottom}); + expect(StyleSheet.flatten(screen.getByTestId(BODY_TEST_ID).props.contentContainerStyle)).not.toHaveProperty('paddingBottom'); + }); + + it('adds the bottom safe area to the scrolling body in two columns when there is no footer', async () => { + // Given a phone in landscape with a home indicator and no footer pinned below the scrollable columns + mockWindowDimensions.mockReturnValue(LANDSCAPE_PHONE); + mockSafeAreaInsets.mockReturnValue(HOME_INDICATOR_INSETS); + const safeAreaPaddingBottom = getSafeAreaPaddingBottom(); + + // When the layout renders without a footer + renderLayout(undefined, {hasFooter: false}); + await waitForBatchedUpdatesWithAct(); + + // Then the body clears the home indicator itself, since it is the last element above the screen edge + expect(safeAreaPaddingBottom).toBeGreaterThan(0); + expect(screen.queryByTestId(FOOTER_TEST_ID)).toBeNull(); + expect(StyleSheet.flatten(screen.getByTestId(BODY_TEST_ID).props.contentContainerStyle)).toMatchObject({paddingBottom: safeAreaPaddingBottom}); + }); + + it.each([ + ['a single column footer', PORTRAIT_PHONE, FOOTER_TEST_ID], + ['a single column pad gap', PORTRAIT_PHONE, PAD_TEST_ID], + ['two columns footer', LANDSCAPE_PHONE, FOOTER_TEST_ID], + ['two columns pad gap', LANDSCAPE_PHONE, PAD_TEST_ID], + ])('keeps the input focused and collapses selection when pressing an empty area of the layout in %s', async (_, windowDimensions, targetTestID) => { + // Given an input with a highlighted range, so the refocus has a selection to collapse, in a layout whose root differs per column count + mockWindowDimensions.mockReturnValue(windowDimensions); + const inputRef = React.createRef(); + renderLayout(inputRef); + await waitForBatchedUpdatesWithAct(); + + const input = screen.getByTestId(INPUT_TEST_ID); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 0, end: 2}}}); + await waitForBatchedUpdatesWithAct(); + + if (!inputRef.current) { + throw new Error('Numeric input ref was not assigned'); + } + const focusSpy = jest.spyOn(inputRef.current, 'focus'); + + // When pressing an empty area of the layout, which bubbles up to the layout root + const event = createMouseDownEvent(document.createElement('div')); + fireEvent(screen.getByTestId(targetTestID), 'mouseDown', event); + await waitForBatchedUpdatesWithAct(); + + // Then the browser blur is prevented, the selection collapses, and the input is refocused + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(input.props.selection).toEqual({start: 2, end: 2}); + expect(focusSpy).toHaveBeenCalledTimes(1); + focusSpy.mockRestore(); + }); + + it('collapses the selection without focusing again when the input is already focused', async () => { + // Given an input that is already focused and has a highlighted range + const inputRef = React.createRef(); + renderLayout(inputRef); + await waitForBatchedUpdatesWithAct(); + + const input = screen.getByTestId(INPUT_TEST_ID); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 0, end: 2}}}); + await waitForBatchedUpdatesWithAct(); + + const inputElement = inputRef.current; + if (!inputElement || !isFocusableInput(inputElement)) { + throw new Error('Numeric input ref was not assigned to a focusable input'); + } + const isFocusedSpy = jest.spyOn(inputElement, 'isFocused').mockReturnValue(true); + const focusSpy = jest.spyOn(inputElement, 'focus'); + + // When pressing an empty area of the layout + const event = createMouseDownEvent(document.createElement('div')); + fireEvent(screen.getByTestId(FOOTER_TEST_ID), 'mouseDown', event); + await waitForBatchedUpdatesWithAct(); + + // Then the browser blur is still prevented and the selection collapses, but focus is not requested twice + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(input.props.selection).toEqual({start: 2, end: 2}); + expect(focusSpy).not.toHaveBeenCalled(); + isFocusedSpy.mockRestore(); + focusSpy.mockRestore(); + }); + + it.each([ + ['a button', () => document.createElement('button')], + [ + 'an element with the button role', + () => { + const element = document.createElement('div'); + element.setAttribute('role', 'button'); + return element; + }, + ], + ['the input', () => document.createElement('input')], + [ + 'an element nested inside a button', + () => { + const button = document.createElement('button'); + const label = document.createElement('span'); + button.appendChild(label); + return label; + }, + ], + ])('does not interfere when pressing %s', async (_, createTarget) => { + // Given the layout, whose root listens to every mouse down bubbling from its parts, and an input with a highlighted range + const inputRef = React.createRef(); + renderLayout(inputRef); + await waitForBatchedUpdatesWithAct(); + + const input = screen.getByTestId(INPUT_TEST_ID); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 0, end: 2}}}); + await waitForBatchedUpdatesWithAct(); + + if (!inputRef.current) { + throw new Error('Numeric input ref was not assigned'); + } + const focusSpy = jest.spyOn(inputRef.current, 'focus'); + + // When pressing an interactive element inside the layout + const event = createMouseDownEvent(createTarget()); + fireEvent(screen.getByTestId(FOOTER_TEST_ID), 'mouseDown', event); + await waitForBatchedUpdatesWithAct(); + + // Then its default behavior is kept, the input is not refocused, and the highlighted range stays, so the press reaches the element + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(focusSpy).not.toHaveBeenCalled(); + expect(input.props.selection).toEqual({start: 0, end: 2}); + focusSpy.mockRestore(); + }); + + it('does not refocus again when a control already handled the press', async () => { + // Given the layout and a mouse down that a control keeping the caret itself (such as the flip button) already prevented + const inputRef = React.createRef(); + renderLayout(inputRef); + await waitForBatchedUpdatesWithAct(); + + if (!inputRef.current) { + throw new Error('Numeric input ref was not assigned'); + } + const focusSpy = jest.spyOn(inputRef.current, 'focus'); + const event = {...createMouseDownEvent(document.createElement('div')), isDefaultPrevented: () => true}; + + // When the event bubbles up to the layout root + fireEvent(screen.getByTestId(FOOTER_TEST_ID), 'mouseDown', event); + await waitForBatchedUpdatesWithAct(); + + // Then the root leaves it alone and does not refocus the input on top of the control + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(focusSpy).not.toHaveBeenCalled(); + focusSpy.mockRestore(); + }); + + it('lays the actions out in a centered row below the amount area on touch screens', async () => { + // Given a currency button and a flip button passed as actions on a touch screen + const styles = getLayoutStyles(); + + // When the layout renders + renderWithProviders( + + + + + } + > + + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then both buttons sit in the main column but outside the amount area, so they never shift the centered amount + const currencyButton = screen.getByText('USD'); + expect(screen.getByTestId(MAIN_TEST_ID)).toContainElement(currencyButton); + expect(screen.getByTestId(MAIN_TEST_ID)).toContainElement(screen.getByTestId(FLIP_TEST_ID)); + expect(screen.getByTestId(AMOUNT_TEST_ID)).not.toContainElement(currencyButton); + + // Then the buttons share one centered row spaced by the layout gap, so they sit side by side under the amount + const actionsRow = findAncestorWithStyle(screen.getByTestId(FLIP_TEST_ID), 'gap', styles.gap2.gap); + expect(actionsRow).toHaveStyle({...styles.flexRow, ...styles.justifyContentCenter}); + expect(actionsRow).toContainElement(currencyButton); + }); + + it('floats the error over the bottom of the amount area in a single column', async () => { + // Given an error on a phone in portrait + const styles = getLayoutStyles(); + + // When the layout renders + renderWithProviders( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the error is positioned absolutely inside the amount area, so showing it never moves the amount or the pad + const error = screen.getByText('Amount is too high'); + const floatingError = findAncestorWithStyle(error, 'position', 'absolute'); + expect(floatingError).toBeDefined(); + expect(screen.getByTestId(AMOUNT_TEST_ID)).toContainElement(floatingError ?? null); + expect(floatingError).toHaveStyle({...styles.b0, ...styles.mb5}); + + // Then the amount container reserves minimum height to prevent layout shifts + expect(findAncestorWithStyle(screen.getByTestId(INPUT_TEST_ID), 'minHeight', styles.moneyRequestAmountContainer.minHeight)).toBeDefined(); + }); + + it('keeps the error in flow under the amount in two columns', async () => { + // Given an error on a phone in landscape, where the main column is sized by its content + mockWindowDimensions.mockReturnValue(LANDSCAPE_PHONE); + const styles = getLayoutStyles(); + + // When the layout renders + renderWithProviders( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the error follows the amount area in the main column instead of floating over it + const error = screen.getByText('Amount is too high'); + expect(screen.getByTestId(MAIN_TEST_ID)).toContainElement(error); + expect(screen.getByTestId(AMOUNT_TEST_ID)).not.toContainElement(error); + expect(findAncestorWithStyle(error, 'position', 'absolute')).toBeFalsy(); + + // Then the amount row reserves no room for a floating error, which would only add empty height to the centred column + expect(findAncestorWithStyle(screen.getByTestId(INPUT_TEST_ID), 'minHeight', styles.moneyRequestAmountContainer.minHeight)).toBeUndefined(); + }); +}); diff --git a/tests/ui/NumericInputLayoutWithoutTouchScreenTest.tsx b/tests/ui/NumericInputLayoutWithoutTouchScreenTest.tsx new file mode 100644 index 000000000000..686221812f4b --- /dev/null +++ b/tests/ui/NumericInputLayoutWithoutTouchScreenTest.tsx @@ -0,0 +1,120 @@ +import {screen} from '@testing-library/react-native'; + +import NumericInput from '@components/NumericInput'; + +import type * as DeviceCapabilities from '@libs/DeviceCapabilities'; + +import type * as NativeNavigation from '@react-navigation/native'; + +import React from 'react'; +import {StyleSheet} from 'react-native'; + +import findAncestorWithStyle from '../utils/findAncestorWithStyle'; +import { + AMOUNT_TEST_ID, + FOOTER_TEST_ID, + INPUT_TEST_ID, + MAIN_TEST_ID, + PAD_TEST_ID, + ROOT_TEST_ID, + getLayoutStyles, + renderLayout, + renderWithProviders, +} from '../utils/NumericInputLayoutTestUtils'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const PORTRAIT_PHONE = {windowWidth: 390, windowHeight: 844}; +const LANDSCAPE_PHONE = {windowWidth: 844, windowHeight: 390}; + +// The real useIsInLandscapeMode runs on top of these dimensions. Jest resolves its native helper (isInLandscapeMode/index.native.ts), +// where the device info mock reports a phone (not a tablet), so the web helper based on window.screen is not covered here +const mockWindowDimensions = jest.fn(() => PORTRAIT_PHONE); + +jest.mock('@hooks/useWindowDimensions', () => () => mockWindowDimensions()); + +jest.mock('@libs/DeviceCapabilities', () => ({ + ...jest.requireActual('@libs/DeviceCapabilities'), + canUseTouchScreen: () => false, +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: jest.fn(() => true), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + })), +})); + +describe('NumericInput layout without touch screen', () => { + beforeEach(() => { + mockWindowDimensions.mockReturnValue(PORTRAIT_PHONE); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('lets the footer pad the bottom of the screen with no top gap, since there is no number pad to end the body', async () => { + // Given a device without a touch screen, where the number pad never renders + const styles = getLayoutStyles(); + + // When the layout renders a footer + renderLayout(); + await waitForBatchedUpdatesWithAct(); + + // Then the pad leaves no empty container, and the centered footer owns the bottom spacing instead of the scroll view + expect(screen.queryByTestId(PAD_TEST_ID)).toBeNull(); + expect(StyleSheet.flatten(screen.getByTestId(FOOTER_TEST_ID).props.style)).toMatchObject({...styles.alignItemsCenter, ...styles.pb5}); + expect(StyleSheet.flatten(screen.getByTestId(FOOTER_TEST_ID).props.style)).not.toHaveProperty('marginTop'); + expect(StyleSheet.flatten(screen.getByTestId(ROOT_TEST_ID).props.contentContainerStyle)).not.toHaveProperty('paddingBottom'); + }); + + it.each([ + ['portrait', PORTRAIT_PHONE], + ['landscape', LANDSCAPE_PHONE], + ])('places the actions right under the amount in %s', async (orientation, windowDimensions) => { + // Given a device without a touch screen, in either orientation, and a currency button passed as an action + mockWindowDimensions.mockReturnValue(windowDimensions); + const styles = getLayoutStyles(); + + // When the layout renders + renderWithProviders( + + } + > + + $ + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the currency button sits inside the amount area but outside the horizontal input row, placed under it + const currencyButton = screen.getByText('USD'); + const inputRow = findAncestorWithStyle(screen.getByTestId(INPUT_TEST_ID), 'flexDirection', 'row'); + expect(screen.getByTestId(AMOUNT_TEST_ID)).toContainElement(currencyButton); + expect(inputRow).toBeDefined(); + expect(inputRow).not.toContainElement(currencyButton); + + // Then the error is positioned according to the orientation without touch screen spacing + const error = screen.getByText('Test error'); + if (orientation === 'portrait') { + const floatingError = findAncestorWithStyle(error, 'position', 'absolute'); + expect(floatingError).toBeDefined(); + expect(screen.getByTestId(AMOUNT_TEST_ID)).toContainElement(floatingError ?? null); + expect(floatingError).toHaveStyle({...styles.b0, ...styles.mb3}); + } else { + expect(screen.getByTestId(MAIN_TEST_ID)).toContainElement(error); + expect(screen.getByTestId(AMOUNT_TEST_ID)).not.toContainElement(error); + expect(findAncestorWithStyle(error, 'position', 'absolute')).toBeUndefined(); + } + }); +}); diff --git a/tests/ui/NumericInputTest.tsx b/tests/ui/NumericInputTest.tsx index 4b0bf4aa6b73..1ab75dd14360 100644 --- a/tests/ui/NumericInputTest.tsx +++ b/tests/ui/NumericInputTest.tsx @@ -1,19 +1,25 @@ import {act, fireEvent, render, screen} from '@testing-library/react-native'; -import ComposeProviders from '@components/ComposeProviders'; -import {LocaleContextProvider} from '@components/LocaleContextProvider'; import type {NumericEditingRef} from '@components/NumericEditingController'; -import NumericInput, {useNumericDynamicFontSize, useNumericInputActions} from '@components/NumericInput'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import NumericInput, {useNumericInputActions} from '@components/NumericInput'; import {PressableWithoutFeedback} from '@components/Pressable'; -import Text from '@components/Text'; + +import variables from '@styles/variables'; import CONST from '@src/CONST'; import type * as NativeNavigation from '@react-navigation/native'; +import {useIsFocused} from '@react-navigation/native'; import React from 'react'; +jest.mock('@hooks/useLocalize', () => () => ({ + fromLocaleDigit: (digit: string) => digit, + toLocaleDigit: (digit: string) => digit, + numberFormat: (num: number) => String(num), + translate: (key: string) => key, +})); + jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useIsFocused: jest.fn(() => true), @@ -29,10 +35,6 @@ const INPUT_TEST_ID = 'numeric-text-input'; const SYMBOL_ACCESSIBILITY_LABEL = 'Select a symbol or currency'; const MINUS_SIGN = '-'; -function renderWithProviders(children: React.ReactNode) { - return render({children}); -} - /** Selects the whole displayed magnitude, as a select-all before a paste does. */ function selectAll(input: ReturnType, length: number) { fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 0, end: length}}}); @@ -53,22 +55,22 @@ function ToggleSignTrigger() { describe('NumericInput', () => { const onInputChange = jest.fn(); - const renderNumericInput = (props: Partial = {}, children?: React.ReactNode) => - renderWithProviders( - - {children ?? ( - <> - - $ - - - )} - , - ); + const buildNumericInput = (props: Partial = {}, children?: React.ReactNode) => ( + + {children ?? ( + <> + $ + + + )} + + ); + + const renderNumericInput = (props: Partial = {}, children?: React.ReactNode) => render(buildNumericInput(props, children)); afterEach(() => { jest.clearAllMocks(); @@ -76,22 +78,14 @@ describe('NumericInput', () => { describe('symbol primitive', () => { it('renders its children as a passive symbol, without a button', () => { + // Given a passive symbol composed beside the number + // When the component is rendered renderNumericInput({value: '12'}, km); + // Then the symbol is shown as plain text, so pressing it cannot open a selector expect(screen.getByText('km')).toBeOnTheScreen(); expect(screen.queryAllByRole(CONST.ROLE.BUTTON, {name: SYMBOL_ACCESSIBILITY_LABEL})).toHaveLength(0); }); - - it('renders its children inside the symbol button and calls onPress when it is pressed', () => { - const onPress = jest.fn(); - renderNumericInput({value: '12'}, km); - - expect(screen.getByText('km')).toBeOnTheScreen(); - - fireEvent.press(screen.getByRole(CONST.ROLE.BUTTON, {name: SYMBOL_ACCESSIBILITY_LABEL})); - - expect(onPress).toHaveBeenCalledTimes(1); - }); }); describe('sign handling', () => { @@ -275,13 +269,7 @@ describe('NumericInput', () => { }); it('toggles the sign and notifies the parent with the signed value', () => { - renderNumericInput( - {value: '12', allowNegative: true}, - <> - - - , - ); + renderNumericInput({value: '12', allowNegative: true}, ); fireEvent.press(screen.getByTestId('toggle-sign')); @@ -290,13 +278,7 @@ describe('NumericInput', () => { }); it('ignores a sign toggle when negative values are not allowed', () => { - renderNumericInput( - {value: '12'}, - <> - - - , - ); + renderNumericInput({value: '12'}, ); fireEvent.press(screen.getByTestId('toggle-sign')); @@ -365,17 +347,7 @@ describe('NumericInput', () => { fireEvent.changeText(screen.getByTestId(INPUT_TEST_ID), '1.23'); // When the accepted number of decimals drops to zero - rerender( - - - - - , - ); + rerender(buildNumericInput({decimals: 0, value: ''}, )); // Then the in-progress value is sanitized to the new precision expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('1'); @@ -388,19 +360,7 @@ describe('NumericInput', () => { selectAll(input, 5); // When the accepted number of decimals drops to zero - rerender( - - - - - - , - ); + rerender(buildNumericInput({decimals: 0, value: '-12.55', allowNegative: true}, )); // Then sanitization keeps the canonical negative sign expect(screen.getByText(MINUS_SIGN)).toBeOnTheScreen(); @@ -444,60 +404,246 @@ describe('NumericInput', () => { }); }); - describe('error primitive', () => { - it('renders the root error where the composition places it', () => { - renderNumericInput( - {errorText: 'Invalid amount'}, - <> - - - , - ); + describe('error', () => { + it('renders the root error, placed by the layout', () => { + // Given an error message supplied to the root + // When the component is rendered + renderNumericInput({errorText: 'Invalid amount'}, ); + // Then the error is displayed with the alert role to inform the user expect(screen.getByText('Invalid amount')).toBeOnTheScreen(); expect(screen.getByRole(CONST.ROLE.ALERT)).toBeOnTheScreen(); }); it('renders nothing when the root has no error', () => { - renderNumericInput( - {}, - <> - - - , - ); + // Given a composition with no error + // When the component is rendered + renderNumericInput({}, ); + // Then no error alert is rendered expect(screen.queryByRole(CONST.ROLE.ALERT)).not.toBeOnTheScreen(); }); }); - describe('useNumericDynamicFontSize', () => { - function FontSizeReadout({symbol}: {symbol?: string}) { - const {fontSize} = useNumericDynamicFontSize(symbol); + describe('dynamic font size (shouldUseDynamicFontSize)', () => { + function extractFontSize(style: unknown): number | undefined { + if (!style) { + return undefined; + } + if (Array.isArray(style)) { + for (let i = style.length - 1; i >= 0; i--) { + const nested = extractFontSize(style.at(i)); + if (nested !== undefined) { + return nested; + } + } + return undefined; + } + if (typeof style === 'object' && 'fontSize' in style && typeof style.fontSize === 'number') { + return style.fontSize; + } + return undefined; + } - return {String(fontSize)}; + function getElementFontSize(element: {props: unknown}): number | undefined { + const rawProps: unknown = element.props; + if (typeof rawProps !== 'object' || rawProps === null || !('style' in rawProps)) { + return undefined; + } + return extractFontSize(rawProps.style); } - it('scales down when the symbol is longer', () => { - renderNumericInput({value: '1234567890'}, ); - const withoutSymbolFontSize = Number(screen.getByTestId('font-size').props.children); + it('does not apply dynamic font size when shouldUseDynamicFontSize is not set', () => { + // Given a NumericInput with a short amount and default dynamic font size (disabled) + renderNumericInput({value: '12'}); + const shortInputFontSize = getElementFontSize(screen.getByTestId(INPUT_TEST_ID)); screen.unmount(); - renderNumericInput({value: '1234567890'}, ); - const withSymbolFontSize = Number(screen.getByTestId('font-size').props.children); - expect(withSymbolFontSize).toBeLessThan(withoutSymbolFontSize); + // When a long amount is rendered without dynamic font size + renderNumericInput({value: '1234567890123'}); + const longInputFontSize = getElementFontSize(screen.getByTestId(INPUT_TEST_ID)); + + // Then the font size remains the full-screen amount size, the same as the symbol's, and does not scale with length + expect(longInputFontSize).toBe(shortInputFontSize); + expect(longInputFontSize).toBe(getElementFontSize(screen.getByText('$'))); + }); + + it('scales font size down across input and symbol when shouldUseDynamicFontSize is enabled', () => { + // Given a NumericInput with dynamic font size enabled and a short amount + renderNumericInput({value: '12', shouldUseDynamicFontSize: true, allowNegative: true}); + // When inspecting font sizes for a short amount + const shortInputFontSize = getElementFontSize(screen.getByTestId(INPUT_TEST_ID)); + const shortSymbolFontSize = getElementFontSize(screen.getByText('$')); + + // Then short amounts use the base font size for both symbol and input + expect(shortInputFontSize).toBeDefined(); + expect(shortSymbolFontSize).toBe(shortInputFontSize); + + screen.unmount(); + + // When rendered with a long amount that requires scaling + renderNumericInput({value: '1234567890123', shouldUseDynamicFontSize: true, allowNegative: true}); + const longInputFontSize = getElementFontSize(screen.getByTestId(INPUT_TEST_ID)); + const longSymbolFontSize = getElementFontSize(screen.getByText('$')); + + // Then the font size is scaled down equally for both input and symbol + expect(longInputFontSize).toBeDefined(); + expect(shortInputFontSize).toBeDefined(); + if (longInputFontSize !== undefined && shortInputFontSize !== undefined) { + expect(longInputFontSize).toBeLessThan(shortInputFontSize); + } + expect(longSymbolFontSize).toBe(longInputFontSize); + }); + + it('accounts for minus sign and symbol length when calculating dynamic font size', () => { + // Given a positive value with dynamic font size enabled + renderNumericInput({value: '1234567890', shouldUseDynamicFontSize: true, allowNegative: true}); + const positiveFontSize = getElementFontSize(screen.getByTestId(INPUT_TEST_ID)); + + screen.unmount(); + + // When the value is negative and the minus sign is shown + renderNumericInput({value: '-1234567890', shouldUseDynamicFontSize: true, allowNegative: true}); + const negativeFontSize = getElementFontSize(screen.getByTestId(INPUT_TEST_ID)); + const minusSignFontSize = getElementFontSize(screen.getByText(MINUS_SIGN)); + + // Then the negative value scales down more because the minus sign consumes space, and the minus sign matches the input + expect(positiveFontSize).toBeDefined(); + expect(negativeFontSize).toBeDefined(); + if (negativeFontSize !== undefined && positiveFontSize !== undefined) { + expect(negativeFontSize).toBeLessThan(positiveFontSize); + } + expect(minusSignFontSize).toBe(negativeFontSize); + + screen.unmount(); + + // When a longer symbol is provided + renderNumericInput( + {value: '1234567890', shouldUseDynamicFontSize: true, allowNegative: true}, + <> + PLN + + , + ); + const longSymbolFontSize = getElementFontSize(screen.getByTestId(INPUT_TEST_ID)); + + // Then font size is further reduced to fit the longer symbol + expect(longSymbolFontSize).toBeDefined(); + if (longSymbolFontSize !== undefined && positiveFontSize !== undefined) { + expect(longSymbolFontSize).toBeLessThan(positiveFontSize); + } }); - it('scales down for negative values, because the sign takes room the input does not display', () => { - renderNumericInput({value: '1234567890'}, ); - const positiveFontSize = Number(screen.getByTestId('font-size').props.children); + it('does not count a symbol that the composition does not render', () => { + // Given a ten-digit amount with dynamic font size enabled and a symbol composed beside it + renderNumericInput({value: '1234567890', shouldUseDynamicFontSize: true}); + const withSymbolFontSize = getElementFontSize(screen.getByTestId(INPUT_TEST_ID)); screen.unmount(); - renderNumericInput({value: '-1234567890', allowNegative: true}, ); - const negativeFontSize = Number(screen.getByTestId('font-size').props.children); - expect(negativeFontSize).toBeLessThan(positiveFontSize); + // When the same amount is composed without a symbol + renderNumericInput({value: '1234567890', shouldUseDynamicFontSize: true}, ); + const withoutSymbolFontSize = getElementFontSize(screen.getByTestId(INPUT_TEST_ID)); + + // Then the amount keeps a larger font, because only symbols that are actually shown take up space in the row + expect(withSymbolFontSize).toBeDefined(); + expect(withoutSymbolFontSize).toBeDefined(); + if (withSymbolFontSize !== undefined && withoutSymbolFontSize !== undefined) { + expect(withoutSymbolFontSize).toBeGreaterThan(withSymbolFontSize); + } + }); + + it('rescales the amount when the composed symbol changes', () => { + // Given a ten-digit amount with dynamic font size enabled and a one-character symbol + const renderWithSymbol = (symbol: string) => + buildNumericInput( + {value: '1234567890', shouldUseDynamicFontSize: true}, + <> + {symbol} + + , + ); + render(renderWithSymbol('$')); + const shortSymbolFontSize = getElementFontSize(screen.getByTestId(INPUT_TEST_ID)); + + // When the composition swaps it for a longer symbol, as after picking another currency + screen.rerender(renderWithSymbol('PLN')); + const longSymbolFontSize = getElementFontSize(screen.getByTestId(INPUT_TEST_ID)); + + // Then the amount and the symbol shrink together, because the symbol reports its new length instead of keeping the old one + expect(shortSymbolFontSize).toBeDefined(); + expect(longSymbolFontSize).toBeDefined(); + if (shortSymbolFontSize !== undefined && longSymbolFontSize !== undefined) { + expect(longSymbolFontSize).toBeLessThan(shortSymbolFontSize); + } + expect(getElementFontSize(screen.getByText('PLN'))).toBe(longSymbolFontSize); + }); + + it('counts a symbol wrapped in another component and stops counting it once removed', () => { + // Given a ten-digit amount whose symbol is rendered by a screen-level wrapper rather than as a direct child, as a + // pressable currency symbol would be + function CurrencySymbol({symbol}: {symbol: string}) { + return {symbol}; + } + const renderAmount = (row: React.ReactNode) => buildNumericInput({value: '1234567890', shouldUseDynamicFontSize: true}, row); + render( + renderAmount( + <> + PLN + + , + ), + ); + const directSymbolFontSize = getElementFontSize(screen.getByTestId(INPUT_TEST_ID)); + + // When the same symbol is rendered through the wrapper + screen.rerender( + renderAmount( + <> + + + , + ), + ); + const wrappedSymbolFontSize = getElementFontSize(screen.getByTestId(INPUT_TEST_ID)); + + // Then it is sized exactly like the direct symbol, because the symbol registers itself wherever it is rendered + expect(wrappedSymbolFontSize).toBeDefined(); + expect(wrappedSymbolFontSize).toBe(directSymbolFontSize); + + // When the wrapper is removed from the composition + screen.rerender(renderAmount()); + const withoutSymbolFontSize = getElementFontSize(screen.getByTestId(INPUT_TEST_ID)); + + // Then the amount grows back, because the unmounted symbol no longer counts toward the row length + expect(withoutSymbolFontSize).toBeDefined(); + if (withoutSymbolFontSize !== undefined && wrappedSymbolFontSize !== undefined) { + expect(withoutSymbolFontSize).toBeGreaterThan(wrappedSymbolFontSize); + } + }); + + it('ensures dynamic font size takes precedence over static fontSize in style prop', () => { + // Given a NumericInput with dynamic font size enabled and a custom style with static fontSize (like styles.iouAmountTextInput) + renderNumericInput( + {value: '0', shouldUseDynamicFontSize: true}, + <> + + hrs + , + ); + + // When inspecting font sizes + const inputFontSize = getElementFontSize(screen.getByTestId(INPUT_TEST_ID)); + const symbolFontSize = getElementFontSize(screen.getByText('hrs')); + + // Then both input and symbol receive the dynamic font size rather than the static fontSize, matching each other + expect(inputFontSize).toBeDefined(); + expect(symbolFontSize).toBe(inputFontSize); + expect(inputFontSize).not.toBe(variables.iouAmountTextSize); }); }); @@ -540,5 +686,370 @@ describe('NumericInput', () => { // Then the selection collapses onto its end expect(input.props.selection).toEqual({start: 3, end: 3}); }); + + it('notifies onInputChange synchronously from toggleSign', () => { + // Given a negative-capable composition whose parent records whether it was notified inside the toggle call + let wasParentUpdatedDuringToggle = false; + let isInsideToggle = false; + function SynchronousToggleProbe() { + const {toggleSign} = useNumericInputActions(); + + return ( + { + isInsideToggle = true; + toggleSign(); + isInsideToggle = false; + }} + /> + ); + } + render( + { + wasParentUpdatedDuringToggle = isInsideToggle; + }} + > + + , + ); + + // When the sign is toggled + fireEvent.press(screen.getByTestId('toggle-sign-sync')); + + // Then the parent was notified before toggleSign returned. NumberWithSymbolForm relies on this to tell a flip from an edit. + expect(wasParentUpdatedDuringToggle).toBe(true); + }); + + it('stores a value through updateNumber without validating it', () => { + // Given a composition that accepts no decimal places, with a root ref + const ref = React.createRef(); + renderNumericInput({value: '10', decimals: 0, ref}); + + // When a value that typing would reject is set imperatively + act(() => { + ref.current?.updateNumber('1.5'); + }); + + // Then the value is stored and displayed as is, because callers use the ref to push already-formatted amounts + expect(ref.current?.getNumber()).toBe('1.5'); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('1.5'); + expect(onInputChange).not.toHaveBeenCalled(); + }); + + it('renders the sign of a negative value set through updateNumber outside the input', () => { + // Given a negative-capable composition with a root ref + const ref = React.createRef(); + renderNumericInput({value: '12', allowNegative: true, ref}); + + // When a negative amount is set imperatively + act(() => { + ref.current?.updateNumber('-5.00'); + }); + + // Then the sign renders separately, the input shows the magnitude with the caret at its end, and the root is not notified + expect(ref.current?.getNumber()).toBe('-5.00'); + expect(screen.getByText(MINUS_SIGN)).toBeOnTheScreen(); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('5.00'); + expect(screen.getByTestId(INPUT_TEST_ID).props.selection).toEqual({start: 4, end: 4}); + expect(onInputChange).not.toHaveBeenCalled(); + }); + }); + + describe('normalization and leading zero', () => { + it('adds a leading zero when a decimal separator is typed into an empty input', () => { + // Given an empty composition that accepts two decimal places + renderNumericInput({value: ''}); + + // When the user types the decimal separator + fireEvent.changeText(screen.getByTestId(INPUT_TEST_ID), '.'); + + // Then a leading zero turns it into a valid in-progress decimal + expect(onInputChange).toHaveBeenLastCalledWith('0.'); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('0.'); + }); + + it.each([ + ['.', '-0.', '0.'], + ['.5', '-0.5', '0.5'], + ])('adds the leading zero inside the magnitude when %s is typed after a lone minus', (typedText, expectedValue, expectedDisplay) => { + // Given a negative composition whose magnitude is still empty + renderNumericInput({value: '-', allowNegative: true}); + + // When the user types a magnitude starting with the decimal separator + fireEvent.changeText(screen.getByTestId(INPUT_TEST_ID), typedText); + + // Then the zero goes after the sign, because the sign is rendered outside the input and never reaches the normalizer + expect(onInputChange).toHaveBeenLastCalledWith(expectedValue); + expect(screen.getByText(MINUS_SIGN)).toBeOnTheScreen(); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue(expectedDisplay); + }); + + it.each([ + // Commas are treated as the decimal separator, and spaces iOS Safari adds when pasting are dropped + ['1 2,5', '12.5'], + // When a period is present, commas are thousands separators and are dropped instead + ['1,234.5', '1234.5'], + ])('normalizes the pasted text %s to %s', (pastedText, expectedValue) => { + // Given an empty composition that accepts two decimal places + renderNumericInput({value: ''}); + + // When the user pastes a localized number + fireEvent.changeText(screen.getByTestId(INPUT_TEST_ID), pastedText); + + // Then the normalized value is committed and displayed + expect(onInputChange).toHaveBeenLastCalledWith(expectedValue); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue(expectedValue); + }); + + it('normalizes the magnitude before restoring the sign', () => { + // Given a negative composition whose magnitude is still empty + renderNumericInput({value: '-', allowNegative: true}); + + // When the user pastes a number with a space and a comma separator + fireEvent.changeText(screen.getByTestId(INPUT_TEST_ID), ' 1,5'); + + // Then the normalized magnitude keeps the sign + expect(onInputChange).toHaveBeenLastCalledWith('-1.5'); + expect(screen.getByText(MINUS_SIGN)).toBeOnTheScreen(); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('1.5'); + }); + + // Quirk locked in on purpose, matching NumberWithSymbolForm: `addLeadingZero('-.', true)` prepends `-0` to the whole + // string (`-0-.`) instead of inserting the zero after the sign, so the value fails validation. + it.each(['-.', '-.5'])('rejects the pasted text %s on an empty input', (pastedText) => { + // Given an empty negative-capable composition + renderNumericInput({value: '', allowNegative: true}); + + // When the user pastes a negative value that starts with the decimal separator + fireEvent.changeText(screen.getByTestId(INPUT_TEST_ID), pastedText); + + // Then the paste is rejected and no sign appears + expect(onInputChange).not.toHaveBeenCalled(); + expect(screen.queryByText(MINUS_SIGN)).not.toBeOnTheScreen(); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue(''); + }); + + it('rejects a typed minus when negative values are not allowed', () => { + // Given a composition that does not allow negative values and displays "12" + renderNumericInput({value: '12'}); + + // When the user types a minus in front of the number + fireEvent.changeText(screen.getByTestId(INPUT_TEST_ID), '-12'); + + // Then the edit is rejected instead of toggling a sign the composition cannot hold + expect(onInputChange).not.toHaveBeenCalled(); + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('12'); + }); + }); + + describe('maxLength', () => { + it('accepts a value that fits maxLength', () => { + // Given a composition limited to two integer digits and displaying "1" + renderNumericInput({value: '1', maxLength: 2}); + + // When the user types a second integer digit + fireEvent.changeText(screen.getByTestId(INPUT_TEST_ID), '12'); + + // Then the edit is committed + expect(onInputChange).toHaveBeenLastCalledWith('12'); + }); + + it('rejects a pasted value that replaces the whole number and exceeds maxLength', () => { + // Given a composition limited to two integer digits with "12" fully selected + renderNumericInput({value: '12', maxLength: 2}); + const input = screen.getByTestId(INPUT_TEST_ID); + selectAll(input, 2); + + // When the user pastes a longer number over the selection + fireEvent.changeText(input, '345'); + + // Then the paste is rejected like typing, because the limit applies to the resulting value + expect(onInputChange).not.toHaveBeenCalled(); + expect(input).toHaveDisplayValue('12'); + }); + + it('does not count decimal digits toward maxLength', () => { + // Given a composition limited to two integer digits and displaying "12" + renderNumericInput({value: '12', maxLength: 2}); + + // When the user appends two decimal places + fireEvent.changeText(screen.getByTestId(INPUT_TEST_ID), '12.34'); + + // Then the edit is committed, because maxLength only limits the integer part + expect(onInputChange).toHaveBeenLastCalledWith('12.34'); + }); + + it('does not count the separately rendered sign toward maxLength', () => { + // Given a negative composition limited to two integer digits and displaying "-1" + renderNumericInput({value: '-1', allowNegative: true, maxLength: 2}); + const input = screen.getByTestId(INPUT_TEST_ID); + + // When the user types a second integer digit + fireEvent.changeText(input, '12'); + + // Then the edit is committed with its sign, because only the magnitude is validated + expect(onInputChange).toHaveBeenLastCalledWith('-12'); + + // When the user types a third integer digit + fireEvent.changeText(input, '123'); + + // Then the edit is rejected and the sign stays + expect(onInputChange).toHaveBeenCalledTimes(1); + expect(screen.getByText(MINUS_SIGN)).toBeOnTheScreen(); + expect(input).toHaveDisplayValue('12'); + }); + }); + + describe('decimals change', () => { + it('leaves a value that is already valid at the new precision untouched', () => { + // Given a composition displaying "1.5" with two accepted decimal places + const {rerender} = renderNumericInput({value: '1.5'}); + + // When the accepted number of decimals drops to one + rerender(buildNumericInput({value: '1.5', decimals: 1})); + + // Then the value still fits, so it is neither changed nor reported + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('1.5'); + expect(onInputChange).not.toHaveBeenCalled(); + }); + }); + + describe('selection', () => { + const signToggleComposition = ( + <> + + + + ); + + it('keeps the caret at the start when toggling the sign of an empty value, so the next digit becomes a negative amount', () => { + // Given an empty negative-capable composition + renderNumericInput({value: '', allowNegative: true}, signToggleComposition); + const input = screen.getByTestId(INPUT_TEST_ID); + + // When the sign is toggled + fireEvent.press(screen.getByTestId('toggle-sign')); + + // Then the sign renders outside the input, so the empty magnitude keeps its caret at the start + expect(onInputChange).toHaveBeenLastCalledWith('-'); + expect(screen.getByText(MINUS_SIGN)).toBeOnTheScreen(); + expect(input.props.selection).toEqual({start: 0, end: 0}); + + // When a digit is typed at the caret + fireEvent.changeText(input, '5'); + + // Then the digit becomes a negative amount + expect(onInputChange).toHaveBeenLastCalledWith('-5'); + expect(input).toHaveDisplayValue('5'); + }); + + it('does not move the caret when toggling the sign of a non-empty value', () => { + // Given a negative-capable composition displaying "12" with the caret between the digits + renderNumericInput({value: '12', allowNegative: true}, signToggleComposition); + const input = screen.getByTestId(INPUT_TEST_ID); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 1, end: 1}}}); + + // When the sign is toggled + fireEvent.press(screen.getByTestId('toggle-sign')); + + // Then the magnitude and the caret are untouched, because only the separately rendered sign changed + expect(onInputChange).toHaveBeenLastCalledWith('-12'); + expect(input.props.selection).toEqual({start: 1, end: 1}); + + // When the user moves the caret to the end and appends a digit + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 2, end: 2}}}); + fireEvent.changeText(input, '123'); + + // Then the digit extends the negative amount + expect(onInputChange).toHaveBeenLastCalledWith('-123'); + expect(input.props.selection).toEqual({start: 3, end: 3}); + }); + + it('keeps the caret in place after a forward delete', () => { + // Given a composition displaying "1234" with the caret after "12" + renderNumericInput({value: '1234'}); + const input = screen.getByTestId(INPUT_TEST_ID); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 2, end: 2}}}); + + // When the character after the caret is forward-deleted + fireEvent(input, 'keyPress', {nativeEvent: {key: 'Delete', ctrlKey: false}}); + fireEvent.changeText(input, '124'); + + // Then the caret stays put, because forward delete removes the character after it + expect(input).toHaveDisplayValue('124'); + expect(input.props.selection).toEqual({start: 2, end: 2}); + }); + + it('clamps a native selection beyond the magnitude length', () => { + // Given a negative composition displaying the magnitude "12" + renderNumericInput({value: '-12', allowNegative: true}); + const input = screen.getByTestId(INPUT_TEST_ID); + + // When native reports a selection past the end of the value + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 10, end: 10}}}); + + // Then the selection is clamped to the displayed magnitude, which does not include the sign + expect(input.props.selection).toEqual({start: 2, end: 2}); + }); + + it('collapses the selection when the screen regains focus', () => { + // Given an unfocused screen whose composition has a range selection + jest.mocked(useIsFocused).mockReturnValue(false); + const {rerender} = renderNumericInput({value: '1234'}); + const input = screen.getByTestId(INPUT_TEST_ID); + fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 1, end: 3}}}); + expect(input.props.selection).toEqual({start: 1, end: 3}); + + // When the screen regains focus + jest.mocked(useIsFocused).mockReturnValue(true); + rerender(buildNumericInput({value: '1234'})); + + // Then the selection collapses onto its end, so returning to the screen never types over a stale range + expect(input.props.selection).toEqual({start: 3, end: 3}); + }); + + it('keeps the sign when a key other than backspace is pressed on an empty negative input', () => { + // Given a negative composition whose magnitude is empty + renderNumericInput({value: '-', allowNegative: true}); + + // When a digit key is pressed + fireEvent(screen.getByTestId(INPUT_TEST_ID), 'keyPress', {nativeEvent: {key: '1'}}); + + // Then the sign stays, because only backspace clears a lone minus + expect(screen.getByText(MINUS_SIGN)).toBeOnTheScreen(); + expect(onInputChange).not.toHaveBeenCalled(); + }); + }); + + describe('external value synchronization', () => { + it('clears the magnitude, the sign and the caret when the value prop resets to empty', () => { + // Given a negative composition displaying "-12" + const {rerender} = renderNumericInput({value: '-12', allowNegative: true}); + const input = screen.getByTestId(INPUT_TEST_ID); + + // When the parent resets the value to empty + rerender(buildNumericInput({value: '', allowNegative: true})); + + // Then nothing of the previous amount is left, and the caret is back at the start + expect(screen.queryByText(MINUS_SIGN)).not.toBeOnTheScreen(); + expect(input).toHaveDisplayValue(''); + expect(input.props.selection).toEqual({start: 0, end: 0}); + }); + + it('ignores an external change to another non-empty value', () => { + // Given a composition displaying "10" + const {rerender} = renderNumericInput({value: '10'}); + + // When the parent rerenders with value "20" + rerender(buildNumericInput({value: '20'})); + + // Then the editing state keeps the current value because external pushes must use the imperative ref + expect(screen.getByTestId(INPUT_TEST_ID)).toHaveDisplayValue('10'); + }); }); }); diff --git a/tests/ui/NumericInputWebTest.tsx b/tests/ui/NumericInputWebTest.tsx index 29a26e4bdd1f..612339481313 100644 --- a/tests/ui/NumericInputWebTest.tsx +++ b/tests/ui/NumericInputWebTest.tsx @@ -1,10 +1,7 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; -import ComposeProviders from '@components/ComposeProviders'; -import {LocaleContextProvider} from '@components/LocaleContextProvider'; import useNumericPressSelection from '@components/NumericEditingController/hooks/useNumericPressSelection/index.web'; import NumericInput from '@components/NumericInput'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; import TextInput from '@components/TextInput'; import type {BaseTextInputProps, BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; @@ -14,6 +11,13 @@ import type * as NativeNavigation from '@react-navigation/native'; import React from 'react'; +jest.mock('@hooks/useLocalize', () => () => ({ + fromLocaleDigit: (digit: string) => digit, + toLocaleDigit: (digit: string) => digit, + numberFormat: (num: number) => String(num), + translate: (key: string) => key, +})); + jest.mock('@libs/shouldIgnoreSelectionWhenUpdatedManually', () => ({ ...jest.requireActual<{default: ShouldIgnoreSelectionWhenUpdatedManually}>('@libs/shouldIgnoreSelectionWhenUpdatedManually'), __esModule: true, @@ -31,28 +35,6 @@ jest.mock('@react-navigation/native', () => ({ const INPUT_TEST_ID = 'numeric-input-web-test-input'; const PRESSABLE_TEST_ID = 'numeric-input-web-test-pressable'; -const CONTAINER_TEST_ID = 'numeric-input-web-test-container'; - -function renderWithProviders(children: React.ReactNode) { - return render({children}); -} - -/** Builds the mouse event the web container handler expects, with `target.id` set to the pressed view's id. */ -function getMouseDownEvent(targetId: string) { - const target = document.createElement('div'); - target.id = targetId; - - return {nativeEvent: {target}, preventDefault: jest.fn()}; -} - -function getContainerViewId(testID: string) { - const container = screen.getByTestId(testID); - if (typeof container.props.id !== 'string') { - throw new Error(`Numeric input container id was not assigned for ${testID}`); - } - - return container.props.id; -} type WebPressSelectionTestProps = { /** Input whose caret the hook reads on press */ @@ -101,151 +83,47 @@ function renderPressSelection(inputRef: {current: BaseTextInputRef | null}, onPr describe('NumericInput web behavior', () => { it('syncs the root selection with the browser caret when pressed', () => { + // Given an input with selection between characters 2 and 4 const onPress = jest.fn(); const handleSelectionChange = jest.fn(); renderPressSelection(getCaretInputRef(2, 4), onPress, handleSelectionChange); + // When the input is pressed fireEvent.press(screen.getByTestId(PRESSABLE_TEST_ID)); + // Then the selection change is dispatched with the browser selection offsets and the original onPress is called expect(handleSelectionChange).toHaveBeenCalledWith(2, 4); expect(onPress).toHaveBeenCalledTimes(1); }); it('leaves the root selection alone when the ref does not hold a form element exposing the caret', () => { + // Given a null input reference const onPress = jest.fn(); const handleSelectionChange = jest.fn(); - renderPressSelection({current: null}, onPress, handleSelectionChange); + // When the input is pressed fireEvent.press(screen.getByTestId(PRESSABLE_TEST_ID)); + // Then selection change is not dispatched while onPress is still called expect(handleSelectionChange).not.toHaveBeenCalled(); expect(onPress).toHaveBeenCalledTimes(1); }); it('applies a selection event after a manual value update, because the browser does not echo a stale one', () => { - renderWithProviders( + // Given a NumericInput rendered with value "12" + render( , ); + // When the value changes and a native selectionChange event is received const input = screen.getByTestId(INPUT_TEST_ID); fireEvent.changeText(input, '13'); fireEvent(input, 'selectionChange', {nativeEvent: {selection: {start: 0, end: 0}}}); + // Then the selection is applied without being ignored expect(input.props.selection).toEqual({start: 0, end: 0}); }); - - describe('container primitive', () => { - const renderContainerComposition = (inputRef?: React.Ref) => - renderWithProviders( - - - - % - - , - ); - - it('focuses the input and collapses the selection when its own empty area is pressed', () => { - // Given a container composition with a range selection on the input - const inputRef = React.createRef(); - renderContainerComposition(inputRef); - - const input = screen.getByTestId(INPUT_TEST_ID); - fireEvent(input, 'selectionChange', { - nativeEvent: {selection: {start: 0, end: 2}}, - }); - expect(input.props.selection).toEqual({start: 0, end: 2}); - - const inputElement = inputRef.current; - if (!inputElement) { - throw new Error('Numeric input ref was not assigned'); - } - const focus = jest.spyOn(inputElement, 'focus'); - - // When the container's own empty area is pressed - const event = getMouseDownEvent(getContainerViewId(CONTAINER_TEST_ID)); - fireEvent(screen.getByTestId(CONTAINER_TEST_ID), 'mouseDown', event); - - // Then the browser blur is prevented, the input is focused, and the selection collapses onto its end - expect(event.preventDefault).toHaveBeenCalledTimes(1); - expect(focus).toHaveBeenCalledTimes(1); - expect(input.props.selection).toEqual({start: 2, end: 2}); - focus.mockRestore(); - }); - - it('does not re-focus the input when its own empty area is pressed if already focused', () => { - // Given a container composition where the input is already focused - const inputRef = React.createRef(); - renderContainerComposition(inputRef); - - const inputElement = inputRef.current; - if (!inputElement) { - throw new Error('Numeric input ref was not assigned'); - } - const isFocused = jest.spyOn(inputElement, 'isFocused').mockReturnValue(true); - const focus = jest.spyOn(inputElement, 'focus'); - - // When the container's own empty area is pressed - const event = getMouseDownEvent(getContainerViewId(CONTAINER_TEST_ID)); - fireEvent(screen.getByTestId(CONTAINER_TEST_ID), 'mouseDown', event); - - // Then the browser blur is prevented, but focus is not called again - expect(event.preventDefault).toHaveBeenCalledTimes(1); - expect(focus).not.toHaveBeenCalled(); - focus.mockRestore(); - isFocused.mockRestore(); - }); - - it('ignores a press that originates from a nested view instead of its own empty area', () => { - // Given a container composition with a range selection on the input - const inputRef = React.createRef(); - renderContainerComposition(inputRef); - - const input = screen.getByTestId(INPUT_TEST_ID); - fireEvent(input, 'selectionChange', { - nativeEvent: {selection: {start: 0, end: 2}}, - }); - - const inputElement = inputRef.current; - if (!inputElement) { - throw new Error('Numeric input ref was not assigned'); - } - const focus = jest.spyOn(inputElement, 'focus'); - - // When the press bubbles up from a nested view, which owns the caret placement itself - const event = getMouseDownEvent('some-nested-view-id'); - fireEvent(screen.getByTestId(CONTAINER_TEST_ID), 'mouseDown', event); - - // Then the container leaves the press and the selection alone - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(focus).not.toHaveBeenCalled(); - expect(input.props.selection).toEqual({start: 0, end: 2}); - focus.mockRestore(); - }); - - it('assigns a distinct target id to each mounted container, so a press only refocuses its own input', () => { - renderWithProviders( - <> - - - - - - - - - - - , - ); - - expect(getContainerViewId(`${CONTAINER_TEST_ID}-one`)).not.toBe(getContainerViewId(`${CONTAINER_TEST_ID}-two`)); - }); - }); }); diff --git a/tests/ui/OnboardingPurpose.tsx b/tests/ui/OnboardingPurpose.tsx index 80b19514c7aa..ceda8a077186 100644 --- a/tests/ui/OnboardingPurpose.tsx +++ b/tests/ui/OnboardingPurpose.tsx @@ -226,6 +226,27 @@ describe('OnboardingPurpose Page', () => { await waitForBatchedUpdatesWithAct(); }); + it('should show the work email in a domain-controlled error that reaches the purpose page', async () => { + // Given a domain-controlled merge error left behind by the work email step, with the email still in the form + const workEmail = 'tester@applause.expensifail.com'; + await TestHelper.signInWithTestUser(1, 'test@gmail.com'); + await act(async () => { + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM, {onboardingWorkEmail: workEmail}); + await Onyx.set(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, 'onboarding.mergeBlockScreen.domainControlledSubtitle'); + }); + + // When the purpose page renders that error + const {unmount} = renderOnboardingPurposePage(SCREENS.ONBOARDING.PURPOSE, {backTo: ''}); + await waitForBatchedUpdatesWithAct(); + + // Then the message names the work email instead of "undefined", because the key interpolates the email + expect(screen.getByText(TestHelper.translateLocal('onboarding.mergeBlockScreen.domainControlledSubtitle', workEmail))).toBeOnTheScreen(); + expect(screen.queryByText(/undefined is a domain controlled login/)).not.toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should navigate to personal details page when user selects EMPLOYER and is from public domain', async () => { await TestHelper.signInWithTestUser(); diff --git a/tests/ui/OnyxTabNavigatorTest.tsx b/tests/ui/OnyxTabNavigatorTest.tsx index 52bcdf749bce..d00435e14651 100644 --- a/tests/ui/OnyxTabNavigatorTest.tsx +++ b/tests/ui/OnyxTabNavigatorTest.tsx @@ -2,44 +2,48 @@ import {act, render} from '@testing-library/react-native'; import {ModalActions} from '@components/Modal/Global/ModalContext'; +import useOnyx from '@hooks/useOnyx'; + import ComposerFocusManager from '@libs/ComposerFocusManager'; import getPlatform from '@libs/getPlatform'; -import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator'; +import OnyxTabNavigator, {createOnyxTabNavigator, TopTab} from '@libs/Navigation/OnyxTabNavigator'; +import type {OnyxTabNavigatorProps} from '@libs/Navigation/OnyxTabNavigator'; +import type {TabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; import {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; +import Tab from '@userActions/Tab'; + import CONST from '@src/CONST'; import KeyboardUtils from '@src/utils/keyboard'; +import type {NavigationProp, ParamListBase} from '@react-navigation/native'; import type {ValueOf} from 'type-fest'; import React from 'react'; import {Keyboard} from 'react-native'; -type MockNavigation = { - getState: () => { - index: number; - routes: Array<{key: string; name: string}>; - }; - dispatch: jest.Mock; -}; - -type MockTabPressEvent = { - target: string; - defaultPrevented: boolean; - preventDefault: jest.Mock; -}; - -type MockScreenListeners = (args: {navigation: MockNavigation}) => { - tabPress: (event: MockTabPressEvent) => void; +import createMock from '../utils/createMock'; + +type TabName = typeof CURRENT_TAB | typeof TARGET_TAB; +type MockNavigation = Pick, 'getState' | 'dispatch'>; +type TabListeners = NonNullable['screenListeners']>; +type TabStateEvent = Parameters>[0]; +type MockTabPressEvent = Parameters>[0] & { + preventDefault: jest.Mock< + ReturnType>[0]['preventDefault']>, + Parameters>[0]['preventDefault']> + >; }; +type MockScreenListeners = (args: {navigation: NavigationProp}) => TabListeners; type ConfirmModalResult = {action: ValueOf}; // Defaults to a promise that never settles, so the modal stays "open" unless a test resolves it. const mockShowConfirmModal = jest.fn((): Promise => new Promise(() => {})); -const mockOnDiscard = jest.fn(); -const mockDispatch = jest.fn(); +const mockOnDiscard = jest.fn, Parameters>(); +const mockDispatch = jest.fn['dispatch']>, Parameters['dispatch']>>(); let mockScreenListeners: MockScreenListeners | undefined; +let mockInitialRouteName: TabName | undefined; jest.mock('@hooks/useConfirmModal', () => ({ __esModule: true, @@ -80,8 +84,9 @@ jest.mock('@libs/getPlatform', () => ({ jest.mock('@react-navigation/material-top-tabs', () => { const ReactModule = jest.requireActual('react'); - const Navigator = ({children, screenListeners}: {children: React.ReactNode; screenListeners: MockScreenListeners}) => { + const Navigator = ({children, screenListeners, initialRouteName}: {children: React.ReactNode; screenListeners: MockScreenListeners; initialRouteName?: TabName}) => { mockScreenListeners = screenListeners; + mockInitialRouteName = initialRouteName; return ReactModule.createElement(ReactModule.Fragment, null, children); }; const Screen = ({children}: {children?: React.ReactNode | (() => React.ReactNode)}) => { @@ -120,23 +125,29 @@ function pressTargetTab(): MockTabPressEvent { throw new Error('Expected the tab navigator to register screen listeners'); } - const navigation: MockNavigation = { - getState: () => ({ - index: 0, - routes: [ - {key: `${CURRENT_TAB}-key`, name: CURRENT_TAB}, - {key: `${TARGET_TAB}-key`, name: TARGET_TAB}, - ], - }), + const navigation = createMock>({ + getState: () => + createMock>({ + index: 0, + routes: [ + {key: `${CURRENT_TAB}-key`, name: CURRENT_TAB}, + {key: `${TARGET_TAB}-key`, name: TARGET_TAB}, + ], + }), dispatch: mockDispatch, - }; + }); const event: MockTabPressEvent = { + type: 'tabPress', target: `${TARGET_TAB}-key`, defaultPrevented: false, - preventDefault: jest.fn(), + preventDefault: jest.fn, Parameters>(), }; - act(() => mockScreenListeners?.({navigation}).tabPress(event)); + const tabPress = mockScreenListeners({navigation}).tabPress; + if (!tabPress) { + throw new Error('Expected tabPress listener'); + } + act(() => tabPress(event)); return event; } @@ -159,7 +170,12 @@ describe('OnyxTabNavigator tab discard input blur', () => { expect(event.preventDefault).toHaveBeenCalledTimes(1); expect(blurActiveInputSpy).toHaveBeenCalledTimes(1); expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); - expect(blurActiveInputSpy.mock.invocationCallOrder.at(0)).toBeLessThan(mockShowConfirmModal.mock.invocationCallOrder.at(0) ?? 0); + const earlierCallOrder1 = blurActiveInputSpy.mock.invocationCallOrder.at(0); + const laterCallOrder1 = mockShowConfirmModal.mock.invocationCallOrder.at(0); + if (earlierCallOrder1 === undefined || laterCallOrder1 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder1).toBeLessThan(laterCallOrder1); }); it('does not blur the active input when there are no unsaved changes', () => { @@ -382,6 +398,27 @@ describe('OnyxTabNavigator keyboard dismissal after discarding changes', () => { expect(mockDispatch).not.toHaveBeenCalled(); }); + it('still jumps after a rejected discard callback and releases the pending switch', async () => { + // Given discarding can fail after the user confirms, but must not strand the user on the old tab. + jest.spyOn(Keyboard, 'isVisible').mockReturnValue(false); + mockShowConfirmModal.mockImplementation(() => Promise.resolve({action: ModalActions.CONFIRM})); + mockOnDiscard.mockRejectedValueOnce(new Error('discard failed')); + renderTabNavigator(true, true); + + // When discard rejects and a later press reaches the same navigator. + await act(async () => { + pressTargetTab(); + }); + await act(async () => { + pressTargetTab(); + }); + + // Then each confirmed attempt can jump and no stale pending-modal gate blocks the next attempt. + expect(mockShowConfirmModal).toHaveBeenCalledTimes(2); + expect(mockOnDiscard).toHaveBeenCalledTimes(2); + expect(mockDispatch).toHaveBeenCalledTimes(2); + }); + it('jumps synchronously after discard when not deferring', async () => { jest.spyOn(Keyboard, 'isVisible').mockReturnValue(false); mockShowConfirmModal.mockImplementation(() => Promise.resolve({action: ModalActions.CONFIRM})); @@ -396,3 +433,219 @@ describe('OnyxTabNavigator keyboard dismissal after discarding changes', () => { expect(mockDispatch).toHaveBeenCalledTimes(1); }); }); + +const {OnyxTabNavigator: FiniteNavigator, TopTab: FiniteTabs} = createOnyxTabNavigator([CURRENT_TAB, TARGET_TAB]); + +function renderFiniteNavigator(props: Partial> = {}, hasTargetScreen = true) { + return render( + null} + {...props} + > + {() => null} + {hasTargetScreen && {() => null}} + {false} + {null} + {React.createElement(React.Fragment)} + , + ); +} + +function navigatorListeners() { + if (!mockScreenListeners) { + throw new Error('Expected actual navigator listeners'); + } + const navigation = createMock>({ + getState: () => + createMock>({ + index: 0, + routes: [ + {key: 'current-key', name: CURRENT_TAB}, + {key: 'target-key', name: TARGET_TAB}, + ], + }), + dispatch: mockDispatch, + }); + return mockScreenListeners({navigation}); +} + +function notifyNavigatorState(listeners: TabListeners, index: number) { + const state = listeners.state; + if (!state) { + throw new Error('Expected actual state listener'); + } + // React Navigation owns the finite route names; an out-of-range index yields undefined via .at(). + const event = createMock({type: 'state', data: {state: {index, routeNames: [CURRENT_TAB, TARGET_TAB]}}}); + act(() => state(event)); + return event; +} + +describe('OnyxTabNavigator finite state notifications', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useOnyx).mockReturnValue(createMock>([CURRENT_TAB, {status: 'loaded'}])); + jest.spyOn(Keyboard, 'isVisible').mockReturnValue(false); + }); + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('calls the caller first, selects initially once and persists before notifying a changed name', () => { + // Given caller state effects and the initial selection share the actual navigator listener. + const callerState = jest.fn>, Parameters>>(); + const selected = jest.fn(); + const initialSelect = jest.fn['onTabSelect']>>, Parameters['onTabSelect']>>>(); + const view = renderFiniteNavigator({screenListeners: {state: callerState}, onTabSelected: selected, onTabSelect: initialSelect}); + const listeners = navigatorListeners(); + + // When initial, changed and repeated unchanged state events arrive. + notifyNavigatorState(listeners, 0); + const event = notifyNavigatorState(listeners, 1); + jest.mocked(useOnyx).mockReturnValue(createMock>([TARGET_TAB, {status: 'loaded'}])); + view.rerender( + null} + onTabSelected={selected} + onTabSelect={initialSelect} + > + {() => null} + {() => null} + , + ); + const secondListeners = navigatorListeners(); + notifyNavigatorState(secondListeners, 1); + view.unmount(); + + // Then the caller keeps event identity and persistence precedes a truthful changed-name notification. + expect(callerState).toHaveBeenCalledWith(event); + expect(initialSelect).toHaveBeenCalledTimes(1); + const initialSelection = initialSelect.mock.calls.at(0); + if (!initialSelection) { + throw new Error('Expected initial tab selection'); + } + expect(initialSelection[0]).toEqual({index: 0}); + expect(Tab.setSelectedTab).toHaveBeenCalledTimes(1); + expect(Tab.setSelectedTab).toHaveBeenCalledWith('test-tab-navigator', TARGET_TAB); + expect(selected).toHaveBeenCalledTimes(1); + expect(selected).toHaveBeenCalledWith(TARGET_TAB); + const earlierCallOrder2 = callerState.mock.invocationCallOrder.at(1); + const laterCallOrder2 = jest.mocked(Tab.setSelectedTab).mock.invocationCallOrder.at(0); + if (earlierCallOrder2 === undefined || laterCallOrder2 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder2).toBeLessThan(laterCallOrder2); + const earlierCallOrder3 = jest.mocked(Tab.setSelectedTab).mock.invocationCallOrder.at(0); + const laterCallOrder3 = selected.mock.invocationCallOrder.at(0); + if (earlierCallOrder3 === undefined || laterCallOrder3 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder3).toBeLessThan(laterCallOrder3); + }); + + it('ignores a missing route name without persisting or notifying and selects initially once', () => { + // Given a valid persisted tab and a temporarily out-of-range navigator index. + const selected = jest.fn(); + const initialSelect = jest.fn['onTabSelect']>>, Parameters['onTabSelect']>>>(); + renderFiniteNavigator({onTabSelected: selected, onTabSelect: initialSelect}); + const listeners = navigatorListeners(); + + // When state no longer has a route at its index, then restores the same stored tab. + notifyNavigatorState(listeners, 4); + notifyNavigatorState(listeners, 0); + + // Then missing names do not escape to consumers, and initialization runs only once. + expect(selected).not.toHaveBeenCalled(); + expect(Tab.setSelectedTab).not.toHaveBeenCalled(); + expect(initialSelect).toHaveBeenCalledTimes(1); + expect(initialSelect).toHaveBeenCalledWith({index: 4}); + }); + + it.each([ + {stored: TARGET_TAB, hasTargetScreen: true, expected: TARGET_TAB}, + {stored: TARGET_TAB, hasTargetScreen: false, expected: CURRENT_TAB}, + {stored: CONST.TAB_REQUEST.SCAN, hasTargetScreen: true, expected: CURRENT_TAB}, + ])('admits stored $stored only when finite and present in current children', ({stored, hasTargetScreen, expected}) => { + // Given Onyx can contain a tab belonging to a different page or a currently hidden screen. + jest.mocked(useOnyx).mockReturnValue(createMock>([stored, {status: 'loaded'}])); + + // When the actual factory chooses its initial route. + renderFiniteNavigator({}, hasTargetScreen); + + // Then both finite-name and current-child admission precede the default fallback. + expect(mockInitialRouteName).toBe(expected); + }); + + it('reapplies an interrupted press before returning for unchanged selection', () => { + // Given React Navigation can leave the old selected tab after an allowed press. + jest.useFakeTimers(); + const selected = jest.fn(); + renderFiniteNavigator({shouldReapplyInterruptedTabPress: true, onTabSelected: selected}); + const listeners = navigatorListeners(); + const press = listeners.tabPress; + if (!press) { + throw new Error('Expected tabPress listener'); + } + const event: MockTabPressEvent = {type: 'tabPress', target: 'target-key', defaultPrevented: false, preventDefault: jest.fn()}; + + // When the old state is reported immediately after the press. + act(() => press(event)); + notifyNavigatorState(listeners, 0); + act(() => jest.advanceTimersByTime(20)); + + // Then the reapplication survives the unchanged comparison without persisting or notifying that old state. + expect(mockDispatch).toHaveBeenCalledWith(expect.objectContaining({type: 'JUMP_TO', payload: {name: TARGET_TAB}})); + expect(selected).not.toHaveBeenCalled(); + expect(Tab.setSelectedTab).not.toHaveBeenCalled(); + }); + + it('cancels queued interrupted jumps on unmount', () => { + // Given an interrupted press has scheduled a frame against this navigator's old state. + jest.useFakeTimers(); + const view = renderFiniteNavigator({shouldReapplyInterruptedTabPress: true}); + const listeners = navigatorListeners(); + const press = listeners.tabPress; + if (!press) { + throw new Error('Expected tabPress listener'); + } + act(() => press({type: 'tabPress', target: 'target-key', defaultPrevented: false, preventDefault: jest.fn()})); + notifyNavigatorState(listeners, 0); + + // When the navigator unmounts before that frame executes. + view.unmount(); + act(() => jest.advanceTimersByTime(20)); + + // Then cleanup removes the queued dispatch against the detached navigator. + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('respects a caller-prevented tab press before consulting discard guards', () => { + // Given the caller has first refusal over a tab press. + renderFiniteNavigator({screenListeners: {tabPress: (event) => event.preventDefault()}}); + const listeners = navigatorListeners(); + const press = listeners.tabPress; + if (!press) { + throw new Error('Expected tabPress listener'); + } + const event: MockTabPressEvent = { + type: 'tabPress', + target: 'target-key', + defaultPrevented: false, + preventDefault: jest.fn(() => { + Object.defineProperty(event, 'defaultPrevented', {value: true}); + }), + }; + + // When the caller blocks the press. + act(() => press(event)); + + // Then no modal, keyboard wait or jump is started by the navigator. + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/ParentNavigationSubtitleTest.tsx b/tests/ui/ParentNavigationSubtitleTest.tsx index 9c7fbff7370e..013a8302df99 100644 --- a/tests/ui/ParentNavigationSubtitleTest.tsx +++ b/tests/ui/ParentNavigationSubtitleTest.tsx @@ -6,11 +6,14 @@ import {isReportActionVisible} from '@libs/ReportActionsUtils'; import type {RouteProp} from '@react-navigation/native'; import React from 'react'; +import Onyx from 'react-native-onyx'; import ParentNavigationSubtitle from '../../src/components/ParentNavigationSubtitle'; import NAVIGATORS from '../../src/NAVIGATORS'; +import ONYXKEYS from '../../src/ONYXKEYS'; import SCREENS from '../../src/SCREENS'; import createMock from '../utils/createMock'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; jest.mock('@libs/Navigation/Navigation', () => ({ __esModule: true, @@ -301,4 +304,112 @@ describe('ParentNavigationSubtitle', () => { expect(Navigation.goBack).not.toHaveBeenCalled(); expect(Navigation.navigate).toHaveBeenCalled(); }); + + describe('visible report actions subscription', () => { + const parentReportID = 'parent-report'; + const otherReportID = 'other-report'; + const expenseReportID = 'expense-report'; + const parentReportActionID = 'parent-action'; + const otherReportActionID = 'other-action'; + const subtitleData = {reportName: 'Parent chat', workspaceName: 'Workspace'}; + + function pressSubtitleLink() { + fireEvent(screen.getByTestId('parent-navigation-subtitle-link'), 'press', { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + nativeEvent: {}, + }); + } + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + mockUseRoute.mockReturnValue(createMock({name: SCREENS.REPORT})); + mockUseRootNavigationState.mockImplementation((selector?: (state: unknown) => unknown) => { + const mockState = getMockRootStateWithReportsSplitStack(otherReportID, expenseReportID); + if (selector) { + return selector(mockState); + } + return undefined; + }); + }); + + it('renders the parent report name as plain text when the user has no access to the parent report', async () => { + // Given the current report says the user cannot access its parent + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, {reportID: expenseReportID, hasParentAccess: false}); + + // When the subtitle renders + render( + , + ); + await waitForBatchedUpdates(); + + // Then the parent name is shown without the link, because hasParentAccess is read through getReportHasParentAccess + expect(screen.queryByTestId('parent-navigation-subtitle-link')).toBeNull(); + expect(screen.getByText(subtitleData.reportName)).toBeTruthy(); + }); + + it('passes only the parent report visibility to isReportActionVisible', async () => { + // Given visibility data for the parent report and for an unrelated report + await Onyx.set(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { + [parentReportID]: {[parentReportActionID]: true}, + [otherReportID]: {[otherReportActionID]: true}, + }); + render( + , + ); + await waitForBatchedUpdates(); + + // When the parent link is pressed + pressSubtitleLink(); + + // Then the visibility check receives only the parent report slice, because the component subscribes through + // reportVisibleActionsSelector instead of the whole derived map + expect(jest.mocked(isReportActionVisible).mock.calls.at(0)?.at(3)).toEqual({[parentReportID]: {[parentReportActionID]: true}}); + }); + + it('reads the new parent report visibility when parentReportID changes', async () => { + // Given visibility data for two reports and a subtitle pointing at the first one + await Onyx.set(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { + [parentReportID]: {[parentReportActionID]: true}, + [otherReportID]: {[parentReportActionID]: false}, + }); + const {rerender} = render( + , + ); + await waitForBatchedUpdates(); + + // When the subtitle is re-rendered for a different parent report + rerender( + , + ); + await waitForBatchedUpdates(); + pressSubtitleLink(); + + // Then the visibility check uses the new parent's slice, because the selector is rebuilt for the new parentReportID + expect(jest.mocked(isReportActionVisible).mock.calls.at(0)?.at(3)).toEqual({[otherReportID]: {[parentReportActionID]: false}}); + }); + }); }); diff --git a/tests/ui/PolicyDistanceRateNameEditPageTest.tsx b/tests/ui/PolicyDistanceRateNameEditPageTest.tsx new file mode 100644 index 000000000000..ce6bb431f4bd --- /dev/null +++ b/tests/ui/PolicyDistanceRateNameEditPageTest.tsx @@ -0,0 +1,202 @@ +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import {ModalProvider} from '@components/Modal/Global/ModalContext'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; + +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; + +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import PolicyDistanceRateNameEditPage from '@pages/workspace/distanceRates/PolicyDistanceRateNameEditPage'; + +import {updatePolicyDistanceRateName} from '@userActions/Policy/DistanceRate'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Policy} from '@src/types/onyx'; +import type {Rate} from '@src/types/onyx/Policy'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@userActions/Policy/DistanceRate', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- test-only module mock. jest.requireActual returns an untyped module + const actual = jest.requireActual('@userActions/Policy/DistanceRate'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- test-only module mock. Spreads the real actions and replaces only updatePolicyDistanceRateName + return { + ...actual, + updatePolicyDistanceRateName: jest.fn(), + }; +}); + +TestHelper.setupGlobalFetchMock(); + +const mockUpdatePolicyDistanceRateName = jest.mocked(updatePolicyDistanceRateName); + +const Stack = createPlatformStackNavigator(); + +const CUSTOM_UNIT_ID = 'unit-1'; +const GOVERNMENT_ATTRIBUTES = {governmentRate: {sourceRateID: 'GB_2026-09-01', rate: 14, startDate: '2026-09-01'}}; + +const RATES: Record = { + cars: {customUnitRateID: 'cars', name: 'Cars - From 9/1/26', rate: 1400, currency: CONST.CURRENCY.USD, enabled: true, startDate: '2026-09-01', attributes: GOVERNMENT_ATTRIBUTES}, + bikes: {customUnitRateID: 'bikes', name: 'Bikes - From 9/1/26', rate: 1400, currency: CONST.CURRENCY.USD, enabled: true, startDate: '2026-09-01', attributes: GOVERNMENT_ATTRIBUTES}, + summer: {customUnitRateID: 'summer', name: 'Summer - 6/1/26 - 8/31/26', rate: 1000, currency: CONST.CURRENCY.USD, enabled: true, startDate: '2026-06-01', endDate: '2026-08-31'}, +}; + +const renderPage = (policyID: string, rateID: string) => + render( + + + + + + + + + + + , + ); + +/** The page wrapper needs an admin on a paid workspace with distance rates on. */ +const setupPolicy = async () => { + await TestHelper.signInWithTestUser(); + const policy: Policy = { + ...LHNTestUtils.getFakePolicy(), + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.CORPORATE, + areDistanceRatesEnabled: true, + customUnits: { + [CUSTOM_UNIT_ID]: { + customUnitID: CUSTOM_UNIT_ID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + enabled: true, + rates: RATES, + }, + }, + }; + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + }); + return policy; +}; + +/** The header title shares the "Name" label, so pick the element that holds the input value. */ +const getNameInput = () => { + const input = screen.getAllByLabelText(TestHelper.translateLocal('common.name')).find((element) => typeof element.props.value === 'string'); + if (!input) { + throw new Error('Expected the name input'); + } + return input; +}; + +/** Waits for the form to be interactive, i.e. past the access wrapper's loading state. */ +const waitForForm = async () => { + await waitForBatchedUpdatesWithAct(); + await waitFor(() => { + expect(getNameInput()).toBeOnTheScreen(); + }); +}; + +const save = async () => { + fireEvent.press(screen.getByRole(CONST.ROLE.BUTTON, {name: TestHelper.translateLocal('common.save')})); + await waitForBatchedUpdatesWithAct(); +}; + +beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); +}); + +beforeEach(async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + }); +}); + +afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.clearAllMocks(); +}); + +describe('PolicyDistanceRateNameEditPage', () => { + it('prefills the name without the date suffix and saves the new name with the suffix appended', async () => { + // Given a government band whose name carries a date suffix the backend added for uniqueness + const policy = await setupPolicy(); + const {unmount} = renderPage(policy.id, 'cars'); + await waitForForm(); + + // Then the input starts from the name without the suffix, matching the name shown everywhere else + expect(getNameInput().props.value).toBe('Cars'); + + // When the user renames it to "Vans" and saves + fireEvent.changeText(getNameInput(), 'Vans'); + await save(); + + // Then the original suffix is appended back, so Onyx and the API still get the full raw name + expect(mockUpdatePolicyDistanceRateName).toHaveBeenCalledWith(policy.id, expect.objectContaining({customUnitID: CUSTOM_UNIT_ID}), [ + expect.objectContaining({customUnitRateID: 'cars', name: 'Vans - From 9/1/26'}), + ]); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('rejects a name that duplicates another rate once the suffix is appended', async () => { + // Given a government band and another band whose full name is "Bikes - From 9/1/26" + const policy = await setupPolicy(); + const {unmount} = renderPage(policy.id, 'cars'); + await waitForForm(); + + // When the user types "Bikes", which only collides once the hidden suffix is added back + fireEvent.changeText(getNameInput(), 'Bikes'); + await save(); + + // Then the duplicate check runs against the full name that would be saved, so the rename is blocked + expect(screen.getByText(TestHelper.translateLocal('workspace.distanceRates.errors.existingRateName'))).toBeOnTheScreen(); + expect(mockUpdatePolicyDistanceRateName).not.toHaveBeenCalled(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('prefills and saves the full name of a custom dated rate', async () => { + // Given a custom rate with dates that a user named with a date on purpose + const policy = await setupPolicy(); + const {unmount} = renderPage(policy.id, 'summer'); + await waitForForm(); + + // Then the input shows the full name, because only government rates hide the suffix + expect(getNameInput().props.value).toBe('Summer - 6/1/26 - 8/31/26'); + + // When the user renames it and saves + fireEvent.changeText(getNameInput(), 'Winter'); + await save(); + + // Then the typed name is saved as is, with nothing appended + expect(mockUpdatePolicyDistanceRateName).toHaveBeenCalledWith(policy.id, expect.objectContaining({customUnitID: CUSTOM_UNIT_ID}), [ + expect.objectContaining({customUnitRateID: 'summer', name: 'Winter'}), + ]); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); +}); diff --git a/tests/ui/ProfilePageTest.tsx b/tests/ui/ProfilePageTest.tsx index e28481374bd9..2d84b11cbfe1 100644 --- a/tests/ui/ProfilePageTest.tsx +++ b/tests/ui/ProfilePageTest.tsx @@ -286,6 +286,7 @@ describe('ProfilePage - agent account', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, { id: '1', name: 'Boulder Development', + areDistanceRatesEnabled: true, commuterExclusions: {method: 'homeAndOffice'}, }); }); @@ -293,6 +294,7 @@ describe('ProfilePage - agent account', () => { renderPageWithNavigation(SCREENS.SETTINGS.PROFILE.ROOT); await waitForBatchedUpdatesWithAct(); + expect(screen.getByTestId('address-menu-item')).toHaveTextContent('info-brickRoadIndicator'); expect(screen.getByTestId('address-menu-item-further-details')).toHaveTextContent('Boulder Development uses this address for commuter exclusions.'); }); @@ -313,6 +315,24 @@ describe('ProfilePage - agent account', () => { expect(screen.queryByTestId('address-menu-item-further-details')).toBeNull(); }); + it('shows the commuter exclusion indicator when distance rates are disabled but the setting remains active', async () => { + await setupUser('user@expensify.com'); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, { + id: '1', + name: 'Boulder Development', + areDistanceRatesEnabled: false, + commuterExclusions: {method: 'homeAndOffice'}, + }); + }); + + renderPageWithNavigation(SCREENS.SETTINGS.PROFILE.ROOT); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId('address-menu-item')).toHaveTextContent('info-brickRoadIndicator'); + expect(screen.getByTestId('address-menu-item-further-details')).toHaveTextContent('Boulder Development uses this address for commuter exclusions.'); + }); + it('shows contact methods, pronouns, timezone and private section for non-agent account', async () => { await setupUser('user@expensify.com'); @@ -325,12 +345,51 @@ describe('ProfilePage - agent account', () => { expect(screen.getByText('Private')).toBeDefined(); }); + it('opens the vacation delegate form from the 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 form opens with Choose delegate unselected, so the member and the clear after date are picked on one screen + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_VACATION_DELEGATE); + expect(Navigation.navigate).not.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')).toBeDefined(); + expect(screen.getByText(/^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); await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, { + await Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, { prompt: 'Reject gambling expenses.', pendingAction: null, }); @@ -381,7 +440,7 @@ describe('ProfilePage - agent account', () => { await setupUser('testbot_123@expensify.ai', true); await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, { + await Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, { prompt: 'Reject gambling expenses.', pendingAction: null, }); @@ -404,7 +463,7 @@ describe('ProfilePage - agent account', () => { await setupUser('testbot_123@expensify.ai', true); await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, { + await Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, { prompt: 'Reject gambling expenses.', pendingAction: 'update', }); @@ -423,7 +482,7 @@ describe('ProfilePage - agent account', () => { await setupUser('testbot_123@expensify.ai', true); await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, { + await Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, { prompt: 'Reject gambling expenses.', pendingAction: null, }); @@ -437,7 +496,7 @@ describe('ProfilePage - agent account', () => { fireEvent.press(screen.getByTestId('save-prompt-button')); await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, { + await Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, { prompt: 'Updated prompt text', pendingAction: 'update', }); @@ -456,7 +515,7 @@ describe('ProfilePage - agent account', () => { await act(async () => { // Simulate a first offline save that is queued but not yet replayed: pendingAction stays 'update', // so isSaving is true. The button is intentionally not disabled while offline. - await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, { + await Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, { prompt: 'First offline edit.', pendingAction: 'update', }); @@ -479,7 +538,7 @@ describe('ProfilePage - agent account', () => { await setupUser('testbot_123@expensify.ai', true); await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, { + await Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, { prompt: 'Reject gambling expenses.', pendingAction: null, }); @@ -495,7 +554,7 @@ describe('ProfilePage - agent account', () => { // Optimistic write sets pendingAction='update' so the user-initiated save shows the loader. await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, { + await Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, { prompt: 'Updated prompt text', pendingAction: 'update', }); @@ -521,7 +580,7 @@ describe('ProfilePage - agent account', () => { await setupUser('testbot_123@expensify.ai', true); await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, { + await Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`, { prompt: 'Reject gambling expenses.', pendingAction: null, }); diff --git a/tests/ui/PublicProfilePageTest.tsx b/tests/ui/PublicProfilePageTest.tsx index 1c1f6b09252e..0aaf32447ee0 100644 --- a/tests/ui/PublicProfilePageTest.tsx +++ b/tests/ui/PublicProfilePageTest.tsx @@ -82,7 +82,7 @@ describe('ProfilePage - agent custom instructions', () => { await act(async () => { await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${AGENT_ACCOUNT_ID}`, {prompt, pendingAction: null}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${AGENT_ACCOUNT_ID}`, {prompt, pendingAction: null}); await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); }); await waitForBatchedUpdatesWithAct(); @@ -126,7 +126,7 @@ describe('ProfilePage - agent custom instructions', () => { await act(async () => { await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${AGENT_ACCOUNT_ID}`, {prompt: 'Ignore #some-room mentions.', pendingAction: null}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.AGENT}${AGENT_ACCOUNT_ID}`, {prompt: 'Ignore #some-room mentions.', pendingAction: null}); await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); }); await waitForBatchedUpdatesWithAct(); diff --git a/tests/ui/ReportActionItemTest.tsx b/tests/ui/ReportActionItemTest.tsx index 6212a44f8585..fb855b0abcb6 100644 --- a/tests/ui/ReportActionItemTest.tsx +++ b/tests/ui/ReportActionItemTest.tsx @@ -20,6 +20,7 @@ import type * as UrlType from '@libs/Url'; import PaymentContent from '@pages/inbox/report/actionContents/PaymentContent'; import ReportActionItem from '@pages/inbox/report/ReportActionItem'; import ReportActionItemMessage from '@pages/inbox/report/ReportActionItemMessage'; +import ReportActionItemParentAction from '@pages/inbox/report/ReportActionItemParentAction'; import colors from '@styles/theme/colors'; @@ -151,6 +152,7 @@ describe('ReportActionItem', () => { { { { { { { { // Then the action message should be displayed expect(screen.getByText(translateLocal('iou.paidElsewhere'))).toBeOnTheScreen(); }); + + it('does not show Explain link when action has no reasoning', async () => { + const action = createReportAction(CONST.REPORT.ACTIONS.TYPE.REIMBURSED, { + paymentMethod: 'ACH', + creditBankAccountLast4: '1234', + }); + + renderItemWithAction(action); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText('Explain')).not.toBeOnTheScreen(); + }); + + it('shows Explain link when action has reasoning, with no double period', async () => { + const action = createReportAction(CONST.REPORT.ACTIONS.TYPE.REIMBURSED, { + paymentMethod: 'ACH', + creditBankAccountLast4: '1234', + reasoning: 'The withdrawal account is outside the United States, so the payment was routed through Corpay.', + }); + + renderItemWithAction(action); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('Explain')).toBeOnTheScreen(); + expect(document.body.textContent).not.toMatch(/\.\.\s*Explain/); + }); }); describe('Modified expense message', () => { @@ -804,6 +838,7 @@ describe('ReportActionItem', () => { { { { { { { { { { { { { { { { { { { { { { { ({reportID})} transactionThreadReport={undefined} @@ -2336,6 +2393,7 @@ describe('ReportActionItem', () => { { { { { { { { { { { { async function reactWithThumbsUp(action: ReportAction, timestamp: string) { await act(async () => { - await Onyx.merge(ONYXKEYS.SESSION, {accountID: ACTOR_ACCOUNT_ID}); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`, buildThumbsUpReaction(timestamp)); + await Onyx.set(ONYXKEYS.SESSION, {accountID: ACTOR_ACCOUNT_ID}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`, buildThumbsUpReaction(timestamp)); + }); + } + + async function clearReactions(action: ReportAction) { + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`, {}); }); } @@ -3626,6 +3700,35 @@ describe('ReportActionItem', () => { expect(screen.queryByText(prompt())).not.toBeOnTheScreen(); }); + it('does not show the acknowledgement again when thumbs-up is added shortly after being removed', async () => { + jest.useFakeTimers(); + + try { + // Given an unrated Concierge reply, the customer can leave feedback. + const action = createConciergeComment(); + renderItemWithAction(action, true); + await waitForBatchedUpdatesWithAct(); + + // When the customer rates it, acknowledge their feedback for four seconds. + await reactWithThumbsUp(action, '2025-07-12 09:03:18.000'); + expect(screen.getByText(translateLocal('concierge.feedback.thanks'))).toBeOnTheScreen(); + + await act(async () => { + jest.advanceTimersByTime(4000); + }); + expect(screen.queryByText(translateLocal('concierge.feedback.thanks'))).not.toBeOnTheScreen(); + + await clearReactions(action); + expect(screen.getByText(prompt())).toBeOnTheScreen(); + + // Then restoring the rating immediately does not repeat the acknowledgement. + await reactWithThumbsUp(action, '2025-07-12 09:03:19.000'); + expect(screen.queryByText(translateLocal('concierge.feedback.thanks'))).not.toBeOnTheScreen(); + } finally { + jest.useRealTimers(); + } + }); + it('does not acknowledge a rating that was already there when the chat was opened', async () => { const action = createConciergeComment(); await reactWithThumbsUp(action, DateUtils.getDBTime()); @@ -3637,6 +3740,75 @@ describe('ReportActionItem', () => { expect(screen.queryByText(prompt())).not.toBeOnTheScreen(); }); + const PARENT_REPORT_ID = 'parentChatReport'; + const THREAD_REPORT_ID = 'threadReport'; + + async function setUpThreadOffConciergeComment(conciergeAction: ReportAction, threadReportNameValuePairs?: Record) { + await act(async () => { + await Onyx.merge(ONYXKEYS.SESSION, {accountID: ACTOR_ACCOUNT_ID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${PARENT_REPORT_ID}`, {reportID: PARENT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT_ID}`, {[conciergeAction.reportActionID]: conciergeAction}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${THREAD_REPORT_ID}`, { + reportID: THREAD_REPORT_ID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: PARENT_REPORT_ID, + parentReportActionID: conciergeAction.reportActionID, + }); + if (threadReportNameValuePairs) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${THREAD_REPORT_ID}`, threadReportNameValuePairs); + } + }); + } + + function renderThreadParentAction(conciergeActionID: string) { + const threadReport = { + reportID: THREAD_REPORT_ID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: PARENT_REPORT_ID, + parentReportActionID: conciergeActionID, + } as Report; + return render( + + + + + + + , + ); + } + + it('offers the prompt on the message a thread hangs off once the rating is taken back', async () => { + // The backend keeps its feedback marker on the thread after the reaction is removed, so the marker must not decide this message + const conciergeAction = createConciergeComment(); + await setUpThreadOffConciergeComment(conciergeAction, {conciergeFeedbackForReportActionID: conciergeAction.reportActionID}); + + renderThreadParentAction(conciergeAction.reportActionID); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(prompt())).toBeOnTheScreen(); + }); + + it('keeps the prompt off the message a thread hangs off while the rating is still there', async () => { + const conciergeAction = createConciergeComment(); + await setUpThreadOffConciergeComment(conciergeAction, {conciergeFeedbackForReportActionID: conciergeAction.reportActionID}); + await reactWithThumbsUp(conciergeAction, DateUtils.getDBTime()); + + renderThreadParentAction(conciergeAction.reportActionID); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText(prompt())).not.toBeOnTheScreen(); + }); + it('does not render while moderation has the message hidden', async () => { renderItemWithAction(createConciergeComment(CONST.MODERATION.MODERATOR_DECISION_HIDDEN), true); await waitForBatchedUpdatesWithAct(); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 75d7d9d353dc..76f9ec963a27 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -160,6 +160,7 @@ type MockInvertedFlashListProps = { data?: OnyxTypes.ReportAction[]; extraData?: unknown; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; + onContentSizeChange?: () => void; }; const mockInvertedFlashList: jest.MockedFunction<(props: MockInvertedFlashListProps) => null> = jest.requireMock('@components/FlashList/InvertedFlashList'); @@ -374,6 +375,35 @@ describe('ReportActionsList (body)', () => { expect(getRenderedReportActionsListItemProps(conciergeReply).isLatestConciergeFeedbackAction).toBe(true); }); + it('marks the newest reply of Concierge and of each custom agent', () => { + // Given a custom agent that replied after Concierge + const agentAccountID = 4242; + const agentReply: OnyxTypes.ReportAction = { + ...conciergeReply, + reportActionID: 'agent-reply', + created: '2023-01-05', + actorAccountID: agentAccountID, + person: [{type: 'TEXT', style: 'strong', text: 'Receipt Checker'}], + }; + mockUseOnyx.mockImplementation((key, options) => { + if (key === ONYXKEYS.PERSONAL_DETAILS_LIST) { + return [[agentAccountID], {status: 'loaded'}]; + } + return getMockOnyxValue(key, options); + }); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [...mockReportActions, conciergeReply, agentReply], + }); + + // When the list renders + renderReportActionsList(); + + // Then both replies show the prompt, because the agent reply must not take the prompt away from Concierge + expect(getRenderedReportActionsListItemProps(conciergeReply).isLatestConciergeFeedbackAction).toBe(true); + expect(getRenderedReportActionsListItemProps(agentReply).isLatestConciergeFeedbackAction).toBe(true); + }); + it('marks nothing while a Concierge answer is still streaming', () => { mockUseConciergeDraft.mockReturnValue({ draftReportAction: {...conciergeReply, message: [{type: 'COMMENT', html: 'Here', text: 'Here'}]}, @@ -869,6 +899,22 @@ describe('ReportActionsList (body)', () => { }); }); + describe('Pending scroll-to-bottom', () => { + it('flushes a pending scroll-to-bottom when the list content size changes', () => { + // Given the list is rendered + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + // When the list content size changes, as it does once FlashList lays out a newly sent comment. In an + // aligned-to-top report this is the only list event that follows, because onLayout does not fire. + getCapturedListProps()?.onContentSizeChange?.(); + + // Then the pending scroll-to-bottom is flushed, so the list lands on the new comment + const scrollHookResult = mockUseReportActionsScroll.mock.results.at(-1)?.value as {flushPendingScrollToBottom: jest.Mock}; + expect(scrollHookResult.flushPendingScrollToBottom).toHaveBeenCalledTimes(1); + }); + }); + describe('Concierge Side Panel', () => { const CONCIERGE_REPORT_ID = '123'; const CURRENT_USER_ACCOUNT_ID = 100; diff --git a/tests/ui/ReportDetailsNameSectionTest.tsx b/tests/ui/ReportDetailsNameSectionTest.tsx new file mode 100644 index 000000000000..0270f2f3608d --- /dev/null +++ b/tests/ui/ReportDetailsNameSectionTest.tsx @@ -0,0 +1,85 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import Navigation from '@libs/Navigation/Navigation'; + +import ReportDetailsNameSection from '@pages/DynamicReportDetailsPage/ReportDetailsNameSection'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import {translateLocal} from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + getActiveRoute: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), +})); +jest.mock('@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute', () => jest.fn(() => 'report-settings-name')); + +const GROUP_CHAT: Report = { + reportID: '1', + reportName: 'Weekend trip', + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + type: CONST.REPORT.TYPE.CHAT, +}; + +const WORKSPACE_CHAT: Report = { + reportID: '2', + reportName: 'Acme expenses', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + type: CONST.REPORT.TYPE.CHAT, + policyID: 'policy', +}; + +async function renderSection(report: Report) { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); +} + +describe('ReportDetailsNameSection', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + afterEach(async () => { + await Onyx.clear(); + jest.clearAllMocks(); + }); + + it('opens the rename page from a report the user can rename, announcing the field name before the report name', async () => { + // Given a group chat, which the user can rename and which shows its name as a push row + await renderSection(GROUP_CHAT); + + // When the name row is pressed + fireEvent.press(screen.getByRole('button', {name: `${translateLocal('newRoomPage.groupName')}, Weekend trip`}), {type: 'press'}); + + // Then the rename page opens + expect(Navigation.navigate).toHaveBeenCalledWith('report-settings-name'); + }); + + it('renders the name of a report that cannot be renamed as static text', async () => { + // Given a workspace chat, whose name the user cannot change + await renderSection(WORKSPACE_CHAT); + + // When the section renders + const row = screen.getByLabelText('Acme expenses'); + + // Then the row is not a button, so it offers no rename action + expect(row).not.toHaveProp('role', CONST.ROLE.BUTTON); + expect(screen.queryByRole('button')).toBeNull(); + }); +}); diff --git a/tests/ui/RuleEditorSeededDraftTest.tsx b/tests/ui/RuleEditorSeededDraftTest.tsx new file mode 100644 index 000000000000..e90c66ad8004 --- /dev/null +++ b/tests/ui/RuleEditorSeededDraftTest.tsx @@ -0,0 +1,216 @@ +import {act, render, screen} from '@testing-library/react-native'; + +import usePolicy from '@hooks/usePolicy'; +import usePolicyData from '@hooks/usePolicyData'; +import type PolicyData from '@hooks/usePolicyData/types'; + +import FlagForReviewRulePageBase from '@pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase'; +import RequireFieldsRulePageBase from '@pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase'; +import SpendRulePageBase from '@pages/workspace/rules/SpendRules/SpendRulePageBase'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type * as ReactNavigationNative from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@react-navigation/native', () => { + const actualNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + formatPhoneNumber: (phoneNumber: string) => phoneNumber, + })), +); +jest.mock('@hooks/useThemeStyles', () => + jest.fn( + () => + new Proxy( + {}, + { + get: () => ({}), + }, + ), + ), +); +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn( + () => + new Proxy( + {}, + { + get: (_target, iconName) => iconName, + }, + ), + ), +})); +jest.mock('@hooks/usePolicy', () => jest.fn()); +jest.mock('@hooks/usePolicyData', () => jest.fn()); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => jest.fn(() => ({canWrite: true, showReadOnlyModal: jest.fn()}))); +jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => false, isBetaEnabledOrUnknown: () => false}))); +jest.mock('@hooks/useCategoryRuleCreateBackPath', () => jest.fn(() => undefined)); +jest.mock('@hooks/useCurrencyList', () => ({ + useCurrencyListActions: () => ({convertToDisplayString: (amount: number) => String(amount), getCurrencyDecimals: () => 2}), +})); +jest.mock('@hooks/useCanWriteCardSpendRules', () => jest.fn(() => true)); +jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: jest.fn()}))); +jest.mock('@hooks/useControlOnlyRuleUpgradeRedirect', () => jest.fn()); +jest.mock('@hooks/useDefaultFundID', () => jest.fn(() => 1)); +jest.mock('@hooks/usePressLoading', () => jest.fn(() => ({isLoading: false, startWithLoading: jest.fn()}))); +jest.mock('@pages/workspace/rules/useRuleDeleteHeaderProps', () => jest.fn(() => ({deleteIconButtonProps: undefined}))); +jest.mock('@pages/workspace/rules/DescribeRuleModal', () => () => null); +jest.mock( + '@pages/workspace/AccessOrNotFoundWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/ScreenWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/ScrollView', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => () => null); +jest.mock('@components/FormAlertWithSubmitButton', () => () => null); +jest.mock('@components/Text', () => () => null); +jest.mock('@components/MenuItemWithTopDescription', () => { + const ReactModule = jest.requireActual('react'); + const {Text} = jest.requireActual<{Text: React.ComponentType<{children?: React.ReactNode}>}>('react-native'); + return ({description, title}: {description?: string; title?: string}) => ReactModule.createElement(Text, null, `${description}: ${title ?? ''}`); +}); +jest.mock('@components/RequireFieldsRules/FieldRequirementSettingRow', () => { + const ReactModule = jest.requireActual('react'); + const {Text} = jest.requireActual<{Text: React.ComponentType<{children?: React.ReactNode}>}>('react-native'); + return ({fieldKey, touchedFields}: {fieldKey: string; touchedFields: Set}) => + ReactModule.createElement(Text, null, `${fieldKey}: ${touchedFields.has(fieldKey) ? 'touched' : 'untouched'}`); +}); +jest.mock('@components/SpendRules/SpendRuleRestrictionTypeToggleRevamp', () => { + const ReactModule = jest.requireActual('react'); + const {Text} = jest.requireActual<{Text: React.ComponentType<{children?: React.ReactNode}>}>('react-native'); + return ({restrictionAction}: {restrictionAction: string | null}) => ReactModule.createElement(Text, null, `restriction: ${restrictionAction ?? 'off'}`); +}); + +const POLICY_ID = 'policy1'; +const TEST_ID = 'RuleEditorSeededDraftTest'; + +const mockedUsePolicy = jest.mocked(usePolicy); +const mockedUsePolicyData = jest.mocked(usePolicyData); + +async function seedDraft( + key: typeof ONYXKEYS.FORMS.FLAG_FOR_REVIEW_RULE_FORM | typeof ONYXKEYS.FORMS.REQUIRE_FIELDS_RULE_FORM | typeof ONYXKEYS.FORMS.SPEND_RULE_FORM, + draft: Record, +) { + await act(async () => { + await Onyx.set(key, draft); + }); +} + +describe('Rule editors opened with a draft from the describe flow', () => { + beforeEach(() => { + jest.clearAllMocks(); + const policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), id: POLICY_ID, role: CONST.POLICY.ROLE.ADMIN, areCategoriesEnabled: false}; + mockedUsePolicy.mockReturnValue(policy); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only policy data. The editors read only the policy and its categories. + mockedUsePolicyData.mockReturnValue({policy, categories: {}, tags: {}, reports: []} as unknown as PolicyData); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + it('keeps the seeded flag for review draft when the editor opens for a new rule', async () => { + // Given the describe flow seeded a flag for review draft + const draft = {category: 'Meals', maxExpenseAmount: '50'}; + await seedDraft(ONYXKEYS.FORMS.FLAG_FOR_REVIEW_RULE_FORM, draft); + + // When the editor opens for a new rule + render( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the editor shows the seeded values, because it must not reset a draft that already exists + expect(await getOnyxValue(ONYXKEYS.FORMS.FLAG_FOR_REVIEW_RULE_FORM)).toEqual(draft); + expect(screen.getByLabelText('common.category, Meals')).toBeOnTheScreen(); + }); + + it('keeps the seeded require fields draft and marks its settings as touched when the editor opens for a new rule', async () => { + // Given the describe flow seeded a require fields draft + const draft = {category: 'Meals', receiptSetting: CONST.FIELD_REQUIREMENTS_DIRECTION.REQUIRE}; + await seedDraft(ONYXKEYS.FORMS.REQUIRE_FIELDS_RULE_FORM, draft); + + // When the editor opens for a new rule + render( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the draft survives and only the seeded setting counts as touched, the same as when Concierge fills in the open editor + expect(await getOnyxValue(ONYXKEYS.FORMS.REQUIRE_FIELDS_RULE_FORM)).toEqual(draft); + expect(screen.getByText('receiptSetting: touched')).toBeOnTheScreen(); + expect(screen.getByText('descriptionSetting: untouched')).toBeOnTheScreen(); + }); + + it('keeps merchant restrictions on when the seeded spend rule draft has merchants', async () => { + // Given the describe flow seeded a spend rule draft that blocks a merchant + const draft = {merchantNames: ['Walmart'], restrictionAction: CONST.SPEND_RULES.ACTION.BLOCK}; + await seedDraft(ONYXKEYS.FORMS.SPEND_RULE_FORM, draft); + + // When the editor opens for a new rule + render( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the merchant restriction shows, because the editor reads the toggle from the seeded draft + expect(await getOnyxValue(ONYXKEYS.FORMS.SPEND_RULE_FORM)).toEqual(draft); + expect(screen.getByText(`restriction: ${CONST.SPEND_RULES.ACTION.BLOCK}`)).toBeOnTheScreen(); + expect(screen.getByText(/workspace\.rules\.spendRules\.blockedMerchants/)).toBeOnTheScreen(); + }); + + it('opens a new spend rule with merchant restrictions off when there is no draft', async () => { + // Given no draft, because the admin picked the spend rule type without describing a rule + + // When the editor opens for a new rule + render( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then merchant restrictions start off, as they do for any new spend rule + expect(screen.getByText('restriction: off')).toBeOnTheScreen(); + expect(screen.queryByText(/workspace\.rules\.spendRules\.blockedMerchants/)).toBeNull(); + }); +}); diff --git a/tests/ui/RulesAgentsTabTest.tsx b/tests/ui/RulesAgentsTabTest.tsx new file mode 100644 index 000000000000..083f7035376e --- /dev/null +++ b/tests/ui/RulesAgentsTabTest.tsx @@ -0,0 +1,131 @@ +import {render} from '@testing-library/react-native'; + +import MenuItemSectionRoot from '@components/MenuItem/presets/MenuItemSectionRoot'; + +import usePolicy from '@hooks/usePolicy'; + +import Navigation from '@libs/Navigation/Navigation'; + +import RulesAgentsTab from '@pages/workspace/rules/tabs/RulesAgentsTab'; + +import ROUTES from '@src/ROUTES'; + +import React from 'react'; + +jest.mock('@components/AgentRules/AgentRulesList', () => jest.fn(() => null)); +jest.mock('@components/AgentRules/useAgentRulesSectionHeader', () => jest.fn(() => ({renderTitle: jest.fn(), renderSubtitle: jest.fn()}))); +jest.mock('@components/EmptyStateComponent/GenericEmptyStateComponent', () => jest.fn(() => null)); +jest.mock('@components/MenuItem', () => + Object.assign( + jest.fn(() => null), + { + Row: jest.fn(() => null), + Leading: jest.fn(() => null), + Content: jest.fn(() => null), + Icon: jest.fn(() => null), + Title: jest.fn(() => null), + }, + ), +); +jest.mock('@components/MenuItem/presets/MenuItemSectionRoot', () => jest.fn(() => null)); +jest.mock( + '@components/ScrollView', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/Section', + () => + ({children}: {children: React.ReactNode}) => + children, +); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({Plus: null})), + useMemoizedLazyIllustrations: jest.fn(() => ({AgentsIceCream: null})), +})); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/usePolicy', () => jest.fn()); +jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: false}))); +jest.mock('@hooks/useStyleUtils', () => jest.fn(() => ({getMaximumWidth: jest.fn(() => ({}))}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn( + () => + new Proxy( + {}, + { + get: () => ({}), + }, + ), + ), +); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), +})); + +const mockedUsePolicy = jest.mocked(usePolicy); +const mockedMenuItemSectionRoot = jest.mocked(MenuItemSectionRoot); +const mockedNavigate = jest.mocked(Navigation.navigate); + +const POLICY_ID = 'POLICY_ID_1'; + +const mockKeyboardEvent = new KeyboardEvent('keydown'); + +function pressAddAgentRule() { + const onPress = mockedMenuItemSectionRoot.mock.calls.at(0)?.at(0)?.onPress; + onPress?.(mockKeyboardEvent); +} + +describe('RulesAgentsTab', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Given a workspace that already has an agent rule, so the tab shows the list and not the empty state + (mockedUsePolicy as jest.Mock).mockReturnValue({ + id: POLICY_ID, + rules: {agentRules: {r1: {ruleID: 'r1', prompt: 'Reject expenses over $75', created: '2026-01-01 00:00:00'}}}, + }); + }); + + it('opens a new agent rule from the row under the list', () => { + // Given an admin who can write rules + render( + , + ); + + // When the admin presses Add agent rule + pressAddAgentRule(); + + // Then the agent rule page opens, because New rule has no agent rule option of its own + expect(mockedNavigate).toHaveBeenCalledWith(ROUTES.RULES_AGENT_NEW.getRoute(POLICY_ID)); + }); + + it('explains why a user who cannot write rules cannot add one', () => { + // Given a user who cannot write rules + const showReadOnlyModal = jest.fn(); + render( + , + ); + + // When the user presses Add agent rule + pressAddAgentRule(); + + // Then the read-only explanation shows and no page opens + expect(showReadOnlyModal).toHaveBeenCalled(); + expect(mockedNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/RulesNewPageDescribeRuleTest.tsx b/tests/ui/RulesNewPageDescribeRuleTest.tsx new file mode 100644 index 000000000000..c719342834a2 --- /dev/null +++ b/tests/ui/RulesNewPageDescribeRuleTest.tsx @@ -0,0 +1,500 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import usePolicy from '@hooks/usePolicy'; + +import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; +import * as API from '@libs/API'; +import type {GenerateRuleParams} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import RulesNewPage from '@pages/workspace/rules/RulesNewPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type {GeneratedRule} from '@src/types/onyx'; +import type {GeneratedRuleState, GeneratedRuleType} from '@src/types/onyx/GeneratedRule'; + +import type * as ReactNavigationNative from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import getOnyxValue from '../utils/getOnyxValue'; +import {translateLocal} from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +let mockPrompt = ''; +let mockIsDescribeRuleBetaEnabled = true; + +jest.mock('@react-navigation/native', () => { + const actualNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualNavigation, + useFocusEffect: (callback: () => void) => { + callback(); + }, + useIsFocused: () => true, + }; +}); +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({Sparkles: 'Sparkles'})), + useMemoizedLazyIllustrations: jest.fn(() => ({CardReaderAlt: 'CardReaderAlt', Flag: 'Flag', CheckboxText: 'CheckboxText', ReportReceipt: 'ReportReceipt', AiBot: 'AiBot'})), +})); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); +jest.mock('@hooks/usePermissions', () => + jest.fn(() => ({ + isBetaEnabled: (beta: string) => beta === jest.requireActual<{default: typeof CONST}>('@src/CONST').default.BETAS.DESCRIBE_RULE && mockIsDescribeRuleBetaEnabled, + })), +); +jest.mock('@hooks/usePolicy', () => jest.fn()); +jest.mock('@hooks/useTheme', () => jest.fn(() => ({icon: '#000'}))); +jest.mock('@components/Icon', () => () => null); +jest.mock('@libs/actions/Policy/Category', () => ({ + ...jest.requireActual>('@libs/actions/Policy/Category'), + openPolicyCategoriesPage: jest.fn(), +})); +jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: jest.fn()}))); +jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({isSmallScreenWidth: false, shouldUseNarrowLayout: false}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn( + () => + new Proxy( + {}, + { + get: () => ({}), + }, + ), + ), +); +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + goBack: jest.fn(), +})); +jest.mock( + '@pages/workspace/AccessOrNotFoundWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/ScreenWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/ScrollView', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => () => null); +jest.mock('@components/Text', () => { + const ReactModule = jest.requireActual('react'); + const {Text} = jest.requireActual<{Text: React.ComponentType<{children?: React.ReactNode}>}>('react-native'); + return ({children}: {children: React.ReactNode}) => ReactModule.createElement(Text, null, children); +}); +jest.mock('@components/MenuItem', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual<{ + Pressable: React.ComponentType<{accessibilityLabel?: string; accessibilityRole?: string; onPress?: () => void; children?: React.ReactNode}>; + Text: React.ComponentType<{children?: React.ReactNode}>; + }>('react-native'); + return ({title, onPress}: {title: string; onPress: () => void}) => + ReactModule.createElement(Pressable, {accessibilityLabel: title, accessibilityRole: 'button', onPress}, ReactModule.createElement(Text, null, title)); +}); +jest.mock('@components/Button', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual<{ + Pressable: React.ComponentType<{onPress?: () => void; children?: React.ReactNode}>; + Text: React.ComponentType<{children?: React.ReactNode}>; + }>('react-native'); + const MockButton = ({onPress, children}: {onPress: () => void; children: React.ReactNode}) => ReactModule.createElement(Pressable, {onPress}, children); + MockButton.Text = ({children}: {children: React.ReactNode}) => ReactModule.createElement(Text, null, children); + return MockButton; +}); +jest.mock('@components/Form/InputWrapper', () => () => null); +jest.mock('@components/Form/FormProvider', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual<{ + Pressable: React.ComponentType<{accessibilityLabel?: string; onPress?: () => void; children?: React.ReactNode}>; + Text: React.ComponentType<{children?: React.ReactNode}>; + }>('react-native'); + return ({children, onSubmit, submitButtonText}: {children: React.ReactNode; onSubmit: (values: {prompt: string}) => void; submitButtonText: string}) => + ReactModule.createElement( + ReactModule.Fragment, + null, + children, + ReactModule.createElement( + Pressable, + {accessibilityLabel: submitButtonText, onPress: () => onSubmit({prompt: mockPrompt})}, + ReactModule.createElement(Text, null, submitButtonText), + ), + ); +}); + +type RulesNewPageProps = PlatformStackScreenProps; + +const POLICY_ID = 'policy1'; + +const mockedUsePolicy = jest.mocked(usePolicy); + +type WriteSpy = jest.SpiedFunction; + +function setAdminPolicy(areCategoriesEnabled = false) { + mockedUsePolicy.mockReturnValue({...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), id: POLICY_ID, role: CONST.POLICY.ROLE.ADMIN, areCategoriesEnabled}); +} + +async function renderRulesNewPage(categoryName?: string) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only screen props. The page reads only route.name and route.params. + const props = { + route: {key: 'Rules_New', name: SCREENS.WORKSPACE.RULES_NEW, params: {policyID: POLICY_ID, categoryName}}, + navigation: {}, + } as unknown as RulesNewPageProps; + render(); + await waitForBatchedUpdatesWithAct(); +} + +function isGenerateRuleParams(parameters: unknown): parameters is GenerateRuleParams { + return typeof parameters === 'object' && parameters !== null && 'generationID' in parameters && 'prompt' in parameters; +} + +/** Submits the prompt through the describe form and returns the parameters of the GenerateRule request */ +function describeRule(writeSpy: WriteSpy, prompt: string): GenerateRuleParams { + mockPrompt = prompt; + fireEvent.press(screen.getByLabelText('common.next')); + const parameters = writeSpy.mock.calls.find(([command]) => command === WRITE_COMMANDS.GENERATE_RULE)?.at(1); + if (!isGenerateRuleParams(parameters)) { + throw new Error('The describe form did not request GenerateRule'); + } + return parameters; +} + +async function answerGeneration(answer: GeneratedRule) { + await act(async () => { + await Onyx.set(ONYXKEYS.GENERATED_RULE, answer); + }); + await waitForBatchedUpdatesWithAct(); +} + +async function getPromptErrors() { + const form = await getOnyxValue(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); + return Object.values(form?.errors ?? {}); +} + +function getAddPolicyAgentRuleCalls(writeSpy: WriteSpy) { + return writeSpy.mock.calls.filter(([command]) => command === WRITE_COMMANDS.ADD_POLICY_AGENT_RULE); +} + +describe('New rule describe section', () => { + let writeSpy: WriteSpy; + + beforeEach(() => { + jest.clearAllMocks(); + mockPrompt = ''; + mockIsDescribeRuleBetaEnabled = true; + writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + setAdminPolicy(); + }); + + afterEach(async () => { + writeSpy.mockRestore(); + await act(async () => { + await Onyx.clear(); + }); + }); + + it('shows the four templates and then the describe section', async () => { + // Given an admin with the describeRule beta on the New rule page + await renderRulesNewPage(); + + // When the admin looks at the options + const options = screen.getAllByRole('button'); + + // Then the four rule types come first, because the admin can describe the rule in the section below them + const expectedOptionNames = [ + 'workspace.rules.newRule.restrictCardSpend', + 'workspace.rules.newRule.flagForReview', + 'workspace.rules.newRule.requireFieldsShort', + 'workspace.rules.newRule.applyExpenseDefaults', + ]; + expect(options).toHaveLength(expectedOptionNames.length); + for (const [index, name] of expectedOptionNames.entries()) { + expect(options.at(index)).toHaveAccessibleName(name); + } + + // And the describe section follows, with its own submit button + expect(screen.getByText('workspace.rules.newRule.startWithTemplate')).toBeOnTheScreen(); + expect(screen.getByText('workspace.rules.newRule.describeItBelow')).toBeOnTheScreen(); + expect(screen.getByLabelText('common.next')).toBeOnTheScreen(); + }); + + it('asks for a template or a description when Next is pressed with an empty box', async () => { + // Given an admin on New rule who neither picks a template nor types a description + await renderRulesNewPage(); + mockPrompt = ''; + + // When the admin presses Next + fireEvent.press(screen.getByLabelText('common.next')); + await waitForBatchedUpdatesWithAct(); + + // Then the error above Next explains both ways forward, and nothing is sent, because the box is optional on New rule + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.selectTemplateOrDescribe')]); + expect(writeSpy.mock.calls.filter(([command]) => command === WRITE_COMMANDS.GENERATE_RULE)).toHaveLength(0); + }); + + it('hides the describe section when New rule opens for a category', async () => { + // Given an admin with the describeRule beta who opens New rule for a category, which the describe section cannot scope a rule to + await renderRulesNewPage('Meals'); + + // Then New rule lists only the rule types, without the describe section + expect(screen.queryByText('workspace.rules.newRule.describeItBelow')).toBeNull(); + expect(screen.queryByLabelText('common.next')).toBeNull(); + expect(screen.getByText('workspace.rules.newRule.subtitle')).toBeOnTheScreen(); + }); + + it('keeps Describe agent rule in place of the describe section without the beta', async () => { + // Given an admin without the describeRule beta, who must see the New rule options that exist without the feature + mockIsDescribeRuleBetaEnabled = false; + await renderRulesNewPage(); + + // When the admin looks at the options + const options = screen.getAllByRole('button'); + + // Then the four rule types come first and Describe agent rule comes last, with no describe section + const expectedOptionNames = [ + 'workspace.rules.newRule.restrictCardSpend', + 'workspace.rules.newRule.flagForReview', + 'workspace.rules.newRule.requireFields', + 'workspace.rules.newRule.applyExpenseDefaults', + 'workspace.rules.newRule.createAgentRule', + ]; + expect(options).toHaveLength(expectedOptionNames.length); + for (const [index, name] of expectedOptionNames.entries()) { + expect(options.at(index)).toHaveAccessibleName(name); + } + + expect(screen.queryByText('workspace.rules.newRule.describeItBelow')).toBeNull(); + + // And Describe agent rule opens the agent rule page + fireEvent.press(screen.getByLabelText('workspace.rules.newRule.createAgentRule')); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_AGENT_NEW.getRoute(POLICY_ID)); + }); + + it('seeds the draft of the generated rule type and opens its editor', async () => { + // Given the admin described a flag for review rule in the describe section, so the request lets Concierge pick the rule type + await renderRulesNewPage(); + const {generationID, ruleType} = describeRule(writeSpy, 'Flag expenses over $500'); + expect(ruleType).toBeUndefined(); + + // When Concierge answers with a flag for review rule + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.RULE, + ruleType: CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW, + rule: {maxExpenseAmount: '500'}, + }); + + // Then the flag for review editor opens with the generated values, so the admin only reviews and saves + expect(await getOnyxValue(ONYXKEYS.FORMS.FLAG_FOR_REVIEW_RULE_FORM)).toEqual({maxExpenseAmount: '500'}); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(POLICY_ID)); + }); + + it('scopes an expense defaults draft to a merchant rule', async () => { + // Given the admin described an expense default for a merchant + await renderRulesNewPage(); + const {generationID} = describeRule(writeSpy, 'Set Uber expenses to not billable'); + + // When Concierge answers with an expense defaults rule + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.RULE, + ruleType: CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS, + rule: {merchantToMatch: 'Uber'}, + }); + + // Then the draft carries the merchant rule type, because the merchant editor sends a draft without one to the type picker + expect(await getOnyxValue(ONYXKEYS.FORMS.MERCHANT_RULE_FORM)).toEqual({merchantToMatch: 'Uber', ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_MERCHANT_NEW.getRoute(POLICY_ID)); + }); + + it('opens the category tax rate editor for a categoryTaxRate answer', async () => { + // Given a workspace with a Meals category and a GST rate that a category rule can use, and an admin who describes a tax default + mockedUsePolicy.mockReturnValue({ + ...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), + id: POLICY_ID, + role: CONST.POLICY.ROLE.ADMIN, + taxRates: {name: 'Tax', defaultExternalID: 'VAT', defaultValue: '5%', foreignTaxDefault: 'VAT', taxes: {VAT: {name: 'VAT', value: '5%'}, GST: {name: 'GST', value: '10%'}}}, + }); + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${POLICY_ID}`, {Meals: {name: 'Meals', enabled: true}}); + }); + await renderRulesNewPage(); + const {generationID} = describeRule(writeSpy, 'Use GST for meals'); + + // When Concierge answers with a category tax rate rule + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.RULE, + ruleType: CONST.GENERATED_RULE.RULE_TYPE.CATEGORY_TAX_RATE, + rule: {categoriesToMatch: ['Meals'], tax: 'GST'}, + }); + + // Then the expense defaults editor opens scoped to categories, because it takes its rule type from the draft + expect(await getOnyxValue(ONYXKEYS.FORMS.MERCHANT_RULE_FORM)).toEqual({categoriesToMatch: ['Meals'], tax: 'GST', ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY}); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_MERCHANT_NEW.getRoute(POLICY_ID)); + }); + + it('shows an error when the generated category already has a tax rate rule', async () => { + // Given a Meals category that already has a tax rate rule + mockedUsePolicy.mockReturnValue({ + ...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), + id: POLICY_ID, + role: CONST.POLICY.ROLE.ADMIN, + rules: { + expenseRules: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention -- the expense rule stores the tax under the field name that the backend uses + tax: {field_id_TAX: {externalID: 'GST'}}, + applyWhen: [{condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, field: CONST.POLICY.FIELDS.CATEGORY, value: 'Meals'}], + }, + ], + }, + }); + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${POLICY_ID}`, {Meals: {name: 'Meals', enabled: true}}); + }); + await renderRulesNewPage(); + const {generationID} = describeRule(writeSpy, 'Use GST for meals'); + + // When Concierge answers with a tax rate rule for that category + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.RULE, + ruleType: CONST.GENERATED_RULE.RULE_TYPE.CATEGORY_TAX_RATE, + rule: {categoriesToMatch: ['Meals'], tax: 'GST'}, + }); + + // Then New rule shows the error and opens nothing, because a category can have one tax rate rule + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.categoryHasRule')]); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it.each([CONST.GENERATED_RULE.STATE.UNSUPPORTED, CONST.GENERATED_RULE.STATE.MULTIPLE_RULES])( + 'drafts an agent rule with the exact prompt for an admin when the answer is %s', + async (state: GeneratedRuleState) => { + // Given an admin described something no standard rule can do + const logSpy = jest.spyOn(Log, 'info'); + const prompt = 'Block gift cards and require receipts over $50'; + await renderRulesNewPage(); + const {generationID} = describeRule(writeSpy, prompt); + + // When Concierge answers that it is not one standard rule + await answerGeneration({generationID, state}); + + // Then the prompt becomes the agent rule draft, and the agent rule page opens, joined to the description by its generationID + expect(await getOnyxValue(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT)).toEqual({prompt}); + expect(logSpy).toHaveBeenCalledWith('[GenerateRule] Offered agent rule fallback', false, {policyID: POLICY_ID, generationID, state}); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_AGENT_NEW.getRoute(POLICY_ID, generationID)); + + // And nothing is saved, because the admin must still review the draft and press Create + expect(getAddPolicyAgentRuleCalls(writeSpy)).toHaveLength(0); + logSpy.mockRestore(); + }, + ); + + it('drafts the instruction of an agent rule answer instead of the description', async () => { + // Given an admin asked in the describe section for something that an agent rule can only approximate + const prompt = 'Block gift cards'; + const agentRulePrompt = 'Hold any gift card expense and ask the submitter to justify it'; + await renderRulesNewPage(); + const {generationID} = describeRule(writeSpy, prompt); + + // When Concierge answers with an agent rule that says what the agent will do + await answerGeneration({generationID, state: CONST.GENERATED_RULE.STATE.AGENT_RULE, agentRulePrompt, explanation: 'Agent rules cannot block a card charge.'}); + + // Then the draft holds the agent's instruction, because that is the rule the admin reviews and saves + expect(await getOnyxValue(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT)).toEqual({prompt: agentRulePrompt}); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_AGENT_NEW.getRoute(POLICY_ID, generationID)); + + // And the answer stays, because the agent rule page reads its explanation by the generationID + expect((await getOnyxValue(ONYXKEYS.GENERATED_RULE))?.generationID).toBe(generationID); + }); + + it.each([undefined, 'futureRuleType'])('shows the unsupported error when a rule answer has the rule type %s', async (generatedRuleType) => { + // Given an admin described a rule in the describe section + await renderRulesNewPage(); + const {generationID} = describeRule(writeSpy, 'Flag expenses over $500'); + + // When Concierge answers with a rule but with no rule type, or with a rule type that this app version does not know + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.RULE, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- simulates an answer from a server that knows more rule types than this app version + ruleType: generatedRuleType as GeneratedRuleType | undefined, + rule: {maxExpenseAmount: '500'}, + }); + + // Then the form shows the unsupported error, because no editor can show these values + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unsupported')]); + expect(screen.getByLabelText('common.next')).toBeOnTheScreen(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('shows why the description cannot be done and stays on New rule', async () => { + // Given an admin asked for something that neither a rule nor an agent rule can do + await renderRulesNewPage(); + const {generationID} = describeRule(writeSpy, 'Text the approver when a report is submitted'); + + // When Concierge answers that it is impossible and suggests another description + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.IMPOSSIBLE, + reason: CONST.GENERATED_RULE.IMPOSSIBLE_REASON.NO_CAPABILITY, + explanation: 'Rules cannot send text messages.', + suggestedPrompt: 'Comment on the report when it is submitted', + }); + + // Then New rule shows the reason and the suggestion, and nothing opens, because no rule can be drafted + expect(screen.getByText('workspace.rules.newRule.impossible.noCapability')).toBeOnTheScreen(); + expect(screen.getByText('Comment on the report when it is submitted')).toBeOnTheScreen(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + expect(await getOnyxValue(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT)).toBeUndefined(); + }); + + it('loads the policy categories when the workspace uses categories and none are loaded', async () => { + // Given a workspace that uses categories, with no categories loaded + setAdminPolicy(true); + + // When New rule opens with the describe section + await renderRulesNewPage(); + + // Then New rule requests the categories, because the describe form keeps only a category that the workspace has + expect(openPolicyCategoriesPage).toHaveBeenCalledWith(POLICY_ID); + }); + + it('keeps the inline error for an unintelligible description', async () => { + // Given an admin typed something Concierge cannot read + await renderRulesNewPage(); + const {generationID} = describeRule(writeSpy, 'asdf qwerty zxcv'); + + // When Concierge answers that the description is unintelligible + await answerGeneration({generationID, state: CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE}); + + // Then the form asks for a clearer description, because an agent rule needs a prompt that makes sense + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unintelligible')]); + expect(Navigation.navigate).not.toHaveBeenCalled(); + expect(await getOnyxValue(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT)).toBeUndefined(); + }); +}); diff --git a/tests/ui/SearchColumnsPageTest.tsx b/tests/ui/SearchColumnsPageTest.tsx new file mode 100644 index 000000000000..d0719828f7a2 --- /dev/null +++ b/tests/ui/SearchColumnsPageTest.tsx @@ -0,0 +1,59 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import type ColumnsSettingsList from '@components/ColumnsSettingsList'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import MockText from '@components/Text'; + +import useOnyx from '@hooks/useOnyx'; + +import Navigation from '@libs/Navigation/Navigation'; +import {buildFilterFormValuesFromQuery, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SearchColumnsPage from '@src/pages/Search/SearchColumnsPage'; +import SCREENS from '@src/SCREENS'; + +import type {ComponentProps} from 'react'; + +const mockGetRootState = jest.fn(); + +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@libs/Navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn()}})); +jest.mock('@libs/Navigation/navigationRef', () => ({__esModule: true, default: {getRootState: () => mockGetRootState() as unknown}})); +jest.mock('@components/ColumnsSettingsList', () => ({onSave}: Pick, 'onSave'>) => ( + onSave(['merchant', 'amount'])}>Save columns +)); + +describe('SearchColumnsPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('keeps mixed Merchant predicates when saving different columns', () => { + const input = 'merchant=I merchant*:I'; + const queryJSON = buildSearchQueryJSON(input); + if (!queryJSON) { + throw new Error('Invalid test query'); + } + + const form = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); + (jest.mocked(useOnyx) as jest.Mock).mockImplementation((key: string) => [key === ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM ? form : undefined, {status: 'loaded'}]); + mockGetRootState.mockReturnValue({ + routes: [{name: NAVIGATORS.TAB_NAVIGATOR, params: {screen: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, params: {screen: SCREENS.SEARCH.ROOT, params: {q: input}}}}], + }); + + render( + + + , + ); + fireEvent.press(screen.getByText('Save columns')); + + const [route] = jest.mocked(Navigation.navigate).mock.calls.at(-1) ?? []; + const updatedQuery = buildSearchQueryJSON(new URL(route ?? '', 'https://example.com').searchParams.get('q') ?? ''); + + expect(updatedQuery?.flatFilters).toEqual(queryJSON.flatFilters); + expect(updatedQuery?.columns).toEqual(['merchant', 'amount']); + }); +}); diff --git a/tests/ui/SearchEditMultiplePageTest.tsx b/tests/ui/SearchEditMultiplePageTest.tsx index 9753e49b0ee2..58a6debf3306 100644 --- a/tests/ui/SearchEditMultiplePageTest.tsx +++ b/tests/ui/SearchEditMultiplePageTest.tsx @@ -12,6 +12,8 @@ import ThemeStylesProvider from '@components/ThemeStylesContextProvider'; import SearchEditMultiplePage from '@pages/Search/SearchEditMultiple/SearchEditMultiplePage'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; +import {getPolicyCategories} from '@userActions/Policy/Category'; +import {openPolicyTagsPage} from '@userActions/Policy/Tag'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -32,6 +34,16 @@ import {createExpenseReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +jest.mock('@userActions/Policy/Category', () => ({ + ...jest.requireActual>('@userActions/Policy/Category'), + getPolicyCategories: jest.fn(), +})); + +jest.mock('@userActions/Policy/Tag', () => ({ + ...jest.requireActual>('@userActions/Policy/Tag'), + openPolicyTagsPage: jest.fn(), +})); + const CURRENT_USER_ACCOUNT_ID = 5; const CURRENT_USER_EMAIL = 'bjorn@vikings.net'; const POLICY_ID = 'policy1'; @@ -136,6 +148,7 @@ describe('SearchEditMultiplePage', () => { }); beforeEach(async () => { + jest.clearAllMocks(); await Onyx.set(ONYXKEYS.SESSION, {email: CURRENT_USER_EMAIL, accountID: CURRENT_USER_ACCOUNT_ID}); await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {[CURRENT_USER_ACCOUNT_ID]: {accountID: CURRENT_USER_ACCOUNT_ID, login: CURRENT_USER_EMAIL}}); await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, POLICY_ID); @@ -173,6 +186,54 @@ describe('SearchEditMultiplePage', () => { expect(screen.getByLabelText(/^Reimbursable/).props.accessibilityState).toEqual(expect.objectContaining({disabled: true})); }); + it('fetches policy categories and tags when they are enabled but not loaded yet', async () => { + // Given a workspace with categories and tags enabled whose categories and tags were never loaded, as after a cache clear + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {areCategoriesEnabled: true, areTagsEnabled: true}); + await selectTransactions([OPEN_TRANSACTION_ID]); + + // When the Edit multiple page opens + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then it fetches them itself, since the search snapshot doesn't include them and the Category/Tag rows depend on them + expect(getPolicyCategories).toHaveBeenCalledWith(POLICY_ID); + expect(openPolicyTagsPage).toHaveBeenCalledWith(POLICY_ID); + }); + + it('fetches policy categories and tags when only a partial set is in Onyx', async () => { + // Given a lazy-loaded account that only holds the category and tag already on its expenses, with no completed read + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {areCategoriesEnabled: true, areTagsEnabled: true}); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${POLICY_ID}`, {Food: {name: 'Food', enabled: true}}); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${POLICY_ID}`, {Department: {name: 'Department', required: false, orderWeight: 0, tags: {Sales: {name: 'Sales', enabled: true}}}}); + await selectTransactions([OPEN_TRANSACTION_ID]); + + // When the Edit multiple page opens + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then it still fetches them, because a partial collection can be missing other categories or whole tag lists + expect(getPolicyCategories).toHaveBeenCalledWith(POLICY_ID); + expect(openPolicyTagsPage).toHaveBeenCalledWith(POLICY_ID); + }); + + it('does not refetch policy categories and tags that are already loaded', async () => { + // Given a workspace with categories and tags enabled whose categories and tags were already fully loaded + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {areCategoriesEnabled: true, areTagsEnabled: true}); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${POLICY_ID}`, {Food: {name: 'Food', enabled: true}}); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${POLICY_ID}`, {Department: {name: 'Department', required: false, orderWeight: 0, tags: {Sales: {name: 'Sales', enabled: true}}}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_CATEGORIES_LOADING_STATE}${POLICY_ID}`, {isLoading: false, hasOnceLoaded: true}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_POLICY_TAGS_LOADING_STATE}${POLICY_ID}`, {isLoading: false, hasOnceLoaded: true}); + await selectTransactions([OPEN_TRANSACTION_ID]); + + // When the Edit multiple page opens + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then no extra request is made, so opening the page doesn't add network traffic when the data is already available + expect(getPolicyCategories).not.toHaveBeenCalled(); + expect(openPolicyTagsPage).not.toHaveBeenCalled(); + }); + it('keeps Merchant editable when a selected expense on the open report has no IOU action', async () => { // Given an expense with no IOU action on an open report the current user submitted const actionlessTransactionID = 'actionlessTransaction'; diff --git a/tests/ui/SearchPageTest.tsx b/tests/ui/SearchPageTest.tsx index 56e05a816278..c51fe13e2f84 100644 --- a/tests/ui/SearchPageTest.tsx +++ b/tests/ui/SearchPageTest.tsx @@ -88,9 +88,10 @@ jest.mock('@libs/actions/Search', () => ({ search: jest.fn(() => Promise.resolve(200)), })); +const mockIsNavigationFocused = jest.fn(() => true); jest.mock('@react-navigation/core', () => ({ ...jest.requireActual('@react-navigation/core'), - useNavigation: jest.fn(() => ({getState: jest.fn(() => undefined), isFocused: jest.fn(() => true)})), + useNavigation: jest.fn(() => ({getState: jest.fn(() => undefined), isFocused: mockIsNavigationFocused})), })); type ListProps = {onEndReached?: () => void; onViewableItemsChanged?: () => void; onSelectRow?: (item: SearchListItem) => void; ListFooterComponent?: unknown}; @@ -111,6 +112,7 @@ jest.mock('@components/Search/SearchList/BaseSearchList', () => ({ type WriteActionsRender = { filteredData: SearchData; applySelection: SearchSelectionActionsValue['applySelection']; + selectAllMatchingItems: SearchSelectionActionsValue['selectAllMatchingItems']; }; type WriteActionsProviderProps = Parameters[0]; @@ -121,8 +123,8 @@ jest.mock('@components/Search/SearchWriteActionsProvider', () => { const {useSearchSelectionActions} = jest.requireActual('@components/Search/SearchContext'); const {default: SearchWriteActionsProvider} = jest.requireActual('@components/Search/SearchWriteActionsProvider'); function MockSearchWriteActionsProvider(props: WriteActionsProviderProps) { - const {applySelection} = useSearchSelectionActions(); - mockRenderWriteActions({filteredData: props.filteredData, applySelection}); + const {applySelection, selectAllMatchingItems} = useSearchSelectionActions(); + mockRenderWriteActions({filteredData: props.filteredData, applySelection, selectAllMatchingItems}); return createElement(SearchWriteActionsProvider, props); } return {__esModule: true, default: MockSearchWriteActionsProvider}; @@ -311,6 +313,7 @@ describe('SearchPageNarrow', () => { mockUseNetwork.mockReturnValue({isOffline: false} as ReturnType); mockSearchQueryParam.mockReturnValue(FAILED_QUERY); mockIsFocused.mockReturnValue(true); + mockIsNavigationFocused.mockReturnValue(true); listProps.onEndReached = undefined; listProps.onViewableItemsChanged = undefined; listProps.onSelectRow = undefined; @@ -532,6 +535,35 @@ describe('SearchPageNarrow', () => { expect(renderedPage.UNSAFE_getByType(SearchLoadingSkeleton)).toBeTruthy(); }); + + it('renders the loading skeleton once a page mounted in the background is shown without results', async () => { + // Given a snapshot whose first response has not arrived yet + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.SNAPSHOT}${failedQueryJSON?.hash}`, { + search: { + type: CONST.SEARCH.DATA_TYPES.CHAT, + offset: 0, + hash: failedQueryJSON?.hash, + isLoading: true, + hasMoreResults: false, + hasResults: false, + state: CONST.SEARCH.SNAPSHOT_STATE.LOADING, + }, + }); + }); + + // When the page mounts unfocused, as native tab bars mount Spend at startup, and is then shown + mockIsNavigationFocused.mockReturnValue(false); + const renderedPage = renderPage(); + + await act(async () => { + jest.runAllTimers(); + }); + + // Then the page keeps its loading skeleton instead of mounting Search with no results to show + expect(renderedPage.UNSAFE_getByType(SearchLoadingSkeleton)).toBeTruthy(); + }); + it('does not repeat the first page the page-level setup already requested', async () => { // Given a query with no snapshot yet, so the page-level setup owns the first request mockSearchQueryParam.mockReturnValue(EXPENSE_QUERY); @@ -1004,6 +1036,56 @@ describe('SearchPageNarrow', () => { expect(mockSearch).toHaveBeenCalledWith(expect.objectContaining({offset: CONST.SEARCH.RESULTS_PAGE_SIZE})); }); + it('asks for its next page without totals', async () => { + // Given Drafts showing its first page, whose response carried the report count + await seedTodoReports(CONST.SEARCH.RESULTS_PAGE_SIZE + 10); + await seedTodoSnapshot(true, {reportCount: CONST.SEARCH.RESULTS_PAGE_SIZE + 10}); + renderPage(TODO_QUERY); + await act(async () => { + jest.advanceTimersByTime(0); + }); + mockSearch.mockClear(); + + // When the list reaches its end + await act(async () => { + listProps.onEndReached?.(); + }); + await act(async () => { + jest.advanceTimersByTime(0); + }); + + // Then the next page is asked for without totals, since a later page with totals would start the counted reports over without page 1's + expect(mockSearch).toHaveBeenCalledWith(expect.objectContaining({offset: CONST.SEARCH.RESULTS_PAGE_SIZE})); + expect(mockSearch.mock.calls.some(([params]) => !!params?.shouldCalculateTotals)).toBe(false); + }); + + it('asks for a fresh count when every match is selected past the first page', async () => { + // Given Drafts paged past its first page, whose response carried the report count + await seedTodoReports(CONST.SEARCH.RESULTS_PAGE_SIZE + 10); + await seedTodoSnapshot(true, {reportCount: CONST.SEARCH.RESULTS_PAGE_SIZE + 10}); + searchWritesLoadingState(); + renderPage(TODO_QUERY); + await act(async () => { + jest.advanceTimersByTime(0); + }); + await act(async () => { + listProps.onEndReached?.(); + }); + await answerTodoPage(CONST.SEARCH.RESULTS_PAGE_SIZE, true); + mockSearch.mockClear(); + + // When every matching report is selected + await act(async () => { + lastWriteActionsRender()?.selectAllMatchingItems(true); + }); + await act(async () => { + jest.advanceTimersByTime(0); + }); + + // Then the page on screen is asked for again with totals, so the count includes reports that left or joined since the first page + expect(mockSearch).toHaveBeenCalledWith(expect.objectContaining({offset: CONST.SEARCH.RESULTS_PAGE_SIZE, shouldCalculateTotals: true})); + }); + it('shows the next rows only once the server answers their page, and ignores ends meanwhile', async () => { const rowCount = CONST.SEARCH.RESULTS_PAGE_SIZE + 20; await seedTodoReports(rowCount); diff --git a/tests/ui/SelectionScreenTest.tsx b/tests/ui/SelectionScreenTest.tsx new file mode 100644 index 000000000000..4bd27e9fdfbd --- /dev/null +++ b/tests/ui/SelectionScreenTest.tsx @@ -0,0 +1,101 @@ +import {render} from '@testing-library/react-native'; + +import SelectionList from '@components/SelectionList'; +import SelectionScreen from '@components/SelectionScreen'; +import type {SelectorType} from '@components/SelectionScreen'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock( + '@pages/workspace/AccessOrNotFoundWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/ScreenWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/OfflineWithFeedback', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/ErrorMessageRow', () => jest.fn(() => null)); +jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined])); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); + +type MockSelectorItem = {value: string; keyForList: string; isSelected?: boolean; text?: string}; + +type MockSelectionListProps = { + data: MockSelectorItem[]; + initiallyFocusedItemKey?: string; + shouldScrollToFocusedIndexOnMount?: boolean; + shouldUpdateFocusedIndex?: boolean; + disableMaintainingScrollPosition?: boolean; +}; + +/** Build `count` options; the option at `selectedIndex` is the pre-selected one. */ +function buildData(count: number, selectedIndex: number): Array> { + return Array.from({length: count}, (_, index) => { + const suffix = String(index + 1).padStart(2, '0'); + return {value: `v${suffix}`, keyForList: `k${suffix}`, text: `Option ${suffix}`, isSelected: index === selectedIndex}; + }); +} + +function screenElement(data: Array>, {withSearch}: {withSearch: boolean}) { + const selectedKey = data.find((item) => item.isSelected)?.keyForList; + return ( + + ); +} + +describe('SelectionScreen searchable list props', () => { + const mockedSelectionList = jest.mocked(SelectionList); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrows the props captured from the mocked SelectionList in this test + const getListProps = () => mockedSelectionList.mock.lastCall?.[0] as MockSelectionListProps | undefined; + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('enables the anti-jump list props for a searchable list without reordering the data', () => { + // Given a searchable list (pinning happens upstream, before filtering) + render(screenElement(buildData(13, 6), {withSearch: true})); + + // Then SelectionScreen passes the data through unchanged... + const props = getListProps(); + expect(props?.data.at(0)?.keyForList).toBe('k01'); + // ...and enables the props that keep the pinned row visible on mount, on select, and after clearing search. + expect(props?.shouldScrollToFocusedIndexOnMount).toBe(false); + expect(props?.disableMaintainingScrollPosition).toBe(true); + expect(props?.shouldUpdateFocusedIndex).toBe(true); + }); + + it('leaves the scroll props at their defaults for a non-searchable list', () => { + // Given a list with no search input + render(screenElement(buildData(13, 6), {withSearch: false})); + + // Then the data is untouched and scroll behavior is left at its defaults + const props = getListProps(); + expect(props?.data.at(0)?.keyForList).toBe('k01'); + expect(props?.shouldScrollToFocusedIndexOnMount).toBe(true); + expect(props?.disableMaintainingScrollPosition).toBe(false); + }); +}); diff --git a/tests/ui/ShareSubmitScanFieldsTest.tsx b/tests/ui/ShareSubmitScanFieldsTest.tsx index c21466ed1865..51be16dd8653 100644 --- a/tests/ui/ShareSubmitScanFieldsTest.tsx +++ b/tests/ui/ShareSubmitScanFieldsTest.tsx @@ -56,8 +56,8 @@ function getDraft(): Promise> { }); } -/** Renders the share Submit confirmation with the real confirmation list and opens the fields behind "Show more". */ -async function renderShareConfirmationAndShowMore() { +/** Renders the share Submit confirmation with the real confirmation list. */ +async function renderShareConfirmation() { render( @@ -73,6 +73,11 @@ async function renderShareConfirmationAndShowMore() { , ); await waitForBatchedUpdatesWithAct(); +} + +/** Renders the share Submit confirmation and opens the fields behind "Show more". */ +async function renderShareConfirmationAndShowMore() { + await renderShareConfirmation(); fireEvent.press(await screen.findByText(translateLocal('common.showMore'))); await waitForBatchedUpdatesWithAct(); } @@ -187,7 +192,7 @@ describe('SubmitDetailsPage — manually entered Scan fields', () => { }); // When the Submit page mounts for the newly picked chat - await renderShareConfirmationAndShowMore(); + await renderShareConfirmation(); // Then the draft follows the new destination, and neither the merchant nor the currency the user entered is // overwritten by the policy's output currency. @@ -195,6 +200,10 @@ describe('SubmitDetailsPage — manually entered Scan fields', () => { expect(draft?.reportID).toBe(SHARED_REPORT_ID); expect(draft?.currency).toBe('EUR'); expect(draft?.merchant).toBe('Starbucks'); + + // And the fields the user already filled in open expanded rather than hiding behind "Show more" + expect(screen.queryByText(translateLocal('common.showMore'))).not.toBeOnTheScreen(); + expect(screen.getByLabelText(translateLocal('common.merchant'))).toHaveDisplayValue('Starbucks'); }); // Clearing the amount sets `isAmountSet` back to false while the picked currency stays on the draft, so the diff --git a/tests/ui/SpendRuleCardPageTest.tsx b/tests/ui/SpendRuleCardPageTest.tsx index 9c977438a523..a6956b8af4b0 100644 --- a/tests/ui/SpendRuleCardPageTest.tsx +++ b/tests/ui/SpendRuleCardPageTest.tsx @@ -103,7 +103,6 @@ jest.mock('@libs/CardUtils', () => ({ })); jest.mock('@libs/OptionsListUtils', () => ({getHeaderMessage: () => ''})); jest.mock('@libs/PersonalDetailsUtils', () => ({temporaryGetDisplayNameOrDefault: () => ''})); -jest.mock('@libs/SpendRulesUtils', () => ({getSpendRuleFormValuesFromCardRule: () => ({cardIDs: []})})); jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); jest.mock('@libs/actions/User', () => ({updateDraftSpendRule: jest.fn()})); jest.mock('@userActions/Policy/Policy', () => ({openPolicyExpensifyCardsPage: jest.fn()})); diff --git a/tests/ui/TableTest.tsx b/tests/ui/TableTest.tsx index c736f3b23654..4570ad085b50 100644 --- a/tests/ui/TableTest.tsx +++ b/tests/ui/TableTest.tsx @@ -668,6 +668,43 @@ describe('Table', () => { expect(screen.getByTestId('row-5')).toBeTruthy(); }); + it('should preserve all datasets on rows that allow text selection', () => { + const props = createDefaultProps(); + const renderItem = ({item, index}: ListRenderItemInfo) => ( + + {item.name} + + ); + + // Given a table row with a caller dataset that allows text selection + render( + + data={props.data.slice(0, 1)} + columns={props.columns} + renderItem={renderItem} + keyExtractor={props.keyExtractor} + > + + , + ); + + // When the rendered host row is inspected + const row = getHostTableRows().find((candidate) => candidate.props.accessibilityLabel === mockData.at(0)?.name); + + // Then the caller, table-row, and copyable-row datasets are all present + expect(row?.props.dataSet).toEqual({ + customMarker: true, + tableRow: true, + [CONST.COPYABLE_ROW_ELEMENT]: true, + }); + }); + it('should render column headers when Header component is used', () => { const props = createDefaultProps(); render( @@ -2584,6 +2621,44 @@ describe('Table', () => { expect(screen.queryByTestId('row-2')).toBeNull(); expect(screen.getByTestId('generic-empty-state')).toBeTruthy(); }); + + it('renders the empty state instead of "No results" when a page-header table hides every row by default', () => { + // Given a page-header table (like the Workspaces list) whose default filter hides every row, with both + // a no-results state and an empty state declared + const props = createDefaultProps(); + const data: TestItem[] = [ + {keyForList: '1', id: '1', name: 'Archived A', category: 'archived', value: 1}, + {keyForList: '2', id: '2', name: 'Archived B', category: 'archived', value: 2}, + ]; + + // When the table renders with no search or filter selected + render( + + data={data} + columns={props.columns} + renderItem={props.renderItem} + keyExtractor={props.keyExtractor} + filters={statusFilterConfig} + isItemInFilter={isItemInStatusFilter} + > + + Page header + + + + + + , + ); + + // Then the empty state shows inside the list with the page header still visible, because the user did + // not search or filter, so "No results found" would be misleading + expect(screen.getByTestId('flash-list')).toBeTruthy(); + expect(screen.getByTestId('table-header-component')).toBeTruthy(); + expect(screen.getAllByTestId('generic-empty-state')).toHaveLength(1); + expect(screen.getByText('No workspaces')).toBeTruthy(); + expect(screen.queryByText('common.noResultsFound')).toBeNull(); + }); }); describe('immediate filter', () => { diff --git a/tests/ui/TextFilterPopupTest.tsx b/tests/ui/TextFilterPopupTest.tsx new file mode 100644 index 000000000000..d1b9872b9ae1 --- /dev/null +++ b/tests/ui/TextFilterPopupTest.tsx @@ -0,0 +1,39 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import TextFilterPopup from '@components/Search/FilterDropdowns/TextFilterPopup'; + +import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; + +import {NavigationContainer} from '@react-navigation/native'; + +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useResponsiveLayout', () => () => ({isSmallScreenWidth: false})); + +describe('TextFilterPopup', () => { + it.each([FILTER_KEYS.DESCRIPTION, FILTER_KEYS.TITLE])('applies the %s text filter without Merchant fields', (baseFilterKey) => { + const closeOverlay = jest.fn(); + const updateFilterForm = jest.fn(); + render( + + + , + ); + + expect(screen.queryByText('search.filters.merchant.matchType')).not.toBeOnTheScreen(); + fireEvent.changeText(screen.getByLabelText('common.description'), 'Supplies'); + expect(screen.queryByText('search.filters.filterType.is.negative')).not.toBeOnTheScreen(); + fireEvent.press(screen.getByText('common.apply')); + + expect(updateFilterForm).toHaveBeenCalledWith({ + [baseFilterKey]: 'Supplies', + [`${baseFilterKey}Not`]: undefined, + }); + expect(closeOverlay).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/ui/TransactionReceiptOdometerReplaceTest.tsx b/tests/ui/TransactionReceiptOdometerReplaceTest.tsx new file mode 100644 index 000000000000..26f633d9cbdc --- /dev/null +++ b/tests/ui/TransactionReceiptOdometerReplaceTest.tsx @@ -0,0 +1,193 @@ +import {act, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {PlaybackContextProvider} from '@components/VideoPlayerContexts/PlaybackContext'; + +import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; + +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; +import {waitForIdle} from '@libs/Network/SequentialQueue'; + +import type {AuthScreensParamList} from '@navigation/types'; + +import AttachmentModalScreen from '@pages/media/AttachmentModalScreen'; +import {AttachmentModalContextProvider} from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Policy, Report, ReportAction, Transaction} from '@src/types/onyx'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import {getGlobalFetchMock, setupGlobalFetchMock, signInWithTestUser, translateLocal} from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; + +const Stack = createPlatformStackNavigator(); + +setupGlobalFetchMock(); + +jest.mock('@rnmapbox/maps', () => { + return { + default: jest.fn(), + MarkerView: jest.fn(), + setAccessToken: jest.fn(), + }; +}); +jest.mock('@src/components/Attachments/AttachmentCarousel/Pager/usePageScrollHandler', () => jest.fn()); +jest.mock('@libs/fetchImage', () => jest.fn((source: string) => Promise.resolve(source))); + +const TEST_USER_LOGIN = 'test@test.com'; +const TEST_USER_ACCOUNT_ID = 1; +const POLICY_ID = 'odometerPolicy'; +const EXPENSE_REPORT_ID = '100'; +const TRANSACTION_THREAD_REPORT_ID = '101'; +const IOU_ACTION_ID = '102'; +const TRANSACTION_ID = '103'; + +const policy: Policy = { + id: POLICY_ID, + name: 'Odometer Workspace', + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.TEAM, + owner: TEST_USER_LOGIN, + outputCurrency: CONST.CURRENCY.USD, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, +}; + +const expenseReport: Report = { + reportID: EXPENSE_REPORT_ID, + policyID: POLICY_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: TEST_USER_ACCOUNT_ID, + managerID: TEST_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + currency: CONST.CURRENCY.USD, +}; + +const transactionThreadReport: Report = { + reportID: TRANSACTION_THREAD_REPORT_ID, + policyID: POLICY_ID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: EXPENSE_REPORT_ID, + parentReportActionID: IOU_ACTION_ID, +}; + +const iouAction: ReportAction = { + reportActionID: IOU_ACTION_ID, + actorAccountID: TEST_USER_ACCOUNT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + childReportID: TRANSACTION_THREAD_REPORT_ID, + created: '2026-10-19 12:00:00.000', + originalMessage: { + IOUReportID: EXPENSE_REPORT_ID, + IOUTransactionID: TRANSACTION_ID, + amount: 1000, + currency: CONST.CURRENCY.USD, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }, + message: [{type: 'COMMENT', html: 'USD 10.00 expense', text: 'USD 10.00 expense'}], +}; + +const odometerTransaction: Transaction = { + transactionID: TRANSACTION_ID, + reportID: EXPENSE_REPORT_ID, + amount: 1000, + currency: CONST.CURRENCY.USD, + created: '2026-10-19', + merchant: '10 mi @ $1.00 / mi', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + comment: {odometerStart: 100, odometerEnd: 110}, + receipt: {source: 'https://www.expensify.com/receipts/w_odometer.jpg', filename: 'w_odometer.jpg'}, +}; + +const renderReceiptModal = (initialParams: AuthScreensParamList[typeof SCREENS.TRANSACTION_RECEIPT]) => + render( + + + + + + + , + ); + +describe('TransactionReceiptModalContent - odometer receipt', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: TEST_USER_ACCOUNT_ID, email: TEST_USER_LOGIN}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[TEST_USER_ACCOUNT_ID]: {accountID: TEST_USER_ACCOUNT_ID, login: TEST_USER_LOGIN}}, + }, + }); + }); + + beforeEach(async () => { + global.fetch = getGlobalFetchMock(); + wrapOnyxWithWaitForBatchedUpdates(Onyx); + await act(async () => { + await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); + }); + await signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + await waitForIdle(); + await act(async () => { + await Onyx.clear(); + }); + jest.clearAllMocks(); + }); + + it('shows Replace for the receipt of a created odometer expense', async () => { + // Given a created odometer expense with a receipt, submitted by the current user on an open report + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`, expenseReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${TRANSACTION_THREAD_REPORT_ID}`, transactionThreadReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXPENSE_REPORT_ID}`, {[IOU_ACTION_ID]: iouAction}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, odometerTransaction); + }); + await waitForBatchedUpdatesWithAct(); + + // When the receipt is opened from the expense + renderReceiptModal({reportID: TRANSACTION_THREAD_REPORT_ID, transactionID: TRANSACTION_ID}); + await waitForBatchedUpdatesWithAct(); + + // Then Replace is shown, because the stitcher no longer owns the receipt once the expense is created + expect(screen.getByText(translateLocal('common.replace'))).toBeOnTheScreen(); + }); + + it('hides Replace for the receipt of an odometer draft', async () => { + // Given an odometer draft whose receipt is built from the odometer photos + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, odometerTransaction); + }); + await waitForBatchedUpdatesWithAct(); + + // When the receipt is opened from the create flow + renderReceiptModal({ + reportID: EXPENSE_REPORT_ID, + transactionID: TRANSACTION_ID, + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + }); + await waitForBatchedUpdatesWithAct(); + + // Then Replace is hidden, so the user cannot overwrite the receipt the stitcher builds from the photos + expect(screen.queryByText(translateLocal('common.replace'))).toBeNull(); + }); +}); diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 0060df9c11e9..3c662449d2c5 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -708,7 +708,18 @@ describe('Unread Indicators', () => { const report = await OnyxUtils.get(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`); if (lastReportAction) { - deleteReportComment(report, lastReportAction, undefined, undefined, [], undefined, undefined, '', false); + deleteReportComment({ + report, + reportAction: lastReportAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: [], + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); } return waitForBatchedUpdates(); }) @@ -764,7 +775,18 @@ describe('Unread Indicators', () => { await waitForBatchedUpdates(); - deleteReportComment(report, firstNewReportAction, undefined, undefined, [], undefined, undefined, '', false); + deleteReportComment({ + report, + reportAction: firstNewReportAction, + originalReportActions: undefined, + reportActions: undefined, + ancestors: [], + isReportArchived: undefined, + isOriginalReportArchived: undefined, + currentEmail: '', + isOffline: false, + currentUserAccountID: 1, + }); await waitForBatchedUpdates(); } diff --git a/tests/ui/VacationDelegateFormPageTest.tsx b/tests/ui/VacationDelegateFormPageTest.tsx new file mode 100644 index 000000000000..70a04ca134b6 --- /dev/null +++ b/tests/ui/VacationDelegateFormPageTest.tsx @@ -0,0 +1,489 @@ +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(), + getTopmostReportId: 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, isLoading}: {onSubmit: (delegate: string, clearAfter: string | undefined) => void; onRemove: () => void; isLoading?: boolean}) => + ReactMock.createElement( + ReactMock.Fragment, + null, + ReactMock.createElement(Text, {testID: 'save-loading'}, String(!!isLoading)), + 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, the confirmed datetime is kept so a failure can restore it, + // and the pick is only applied once the backend saved it, since the response can still be a 305 + 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({ + previousDelegate: ORIGINAL_DELEGATE_EMAIL, + previousClearAfter: ORIGINAL_CLEAR_AFTER, + }), + }), + ], + successData: [expect.objectContaining({value: expect.objectContaining({delegate: DELEGATE_B_EMAIL, clearAfter: 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.anything()); + 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(); + 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 + 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('keeps Save loading until the error modal is dismissed, then lets it submit again', async () => { + // Given a request that fails, and an error modal held open until the test dismisses it + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve({jsonCode: CONST.JSON_CODE.BAD_REQUEST})); + let dismissModal: () => void = () => {}; + mockShowConfirmModal.mockImplementation( + () => + new Promise((resolve) => { + dismissModal = () => resolve({action: 'CONFIRM'}); + }), + ); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // When the save fails and the error modal shows + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + + // Then Save keeps spinning while the modal is open, so the modal is the only place the error shows + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('save-loading')).toHaveTextContent('true'); + + // When the modal is dismissed + await act(async () => { + dismissModal(); + await waitForBatchedUpdatesWithAct(); + }); + + // Then Save stops loading and a new save is sent instead of being ignored as still pending + expect(screen.getByTestId('save-loading')).toHaveTextContent('false'); + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + expect(apiSideEffectSpy).toHaveBeenCalledTimes(2); + }); + + it('queues the save when only the clear after date of the saved delegate changes', async () => { + // Given a saved delegate, and a queued write mocked so only what the page sends is under test + const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve({jsonCode: CONST.JSON_CODE.SUCCESS})); + // 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(); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {creator: CREATOR_EMAIL, delegate: DELEGATE_B_EMAIL}); + }); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // When the same delegate is saved with a new clear after date + fireEvent.press(screen.getByTestId('select-delegate-b-with-date')); + await waitForBatchedUpdatesWithAct(); + + // Then it goes through the queued write with the policy diff warning overridden, since the delegate was already accepted, + // so the change stays optimistic offline instead of failing a one-off request, and the page goes back to Profile + expect(apiWriteSpy).toHaveBeenCalledWith( + WRITE_COMMANDS.SET_VACATION_DELEGATE, + expect.objectContaining({vacationDelegateEmail: DELEGATE_B_EMAIL, clearAfter: CLEAR_AFTER, overridePolicyDiffWarning: true}), + expect.anything(), + ); + expect(apiSideEffectSpy).not.toHaveBeenCalled(); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.SETTINGS_PROFILE.route); + }); + + 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 and success data it was handed are where that target and the pick are 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({previousDelegate: ORIGINAL_DELEGATE_EMAIL})})], + successData: [expect.objectContaining({value: expect.objectContaining({delegate: DELEGATE_B_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..c738159ff2d8 100644 --- a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx +++ b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx @@ -534,6 +534,92 @@ describe('VacationDelegateMissingWorkspacesPage', () => { expect(rows.at(1)).toHaveTextContent('Zebra Workspace'); }); + 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 dismissModalSpy = jest.spyOn(Navigation, 'dismissModal').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 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(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 () => { // 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 @@ -584,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 7faeaf2f7cfd..41ca723c0dd3 100644 --- a/tests/ui/VacationDelegatePageTest.tsx +++ b/tests/ui/VacationDelegatePageTest.tsx @@ -5,22 +5,21 @@ 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 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'; -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 +30,8 @@ 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 Stack = createPlatformStackNavigator(); jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), @@ -42,72 +41,45 @@ 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. The preselected +// delegate is rendered as text so tests can check which member the picker would show as selected. 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}) => + return ({onSelectRow, vacationDelegate}: {onSelectRow: (option: {login: string}) => void; vacationDelegate?: {delegate?: string}}) => 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(Text, {testID: 'preselected-delegate'}, vacationDelegate?.delegate ?? 'none'), ); }); -function renderPage() { - return render( - - - - - , - ); -} - -// 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(); +/** + * 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; -function renderPageWithStack() { return render( - + - + null} /> null} + name={SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_SELECT} + component={VacationDelegatePage} /> @@ -116,14 +88,13 @@ function renderPageWithStack() { } describe('VacationDelegatePage', () => { - let apiSideEffectSpy: jest.SpyInstance; - beforeAll(() => { Onyx.init({keys: ONYXKEYS}); }); beforeEach(async () => { - mockShowConfirmModal.mockReset().mockResolvedValue({action: 'CLOSE'}); + jest.mocked(Navigation.goBack).mockClear(); + jest.mocked(Navigation.navigate).mockClear(); await act(async () => { await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); }); @@ -139,187 +110,85 @@ 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, 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(); 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); - }); - - 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})); + // 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(); - - 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(); + 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 second row is tapped before the first request settles + // When a member is picked 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(), - ); + // 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('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(); + 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}); + }); renderPage(); await waitForBatchedUpdatesWithAct(); - // When a delegate row is tapped and the request rejects + // When that member is picked 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(); + // 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?.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); + expect(vacationDelegate?.delegate).toBe(DELEGATE_A_EMAIL); }); - 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 preselect a saved delegate whose clear after date has passed', async () => { + // Given a saved delegate whose clear after date has passed, but whose clearing hasn't reached the client yet 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, clearAfter: '2020-01-01 00:00:00'}); }); + // When the picker opens 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})})], - }), - ); + // Then no member is preselected, since an expired delegate reads as no delegate everywhere else, including Profile + expect(screen.getByTestId('preselected-delegate')).toHaveTextContent('none'); }); - 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 + it('preselects a saved delegate whose clear after date is still ahead', async () => { + // Given a saved delegate that clears in the future await act(async () => { - resolveSideEffect({jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff: {adminPolicies: [], nonAdminPolicies: []}}}); - await waitForBatchedUpdatesWithAct(); + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {creator: CREATOR_EMAIL, delegate: DELEGATE_A_EMAIL, clearAfter: '2999-01-01 00:00:00'}); }); - // 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); + // When the picker opens + renderPage(); + await waitForBatchedUpdatesWithAct(); - 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(); + // Then that delegate is preselected, so only expired delegates are hidden + expect(screen.getByTestId('preselected-delegate')).toHaveTextContent(DELEGATE_A_EMAIL); }); }); diff --git a/tests/ui/ValueSelectionListTest.tsx b/tests/ui/ValueSelectionListTest.tsx index 796952b98437..9bb3185061d8 100644 --- a/tests/ui/ValueSelectionListTest.tsx +++ b/tests/ui/ValueSelectionListTest.tsx @@ -1,4 +1,4 @@ -import {render} from '@testing-library/react-native'; +import {act, render} from '@testing-library/react-native'; import SelectionList from '@components/SelectionList'; import ValueSelectionList from '@components/ValuePicker/ValueSelectionList'; @@ -20,6 +20,10 @@ jest.mock('@react-navigation/native', () => { jest.mock('@components/SelectionList', () => jest.fn(() => null)); jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); describe('ValueSelectionList', () => { const mockedSelectionList = jest.mocked(SelectionList); @@ -50,10 +54,104 @@ describe('ValueSelectionList', () => { }), ); expect(selectionListProps?.initiallyFocusedItemKey).toBe(items.at(-1)?.value); - expect(selectionListProps?.shouldUpdateFocusedIndex).toBeUndefined(); expect(selectionListProps?.shouldScrollToFocusedIndexOnMount).toBe(false); }); + it('hides the search input when the list has fewer items than the standard limit', () => { + // Given a list just below the limit, where scanning is easy without search + const shortItems = items.slice(0, CONST.STANDARD_LIST_ITEM_LIMIT - 1); + + // When the list renders + render( + , + ); + + // Then no search label is passed, so SelectionList shows no input and keeps its default focus behavior + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.textInputOptions?.label).toBeUndefined(); + expect(selectionListProps?.shouldUpdateFocusedIndex).toBe(false); + expect(selectionListProps?.disableMaintainingScrollPosition).toBe(false); + }); + + it('shows the search input when the list reaches the standard limit', () => { + // Given a list at the limit, such as a user with 12 workspaces on the new room page + const limitItems = items.slice(0, CONST.STANDARD_LIST_ITEM_LIMIT); + + // When the list renders + render( + , + ); + + // Then the search input is shown with the scroll-stability props searchable lists use + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.textInputOptions?.label).toBe('common.search'); + expect(selectionListProps?.shouldUpdateFocusedIndex).toBe(true); + expect(selectionListProps?.disableMaintainingScrollPosition).toBe(true); + }); + + describe('searching', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + const search = (value: string) => { + act(() => { + mockedSelectionList.mock.lastCall?.[0]?.textInputOptions?.onChangeText?.(value); + }); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + }); + }; + + it('filters the options by label and keeps the search input visible', () => { + // Given a searchable list + render( + , + ); + + // When the user searches for one label + search('Label 13'); + + // Then only the matching option remains, and the input stays because it is gated on the unfiltered length + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.map((item) => item.keyForList)).toEqual(['value-13']); + expect(selectionListProps?.textInputOptions?.label).toBe('common.search'); + expect(selectionListProps?.textInputOptions?.headerMessage).toBeUndefined(); + }); + + it('shows the no-results message when the search matches nothing', () => { + // Given a searchable list + render( + , + ); + + // When the user searches for text no option contains + search('zzzz'); + + // Then the list is empty and the user is told nothing matched + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data).toEqual([]); + expect(selectionListProps?.textInputOptions?.headerMessage).toBe('common.noResultsFound'); + }); + }); + it('keeps the initially pinned value at the top while the live selection changes during the same mount', () => { const {rerender} = render( { + const ComposedHeader = jest.fn(({children}: PropsWithChildren) => children); + const BackButton = jest.fn(() => null); + const Title = jest.fn(() => null); -jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); + Object.assign(ComposedHeader, {BackButton, Title}); + + return {__esModule: true, default: ComposedHeader}; +}); jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); jest.mock('@components/SelectionList', () => jest.fn(() => null)); jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); jest.mock('@components/Text', () => jest.fn(() => null)); jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'admin@example.com'}))); +jest.mock('@hooks/useDynamicBackPath', () => jest.fn()); jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); -jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => true}))); +jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => mockIsWorkArrangementBetaEnabled}))); jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); jest.mock('@libs/actions/Policy/DistanceRate', () => ({setEmployeeWorkArrangement: jest.fn()})); jest.mock('@libs/PolicyUtils', () => ({ canMemberWrite: jest.fn(() => true), - isMemberInHomeAndOfficeWorkspace: jest.fn(() => true), + isMemberInHomeAndOfficeWorkspace: jest.fn((_policy: Policy, login: string) => login === 'member@example.com'), +})); +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({ + __esModule: true, + default: ({children, shouldBeBlocked}: PropsWithChildren<{shouldBeBlocked: boolean}>) => (shouldBeBlocked ? null : children), })); -jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: ({children}: PropsWithChildren) => children})); jest.mock('@pages/workspace/withPolicyAndFullscreenLoading', () => (Component: React.ComponentType) => Component); -type MockWorkArrangementOption = {value: boolean}; +type MockWorkArrangementOption = {value: boolean; isSelected?: boolean}; type MockSelectionListProps = { data: MockWorkArrangementOption[]; onSelectRow?: (item: MockWorkArrangementOption) => void; }; +type MockHeaderBackButtonProps = { + onPress?: () => void; +}; + type WorkArrangementPageTestProps = React.ComponentProps & {policy: Policy; personalDetails: PersonalDetailsList}; describe('WorkArrangementPage', () => { const policyID = 'policy123'; const accountID = 12345; const memberLogin = 'member@example.com'; - const personalDetails = {[accountID]: {login: memberLogin}}; + const personalDetails: PersonalDetailsList = {[accountID]: createMock({accountID, login: memberLogin})}; + const mockedHeaderBackButton = jest.mocked(Header.BackButton); const policy = { id: policyID, employeeList: {[memberLogin]: {email: memberLogin}}, @@ -58,23 +90,42 @@ describe('WorkArrangementPage', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrows props captured from the mocked SelectionList const getSelectionListProps = () => mockedSelectionList.mock.lastCall?.[0] as MockSelectionListProps | undefined; - beforeEach(() => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { jest.clearAllMocks(); + mockIsWorkArrangementBetaEnabled = true; jest.mocked(canMemberWrite).mockReturnValue(true); + jest.mocked(useDynamicBackPath).mockReturnValue(`workspaces/${policyID}/members/invite/invite-message` as Route); + await act(async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + await Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, {}); + await Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_WORK_ARRANGEMENT_DRAFT}${policyID}`, null); + }); }); - const renderPage = () => { + const getPage = (routeAccountID = accountID, details: PersonalDetailsList = personalDetails, pagePolicy: Policy = createMock(policy), isInviteFlow = false) => { const props = createMock({ - policy: createMock(policy), - personalDetails, - route: {params: {policyID, accountID: String(accountID)}}, + policy: pagePolicy, + personalDetails: details, + route: isInviteFlow + ? {name: SCREENS.WORKSPACE.INVITE_WORK_ARRANGEMENT, params: {policyID}} + : {name: SCREENS.WORKSPACE.MEMBER_WORK_ARRANGEMENT, params: {policyID, accountID: String(routeAccountID)}}, }); // The HOC mock exposes the wrapped screen directly, which accepts the policy prop that the production HOC normally injects. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the test HOC mock returns the unwrapped component with its injected policy props restored const UnwrappedWorkArrangementPage = WorkArrangementPage as React.ComponentType; - return render(); + return ( + + + + ); }; + const renderPage = (routeAccountID = accountID, details: PersonalDetailsList = personalDetails, pagePolicy?: Policy) => render(getPage(routeAccountID, details, pagePolicy)); + it('does nothing when the selected arrangement is already active', () => { // Given the workspace default is no regular workspace renderPage(); @@ -104,6 +155,17 @@ describe('WorkArrangementPage', () => { expect(Navigation.goBack).not.toHaveBeenCalled(); }); + it('blocks direct access to the work arrangement page when the beta is disabled', () => { + // Given the work arrangement route is opened while its beta is disabled + mockIsWorkArrangementBetaEnabled = false; + + // When the page checks whether the current user can access it + renderPage(); + + // Then the route is blocked instead of rendering the arrangement selector + expect(mockedSelectionList).not.toHaveBeenCalled(); + }); + it('updates the arrangement and returns to member details when a different option is selected', () => { // Given the page is open for a member whose current arrangement is no regular workspace renderPage(); @@ -114,7 +176,151 @@ describe('WorkArrangementPage', () => { }); // Then the update is requested and the member details page is shown - expect(setEmployeeWorkArrangement).toHaveBeenCalledWith(policy, [accountID], true, personalDetails, expect.any(Function)); + expect(setEmployeeWorkArrangement).toHaveBeenCalledWith(policy, [accountID], true, personalDetails); expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); }); + + it('updates the invite draft and returns to invite confirmation when a different option is selected', async () => { + // Given the work arrangement editor was opened from invite confirmation for an unsent invitee + const inviteAccountID = 23456; + const invitePolicy = createMock({...policy, employeeList: {}}); + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, {[memberLogin]: inviteAccountID}); + }); + render(getPage(inviteAccountID, {[inviteAccountID]: createMock({accountID: inviteAccountID, login: memberLogin})}, invitePolicy, true)); + await waitForBatchedUpdatesWithAct(); + + // When the admin selects office-based + await act(async () => { + getSelectionListProps()?.onSelectRow?.({value: true}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then only the invite arrangement draft changes and the editor returns to confirmation + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_WORK_ARRANGEMENT_DRAFT}${policyID}`)).toBe(true); + expect(setEmployeeWorkArrangement).not.toHaveBeenCalled(); + expect(Navigation.goBack).toHaveBeenCalledWith(`workspaces/${policyID}/members/invite/invite-message`); + }); + + it('defaults an invite to office-based when the workspace has no work arrangement default', async () => { + // Given an unsent invite whose workspace has no configured work arrangement default + const inviteAccountID = 23456; + const invitePolicy = createMock({ + ...policy, + employeeList: {}, + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE}, + }); + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, {[memberLogin]: inviteAccountID}); + }); + render(getPage(inviteAccountID, personalDetails, invitePolicy, true)); + await waitForBatchedUpdatesWithAct(); + + // When the admin opens the work arrangement selection for the invite + // Then the Office-Based option matches the confirmation page's default + expect(getSelectionListProps()?.data.find((option) => option.value)?.isSelected).toBe(true); + }); + + it('defaults a member to office-based when neither the member nor workspace has an arrangement', () => { + // Given a home and office workspace with no member override or workspace default + const policyWithoutDefault = createMock({...policy, commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE}}); + renderPage(accountID, personalDetails, policyWithoutDefault); + + // When the member's work arrangement settings load + // Then Office-based is selected, matching the member details page and server fallback + expect(getSelectionListProps()?.data.find((option) => option.value)?.isSelected).toBe(true); + expect(getSelectionListProps()?.data.find((option) => !option.value)?.isSelected).toBe(false); + }); + + it('returns to the workflow invite confirmation after changing an invited approver arrangement', async () => { + // Given an unsent approver invite that was opened from the workflow expenses-from flow + const inviteAccountID = 23456; + const invitePolicy = createMock({...policy, employeeList: {}}); + const workflowInviteConfirmationPath = createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_INVITE_MESSAGE.path, `workspaces/${policyID}/workflows/approvals/expenses-from`); + jest.mocked(useDynamicBackPath).mockReturnValue(workflowInviteConfirmationPath); + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, {[memberLogin]: inviteAccountID}); + }); + render(getPage(inviteAccountID, personalDetails, invitePolicy, true)); + await waitForBatchedUpdatesWithAct(); + + // When the admin changes the invited approver to office-based + act(() => { + getSelectionListProps()?.onSelectRow?.({value: true}); + }); + + // Then the workflow's invite confirmation stays as the return destination + expect(Navigation.goBack).toHaveBeenCalledWith(workflowInviteConfirmationPath); + }); + + it('uses the resolved account ID for the first arrangement change after an offline invite syncs', async () => { + // Given the arrangement page was opened while the invite had only an optimistic account ID + const optimisticAccountID = generateAccountID(memberLogin); + const optimisticPersonalDetails: PersonalDetailsList = { + [optimisticAccountID]: createMock({accountID: optimisticAccountID, login: memberLogin, isOptimisticPersonalDetail: true}), + }; + const pendingInvitePolicy = createMock({ + ...policy, + employeeList: {[memberLogin]: {email: memberLogin, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}}, + }); + await act(async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, optimisticPersonalDetails); + }); + const renderedPage = renderPage(optimisticAccountID, optimisticPersonalDetails, pendingInvitePolicy); + + // When the invite syncs and replaces the optimistic personal details with the server account ID + const syncedAccountID = 67890; + const syncedPersonalDetail = createMock({accountID: syncedAccountID, login: memberLogin}); + const syncedPersonalDetails: PersonalDetailsList = {[syncedAccountID]: syncedPersonalDetail}; + const syncedPolicy = createMock({...pendingInvitePolicy, employeeList: {[memberLogin]: {email: memberLogin, pendingAction: null}}}); + await act(async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, syncedPersonalDetails); + }); + renderedPage.rerender(getPage(optimisticAccountID, syncedPersonalDetails, syncedPolicy)); + + // And the admin changes the arrangement for the first time + act(() => { + getSelectionListProps()?.onSelectRow?.({value: true}); + }); + + // Then the update uses the synced account ID and the current personal details + expect(setEmployeeWorkArrangement).toHaveBeenCalledWith(syncedPolicy, [syncedAccountID], true, syncedPersonalDetails); + }); + + it('keeps the member profile as the back destination after an offline invite syncs', async () => { + // Given the arrangement page was opened while the invite had only an optimistic account ID + const optimisticAccountID = generateAccountID(memberLogin); + const optimisticPersonalDetails: PersonalDetailsList = { + [optimisticAccountID]: createMock({accountID: optimisticAccountID, login: memberLogin, isOptimisticPersonalDetail: true}), + }; + const pendingInvitePolicy = createMock({ + ...policy, + employeeList: {[memberLogin]: {email: memberLogin, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}}, + }); + await act(async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, optimisticPersonalDetails); + }); + const renderedPage = renderPage(optimisticAccountID, optimisticPersonalDetails, pendingInvitePolicy); + + // When the invite syncs and replaces the optimistic personal details with the server account ID + const syncedAccountID = 67890; + const syncedPersonalDetail = createMock({accountID: syncedAccountID, login: memberLogin}); + const syncedPersonalDetails: PersonalDetailsList = {[syncedAccountID]: syncedPersonalDetail}; + const syncedPolicy = createMock({...pendingInvitePolicy, employeeList: {[memberLogin]: {email: memberLogin, pendingAction: null}}}); + await act(async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, syncedPersonalDetails); + }); + renderedPage.rerender(getPage(optimisticAccountID, syncedPersonalDetails, syncedPolicy)); + + expect(getSelectionListProps()).toBeDefined(); + + // And the admin presses back + const headerProps = mockedHeaderBackButton.mock.lastCall?.[0] as MockHeaderBackButtonProps | undefined; + act(() => { + headerProps?.onPress?.(); + }); + + // Then navigation targets the member profile with the synced account ID + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, syncedAccountID)); + }); }); diff --git a/tests/ui/WorkEmailOnboarding.tsx b/tests/ui/WorkEmailOnboarding.tsx index fa78d4025815..308c28ac2012 100644 --- a/tests/ui/WorkEmailOnboarding.tsx +++ b/tests/ui/WorkEmailOnboarding.tsx @@ -14,6 +14,7 @@ import {openOldDotLink} from '@libs/actions/Link'; import {AddWorkEmail} from '@libs/actions/Session'; import * as SessionActions from '@libs/actions/Session'; import * as TaskActions from '@libs/actions/Task'; +import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; import HttpUtils from '@libs/HttpUtils'; import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; @@ -325,6 +326,67 @@ function AddWorkEmailWithDomainControlledError() { return waitForBatchedUpdates().then(() => (HttpUtils.xhr = originalXhr)); } +async function renderWorkEmailPageWithTypedEmail(email: string) { + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false, isFromPublicDomain: true}); + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + }); + const result = renderOnboardingWorkEmailPage(SCREENS.ONBOARDING.WORK_EMAIL, undefined); + await waitForBatchedUpdatesWithAct(); + fireEvent.changeText(screen.getByLabelText(TestHelper.translateLocal('common.workEmail')), email); + await waitForBatchedUpdatesWithAct(); + return result; +} + +// The device back button, the browser back button and a deep link opened during onboarding all only set this message +async function pressBackButton() { + await act(async () => { + await Onyx.set(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, 'onboarding.purpose.errorBackButton'); + }); + await waitForBatchedUpdatesWithAct(); +} + +async function pressAddWorkEmail() { + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('onboarding.workEmail.addWorkEmail')})); + await waitForBatchedUpdatesWithAct(); +} + +async function waitForMilliseconds(milliseconds: number) { + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); + }); +} + +// FormProvider ignores presses that come within a second of the previous one, so a second press must wait it out to reach the submit at all +async function waitForSubmitDebounce() { + await waitForMilliseconds(1100); +} + +// A press reaches onSubmit only after a timeout and the keyboard dismissal, so a check that nothing was sent must wait for both +async function waitForSubmitToSettle() { + await waitForMilliseconds(300); +} + +function getAddWorkEmailRequestCount(xhrSpy: jest.SpyInstance) { + return xhrSpy.mock.calls.filter(([command]) => command === SIDE_EFFECT_REQUEST_COMMANDS.ADD_WORK_EMAIL).length; +} + +function mockPendingAddWorkEmail() { + let resolveAddWorkEmail: (response: OnyxResponse) => void = () => {}; + const xhrSpy = jest.spyOn(HttpUtils, 'xhr').mockImplementation((command) => { + if (command !== SIDE_EFFECT_REQUEST_COMMANDS.ADD_WORK_EMAIL) { + return Promise.resolve({jsonCode: 200}); + } + return new Promise((resolve) => { + resolveAddWorkEmail = resolve; + }); + }); + return {xhrSpy, answerAddWorkEmail: () => resolveAddWorkEmail({jsonCode: 200})}; +} + describe('OnboardingWorkEmail Page', () => { beforeAll(() => { Onyx.init({ @@ -963,7 +1025,7 @@ describe('OnboardingWorkEmail Page', () => { expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_PRIVATE_DOMAIN.getRoute(), {forceReplace: true}); }); expect(screen.queryByText(TestHelper.translateLocal('onboarding.workEmailValidationError.sameAsSignupEmail'))).not.toBeOnTheScreen(); - expect(xhrSpy.mock.calls.filter(([command]) => command === 'AddWorkEmail')).toHaveLength(0); + expect(getAddWorkEmailRequestCount(xhrSpy)).toBe(0); xhrSpy.mockRestore(); unmount(); @@ -998,7 +1060,7 @@ describe('OnboardingWorkEmail Page', () => { // Then the new email is sent to the backend, because only the email that is already the account's login skips the request await waitFor(() => { - expect(xhrSpy.mock.calls.filter(([command]) => command === 'AddWorkEmail')).toHaveLength(1); + expect(getAddWorkEmailRequestCount(xhrSpy)).toBe(1); }); await waitFor(() => { expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_PRIVATE_DOMAIN.getRoute(), {forceReplace: true}); @@ -1043,12 +1105,134 @@ describe('OnboardingWorkEmail Page', () => { await waitFor(() => { expect(screen.getByText(TestHelper.translateLocal('onboarding.workEmailValidationError.publicEmail'))).toBeOnTheScreen(); }); - expect(xhrSpy.mock.calls.filter(([command]) => command === 'AddWorkEmail')).toHaveLength(0); + expect(getAddWorkEmailRequestCount(xhrSpy)).toBe(0); xhrSpy.mockRestore(); unmount(); await waitForBatchedUpdatesWithAct(); }); + + it('should show the back button message once and still let the user submit a work email', async () => { + // Given an unvalidated user who typed a work email and then pressed the device or browser back button + const {unmount} = await renderWorkEmailPageWithTypedEmail(workEmail); + const xhrSpy = jest.spyOn(HttpUtils, 'xhr').mockImplementation(() => { + const mockedResponse: OnyxResponse = { + jsonCode: 200, + onyxData: [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_ONBOARDING, value: {shouldValidate: false}}], + }; + return Promise.resolve(mockedResponse); + }); + try { + await pressBackButton(); + + // Then the message shows once, and never as an error on the form, which is what would block the submit + expect(screen.getAllByText(TestHelper.translateLocal('onboarding.purpose.errorBackButton'))).toHaveLength(1); + expect((await getOnyxValue(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM))?.errors).toBeFalsy(); + + // When the user presses "Add work email" without editing the email + await pressAddWorkEmail(); + + // Then the email is sent, because the back button message doesn't count as an error on the form + await waitFor(() => { + expect(getAddWorkEmailRequestCount(xhrSpy)).toBe(1); + }); + } finally { + xhrSpy.mockRestore(); + unmount(); + await waitForBatchedUpdatesWithAct(); + } + }); + + it('should keep the work email form loading while its request is pending after the back button message was shown', async () => { + // Given the back button message is showing and the AddWorkEmail request doesn't answer until the end of the test + const {unmount} = await renderWorkEmailPageWithTypedEmail(workEmail); + const {xhrSpy, answerAddWorkEmail} = mockPendingAddWorkEmail(); + try { + await pressBackButton(); + + // When the user submits + await pressAddWorkEmail(); + + // Then the back button message is gone, and clearing it doesn't stop the loading while the request is still pending + await waitFor(() => { + expect(getAddWorkEmailRequestCount(xhrSpy)).toBe(1); + }); + expect(screen.queryByText(TestHelper.translateLocal('onboarding.purpose.errorBackButton'))).not.toBeOnTheScreen(); + expect((await getOnyxValue(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM))?.isLoading).toBe(true); + + // When the user presses "Add work email" again before the request answers + await waitForSubmitDebounce(); + await pressAddWorkEmail(); + await waitForSubmitToSettle(); + + // Then the same email isn't sent a second time + expect(getAddWorkEmailRequestCount(xhrSpy)).toBe(1); + } finally { + answerAddWorkEmail(); + await waitForBatchedUpdatesWithAct(); + xhrSpy.mockRestore(); + unmount(); + await waitForBatchedUpdatesWithAct(); + } + }); + + it('should keep the work email form loading when the back button is pressed while its request is pending', async () => { + // Given an unvalidated user who submitted a work email whose AddWorkEmail request doesn't answer until the end of the test + const {unmount} = await renderWorkEmailPageWithTypedEmail(workEmail); + const {xhrSpy, answerAddWorkEmail} = mockPendingAddWorkEmail(); + try { + await pressAddWorkEmail(); + await waitFor(() => { + expect(getAddWorkEmailRequestCount(xhrSpy)).toBe(1); + }); + + // When the user presses the device or browser back button before the request answers + await pressBackButton(); + + // Then the message shows once, the form gets no error, and the form stays loading + expect(screen.getAllByText(TestHelper.translateLocal('onboarding.purpose.errorBackButton'))).toHaveLength(1); + const form = await getOnyxValue(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM); + expect(form?.errors).toBeFalsy(); + expect(form?.isLoading).toBe(true); + + // When the user presses "Add work email" again before the request answers + await waitForSubmitDebounce(); + await pressAddWorkEmail(); + await waitForSubmitToSettle(); + + // Then the same email isn't sent a second time + expect(getAddWorkEmailRequestCount(xhrSpy)).toBe(1); + } finally { + answerAddWorkEmail(); + await waitForBatchedUpdatesWithAct(); + xhrSpy.mockRestore(); + unmount(); + await waitForBatchedUpdatesWithAct(); + } + }); + + it('should clear the back button message when the user submits an email that fails validation', async () => { + // Given an unvalidated user with an invalid email typed and the back button message showing + const {unmount} = await renderWorkEmailPageWithTypedEmail('not-an-email'); + const xhrSpy = jest.spyOn(HttpUtils, 'xhr'); + try { + await pressBackButton(); + + // When the user presses "Add work email" + await pressAddWorkEmail(); + + // Then only the validation error shows, because pressing the button replaces the back button message, and nothing is sent + await waitFor(() => { + expect(screen.getByText(TestHelper.translateLocal('onboarding.workEmailValidationError.publicEmail'))).toBeOnTheScreen(); + }); + expect(screen.queryByText(TestHelper.translateLocal('onboarding.purpose.errorBackButton'))).not.toBeOnTheScreen(); + expect(getAddWorkEmailRequestCount(xhrSpy)).toBe(0); + } finally { + xhrSpy.mockRestore(); + unmount(); + await waitForBatchedUpdatesWithAct(); + } + }); }); describe('OnboardingWorkEmailValidation Page', () => { diff --git a/tests/ui/WorkEmailOnboardingNativeBack.tsx b/tests/ui/WorkEmailOnboardingNativeBack.tsx index 68464ebcdc7a..8e02443968ac 100644 --- a/tests/ui/WorkEmailOnboardingNativeBack.tsx +++ b/tests/ui/WorkEmailOnboardingNativeBack.tsx @@ -1,4 +1,4 @@ -import {act, render, waitFor} from '@testing-library/react-native'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; import HTMLEngineProvider from '@components/HTMLEngineProvider'; @@ -9,6 +9,8 @@ import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; +import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; +import HttpUtils from '@libs/HttpUtils'; import navigationRef from '@libs/Navigation/navigationRef'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types'; @@ -28,12 +30,13 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {HardwareBackPressEvent} from 'react-native/Libraries/Utilities/BackHandler'; import {PortalProvider} from '@gorhom/portal'; -import {NavigationContainer} from '@react-navigation/native'; +import {NavigationContainer, StackActions} from '@react-navigation/native'; import React from 'react'; import {BackHandler, View} from 'react-native'; import Onyx from 'react-native-onyx'; import createMock from '../utils/createMock'; +import getOnyxValue from '../utils/getOnyxValue'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -61,11 +64,20 @@ function OnboardingPersonalDetailsStub() { return ; } +// Stands in for the intent list; only whether back lands on it matters. +function OnboardingPurposeStub() { + return ; +} + // The native (`index.native.tsx`) screens are imported directly: only those register the hardware back handler under // test, and Jest resolves the web `index.tsx` for a bare `@pages/...` import. function OnboardingModalNavigator() { return ( + { expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PERSONAL_DETAILS]); }); }); + + it('should show the back button message once and still send the work email after a system back press on the work email screen', async () => { + // Given an unvalidated public-domain user on the work email screen with a work email typed + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false, isFromPublicDomain: true}); + }); + + renderOnboardingStack([SCREENS.ONBOARDING.WORK_EMAIL]); + + await waitForBatchedUpdatesWithAct(); + + fireEvent.changeText(screen.getByLabelText(TestHelper.translateLocal('common.workEmail')), workEmail); + + await waitForBatchedUpdatesWithAct(); + + const xhrSpy = jest.spyOn(HttpUtils, 'xhr').mockImplementation(() => Promise.resolve({jsonCode: 200})); + try { + // When Android's system back button is pressed + const consumed = pressHardwareBack(); + + await waitForBatchedUpdatesWithAct(); + + // Then the screen keeps the press, and the message it sets shows once without becoming an error on the form + expect(consumed).toBe(true); + expect(screen.getAllByText(TestHelper.translateLocal('onboarding.purpose.errorBackButton'))).toHaveLength(1); + expect((await getOnyxValue(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM))?.errors).toBeFalsy(); + + // When the user presses "Add work email" without editing the email + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('onboarding.workEmail.addWorkEmail')})); + + await waitForBatchedUpdatesWithAct(); + + // Then the work email is sent, because the back button message doesn't block the submit + await waitFor(() => { + expect(xhrSpy.mock.calls.filter(([command]) => command === SIDE_EFFECT_REQUEST_COMMANDS.ADD_WORK_EMAIL)).toHaveLength(1); + }); + } finally { + xhrSpy.mockRestore(); + } + }); + + it('should swallow a system back press and show the error when work email is the first onboarding screen', async () => { + // Given work email is the only onboarding route, which is where a public-domain account starts onboarding + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false}); + }); + + renderOnboardingStack([SCREENS.ONBOARDING.WORK_EMAIL]); + + await waitForBatchedUpdatesWithAct(); + + // When Android's system back button is pressed + const consumed = pressHardwareBack(); + + await waitForBatchedUpdatesWithAct(); + + // Then the press is consumed so it cannot pop the onboarding modal, and the user is told to finish setup + expect(consumed).toBe(true); + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.WORK_EMAIL]); + let errorMessage: OnyxEntry; + await TestHelper.getOnyxData({ + key: ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, + callback: (value) => { + errorMessage = value ?? undefined; + }, + }); + expect(errorMessage).toBe('onboarding.purpose.errorBackButton'); + }); + + it('should go back to the intent list when the join workspace intent opened work email', async () => { + // Given the intent list sent the user to work email after they picked "Join my company workspace", so the + // intent list is behind this screen + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + }); + + renderOnboardingStack([SCREENS.ONBOARDING.PURPOSE]); + + await waitForBatchedUpdatesWithAct(); + + // Push work email after mount like the intent list does, so its back handler subscribes after the + // NavigationContainer's one and gets the press first, as it does in the app + act(() => { + navigationRef.dispatch(StackActions.push(SCREENS.ONBOARDING.WORK_EMAIL)); + }); + + await waitForBatchedUpdatesWithAct(); + + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL]); + + // Given adding the work email failed, leaving errors that the intent list would render if they followed the user back + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {isMergingAccountBlocked: true}); + await Onyx.set(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, 'onboarding.purpose.errorBackButton'); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM, {errors: {error: 'Work email error'}}); + }); + + await waitForBatchedUpdatesWithAct(); + + // When Android's system back button is pressed + const consumed = pressHardwareBack(); + + await waitForBatchedUpdatesWithAct(); + + // Then the screen handles the press like the header back button: it returns to the intent list and no error is left behind + expect(consumed).toBe(true); + await waitFor(() => { + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE]); + }); + let errorMessage: OnyxEntry; + await TestHelper.getOnyxData({ + key: ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, + callback: (value) => { + errorMessage = value ?? undefined; + }, + }); + expect(errorMessage).toBeUndefined(); + expect((await getOnboardingValues())?.isMergingAccountBlocked).toBe(false); + expect((await getOnyxValue(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM))?.errors).toBeFalsy(); + }); + + it('should replace work email with the intent list when the join workspace intent is saved but work email is the only route', async () => { + // Given the user picked "Join my company workspace" and then reopened the app, which rebuilds the onboarding + // stack with work email as its only route + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + }); + + renderOnboardingStack([SCREENS.ONBOARDING.WORK_EMAIL]); + + await waitForBatchedUpdatesWithAct(); + + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.WORK_EMAIL]); + + // When Android's system back button is pressed + const consumed = pressHardwareBack(); + + await waitForBatchedUpdatesWithAct(); + + // Then the press is handled here so it cannot close the onboarding modal, and the intent list replaces work + // email, matching the header back button + expect(consumed).toBe(true); + await waitFor(() => { + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE]); + }); + + // Then the "finish setup" error is not shown, because the user had somewhere to go back to + let errorMessage: OnyxEntry; + await TestHelper.getOnyxData({ + key: ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, + callback: (value) => { + errorMessage = value ?? undefined; + }, + }); + expect(errorMessage).toBeUndefined(); + }); }); diff --git a/tests/ui/WorkEmailOnboardingNavigation.tsx b/tests/ui/WorkEmailOnboardingNavigation.tsx index 3c777bdcd92b..13551b93894a 100644 --- a/tests/ui/WorkEmailOnboardingNavigation.tsx +++ b/tests/ui/WorkEmailOnboardingNavigation.tsx @@ -11,18 +11,23 @@ import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; import {AddWorkEmail, MergeIntoAccountAndLogin} from '@libs/actions/Session'; import HttpUtils from '@libs/HttpUtils'; +import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types'; +import OnboardingPrivateDomain from '@pages/OnboardingPrivateDomain'; import OnboardingWorkEmail from '@pages/OnboardingWorkEmail'; import OnboardingWorkEmailValidation from '@pages/OnboardingWorkEmailValidation'; import OnboardingWorkspaces from '@pages/OnboardingWorkspaces'; +import {completeOnboarding} from '@userActions/Report'; + import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Onboarding, Response as OnyxResponse} from '@src/types/onyx'; @@ -47,6 +52,18 @@ jest.mock('@rnmapbox/maps', () => { }; }); +jest.mock('@userActions/Report', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@userActions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + completeOnboarding: jest.fn(), + }; +}); + +const mockCompleteOnboarding = jest.mocked(completeOnboarding); + TestHelper.setupGlobalFetchMock(); const RootStack = createPlatformStackNavigator<{[NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR]: undefined}>(); @@ -121,6 +138,18 @@ function OnboardingModalNavigatorWithPurpose() { name={SCREENS.ONBOARDING.WORK_EMAIL} component={OnboardingWorkEmail} /> + + + ); } @@ -367,6 +396,209 @@ describe('Onboarding work email navigation', () => { }); }); + it('should clear the work email error when Back is pressed so the intent list does not show it', async () => { + await TestHelper.signInWithTestUser(); + + // Given AddWorkEmail failed with the domain-controlled error while joining a company workspace + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + isMergingAccountBlocked: true, + }); + await Onyx.merge(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, 'onboarding.mergeBlockScreen.domainControlledSubtitle'); + await Onyx.merge(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM, { + onboardingWorkEmail: workEmail, + }); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false, isFromPublicDomain: true}); + }); + + renderOnboardingStack([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL], OnboardingModalNavigatorWithPurpose); + + await waitForBatchedUpdatesWithAct(); + + // When the user presses Back instead of confirming the blocked screen + await waitFor(() => { + expect(screen.getByLabelText(TestHelper.translateLocal('common.back'))).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByLabelText(TestHelper.translateLocal('common.back'))); + + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE]); + }); + + // Then the error is cleared, because the intent list translates it without the work email and would show + // "undefined is a domain controlled login" + let errorMessageTranslationKey: OnyxEntry; + await TestHelper.getOnyxData({ + key: ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, + callback: (value) => { + errorMessageTranslationKey = value ?? undefined; + }, + }); + expect(errorMessageTranslationKey).toBeFalsy(); + + let onboardingValues: OnyxEntry; + await TestHelper.getOnyxData({ + key: ONYXKEYS.NVP_ONBOARDING, + callback: (value) => { + onboardingValues = value; + }, + }); + expect(onboardingValues?.isMergingAccountBlocked).toBe(false); + }); + + it('should reopen magic code after skipping an existing work email and submitting it again', async () => { + // Given a public-domain user joining a workspace with an existing work account + await TestHelper.signInWithTestUser(1, 'test@gmail.com'); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false, isFromPublicDomain: true}); + }); + renderOnboardingStack([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL], OnboardingModalNavigatorWithPurpose); + await waitForBatchedUpdatesWithAct(); + + const restoreXhr = mockResponseOnce({shouldValidate: true}); + try { + fireEvent.changeText(screen.getByLabelText(TestHelper.translateLocal('common.workEmail')), workEmail); + fireEvent.press(screen.getByText(TestHelper.translateLocal('onboarding.workEmail.addWorkEmail'))); + await waitForBatchedUpdatesWithAct(); + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL_VALIDATION]); + + // When they skip validation, return to the intent list, and submit the same email on a new visit + fireEvent.press(screen.getByText(TestHelper.translateLocal('common.skip'))); + await waitForBatchedUpdatesWithAct(); + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.PERSONAL_DETAILS]); + act(() => Navigation.goBack(ROUTES.ONBOARDING_PURPOSE.getRoute())); + await waitForBatchedUpdatesWithAct(); + act(() => Navigation.navigate(ROUTES.ONBOARDING_WORK_EMAIL.getRoute())); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByText(TestHelper.translateLocal('onboarding.workEmail.addWorkEmail'))); + await waitForBatchedUpdatesWithAct(); + + // Then stale completion flags must not immediately skip the newly opened validation screen + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL_VALIDATION]); + expect(screen.queryByTestId('onboarding-personal-details-stub')).not.toBeOnTheScreen(); + } finally { + restoreXhr(); + } + }); + + it('should complete onboarding with the saved name when the work email step is skipped', async () => { + // Given a public-domain Join Workspace user who entered their name earlier in onboarding + const dismissModal = jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); + await TestHelper.signInWithTestUser(1, 'test@gmail.com'); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false, isFromPublicDomain: true}); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM, {firstName: 'Test', lastName: 'User'}); + }); + renderOnboardingStack([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL], OnboardingModalNavigatorWithPurpose); + await waitForBatchedUpdatesWithAct(); + + // When they skip adding a work email + fireEvent.press(screen.getByTestId('onboardingPrivateEmailSkipButton')); + await waitForBatchedUpdatesWithAct(); + + // Then onboarding completes with that name instead of asking for it again + expect(mockCompleteOnboarding).toHaveBeenCalledTimes(1); + expect(mockCompleteOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + engagementChoice: CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE, + firstName: 'Test', + lastName: 'User', + }), + ); + expect(getOnboardingRouteNames()).not.toContain(SCREENS.ONBOARDING.PERSONAL_DETAILS); + expect(dismissModal).toHaveBeenCalled(); + dismissModal.mockRestore(); + }); + + it('should complete onboarding with the saved name when work email validation is skipped', async () => { + // Given a public-domain Join Workspace user with a saved name who reached validation for an existing work account + const dismissModal = jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); + await TestHelper.signInWithTestUser(1, 'test@gmail.com'); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false, shouldValidate: true}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false, isFromPublicDomain: true}); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM, {firstName: 'Test', lastName: 'User'}); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM, {onboardingWorkEmail: workEmail}); + }); + renderOnboardingStack([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL_VALIDATION], OnboardingModalNavigatorWithPurpose); + await waitForBatchedUpdatesWithAct(); + + // When they skip validating the work email + fireEvent.press(screen.getByText(TestHelper.translateLocal('common.skip'))); + await waitForBatchedUpdatesWithAct(); + + // Then onboarding completes with that name instead of asking for it again + expect(mockCompleteOnboarding).toHaveBeenCalledTimes(1); + expect(mockCompleteOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + engagementChoice: CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE, + firstName: 'Test', + }), + ); + expect(getOnboardingRouteNames()).not.toContain(SCREENS.ONBOARDING.PERSONAL_DETAILS); + expect(dismissModal).toHaveBeenCalled(); + dismissModal.mockRestore(); + }); + + it('should resume primary work email validation after going back and resubmitting', async () => { + // Given a work email that became the unvalidated primary login on the first AddWorkEmail request + await TestHelper.signInWithTestUser(1, workEmail); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false, isFromPublicDomain: false}); + await Onyx.set(ONYXKEYS.LOGINS, {[workEmail]: {validatedDate: ''}}); + }); + renderOnboardingStack([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL], OnboardingModalNavigatorWithPurpose); + await waitForBatchedUpdatesWithAct(); + + // When the same primary email is submitted, including with different casing + fireEvent.changeText(screen.getByLabelText(TestHelper.translateLocal('common.workEmail')), workEmail.toUpperCase()); + fireEvent.press(screen.getByText(TestHelper.translateLocal('onboarding.workEmail.addWorkEmail'))); + await waitForBatchedUpdatesWithAct(); + + // Then it opens validation, and Back still restores the work-email form for another submission + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.PRIVATE_DOMAIN]); + fireEvent.press(screen.getByLabelText(TestHelper.translateLocal('common.back'))); + await waitForBatchedUpdatesWithAct(); + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL]); + fireEvent.press(screen.getByText(TestHelper.translateLocal('onboarding.workEmail.addWorkEmail'))); + await waitForBatchedUpdatesWithAct(); + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.PRIVATE_DOMAIN]); + }); + + it('should return directly to purpose when private-domain validation was opened from the intent list', async () => { + // Given an unvalidated private-domain user whose earlier direct add left shouldValidate false + await TestHelper.signInWithTestUser(1, workEmail); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false, shouldValidate: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false, isFromPublicDomain: false}); + await Onyx.set(ONYXKEYS.LOGINS, {[workEmail]: {validatedDate: ''}}); + }); + renderOnboardingStack([SCREENS.ONBOARDING.PURPOSE], OnboardingModalNavigatorWithPurpose); + await waitForBatchedUpdatesWithAct(); + act(() => Navigation.navigate(ROUTES.ONBOARDING_PRIVATE_DOMAIN.getRoute(ROUTES.ONBOARDING_PURPOSE.getRoute()))); + await waitForBatchedUpdatesWithAct(); + + // When Back is pressed on the screen reached directly from Join my company workspace + fireEvent.press(screen.getByLabelText(TestHelper.translateLocal('common.back'))); + await waitForBatchedUpdatesWithAct(); + + // Then it returns to the actual origin without inserting a work-email screen + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE]); + expect(screen.getByTestId('onboarding-purpose-stub')).toBeOnTheScreen(); + }); + it('should keep Back working on a later visit to Join a workspace after the merge', async () => { await TestHelper.signInWithTestUser(); diff --git a/tests/ui/WorkspaceCompanyCardFeedSelectorPageTest.tsx b/tests/ui/WorkspaceCompanyCardFeedSelectorPageTest.tsx index 7eb686ae37b6..8f51d8fe3414 100644 --- a/tests/ui/WorkspaceCompanyCardFeedSelectorPageTest.tsx +++ b/tests/ui/WorkspaceCompanyCardFeedSelectorPageTest.tsx @@ -1,6 +1,6 @@ import {act, fireEvent, render, screen} from '@testing-library/react-native'; -import type {CompanyCardFeedWithDomainID} from '@hooks/useCardFeeds'; +import type {CombinedCardFeed, CompanyCardFeedWithDomainID} from '@hooks/useCardFeeds'; import type {CardFeedListItem} from '@hooks/useOtherFeedsForFeedSelector'; import Navigation from '@libs/Navigation/Navigation'; @@ -9,7 +9,7 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import WorkspaceCompanyCardFeedSelectorPage from '@pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage'; -import {clearAddNewCardFlow} from '@userActions/CompanyCards'; +import {clearAddNewCardFlow, linkCardFeedToPolicy} from '@userActions/CompanyCards'; import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -19,8 +19,14 @@ import React from 'react'; const POLICY_ID = 'policy123'; +const OTHER_WORKSPACE_FEED_KEY: CompanyCardFeedWithDomainID = `${CONST.COMPANY_CARD.FEED_BANK_NAME.VISA}${CONST.COMPANY_CARD.FEED_KEY_SEPARATOR}123`; + +// Matches what useOtherFeedsForFeedSelector builds: the row value is `${fundID}_${feed}`, while the selection uses the main list key `${feed}#${fundID}`. const OTHER_WORKSPACE_FEED: CardFeedListItem = { - value: `${CONST.COMPANY_CARD.FEED_BANK_NAME.VISA}${CONST.COMPANY_CARD.FEED_KEY_SEPARATOR}123`, + // The hook casts its `${fundID}_${feed}` row id to this type the same way. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + value: `123_${CONST.COMPANY_CARD.FEED_BANK_NAME.VISA}` as CompanyCardFeedWithDomainID, + selectionValue: OTHER_WORKSPACE_FEED_KEY, feed: CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, fundID: 123, text: 'Other workspace feed', @@ -32,6 +38,8 @@ let mockIsBlockedToAddNewFeeds = false; let mockIsOffline = false; let mockOtherFeeds: CardFeedListItem[] = []; let mockCommittedFeedName: CompanyCardFeedWithDomainID | undefined; +let mockCompanyCardFeeds: Partial> = {}; +let mockPrimaryContactMethod = 'admin@example.com'; let mockCapturedOnResume: ((payload?: () => void) => void) | undefined; const mockVerifyAccountAndResume = jest.fn void]>(); @@ -43,6 +51,18 @@ jest.mock('@hooks/useVerifyAccountAndResume', () => ({ }, })); +// The available feeds render in a real SelectionList, which reads focus from navigation. +jest.mock('@react-navigation/native', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actualNav = jest.requireActual('@react-navigation/native'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actualNav, + useIsFocused: () => true, + useFocusEffect: jest.fn(), + }; +}); + jest.mock('@components/HeaderWithBackButton', () => () => null); jest.mock( '@components/ScreenWrapper', @@ -79,7 +99,7 @@ jest.mock('@hooks/useCompanyCardIcons', () => ({ jest.mock('@hooks/useCompanyCards', () => ({ __esModule: true, - default: () => ({companyCardFeeds: {}, feedName: mockCommittedFeedName}), + default: () => ({companyCardFeeds: mockCompanyCardFeeds, feedName: mockCommittedFeedName}), })); jest.mock('@hooks/useIsBlockedToAddFeed', () => ({ @@ -123,7 +143,7 @@ jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ jest.mock('@hooks/usePrimaryContactMethod', () => ({ __esModule: true, - default: () => 'admin@example.com', + default: () => mockPrimaryContactMethod, })); jest.mock('@hooks/useThemeIllustrations', () => ({ @@ -189,6 +209,8 @@ describe('WorkspaceCompanyCardFeedSelectorPage', () => { mockIsOffline = false; mockOtherFeeds = []; mockCommittedFeedName = undefined; + mockCompanyCardFeeds = {}; + mockPrimaryContactMethod = 'admin@example.com'; mockCapturedOnResume = undefined; }); @@ -201,7 +223,7 @@ describe('WorkspaceCompanyCardFeedSelectorPage', () => { it('keeps Save disabled when the committed feed is an other-workspace feed the user has not picked', () => { // Given the committed feed is the other-workspace feed, so it is staged without the user picking anything - mockCommittedFeedName = OTHER_WORKSPACE_FEED.value; + mockCommittedFeedName = OTHER_WORKSPACE_FEED_KEY; // When the page opens renderFeedSelectorPage(); @@ -212,7 +234,7 @@ describe('WorkspaceCompanyCardFeedSelectorPage', () => { it('starts the link flow once the user picks the other-workspace feed', () => { // Given the committed feed is the other-workspace feed - mockCommittedFeedName = OTHER_WORKSPACE_FEED.value; + mockCommittedFeedName = OTHER_WORKSPACE_FEED_KEY; renderFeedSelectorPage(); // When the user picks it and presses Save @@ -242,6 +264,64 @@ describe('WorkspaceCompanyCardFeedSelectorPage', () => { // Then Save is disabled, because the link request never settles offline expect(getSaveButton()).toBeDisabled(); }); + + const rerenderFeedSelectorPage = (rerender: ReturnType['rerender']) => + rerender( + , + ); + + // Mirrors what Onyx holds once the link response lands: the feed leaves "From other workspaces" and shows up in the available feeds list. + const moveFeedIntoAvailableList = () => { + mockOtherFeeds = []; + mockCompanyCardFeeds = {[OTHER_WORKSPACE_FEED_KEY]: {feed: CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, domainID: 123}}; + }; + + it('keeps the staged feed checked in place while the link response lands before the page closes', () => { + // Given a user with a validated work email stages the other-workspace feed and presses Save + mockPrimaryContactMethod = ''; + jest.mocked(linkCardFeedToPolicy).mockReturnValue(new Promise(() => {})); + const {rerender} = renderFeedSelectorPage(); + fireEvent.press(screen.getByText(OTHER_WORKSPACE_FEED.text ?? '')); + fireEvent.press(getSaveButton()); + expect(linkCardFeedToPolicy).toHaveBeenCalledTimes(1); + + // When the link response reaches Onyx before the page closes + moveFeedIntoAvailableList(); + rerenderFeedSelectorPage(rerender); + + // Then the feed stays checked under "From other workspaces" and no available feed row appears, so nothing moves while the RHP closes + expect(screen.getByText('workspace.companyCards.fromOtherWorkspaces')).toBeOnTheScreen(); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}${OTHER_WORKSPACE_FEED.keyForList}`).props.accessibilityState).toMatchObject({selected: true}); + expect(screen.queryByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}${OTHER_WORKSPACE_FEED_KEY}`)).not.toBeOnTheScreen(); + }); + + it('shows the latest feeds again when linking fails', async () => { + // Given a user with a validated work email stages the other-workspace feed, presses Save, and the feeds change while the link request is pending + mockPrimaryContactMethod = ''; + let rejectLink: (error: string) => void = () => {}; + jest.mocked(linkCardFeedToPolicy).mockReturnValue( + new Promise((resolve, reject) => { + rejectLink = reject; + }), + ); + const {rerender} = renderFeedSelectorPage(); + fireEvent.press(screen.getByText(OTHER_WORKSPACE_FEED.text ?? '')); + fireEvent.press(getSaveButton()); + moveFeedIntoAvailableList(); + rerenderFeedSelectorPage(rerender); + + // When the link request fails + await act(async () => { + rejectLink('common.genericErrorMessage'); + }); + + // Then the page stops freezing the lists, because the page stays open and has to reflect the current feeds + expect(screen.queryByText('workspace.companyCards.fromOtherWorkspaces')).not.toBeOnTheScreen(); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}${OTHER_WORKSPACE_FEED_KEY}`)).toBeOnTheScreen(); + }); }); it('navigates a validated user on a plan blocked from new feeds straight to the upgrade screen', () => { diff --git a/tests/ui/WorkspaceCompanyCardsTableControlsTest.tsx b/tests/ui/WorkspaceCompanyCardsTableControlsTest.tsx index 5eced387c3f2..1df42c526da5 100644 --- a/tests/ui/WorkspaceCompanyCardsTableControlsTest.tsx +++ b/tests/ui/WorkspaceCompanyCardsTableControlsTest.tsx @@ -1,16 +1,21 @@ import {render, screen} from '@testing-library/react-native'; import WorkspaceCompanyCardsTableControls from '@components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls'; +import type {WorkspaceCompanyCardTableItemData} from '@components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow'; + +import {formatMaskedCardName} from '@libs/CardUtils'; +import localFileDownload from '@libs/localFileDownload'; import React from 'react'; type MockButtonProps = { customText: string; isDisabled?: boolean; + options?: Array<{value: string; onSelected?: () => void | Promise}>; }; const mockButtonProps: {current?: MockButtonProps} = {}; -let mockProcessedData: Array<{selected?: boolean; disabled?: boolean; isAssigned?: boolean}> = []; +let mockProcessedData: Array & {selected?: boolean}> = []; let mockShouldUseNarrowLayout = true; jest.mock('@components/ButtonWithDropdownMenu', () => { @@ -127,4 +132,17 @@ describe('WorkspaceCompanyCardsTableControls narrow-layout selection mode', () = expect(screen.queryByTestId('WorkspaceCompanyCardsBulkActions')).toBeNull(); expect(screen.getByTestId('WorkspaceCompanyCardsFilterBar')).toBeTruthy(); }); + + it('exports formula-like card names as quoted text', async () => { + // Given a selected company card with a formula-like custom name + mockProcessedData = [{selected: true, isAssigned: true, cardName: 'card123', customCardName: '=SUM(1,2)', cardholder: {accountID: 1, login: 'alex@example.com'}}]; + jest.mocked(formatMaskedCardName).mockReturnValue('1234'); + renderControls(true); + + // When the CSV export action is selected + await mockButtonProps.current?.options?.find((option) => option.value === 'exportCSV')?.onSelected?.(); + + // Then the existing export still uses one safe text cell for the card name + expect(localFileDownload).toHaveBeenCalledWith('CompanyCards_policy123.csv', expect.stringContaining('\r\nalex@example.com,"\'=SUM(1,2)",1234,'), expect.any(Function)); + }); }); diff --git a/tests/ui/WorkspaceDistanceRatesTableInlineRenameTest.tsx b/tests/ui/WorkspaceDistanceRatesTableInlineRenameTest.tsx new file mode 100644 index 000000000000..3d2d0a59ab05 --- /dev/null +++ b/tests/ui/WorkspaceDistanceRatesTableInlineRenameTest.tsx @@ -0,0 +1,217 @@ +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import {ModalProvider} from '@components/Modal/Global/ModalContext'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; +import WorkspaceDistanceRatesTable from '@components/Tables/WorkspaceDistanceRatesTable'; +import type {DistanceRateTableItemData} from '@components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow'; + +import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; +import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; +import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; + +import {renameDistanceRateInline} from '@libs/actions/Policy/InlineEdit'; + +import {updatePolicyDistanceRateName} from '@userActions/Policy/DistanceRate'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {CustomUnit, Rate} from '@src/types/onyx/Policy'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@userActions/Policy/DistanceRate', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- test-only module mock. jest.requireActual returns an untyped module + const actual = jest.requireActual('@userActions/Policy/DistanceRate'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- test-only module mock. Spreads the real actions and replaces only updatePolicyDistanceRateName + return { + ...actual, + updatePolicyDistanceRateName: jest.fn(), + }; +}); + +// Forces the editable cells into the wide layout, which jsdom's default viewport doesn't naturally satisfy. +jest.mock('@hooks/useResponsiveLayoutOnWideRHP', () => ({ + __esModule: true, + default: () => ({isLargeScreenWidth: true, shouldUseNarrowLayout: false, isInNarrowPaneModal: false}), +})); + +// EditableCell only enables the edit-pencil button while the cell is hovered, which jsdom can't simulate reliably. +jest.mock('@components/Hoverable', () => ({ + __esModule: true, + default: ({children}: {children: ((isHovered: boolean) => React.ReactNode) | React.ReactNode}) => (typeof children === 'function' ? children(true) : children), +})); + +const mockUpdatePolicyDistanceRateName = jest.mocked(updatePolicyDistanceRateName); + +const WIDE_LAYOUT: ResponsiveLayoutResult = { + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: true, + isExtraLargeScreenWidth: false, + isExtraSmallScreenWidth: false, + isSmallScreen: false, + onboardingIsMediumOrLargerScreenWidth: true, + isInLandscapeMode: false, +}; + +const SCREEN_WRAPPER_STATUS = {didScreenTransitionEnd: true, isSafeAreaTopPaddingApplied: true, isSafeAreaBottomPaddingApplied: true}; + +const POLICY_ID = 'policy-1'; +const GOVERNMENT_ATTRIBUTES = {governmentRate: {sourceRateID: 'GB_2026-09-01', rate: 14, startDate: '2026-09-01'}}; + +const BASE_RATE = {rate: 1400, currency: CONST.CURRENCY.USD, enabled: true, startDate: '2026-09-01', attributes: GOVERNMENT_ATTRIBUTES}; +const CARS_RATE: Rate = {...BASE_RATE, customUnitRateID: 'cars', name: 'Cars - From 9/1/26'}; +const BIKES_RATE: Rate = {...BASE_RATE, customUnitRateID: 'bikes', name: 'Bikes - From 9/1/26'}; + +const CUSTOM_UNIT: CustomUnit = { + customUnitID: 'unit-1', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + enabled: true, + rates: {cars: CARS_RATE, bikes: BIKES_RATE}, +}; + +/** Mirrors how PolicyDistanceRatesPage wires the inline name cell to renameDistanceRateInline. */ +function buildRow(rate: Rate): DistanceRateTableItemData { + return { + keyForList: rate.customUnitRateID, + rateID: rate.customUnitRateID, + rate, + formattedRate: '$14.00 / mile', + enabled: true, + isLocked: false, + canEditName: true, + canEditRate: false, + action: jest.fn(), + dismissError: jest.fn(), + onToggleEnabled: jest.fn(), + onRenameName: (newName: string) => { + renameDistanceRateInline(TestHelper.translateLocal, POLICY_ID, CUSTOM_UNIT, rate, newName); + }, + onChangeRate: jest.fn(), + }; +} + +function renderTable() { + return render( + + + + + + + + + + + , + ); +} + +/** Starts editing the name cell, which is the first editable cell in the row, and returns its input. */ +async function startEditingName() { + await waitFor(() => { + expect(screen.getAllByLabelText(TestHelper.translateLocal('common.edit')).length).toBeGreaterThan(0); + }); + const editButton = screen.getAllByLabelText(TestHelper.translateLocal('common.edit')).at(0); + if (!editButton) { + throw new Error('Expected the name edit button'); + } + fireEvent.press(editButton); + await waitForBatchedUpdatesWithAct(); + + const input = screen.getAllByLabelText(TestHelper.translateLocal('common.name')).find((element) => typeof element.props.value === 'string'); + if (!input) { + throw new Error('Expected the name input'); + } + return input; +} + +describe('WorkspaceDistanceRatesTable inline rename', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + }); + jest.spyOn(useResponsiveLayoutModule, 'default').mockReturnValue(WIDE_LAYOUT); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.clearAllMocks(); + }); + + it('prefills the name without the date suffix and saves the new name with the suffix appended', async () => { + // Given a government band whose name carries a date suffix the backend added for uniqueness + renderTable(); + await waitForBatchedUpdatesWithAct(); + + // When the user starts editing its name + const input = await startEditingName(); + + // Then the edit field starts from the name without the suffix, matching the name shown in the table + expect(input.props.value).toBe('Cars'); + + // When the user renames it to "Vans" and blurs + fireEvent.changeText(input, 'Vans'); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then the original suffix is appended back, so Onyx and the API still get the full raw name + expect(mockUpdatePolicyDistanceRateName).toHaveBeenCalledWith(POLICY_ID, CUSTOM_UNIT, [expect.objectContaining({customUnitRateID: 'cars', name: 'Vans - From 9/1/26'})]); + }); + + it('does not rename when the user blurs without typing', async () => { + // Given a government band whose name carries a date suffix + renderTable(); + await waitForBatchedUpdatesWithAct(); + + // When the user starts editing its name and blurs without typing + const input = await startEditingName(); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then nothing is saved, because the full name would be unchanged + expect(mockUpdatePolicyDistanceRateName).not.toHaveBeenCalled(); + }); + + it('does not save a name that duplicates another rate once the suffix is appended', async () => { + // Given a government band and another band whose full name is "Bikes - From 9/1/26" + renderTable(); + await waitForBatchedUpdatesWithAct(); + + // When the user types "Bikes", which only collides once the hidden suffix is added back + const input = await startEditingName(); + fireEvent.changeText(input, 'Bikes'); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then the duplicate check runs against the full name that would be saved, so the rename is dropped + expect(mockUpdatePolicyDistanceRateName).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/WorkspaceExpensifyCardListPageWaitlistTest.tsx b/tests/ui/WorkspaceExpensifyCardListPageWaitlistTest.tsx new file mode 100644 index 000000000000..f2ccf7951d62 --- /dev/null +++ b/tests/ui/WorkspaceExpensifyCardListPageWaitlistTest.tsx @@ -0,0 +1,151 @@ +import {render, screen} from '@testing-library/react-native'; + +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; + +import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; + +import WorkspaceExpensifyCardListPage from '@pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage'; + +import type SCREENS from '@src/SCREENS'; +import type {Card, WorkspaceCardsList} from '@src/types/onyx'; +import type CardOnWaitlist from '@src/types/onyx/CardOnWaitlist'; + +import type ReactNative from 'react-native'; + +import React from 'react'; + +import createMock from '../utils/createMock'; + +const policyID = 'policy123'; +const workspaceAccountID = 200; +const domainFundID = 100; +const memberLogin = 'admin@example.com'; +const card = createMock({cardID: 1, accountID: 1, nameValuePairs: {cardTitle: 'Existing card'}}); +const cardsList = createMock({[card.cardID]: card}); +const route = createMock>({params: {policyID}}); + +let mockCardOnWaitlist: CardOnWaitlist | undefined; +let mockWorkspaceAccountID: number | undefined = workspaceAccountID; + +jest.mock('@components/Button', () => { + const {Text, View} = jest.requireActual('react-native'); + function MockButton({children}: {children: React.ReactNode}) { + return {children}; + } + function MockButtonText({children}: {children: React.ReactNode}) { + return {children}; + } + MockButton.Icon = () => null; + MockButton.Text = MockButtonText; + return {__esModule: true, default: MockButton}; +}); +jest.mock('@components/ButtonWithDropdownMenu', () => () => null); +jest.mock('@components/DelegateNoAccessModalProvider', () => ({useDelegateNoAccessActions: () => ({}), useDelegateNoAccessState: () => ({isDelegateAccessRestricted: false})})); +jest.mock( + '@components/HeaderWithBackButton', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/LockedAccountModalProvider', () => ({useLockedAccountActions: () => ({}), useLockedAccountState: () => ({isAccountLocked: false})})); +jest.mock( + '@components/ScreenWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/Tables/WorkspaceExpensifyCardsTable', () => () => null); +jest.mock('@hooks/useAndroidBackButtonHandler', () => () => {}); +jest.mock('@hooks/useCleanupSelectedOptions', () => () => {}); +jest.mock('@hooks/useConfirmModal', () => ({__esModule: true, default: () => ({showConfirmModal: jest.fn()})})); +jest.mock('@hooks/useCurrencyForExpensifyCard', () => () => 'USD'); +jest.mock('@hooks/useCurrencyList', () => ({useCurrencyListActions: () => ({convertToDisplayString: jest.fn()})})); +jest.mock('@hooks/useEmptyViewHeaderHeight', () => () => 0); +jest.mock('@hooks/useExpensifyCardFeedsForFeedSelector', () => () => ({allFeeds: []})); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({Plus: 'Plus'})})); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key, formatPhoneNumber: (phone: string) => phone})); +jest.mock('@hooks/useMobileSelectionMode', () => () => false); +jest.mock('@hooks/useOnyx', () => (key: string) => [key === `nvp_expensify_onCardWaitlist_${policyID}` ? mockCardOnWaitlist : undefined]); +jest.mock('@hooks/usePersonalDetailByLogin', () => ({usePersonalDetailsByLogins: () => ({[memberLogin]: {accountID: 1, login: memberLogin}})})); +jest.mock('@hooks/usePersonalDetails', () => ({useAllPersonalDetails: () => [{}]})); +jest.mock('@hooks/usePolicy', () => () => ({id: policyID, policyAccountID: mockWorkspaceAccountID, employeeList: {[memberLogin]: {}}})); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => () => ({canWrite: true})); +jest.mock('@hooks/useResponsiveLayout', () => () => ({shouldUseNarrowLayout: false, isMediumScreenWidth: false, isInLandscapeMode: false})); +jest.mock('@hooks/useShouldDisplayButtonsInSeparateLine', () => () => false); +jest.mock('@hooks/useThemeStyles', () => () => ({})); +jest.mock('@hooks/useWindowDimensions', () => () => ({windowHeight: 800})); + +describe('WorkspaceExpensifyCardListPage waitlist', () => { + beforeEach(() => { + mockCardOnWaitlist = undefined; + mockWorkspaceAccountID = workspaceAccountID; + }); + + it('shows Issue card for a provisioned domain feed despite a workspace waitlist', () => { + // Given a domain feed with cards and an outstanding waitlist request for the workspace feed + mockCardOnWaitlist = createMock({domainName: `expensify-${policyID}.exfy`, bankAccountIsNotOnOtherDomain: false, passedLatestChecks: true}); + + // When the admin views the domain feed + render( + , + ); + + // Then the workspace's pending request must not block issuing cards on the live domain feed + expect(screen.getByText('workspace.expensifyCard.issueCard')).toBeOnTheScreen(); + }); + + it('hides Issue card while the selected workspace feed is on the waitlist', () => { + // Given the workspace feed has a pending card application and an existing card list + mockCardOnWaitlist = createMock({domainName: `expensify-${policyID}.exfy`, bankAccountIsNotOnOtherDomain: false}); + + // When the admin views the workspace feed + render( + , + ); + + // Then the pending workspace feed keeps its Issue card action hidden + expect(screen.queryByText('workspace.expensifyCard.issueCard')).not.toBeOnTheScreen(); + }); + + it('shows Issue card on a workspace feed that is not on the waitlist', () => { + // Given a live workspace feed with no pending application + + // When the admin views its cards + render( + , + ); + + // Then the existing workspace issue flow remains available + expect(screen.getByText('workspace.expensifyCard.issueCard')).toBeOnTheScreen(); + }); + + it('keeps Issue card hidden until the workspace account ID is known', () => { + // Given a pending card application while the workspace account ID has not loaded yet + mockCardOnWaitlist = createMock({domainName: `expensify-${policyID}.exfy`}); + mockWorkspaceAccountID = undefined; + + // When the card list renders before the policy finishes loading + render( + , + ); + + // Then the app cannot assume the selected feed is unrelated to the pending application + expect(screen.queryByText('workspace.expensifyCard.issueCard')).not.toBeOnTheScreen(); + }); +}); diff --git a/tests/ui/WorkspaceInitialPageTest.tsx b/tests/ui/WorkspaceInitialPageTest.tsx index f60ff10d5d37..693b4ecb8146 100644 --- a/tests/ui/WorkspaceInitialPageTest.tsx +++ b/tests/ui/WorkspaceInitialPageTest.tsx @@ -67,7 +67,8 @@ jest.mock('@hooks/useWorkspaceAccountID', () => jest.fn()); jest.mock('@components/BlockingViews/FullPageNotFoundView', () => (props: {children: React.ReactNode; shouldShow: boolean}) => mockFullPageNotFoundView(props)); jest.mock('@components/HeaderWithBackButton', () => jest.fn()); jest.mock('@components/HighlightableMenuItem', () => jest.fn()); -jest.mock('@components/Navigation/TabBarBottomContent', () => jest.fn()); +jest.mock('@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps', () => () => ({})); +jest.mock('@components/Navigation/TabBarBottomContent/useTabRootScrollProps', () => () => ({})); jest.mock( '@components/OfflineWithFeedback', () => diff --git a/tests/ui/WorkspaceInviteMessageDEWApproverRowTest.tsx b/tests/ui/WorkspaceInviteMessageDEWApproverRowTest.tsx index 856414213916..4d1ed89c1e14 100644 --- a/tests/ui/WorkspaceInviteMessageDEWApproverRowTest.tsx +++ b/tests/ui/WorkspaceInviteMessageDEWApproverRowTest.tsx @@ -162,7 +162,8 @@ describe('WorkspaceInviteMessageComponent - Approver row on Dynamic External Wor // Then the default approver is forwarded so the backend sets it as the new member's submitsTo expect(addMembersToWorkspaceMock).toHaveBeenCalledTimes(1); - expect(addMembersToWorkspaceMock.mock.lastCall?.at(-1)).toBe(ADMIN_EMAIL); + // The work arrangement is passed after the approver, so the approver is the second-to-last argument. + expect(addMembersToWorkspaceMock.mock.lastCall?.at(-2)).toBe(ADMIN_EMAIL); }); it('hides the Approver row and sends no approver on a DEW workspace with "Hide People Table Columns" set', async () => { diff --git a/tests/ui/WorkspaceInviteMessageFastEditSaveTest.tsx b/tests/ui/WorkspaceInviteMessageFastEditSaveTest.tsx index 06be39b8b2bb..e400b3578530 100644 --- a/tests/ui/WorkspaceInviteMessageFastEditSaveTest.tsx +++ b/tests/ui/WorkspaceInviteMessageFastEditSaveTest.tsx @@ -6,12 +6,14 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {addMembersToWorkspace} from '@libs/actions/Policy/Member'; import {saveFastEditApprovalWorkflow} from '@libs/actions/Workflow'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import WorkspaceInviteMessageComponent from '@pages/workspace/members/WorkspaceInviteMessageComponent'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; import type {ApprovalWorkflowOnyx, Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; @@ -36,10 +38,10 @@ const CAROL_ACCOUNT_ID = 3; const DANA_EMAIL = 'dana@example.com'; const DANA_ACCOUNT_ID = 4; -// "+N more" and the create flow open expenses-from with no nested backTo. The Edit page passes itself as the nested backTo. -const NO_NESTED_BACK_TO = `workspaces/${POLICY_ID}/workflows/approvals/expenses-from` as Route; +// "+N more" and the create flow open expenses-from directly. The Edit page opens it as a dynamic child route. +const DIRECT_EXPENSES_FROM_ROUTE = `workspaces/${POLICY_ID}/workflows/approvals/expenses-from` as Route; const EDIT_PAGE_ROUTE = `workspaces/${POLICY_ID}/workflows/approvals/edit`; -const EDIT_PAGE_BACK_TO = `workspaces/${POLICY_ID}/workflows/approvals/expenses-from?backTo=${encodeURIComponent(EDIT_PAGE_ROUTE)}` as Route; +const EDIT_PAGE_EXPENSES_FROM_ROUTE = createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.path, EDIT_PAGE_ROUTE); jest.mock('@react-navigation/native', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -141,13 +143,13 @@ async function seedHandOff(overrides: Partial) { }); } -async function renderAndPressInvite(backTo: Route) { +async function renderInviteMessage(backTo: Route, policy = buildPolicy()) { render( , ); await waitForBatchedUpdatesWithAct(); +} + +async function renderAndPressInvite(backTo: Route) { + await renderInviteMessage(backTo); fireEvent.press(screen.getByText('Invite')); await waitForBatchedUpdatesWithAct(); } @@ -172,6 +178,7 @@ describe('WorkspaceInviteMessageComponent - "+N more" workflow edit', () => { await Onyx.clear(); await Onyx.set(ONYXKEYS.HAS_LOADED_APP, true); await Onyx.set(ONYXKEYS.IS_LOADING_REPORT_DATA, false); + await Onyx.set(ONYXKEYS.BETAS, []); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy()); await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { [ALICE_ACCOUNT_ID]: buildPersonalDetails(ALICE_EMAIL, ALICE_ACCOUNT_ID, 'alice'), @@ -191,12 +198,25 @@ describe('WorkspaceInviteMessageComponent - "+N more" workflow edit', () => { }); }); + it('hides the work arrangement row when its beta is disabled', async () => { + // Given a home and office workspace where the work arrangement beta is disabled + const policy = { + ...buildPolicy(), + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, isOfficeWorkArrangement: true}, + }; + await renderInviteMessage(DIRECT_EXPENSES_FROM_ROUTE, policy); + + // When the invite confirmation page renders + // Then the unavailable work arrangement editor is not shown + expect(screen.queryByText('Work arrangement')).not.toBeOnTheScreen(); + }); + it('goes back to Workflows and saves the workflow with the invited member on a "+N more" edit', async () => { // Given a "+N more" edit that came here to invite Dana await seedHandOff({isFastEdit: true}); // When the admin sends the invite - await renderAndPressInvite(NO_NESTED_BACK_TO); + await renderAndPressInvite(DIRECT_EXPENSES_FROM_ROUTE); // Then the admin lands on the Workflows page and the workflow is saved with Dana in it expect(goBackMock).toHaveBeenCalledTimes(1); @@ -216,24 +236,25 @@ describe('WorkspaceInviteMessageComponent - "+N more" workflow edit', () => { }); // When the admin sends the invite - await renderAndPressInvite(NO_NESTED_BACK_TO); + await renderAndPressInvite(DIRECT_EXPENSES_FROM_ROUTE); // Then the invite assigns Dana to Alice while the fast-edit save leaves Dana out so it cannot reassign them to Carol - expect(addMembersToWorkspaceMock.mock.lastCall?.at(-1)).toBe(ALICE_EMAIL); + expect(addMembersToWorkspaceMock.mock.lastCall?.at(-2)).toBe(ALICE_EMAIL); expect(saveFastEditApprovalWorkflowMock).toHaveBeenCalledTimes(1); const [params] = saveFastEditApprovalWorkflowMock.mock.calls.at(0) ?? []; expect(params?.approvalWorkflow.members.map((member) => member.email)).toEqual([ALICE_EMAIL, BOB_EMAIL]); }); - it('returns to the Edit page without saving when the invite came from the Edit page', async () => { + it('replaces the invite page with the Approver step without saving when the invite came from the Edit page', async () => { // Given an invite reached from the Edit page, which owns the save await seedHandOff({isFastEdit: false}); // When the admin sends the invite - await renderAndPressInvite(EDIT_PAGE_BACK_TO); + await renderAndPressInvite(EDIT_PAGE_EXPENSES_FROM_ROUTE); - // Then the admin goes back to the Edit page and nothing is saved here - expect(goBackMock.mock.calls.at(0)?.at(0)).toBe(EDIT_PAGE_ROUTE); + // Then the invite page is replaced by the Approver step so it cannot be reopened by a swipe-back, and nothing is saved here + expect(navigateMock).toHaveBeenCalledWith(`workspaces/${POLICY_ID}/workflows/approvals/approver?approverIndex=0`, {forceReplace: true}); + expect(goBackMock).not.toHaveBeenCalled(); expect(saveFastEditApprovalWorkflowMock).not.toHaveBeenCalled(); }); @@ -242,7 +263,7 @@ describe('WorkspaceInviteMessageComponent - "+N more" workflow edit', () => { await seedHandOff({action: CONST.APPROVAL_WORKFLOW.ACTION.CREATE, isInitialFlow: true}); // When the admin sends the invite - await renderAndPressInvite(NO_NESTED_BACK_TO); + await renderAndPressInvite(DIRECT_EXPENSES_FROM_ROUTE); // Then the admin continues to the approver step and nothing is saved yet expect(navigateMock).toHaveBeenCalledTimes(1); diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index a0f7cf1bce9d..21a372233dd2 100644 --- a/tests/ui/WorkspaceMemberDetailsPageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsPageTest.tsx @@ -23,6 +23,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type {Policy} from '@src/types/onyx'; +import type {CompanyAddress, OfficeLocation} from '@src/types/onyx/Policy'; import {PortalProvider} from '@gorhom/portal'; import {NavigationContainer} from '@react-navigation/native'; @@ -238,6 +239,90 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); + const setupOfficeLocations = async ({ + memberOfficeID, + hasOffices = true, + hasCompanyAddress = true, + betaEnabled = true, + }: { + memberOfficeID?: string; + hasOffices?: boolean; + hasCompanyAddress?: boolean; + betaEnabled?: boolean; + }) => { + const typedPolicy = createMock(policy); + const employeeList = { + ...typedPolicy.employeeList, + [invitedEmail]: {...typedPolicy.employeeList?.[invitedEmail], officeID: memberOfficeID}, + }; + const officeLocations: Record = { + OFFICE1: {name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}, isDefault: true}, + OFFICE2: {name: 'New Jersey Office', address: {addressStreet: '900 Asbury Ave', city: 'Ocean City', state: 'NJ', zipCode: '08226', country: 'US'}}, + }; + const companyAddress: CompanyAddress = {addressStreet: '1 Market St', city: 'San Francisco', state: 'CA', zipCode: '94105', country: 'US'}; + const officeLocationsPolicy = createMock({ + ...typedPolicy, + address: hasCompanyAddress ? companyAddress : undefined, + officeLocations: hasOffices ? officeLocations : undefined, + employeeList, + }); + await act(async () => { + await Onyx.set(ONYXKEYS.BETAS, betaEnabled ? [CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS] : [CONST.BETAS.COMMUTER_EXCLUSIONS]); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, officeLocationsPolicy); + }); + const {unmount} = renderPage({policyID: policy.id, accountID: String(invitedAccountID)}); + await waitForBatchedUpdatesWithAct(); + return unmount; + }; + + it.each([ + ['the office assigned to the member', 'OFFICE2', 'New Jersey Office', '900 Asbury Ave, Ocean City, NJ 08226'], + ['the company address when the member is assigned to it', CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, 'Company address', '1 Market St, San Francisco, CA 94105'], + ['the primary office when the member has no office', undefined, 'Headquarters', '88 Kearny St, San Francisco, CA 94108'], + ])('shows %s as the office location', async (_description, memberOfficeID, name, address) => { + // Given a workspace with offices and a member with the given office assignment + const unmount = await setupOfficeLocations({memberOfficeID}); + + // Then the office location item names that office and its address + const row = await screen.findByTestId('member-office-location-menu-item'); + expect(within(row).getByText(TestHelper.translateLocal('workspace.people.officeLocationWithAddress', {name, address}))).toBeOnTheScreen(); + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('shows the company address as the office location when the workspace has no other office', async () => { + // Given a workspace whose only office is its company address + const unmount = await setupOfficeLocations({hasOffices: false}); + + // Then the office location item shows the company address, which counts as an office as it does on the workspace overview + const row = await screen.findByTestId('member-office-location-menu-item'); + expect( + within(row).getByText(TestHelper.translateLocal('workspace.people.officeLocationWithAddress', {name: 'Company address', address: '1 Market St, San Francisco, CA 94105'})), + ).toBeOnTheScreen(); + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('hides the office location item when the workspace has neither an office nor a company address', async () => { + // Given a workspace without any office to assign members to + const unmount = await setupOfficeLocations({hasOffices: false, hasCompanyAddress: false}); + + // Then the office location item is hidden + expect(screen.queryByTestId('member-office-location-menu-item')).not.toBeOnTheScreen(); + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('hides the office location item without the commuter exclusions arrangements beta', async () => { + // Given a workspace with offices and the commuter exclusions beta, but not the arrangements beta that office assignments belong to + const unmount = await setupOfficeLocations({betaEnabled: false}); + + // Then the office location item is hidden + expect(screen.queryByTestId('member-office-location-menu-item')).not.toBeOnTheScreen(); + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should show the member details when the route accountID matches their personal details entry', async () => { const {unmount} = renderPage({policyID: policy.id, accountID: String(invitedAccountID)}); await waitForBatchedUpdatesWithAct(); diff --git a/tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx b/tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx new file mode 100644 index 000000000000..f599afdd8d82 --- /dev/null +++ b/tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx @@ -0,0 +1,167 @@ +import {act, render} from '@testing-library/react-native'; + +import Button from '@components/Button'; +import SelectionList from '@components/SelectionList'; + +import {updateEmployeeOfficeLocation} from '@libs/actions/Policy/DistanceRate'; +import Navigation from '@libs/Navigation/Navigation'; +import {canMemberWrite} from '@libs/PolicyUtils'; + +import WorkspaceMemberOfficeLocationPage from '@pages/workspace/members/WorkspaceMemberOfficeLocationPage'; + +import ROUTES from '@src/ROUTES'; +import type {PersonalDetailsList, Policy} from '@src/types/onyx'; +import type {CompanyAddress, OfficeLocation} from '@src/types/onyx/Policy'; + +import type React from 'react'; +import type {PropsWithChildren} from 'react'; + +import createMock from '../utils/createMock'; + +jest.mock('@components/Button', () => + Object.assign( + jest.fn(() => null), + {Text: () => null, KeyboardShortcut: () => null}, + ), +); +jest.mock('@components/FixedFooter', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); +jest.mock('@components/Text', () => jest.fn(() => null)); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'admin@example.com'}))); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key, localeCompare: (a: string, b: string) => a.localeCompare(b)}))); +jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => true}))); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); +jest.mock('@libs/actions/Policy/DistanceRate', () => ({updateEmployeeOfficeLocation: jest.fn()})); +jest.mock('@libs/PolicyUtils', () => ({ + ...jest.requireActual>('@libs/PolicyUtils'), + canMemberWrite: jest.fn(() => true), +})); +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: ({children}: PropsWithChildren) => children})); +jest.mock('@pages/workspace/withPolicyAndFullscreenLoading', () => (Component: React.ComponentType) => Component); + +const companyAddress: CompanyAddress = {addressStreet: '1 Market St', city: 'San Francisco', state: 'CA', zipCode: '94105', country: 'US'}; +const headquartersAndNewJerseyOffices: Record = { + OFFICE1: {name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}, isDefault: true}, + OFFICE2: {name: 'New Jersey Office', address: {addressStreet: '900 Asbury Ave', city: 'Ocean City', state: 'NJ', zipCode: '08226', country: 'US'}}, +}; + +type MockOfficeLocationOption = {keyForList: string; isSelected?: boolean}; + +type MockSelectionListProps = { + data: MockOfficeLocationOption[]; + onSelectRow?: (item: MockOfficeLocationOption) => void; +}; + +type MockButtonProps = { + onPress?: () => void; + isDisabled?: boolean; +}; + +type WorkspaceMemberOfficeLocationPageTestProps = React.ComponentProps & {policy: Policy; personalDetails: PersonalDetailsList}; + +describe('WorkspaceMemberOfficeLocationPage', () => { + const policyID = 'policy123'; + const accountID = 12345; + const memberLogin = 'member@example.com'; + const personalDetails = {[accountID]: {login: memberLogin}}; + const mockedSelectionList = jest.mocked(SelectionList); + const getSelectionListProps = () => mockedSelectionList.mock.lastCall?.[0] as MockSelectionListProps | undefined; + const getConfirmButtonProps = () => jest.mocked(Button).mock.lastCall?.[0] as MockButtonProps | undefined; + const getSelectedOfficeIDs = () => + getSelectionListProps() + ?.data.filter((option) => option.isSelected) + .map((option) => option.keyForList); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(canMemberWrite).mockReturnValue(true); + }); + + const renderPage = (officeID?: string) => { + const policy = { + id: policyID, + address: companyAddress, + officeLocations: headquartersAndNewJerseyOffices, + employeeList: {[memberLogin]: {email: memberLogin, officeID}}, + }; + const props = createMock({ + policy: createMock(policy), + personalDetails, + route: {params: {policyID, accountID: String(accountID)}}, + }); + // The HOC mock exposes the wrapped screen directly, which accepts the policy prop that the production HOC normally injects. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the test HOC mock returns the unwrapped component with its injected policy props restored + const UnwrappedPage = WorkspaceMemberOfficeLocationPage as React.ComponentType; + render(); + return props.policy; + }; + + it('lists the company address and the offices, with the primary office selected for a member without one', () => { + // Given a member who follows the primary office + renderPage(); + + // When the page lists the offices + // Then the company address and every office are offered, and the primary office is the selected one and can be confirmed + expect(getSelectionListProps()?.data.map((option) => option.keyForList)).toEqual(['1', 'OFFICE1', 'OFFICE2']); + expect(getSelectedOfficeIDs()).toEqual(['OFFICE1']); + expect(getConfirmButtonProps()?.isDisabled).toBe(false); + }); + + it('returns to the member details without a change when the member’s office is confirmed', () => { + // Given a member who follows the primary office + renderPage(); + + // When the office they follow is confirmed without choosing another one + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then nothing is sent, so they keep following whichever office is primary, and the member details are shown + expect(updateEmployeeOfficeLocation).not.toHaveBeenCalled(); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); + }); + + it('does nothing for a user who cannot manage members', () => { + // Given a user without write access to the workspace members + jest.mocked(canMemberWrite).mockReturnValue(false); + renderPage(); + + // When they choose another office and confirm + act(() => { + getSelectionListProps()?.onSelectRow?.({keyForList: 'OFFICE2'}); + }); + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then nothing is sent, since changing office assignments needs write access to the members + expect(updateEmployeeOfficeLocation).not.toHaveBeenCalled(); + }); + + it('assigns the chosen office once it is confirmed and returns to the member details', () => { + // Given a member who follows the primary office + const policy = renderPage(); + + // When the company address is chosen + act(() => { + getSelectionListProps()?.onSelectRow?.({keyForList: '1'}); + }); + + // Then it is selected but nothing is sent before confirming + expect(getSelectedOfficeIDs()).toEqual(['1']); + expect(updateEmployeeOfficeLocation).not.toHaveBeenCalled(); + + // When the choice is confirmed + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then the member is assigned to it and the member details are shown + expect(updateEmployeeOfficeLocation).toHaveBeenCalledWith(policy, [accountID], '1', personalDetails); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); + }); +}); diff --git a/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx b/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx new file mode 100644 index 000000000000..7d4a783e5d66 --- /dev/null +++ b/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx @@ -0,0 +1,181 @@ +import {act, render} from '@testing-library/react-native'; + +import Button from '@components/Button'; +import SelectionList from '@components/SelectionList'; + +import useOnyx from '@hooks/useOnyx'; + +import {updateEmployeeOfficeLocation} from '@libs/actions/Policy/DistanceRate'; +import {clearWorkspaceMembersSelectedForBulkEdit} from '@libs/actions/Policy/Member'; +import Navigation from '@libs/Navigation/Navigation'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import WorkspaceMembersChangeOfficeLocationPage from '@pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage'; + +import ROUTES from '@src/ROUTES'; +import type {PersonalDetailsList, Policy} from '@src/types/onyx'; +import type {OfficeLocation} from '@src/types/onyx/Policy'; + +import type React from 'react'; +import type {PropsWithChildren} from 'react'; + +import createMock from '../utils/createMock'; + +jest.mock('@components/Button', () => + Object.assign( + jest.fn(() => null), + {Text: () => null, KeyboardShortcut: () => null}, + ), +); +jest.mock('@components/FixedFooter', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); +jest.mock('@components/Text', () => jest.fn(() => null)); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'admin@example.com'}))); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key, localeCompare: (a: string, b: string) => a.localeCompare(b)}))); +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => true}))); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); +jest.mock('@libs/actions/Policy/DistanceRate', () => ({updateEmployeeOfficeLocation: jest.fn()})); +jest.mock('@libs/actions/Policy/Member', () => ({clearWorkspaceMembersSelectedForBulkEdit: jest.fn()})); +jest.mock('@libs/PolicyUtils', () => ({ + ...jest.requireActual>('@libs/PolicyUtils'), + canMemberWrite: jest.fn(() => true), +})); +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: jest.fn(({children}: PropsWithChildren) => children)})); +jest.mock('@pages/workspace/withPolicyAndFullscreenLoading', () => (Component: React.ComponentType) => Component); + +const headquartersAndNewJerseyOffices: Record = { + OFFICE1: {name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}, isDefault: true}, + OFFICE2: {name: 'New Jersey Office', address: {addressStreet: '900 Asbury Ave', city: 'Ocean City', state: 'NJ', zipCode: '08226', country: 'US'}}, +}; + +type MockOfficeLocationOption = {keyForList: string; isSelected?: boolean}; + +type MockSelectionListProps = { + data: MockOfficeLocationOption[]; + onSelectRow?: (item: MockOfficeLocationOption) => void; +}; + +type MockButtonProps = { + onPress?: () => void; + isDisabled?: boolean; +}; + +type WorkspaceMembersChangeOfficeLocationPageTestProps = React.ComponentProps & { + policy: Policy; + personalDetails: PersonalDetailsList; +}; + +describe('WorkspaceMembersChangeOfficeLocationPage', () => { + const policyID = 'policy123'; + const member1AccountID = 1; + const member2AccountID = 2; + const member1Login = 'member1@example.com'; + const member2Login = 'member2@example.com'; + const personalDetails = {[member1AccountID]: {login: member1Login}, [member2AccountID]: {login: member2Login}}; + const getSelectionListProps = () => jest.mocked(SelectionList).mock.lastCall?.[0] as MockSelectionListProps | undefined; + const getConfirmButtonProps = () => jest.mocked(Button).mock.lastCall?.[0] as MockButtonProps | undefined; + const getSelectedOfficeIDs = () => + getSelectionListProps() + ?.data.filter((option) => option.isSelected) + .map((option) => option.keyForList); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useOnyx).mockReturnValue([{policyID, accountIDs: [member1AccountID, member2AccountID]}, {status: 'loaded'}]); + }); + + const renderPage = (member1OfficeID?: string, member2OfficeID?: string) => { + const policy = { + id: policyID, + officeLocations: headquartersAndNewJerseyOffices, + employeeList: { + [member1Login]: {email: member1Login, officeID: member1OfficeID}, + [member2Login]: {email: member2Login, officeID: member2OfficeID}, + }, + }; + const props = createMock({ + policy: createMock(policy), + personalDetails, + route: {params: {policyID}}, + }); + // The HOC mock exposes the wrapped screen directly, which accepts the policy prop that the production HOC normally injects. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the test HOC mock returns the unwrapped component with its injected policy props restored + const UnwrappedPage = WorkspaceMembersChangeOfficeLocationPage as React.ComponentType; + render(); + return props.policy; + }; + + it('selects the office the selected members share', () => { + // Given one member assigned to the primary office and one following it + renderPage('OFFICE1'); + + // When the page lists the offices + // Then their shared office is selected and can be confirmed + expect(getSelectedOfficeIDs()).toEqual(['OFFICE1']); + expect(getConfirmButtonProps()?.isDisabled).toBe(false); + }); + + it('selects nothing while the selected members are in different offices', () => { + // Given members in different offices + renderPage('OFFICE1', 'OFFICE2'); + + // When the page lists the offices + // Then no office is selected and there is nothing to confirm yet + expect(getSelectedOfficeIDs()).toEqual([]); + expect(getConfirmButtonProps()?.isDisabled).toBe(true); + }); + + it('assigns the chosen office to the selected members outside it and hands back the selection once the page closes', () => { + // Given members in different offices + const policy = renderPage('OFFICE1', 'OFFICE2'); + + // When the New Jersey office is chosen and confirmed + act(() => { + getSelectionListProps()?.onSelectRow?.({keyForList: 'OFFICE2'}); + }); + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then only the member outside it is assigned to it, and the selection is cleared after returning to the members + expect(updateEmployeeOfficeLocation).toHaveBeenCalledWith(policy, [member1AccountID], 'OFFICE2', personalDetails); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); + }); + + it('keeps members following the primary office unassigned when their shared office is confirmed', () => { + // Given both members follow the primary office + renderPage(); + + // When the office they share is confirmed without choosing another one + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then nobody is assigned to it, so both keep following whichever office is primary, and the page still closes + expect(updateEmployeeOfficeLocation).not.toHaveBeenCalled(); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); + }); + + it('leaves out members picked on another workspace', () => { + // Given members picked on another workspace's member list who also belong to this workspace + jest.mocked(useOnyx).mockReturnValue([{policyID: 'otherPolicy', accountIDs: [member1AccountID, member2AccountID]}, {status: 'loaded'}]); + renderPage('OFFICE1', 'OFFICE2'); + + // When an office is chosen and confirmed on this workspace's page + act(() => { + getSelectionListProps()?.onSelectRow?.({keyForList: 'OFFICE2'}); + }); + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then the page is blocked and nobody is assigned to the office + expect(jest.mocked(AccessOrNotFoundWrapper).mock.lastCall?.[0].shouldBeBlocked).toBe(true); + expect(updateEmployeeOfficeLocation).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx b/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx new file mode 100644 index 000000000000..3ec6d2e050a1 --- /dev/null +++ b/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx @@ -0,0 +1,167 @@ +import {act, render} from '@testing-library/react-native'; + +import Button from '@components/Button'; +import WorkArrangementSelector from '@components/WorkArrangementSelector'; + +import useOnyx from '@hooks/useOnyx'; + +import {setEmployeeWorkArrangement} from '@libs/actions/Policy/DistanceRate'; +import {clearWorkspaceMembersSelectedForBulkEdit} from '@libs/actions/Policy/Member'; +import Navigation from '@libs/Navigation/Navigation'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import WorkspaceMembersChangeWorkArrangementPage from '@pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {PersonalDetailsList, Policy} from '@src/types/onyx'; + +import type React from 'react'; +import type {PropsWithChildren} from 'react'; + +import createMock from '../utils/createMock'; + +jest.mock('@components/Button', () => + Object.assign( + jest.fn(() => null), + {Text: () => null, KeyboardShortcut: () => null}, + ), +); +jest.mock('@components/FixedFooter', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/ScrollView', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/Text', () => jest.fn(() => null)); +jest.mock('@components/WorkArrangementSelector', () => jest.fn(() => null)); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'admin@example.com'}))); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => true}))); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); +jest.mock('@libs/actions/Policy/DistanceRate', () => ({setEmployeeWorkArrangement: jest.fn()})); +jest.mock('@libs/actions/Policy/Member', () => ({clearWorkspaceMembersSelectedForBulkEdit: jest.fn()})); +jest.mock('@libs/PolicyUtils', () => ({canMemberWrite: jest.fn(() => true)})); +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: jest.fn(({children}: PropsWithChildren) => children)})); +jest.mock('@pages/workspace/withPolicyAndFullscreenLoading', () => (Component: React.ComponentType) => Component); + +type MockWorkArrangementSelectorProps = { + isOffice: boolean | undefined; + onSelect: (isOffice: boolean) => void; +}; + +type MockButtonProps = { + onPress?: () => void; + isDisabled?: boolean; +}; + +type WorkspaceMembersChangeWorkArrangementPageTestProps = React.ComponentProps & { + policy: Policy; + personalDetails: PersonalDetailsList; +}; + +describe('WorkspaceMembersChangeWorkArrangementPage', () => { + const policyID = 'policy123'; + const member1AccountID = 1; + const member2AccountID = 2; + const member1Login = 'member1@example.com'; + const member2Login = 'member2@example.com'; + const personalDetails = {[member1AccountID]: {login: member1Login}, [member2AccountID]: {login: member2Login}}; + const getSelectorProps = () => jest.mocked(WorkArrangementSelector).mock.lastCall?.[0] as MockWorkArrangementSelectorProps | undefined; + const getConfirmButtonProps = () => jest.mocked(Button).mock.lastCall?.[0] as MockButtonProps | undefined; + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useOnyx).mockReturnValue([{policyID, accountIDs: [member1AccountID, member2AccountID]}, {status: 'loaded'}]); + }); + + const renderPage = (member1IsOffice?: boolean, member2IsOffice?: boolean) => { + const policy = { + id: policyID, + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, isOfficeWorkArrangement: false}, + employeeList: { + [member1Login]: {email: member1Login, hasOfficeWorkArrangement: member1IsOffice}, + [member2Login]: {email: member2Login, hasOfficeWorkArrangement: member2IsOffice}, + }, + }; + const props = createMock({ + policy: createMock(policy), + personalDetails, + route: {params: {policyID}}, + }); + // The HOC mock exposes the wrapped screen directly, which accepts the policy prop that the production HOC normally injects. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the test HOC mock returns the unwrapped component with its injected policy props restored + const UnwrappedPage = WorkspaceMembersChangeWorkArrangementPage as React.ComponentType; + render(); + return props.policy; + }; + + it('selects the arrangement the selected members share', () => { + // Given a remote member and one following the remote workspace default + renderPage(false); + + // When the page shows the arrangements + // Then their shared arrangement is selected and can be confirmed + expect(getSelectorProps()?.isOffice).toBe(false); + expect(getConfirmButtonProps()?.isDisabled).toBe(false); + }); + + it('selects nothing while the selected members have different arrangements', () => { + // Given an office-based member and a remote one + renderPage(true, false); + + // When the page shows the arrangements + // Then no arrangement is selected and there is nothing to confirm yet + expect(getSelectorProps()?.isOffice).toBeUndefined(); + expect(getConfirmButtonProps()?.isDisabled).toBe(true); + }); + + it('applies the chosen arrangement to the selected members without it and hands back the selection once the page closes', () => { + // Given an office-based member and a remote one + const policy = renderPage(true, false); + + // When office-based is chosen and confirmed + act(() => { + getSelectorProps()?.onSelect(true); + }); + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then only the remote member is made office-based, and the selection is cleared after returning to the members + expect(setEmployeeWorkArrangement).toHaveBeenCalledWith(policy, [member2AccountID], true, personalDetails); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); + }); + + it('keeps members following the workspace default without an arrangement of their own when their shared arrangement is confirmed', () => { + // Given both members follow the remote workspace default + renderPage(); + + // When the arrangement they share is confirmed without choosing the other one + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then neither member gets an arrangement of their own, so both keep following the workspace default, and the page still closes + expect(setEmployeeWorkArrangement).not.toHaveBeenCalled(); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); + }); + + it('leaves out members picked on another workspace', () => { + // Given members picked on another workspace's member list who also belong to this workspace + jest.mocked(useOnyx).mockReturnValue([{policyID: 'otherPolicy', accountIDs: [member1AccountID, member2AccountID]}, {status: 'loaded'}]); + renderPage(true, false); + + // When office-based is chosen and confirmed on this workspace's page + act(() => { + getSelectorProps()?.onSelect(true); + }); + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then the page is blocked and nobody's arrangement changes + expect(jest.mocked(AccessOrNotFoundWrapper).mock.lastCall?.[0].shouldBeBlocked).toBe(true); + expect(setEmployeeWorkArrangement).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/WorkspaceMembersTableTest.tsx b/tests/ui/WorkspaceMembersTableTest.tsx new file mode 100644 index 000000000000..261cb0474aa6 --- /dev/null +++ b/tests/ui/WorkspaceMembersTableTest.tsx @@ -0,0 +1,118 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import {ModalProvider} from '@components/Modal/Global/ModalContext'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; +import type {TableHandle} from '@components/Table'; +import type {WorkspaceMemberRowData, WorkspaceMembersTableColumnKey} from '@components/Tables/WorkspaceMembersTable'; +import WorkspaceMembersTable from '@components/Tables/WorkspaceMembersTable'; + +import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +// Per-column sort header buttons only render in the wide layout, since TableHeader renders just the table title on +// narrow, so the layout hook is pinned to wide rather than left at whatever the test environment reports. +jest.mock('@hooks/useResponsiveLayout', () => ({ + __esModule: true, + default: jest.fn(() => ({shouldUseNarrowLayout: false, isSmallScreenWidth: false, isMediumScreenWidth: false})), +})); + +const SCREEN_WRAPPER_STATUS = {didScreenTransitionEnd: true, isSafeAreaTopPaddingApplied: true, isSafeAreaBottomPaddingApplied: true}; + +function buildMember(overrides: Partial & Pick): WorkspaceMemberRowData { + return { + role: CONST.POLICY.ROLE.USER, + shouldShowEmployeeUserID: false, + shouldShowEmployeePayrollID: false, + invitedSecondaryLogin: '', + action: jest.fn(), + dismissError: jest.fn(), + ...overrides, + }; +} + +function renderTable(members: WorkspaceMemberRowData[], tableRef: React.RefObject | null>) { + return render( + + + + + + + + + , + ); +} + +describe('WorkspaceMembersTable', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + }); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + describe('bank account sorting', () => { + it('sorts members by their bank account last four, with members without one last in both directions', async () => { + // Given members where only some have a bank account, since the ones without must sort last + const members: WorkspaceMemberRowData[] = [ + buildMember({keyForList: 'nina', login: 'nina@example.com', email: 'nina@example.com', name: 'Nina', accountID: 1, bankAccountLastFour: '5382'}), + buildMember({keyForList: 'carol', login: 'carol@example.com', email: 'carol@example.com', name: 'Carol', accountID: 2}), + buildMember({keyForList: 'adam', login: 'adam@example.com', email: 'adam@example.com', name: 'Adam', accountID: 3, bankAccountLastFour: '1809'}), + buildMember({keyForList: 'brian', login: 'brian@example.com', email: 'brian@example.com', name: 'Brian', accountID: 4}), + ]; + + const tableRef = React.createRef>(); + renderTable(members, tableRef); + await waitForBatchedUpdatesWithAct(); + + // When the Bank account header is pressed, which sorts descending first + const [bankAccountHeader] = screen.getAllByLabelText(TestHelper.translateLocal('common.bankAccount')); + fireEvent.press(bankAccountHeader); + + // Then the last fours go high to low and the members without one stay at the bottom + expect(tableRef.current?.getActiveSorting()).toEqual({columnKey: 'bankAccount', order: 'desc'}); + expect(tableRef.current?.getProcessedData().map((item) => item.name)).toEqual(['Nina', 'Adam', 'Carol', 'Brian']); + + // When the header is pressed again to flip the order + fireEvent.press(bankAccountHeader); + + // Then the last fours go low to high and the members without one are still at the bottom + expect(tableRef.current?.getActiveSorting()).toEqual({columnKey: 'bankAccount', order: 'asc'}); + expect(tableRef.current?.getProcessedData().map((item) => item.name)).toEqual(['Adam', 'Nina', 'Brian', 'Carol']); + }); + }); +}); diff --git a/tests/ui/WorkspaceMembersTest.tsx b/tests/ui/WorkspaceMembersTest.tsx index e53a26624b2c..25b20ad8c6ad 100644 --- a/tests/ui/WorkspaceMembersTest.tsx +++ b/tests/ui/WorkspaceMembersTest.tsx @@ -1,6 +1,5 @@ import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react-native'; -import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import ComposeProviders from '@components/ComposeProviders'; import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; @@ -13,15 +12,21 @@ import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; +import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; -import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; +import type {SettingsNavigatorParamList, WorkspaceSplitNavigatorParamList} from '@navigation/types'; +import WorkspaceMembersRolePage from '@pages/workspace/members/WorkspaceMembersRolePage'; import WorkspaceMembersPage from '@pages/workspace/WorkspaceMembersPage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type {OfficeLocation} from '@src/types/onyx/Policy'; + +import type {ValueOf} from 'type-fest'; import {PortalProvider} from '@gorhom/portal'; import {NavigationContainer} from '@react-navigation/native'; @@ -58,6 +63,41 @@ const renderPage = (initialRouteName: typeof SCREENS.WORKSPACE.MEMBERS, initialP ); }; +const RoleStack = createPlatformStackNavigator(); + +const renderRolePage = (policyID: string) => { + return render( + + + + + + + + + , + ); +}; + +/** + * The wide layout offers the bulk actions in the floating bar over the table, which gives the first few an inline + * button and moves the rest behind "More". Opening that menu puts every action on screen at once, whichever half of + * the bar it landed in. Only the bar's button carries this label, so the page's own "More" button is not matched. + */ +const openBulkActions = async () => { + const moreButton = screen.queryByLabelText(TestHelper.translateLocal('common.more')); + if (!moreButton) { + return; + } + + fireEvent.press(moreButton); + await waitForBatchedUpdatesWithAct(); +}; + const selectCheckboxByMemberName = (memberName: string) => { const memberEmailByName: Record = { Owner: 'owner@gmail.com', @@ -83,7 +123,6 @@ describe('WorkspaceMembers', () => { const selfAccountID = 1206; const selfEmail = 'test@example.com'; const ADMIN_OPTION = 'Admin User'; - const AUDITOR_OPTION = 'Auditor User'; const USER_OPTION = 'Member User'; const policy = { ...LHNTestUtils.getFakePolicy(), @@ -136,399 +175,235 @@ describe('WorkspaceMembers', () => { }); describe('Changing roles options', () => { - it('should show Make member/auditor/card admin when admin is selected', async () => { + it('should offer one Change role action instead of one action per role', async () => { + // Given the members page with an admin selected const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); await waitForBatchedUpdatesWithAct(); - // Wait for initial render and verify members are visible await waitFor(() => { expect(screen.getByText(ADMIN_OPTION)).toBeOnTheScreen(); }); - // Select admin option by clicking the checkbox selectCheckboxByMemberName('Admin'); - const dropdownMenuButtonTestID = 'WorkspaceMembersPage-header-dropdown-menu-button'; - // Wait for selection mode to be active and click the dropdown menu button - await waitFor(() => { - expect(screen.getByTestId(dropdownMenuButtonTestID)).toBeOnTheScreen(); - }); - - // Click the "1 selected" button to open the menu - const dropdownButton = screen.getByTestId(dropdownMenuButtonTestID); - const bulkActionsDropdown = screen.UNSAFE_getAllByType(ButtonWithDropdownMenu).find(({props}) => props.testID === dropdownMenuButtonTestID); - expect(bulkActionsDropdown?.props.shouldPopoverUseScrollView).toBe(true); - fireEvent.press(dropdownButton); - - await waitForBatchedUpdatesWithAct(); - - // Wait for menu items to be visible - await waitFor(() => { - const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1}); - expect(screen.getByText(makeMemberText)).toBeOnTheScreen(); - }); - - // Find and verify "Make member" dropdown menu item - const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1}); - const makeMemberMenuItem = screen.getByTestId(`PopoverMenuItem-${makeMemberText}`); - expect(makeMemberMenuItem).toBeOnTheScreen(); + // When the actions the bar moved behind "More" are opened + await openBulkActions(); - // Find and verify "Make auditor" dropdown menu item - const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 1}); - const makeAuditorMenuItem = screen.getByTestId(`PopoverMenuItem-${makeAuditorText}`); - expect(makeAuditorMenuItem).toBeOnTheScreen(); - - // Guest role assignment is temporarily blocked, so the "Make guest" item is not present - const makeGuestText = TestHelper.translateLocal('workspace.people.makeGuest', {count: 1}); - const makeGuestMenuItem = screen.queryByTestId(`PopoverMenuItem-${makeGuestText}`); - expect(makeGuestMenuItem).not.toBeOnTheScreen(); - - // Find and verify "Make card admin" dropdown menu item - const makeCardAdminText = TestHelper.translateLocal('workspace.people.makeCardAdmin', {count: 1}); - const makeCardAdminMenuItem = screen.getByTestId(`PopoverMenuItem-${makeCardAdminText}`); - expect(makeCardAdminMenuItem).toBeOnTheScreen(); - - // Find and verify "Make admin" dropdown menu item is not present - const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1}); - const makeAdminMenuItem = screen.queryByTestId(`PopoverMenuItem-${makeAdminText}`); - expect(makeAdminMenuItem).not.toBeOnTheScreen(); + // Then the bar offers Change role alongside Remove, and none of the per-role actions it replaced + expect(screen.getByText(TestHelper.translateLocal('workspace.people.changeRole'))).toBeOnTheScreen(); + expect(screen.getByText(TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1}))).toBeOnTheScreen(); + expect(screen.queryByText(TestHelper.translateLocal('workspace.people.makeMember', {count: 1}))).not.toBeOnTheScreen(); + expect(screen.queryByText(TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1}))).not.toBeOnTheScreen(); unmount(); await waitForBatchedUpdatesWithAct(); }); - it('should show Make admin/auditor/card admin when member is selected', async () => { + it('should not offer Change role on a workspace without paid roles', async () => { + // Given a Submit workspace, which has no roles to move a member between + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, {type: CONST.POLICY.TYPE.SUBMIT}); + }); + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); await waitForBatchedUpdatesWithAct(); - // Wait for initial render and verify members are visible await waitFor(() => { expect(screen.getByText(USER_OPTION)).toBeOnTheScreen(); }); - // Select member option by clicking the checkbox + // When a member is selected and the actions are opened selectCheckboxByMemberName('Member'); - const dropdownMenuButtonTestID = 'WorkspaceMembersPage-header-dropdown-menu-button'; + await openBulkActions(); - // Wait for selection mode to be active and click the dropdown menu button - await waitFor(() => { - expect(screen.getByTestId(dropdownMenuButtonTestID)).toBeOnTheScreen(); - }); - - // Click the "1 selected" button to open the menu - const dropdownButton = screen.getByTestId(dropdownMenuButtonTestID); - fireEvent.press(dropdownButton); - - await waitForBatchedUpdatesWithAct(); - - // Wait for menu items to be visible - await waitFor(() => { - const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1}); - expect(screen.getByText(makeAdminText)).toBeOnTheScreen(); - }); - - // Find and verify "Make admin" dropdown menu item - const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1}); - const makeAdminMenuItem = screen.getByTestId(`PopoverMenuItem-${makeAdminText}`); - expect(makeAdminMenuItem).toBeOnTheScreen(); - - // Find and verify "Make auditor" dropdown menu item - const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 1}); - const makeAuditorMenuItem = screen.getByTestId(`PopoverMenuItem-${makeAuditorText}`); - expect(makeAuditorMenuItem).toBeOnTheScreen(); - - // Find and verify "Make card admin" dropdown menu item - const makeCardAdminText = TestHelper.translateLocal('workspace.people.makeCardAdmin', {count: 1}); - const makeCardAdminMenuItem = screen.getByTestId(`PopoverMenuItem-${makeCardAdminText}`); - expect(makeCardAdminMenuItem).toBeOnTheScreen(); - - // Find and verify "Make member" dropdown menu item is not present - const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1}); - const makeMemberMenuItem = screen.queryByTestId(`PopoverMenuItem-${makeMemberText}`); - expect(makeMemberMenuItem).not.toBeOnTheScreen(); + // Then only Remove is offered + expect(screen.getByText(TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1}))).toBeOnTheScreen(); + expect(screen.queryByText(TestHelper.translateLocal('workspace.people.changeRole'))).not.toBeOnTheScreen(); unmount(); await waitForBatchedUpdatesWithAct(); }); + }); - it('should show Make member/admin/card admin when auditor is selected', async () => { - const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); - await waitForBatchedUpdatesWithAct(); - - // Wait for initial render and verify members are visible - await waitFor(() => { - expect(screen.getByText(AUDITOR_OPTION)).toBeOnTheScreen(); - }); - - // Select auditor option by clicking the checkbox - selectCheckboxByMemberName('Auditor'); - const dropdownMenuButtonTestID = 'WorkspaceMembersPage-header-dropdown-menu-button'; + describe('Role selection screen', () => { + const roleName = (role: ValueOf) => TestHelper.translateLocal('workspace.common.roleName', role); + const memberRoleName = () => TestHelper.translateLocal('common.member'); - // Wait for selection mode to be active and click the dropdown menu button - await waitFor(() => { - expect(screen.getByTestId(dropdownMenuButtonTestID)).toBeOnTheScreen(); + const selectForRoleChange = async (logins: string[]) => { + await act(async () => { + await Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, {policyID: policy.id, logins}); }); + }; - // Click the "1 selected" button to open the menu - const dropdownButton = screen.getByTestId(dropdownMenuButtonTestID); - fireEvent.press(dropdownButton); + it('should offer every role the current member may assign', async () => { + // Given an admin selected for a role change on a Control workspace + await selectForRoleChange([adminEmail]); + const {unmount} = renderRolePage(policy.id); await waitForBatchedUpdatesWithAct(); - // Wait for menu items to be visible + // Then the screen lists all of the Control roles, with the selection's shared role already picked await waitFor(() => { - const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1}); - expect(screen.getByText(makeMemberText)).toBeOnTheScreen(); + expect(screen.getByText(roleName(CONST.POLICY.ROLE.ADMIN))).toBeOnTheScreen(); }); - - // Find and verify "Make member" dropdown menu item - const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1}); - const makeMemberMenuItem = screen.getByTestId(`PopoverMenuItem-${makeMemberText}`); - expect(makeMemberMenuItem).toBeOnTheScreen(); - - // Find and verify "Make admin" dropdown menu item - const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1}); - const makeAdminMenuItem = screen.getByTestId(`PopoverMenuItem-${makeAdminText}`); - expect(makeAdminMenuItem).toBeOnTheScreen(); - - // Find and verify "Make card admin" dropdown menu item - const makeCardAdminText = TestHelper.translateLocal('workspace.people.makeCardAdmin', {count: 1}); - const makeCardAdminMenuItem = screen.getByTestId(`PopoverMenuItem-${makeCardAdminText}`); - expect(makeCardAdminMenuItem).toBeOnTheScreen(); - - // Find and verify "Make auditor" dropdown menu item is not present - const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 1}); - const makeAuditorMenuItem = screen.queryByTestId(`PopoverMenuItem-${makeAuditorText}`); - expect(makeAuditorMenuItem).not.toBeOnTheScreen(); + expect(screen.getByText(roleName(CONST.POLICY.ROLE.AUDITOR))).toBeOnTheScreen(); + expect(screen.getByText(roleName(CONST.POLICY.ROLE.CARD_ADMIN))).toBeOnTheScreen(); + expect(screen.getByText(roleName(CONST.POLICY.ROLE.PEOPLE_ADMIN))).toBeOnTheScreen(); + expect(screen.getByText(roleName(CONST.POLICY.ROLE.PAYMENTS_ADMIN))).toBeOnTheScreen(); + expect(screen.getByText(memberRoleName())).toBeOnTheScreen(); unmount(); await waitForBatchedUpdatesWithAct(); }); - it('should show Make member/admin/auditor/card admin when mix is selected', async () => { - const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); - await waitForBatchedUpdatesWithAct(); - - // Wait for initial render and verify members are visible - await waitFor(() => { - expect(screen.getByText(AUDITOR_OPTION)).toBeOnTheScreen(); - }); - await waitFor(() => { - expect(screen.getByText(ADMIN_OPTION)).toBeOnTheScreen(); - }); - - // Select options by clicking the checkboxes - selectCheckboxByMemberName('Auditor'); - selectCheckboxByMemberName('Admin'); - const dropdownMenuButtonTestID = 'WorkspaceMembersPage-header-dropdown-menu-button'; - - // Wait for selection mode to be active and click the dropdown menu button - await waitFor(() => { - expect(screen.getByTestId(dropdownMenuButtonTestID)).toBeOnTheScreen(); + it('should only offer member and auditor to a People Admin', async () => { + // Given a People Admin, who may assign no role above auditor + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + role: CONST.POLICY.ROLE.PEOPLE_ADMIN, + employeeList: {[selfEmail]: {email: selfEmail, role: CONST.POLICY.ROLE.PEOPLE_ADMIN}}, + }); }); + await selectForRoleChange([userEmail]); - // Click the "2 selected" button to open the menu - const dropdownButton = screen.getByTestId(dropdownMenuButtonTestID); - fireEvent.press(dropdownButton); - + const {unmount} = renderRolePage(policy.id); await waitForBatchedUpdatesWithAct(); - // Wait for menu items to be visible + // Then only the two roles they may assign are listed await waitFor(() => { - const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 2}); - expect(screen.getByText(makeMemberText)).toBeOnTheScreen(); + expect(screen.getByText(roleName(CONST.POLICY.ROLE.AUDITOR))).toBeOnTheScreen(); }); - - // Find and verify "Make members" dropdown menu item (plural form for 2 selected items) - const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 2}); - const makeMemberMenuItem = screen.getByTestId(`PopoverMenuItem-${makeMemberText}`); - expect(makeMemberMenuItem).toBeOnTheScreen(); - - // Find and verify "Make admins" dropdown menu item (plural form for 2 selected items) - const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 2}); - const makeAdminMenuItem = screen.getByTestId(`PopoverMenuItem-${makeAdminText}`); - expect(makeAdminMenuItem).toBeOnTheScreen(); - - // Find and verify "Make auditors" dropdown menu item (plural form for 2 selected items) - const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 2}); - const makeAuditorMenuItem = screen.getByTestId(`PopoverMenuItem-${makeAuditorText}`); - expect(makeAuditorMenuItem).toBeOnTheScreen(); - - // Find and verify "Make card admins" dropdown menu item (plural form for 2 selected items) - const makeCardAdminText = TestHelper.translateLocal('workspace.people.makeCardAdmin', {count: 2}); - const makeCardAdminMenuItem = screen.getByTestId(`PopoverMenuItem-${makeCardAdminText}`); - expect(makeCardAdminMenuItem).toBeOnTheScreen(); + expect(screen.getByText(memberRoleName())).toBeOnTheScreen(); + expect(screen.queryByText(roleName(CONST.POLICY.ROLE.ADMIN))).not.toBeOnTheScreen(); + expect(screen.queryByText(roleName(CONST.POLICY.ROLE.CARD_ADMIN))).not.toBeOnTheScreen(); + expect(screen.queryByText(roleName(CONST.POLICY.ROLE.PEOPLE_ADMIN))).not.toBeOnTheScreen(); unmount(); await waitForBatchedUpdatesWithAct(); }); - it('should only show member and auditor role actions for People Admin', async () => { - const peopleAdminPolicy = { - ...policy, - role: CONST.POLICY.ROLE.PEOPLE_ADMIN, - employeeList: { - ...policy.employeeList, - [selfEmail]: {email: selfEmail, role: CONST.POLICY.ROLE.PEOPLE_ADMIN}, - }, - }; - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, peopleAdminPolicy); - }); + it('should ask for a role rather than disabling Save when the selection has no role in common', async () => { + // Given a mixed selection, which leaves the screen with nothing picked + await selectForRoleChange([adminEmail, userEmail]); - const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + const {unmount} = renderRolePage(policy.id); await waitForBatchedUpdatesWithAct(); - await waitFor(() => { - expect(screen.getByText(USER_OPTION)).toBeOnTheScreen(); + // When Save is pressed without picking a role + const saveButton = await screen.findByText(TestHelper.translateLocal('common.save')); + fireEvent.press(saveButton, { + nativeEvent: {}, + type: 'press', + target: saveButton, + currentTarget: saveButton, }); - - selectCheckboxByMemberName('Member'); - fireEvent.press(screen.getByTestId('WorkspaceMembersPage-header-dropdown-menu-button')); - await waitForBatchedUpdatesWithAct(); - - const removeText = TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1}); - expect(screen.getByTestId(`PopoverMenuItem-${removeText}`)).toBeOnTheScreen(); - - const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 1}); - expect(screen.getByTestId(`PopoverMenuItem-${makeAuditorText}`)).toBeOnTheScreen(); - - const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1}); - expect(screen.queryByTestId(`PopoverMenuItem-${makeAdminText}`)).not.toBeOnTheScreen(); - - const makeCardAdminText = TestHelper.translateLocal('workspace.people.makeCardAdmin', {count: 1}); - expect(screen.queryByTestId(`PopoverMenuItem-${makeCardAdminText}`)).not.toBeOnTheScreen(); - - const makePeopleAdminText = TestHelper.translateLocal('workspace.people.makePeopleAdmin', {count: 1}); - expect(screen.queryByTestId(`PopoverMenuItem-${makePeopleAdminText}`)).not.toBeOnTheScreen(); - - unmount(); await waitForBatchedUpdatesWithAct(); - }); - it('should let People Admin make auditors members', async () => { - const peopleAdminPolicy = { - ...policy, - role: CONST.POLICY.ROLE.PEOPLE_ADMIN, - employeeList: { - ...policy.employeeList, - [selfEmail]: {email: selfEmail, role: CONST.POLICY.ROLE.PEOPLE_ADMIN}, - }, - }; - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, peopleAdminPolicy); + // Then the screen asks for one instead of leaving the button dead + await waitFor(() => { + expect(screen.getByText(TestHelper.translateLocal('common.error.pleaseSelectOne'))).toBeOnTheScreen(); }); - const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + // When Save is pressed again with still nothing picked, the ask stays up rather than being counted as handled + fireEvent.press(saveButton, { + nativeEvent: {}, + type: 'press', + target: saveButton, + currentTarget: saveButton, + }); await waitForBatchedUpdatesWithAct(); - await waitFor(() => { - expect(screen.getByText(AUDITOR_OPTION)).toBeOnTheScreen(); - }); + expect(screen.getByText(TestHelper.translateLocal('common.error.pleaseSelectOne'))).toBeOnTheScreen(); - selectCheckboxByMemberName('Auditor'); - fireEvent.press(screen.getByTestId('WorkspaceMembersPage-header-dropdown-menu-button')); + // When a role is then picked, the message clears + const auditorRow = screen.getByText(roleName(CONST.POLICY.ROLE.AUDITOR)); + fireEvent.press(auditorRow, { + nativeEvent: {}, + type: 'press', + target: auditorRow, + currentTarget: auditorRow, + }); await waitForBatchedUpdatesWithAct(); - const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1}); - expect(screen.getByTestId(`PopoverMenuItem-${makeMemberText}`)).toBeOnTheScreen(); - - const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1}); - expect(screen.queryByTestId(`PopoverMenuItem-${makeAdminText}`)).not.toBeOnTheScreen(); + expect(screen.queryByText(TestHelper.translateLocal('common.error.pleaseSelectOne'))).not.toBeOnTheScreen(); unmount(); await waitForBatchedUpdatesWithAct(); }); - it('should hide demotions but offer Make payments admin when the selected member is the Authorized Payer resolved via policy.reimburser', async () => { - // Given a workspace whose Authorized Payer is an admin configured through policy.reimburser - // (the canonical resolution) rather than achAccount.reimburser. Demotions to roles that cannot - // pay must stay hidden, but changing to Payments Admin (the other valid payer role) must be offered. + it('should ignore a selection stashed on another workspace', async () => { + // Given an admin stashed for a role change on a different workspace, as backing out of this screen there would leave behind await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, - reimburser: adminEmail, - }); + await Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, {policyID: 'ANOTHER_WORKSPACE', logins: [adminEmail]}); }); - const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + const {unmount} = renderRolePage(policy.id); await waitForBatchedUpdatesWithAct(); - await waitFor(() => { - expect(screen.getByText(ADMIN_OPTION)).toBeOnTheScreen(); + // When Save is pressed + const saveButton = await screen.findByText(TestHelper.translateLocal('common.save')); + fireEvent.press(saveButton, { + nativeEvent: {}, + type: 'press', + target: saveButton, + currentTarget: saveButton, }); - - // When that payer is bulk-selected and the actions dropdown is opened - selectCheckboxByMemberName('Admin'); - fireEvent.press(await screen.findByTestId('WorkspaceMembersPage-header-dropdown-menu-button')); await waitForBatchedUpdatesWithAct(); - // Then the Remove option is still available - const removeText = TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1}); + // Then the stashed admin was never adopted, so the screen has nothing picked to save await waitFor(() => { - expect(screen.getByTestId(`PopoverMenuItem-${removeText}`)).toBeOnTheScreen(); + expect(screen.getByText(TestHelper.translateLocal('common.error.pleaseSelectOne'))).toBeOnTheScreen(); }); - // ...the demotions that would strip the payer of pay capability are hidden - const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1}); - expect(screen.queryByTestId(`PopoverMenuItem-${makeMemberText}`)).not.toBeOnTheScreen(); - - const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 1}); - expect(screen.queryByTestId(`PopoverMenuItem-${makeAuditorText}`)).not.toBeOnTheScreen(); + unmount(); + await waitForBatchedUpdatesWithAct(); + }); - const makeCardAdminText = TestHelper.translateLocal('workspace.people.makeCardAdmin', {count: 1}); - expect(screen.queryByTestId(`PopoverMenuItem-${makeCardAdminText}`)).not.toBeOnTheScreen(); + it('should drop the selection when the screen is left without saving', async () => { + // Given an admin selected for a role change + await selectForRoleChange([adminEmail]); - // ...but Make payments admin IS offered — Payments Admin is a valid payer role - const makePaymentsAdminText = TestHelper.translateLocal('workspace.people.makePaymentsAdmin', {count: 1}); - expect(screen.getByTestId(`PopoverMenuItem-${makePaymentsAdminText}`)).toBeOnTheScreen(); + const {unmount} = renderRolePage(policy.id); + await waitForBatchedUpdatesWithAct(); + // When the screen is left without saving unmount(); await waitForBatchedUpdatesWithAct(); + + // Then nothing is left for a later visit to act on, and the empty selection that reports a saved change is not written + const stashedSelection = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + expect(stashedSelection).toBeUndefined(); }); - it('should offer Make workspace admin but hide demotions when the selected member is a Payments Admin who is the Authorized Payer', async () => { - // Given a Payments Admin who is also the Authorized Payer. Admin and Payments Admin are both valid - // payer roles, so promoting this payer to Admin keeps them a valid payer and must be offered. - // Every demotion to a role that cannot pay (Member, Auditor, Card Admin) stays gated on the payer. + it('should only offer the roles that can pay when the selection holds the Authorized Payer', async () => { + // Given a selection holding the Authorized Payer, who must stay a valid payer await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, - reimburser: userEmail, - employeeList: { - [userEmail]: {email: userEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, - }, + reimburser: adminEmail, }); }); + await selectForRoleChange([adminEmail]); - const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + const {unmount} = renderRolePage(policy.id); await waitForBatchedUpdatesWithAct(); + // Then only Admin and Payments Admin are offered, and every role that cannot pay is withheld await waitFor(() => { - expect(screen.getByText(USER_OPTION)).toBeOnTheScreen(); + expect(screen.getByText(roleName(CONST.POLICY.ROLE.PAYMENTS_ADMIN))).toBeOnTheScreen(); }); - - // When that payer is bulk-selected and the actions dropdown is opened - selectCheckboxByMemberName('Member'); - fireEvent.press(await screen.findByTestId('WorkspaceMembersPage-header-dropdown-menu-button')); - await waitForBatchedUpdatesWithAct(); - - // Then the Remove option is still available - const removeText = TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1}); - await waitFor(() => { - expect(screen.getByTestId(`PopoverMenuItem-${removeText}`)).toBeOnTheScreen(); - }); - - // ...and "Make workspace admin" IS offered — Admin is a valid payer role - const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1}); - expect(screen.getByTestId(`PopoverMenuItem-${makeAdminText}`)).toBeOnTheScreen(); - - // ...but the demotions that would strip the payer of pay capability stay hidden - const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1}); - expect(screen.queryByTestId(`PopoverMenuItem-${makeMemberText}`)).not.toBeOnTheScreen(); - - const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 1}); - expect(screen.queryByTestId(`PopoverMenuItem-${makeAuditorText}`)).not.toBeOnTheScreen(); + expect(screen.getByText(roleName(CONST.POLICY.ROLE.ADMIN))).toBeOnTheScreen(); + expect(screen.queryByText(memberRoleName())).not.toBeOnTheScreen(); + expect(screen.queryByText(roleName(CONST.POLICY.ROLE.AUDITOR))).not.toBeOnTheScreen(); + expect(screen.queryByText(roleName(CONST.POLICY.ROLE.CARD_ADMIN))).not.toBeOnTheScreen(); unmount(); await waitForBatchedUpdatesWithAct(); @@ -545,9 +420,8 @@ describe('WorkspaceMembers', () => { // Select all fireEvent.press(screen.getByLabelText(TestHelper.translateLocal('workspace.common.selectAll'))); - // Open dropdown - fireEvent.press(await screen.findByTestId('WorkspaceMembersPage-header-dropdown-menu-button')); - await waitForBatchedUpdatesWithAct(); + // Open the bulk actions + await openBulkActions(); // Click "Remove members" const removeText = TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 3}); @@ -596,8 +470,7 @@ describe('WorkspaceMembers', () => { // When the admin removes that approver const row = await screen.findByLabelText(new RegExp(`^${approverWithoutDetails}`)); fireEvent.press(within(row).getByLabelText(TestHelper.translateLocal('common.select'))); - fireEvent.press(await screen.findByTestId('WorkspaceMembersPage-header-dropdown-menu-button')); - await waitForBatchedUpdatesWithAct(); + await openBulkActions(); const removeMenuItem = screen.getByText(TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1})); fireEvent.press(removeMenuItem, {nativeEvent: {}, type: 'press', target: removeMenuItem, currentTarget: removeMenuItem}); await waitForBatchedUpdatesWithAct(); @@ -628,11 +501,10 @@ describe('WorkspaceMembers', () => { }); }; - const selectAdminAndOpenDropdown = async () => { + const selectAdminAndOpenBulkActions = async () => { await screen.findByText(ADMIN_OPTION); selectCheckboxByMemberName('Admin'); - fireEvent.press(await screen.findByTestId('WorkspaceMembersPage-header-dropdown-menu-button')); - await waitForBatchedUpdatesWithAct(); + await openBulkActions(); }; it('should show the unable-to-remove modal when removing a RuleBot enforcing agent rules', async () => { @@ -641,7 +513,7 @@ describe('WorkspaceMembers', () => { const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); await waitForBatchedUpdatesWithAct(); - await selectAdminAndOpenDropdown(); + await selectAdminAndOpenBulkActions(); const removeMenuItem = screen.getByText(TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1})); fireEvent.press(removeMenuItem, { @@ -661,22 +533,35 @@ describe('WorkspaceMembers', () => { }); it('should show the unable-to-change-role modal when demoting a RuleBot enforcing agent rules', async () => { + // Given the RuleBot selected for a role change await makeAdminTheRuleBot(); + await act(async () => { + await Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, {policyID: policy.id, logins: [adminEmail]}); + }); - const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + const {unmount} = renderRolePage(policy.id); await waitForBatchedUpdatesWithAct(); - await selectAdminAndOpenDropdown(); + // When a role below admin is picked and saved + const memberRow = await screen.findByText(TestHelper.translateLocal('common.member')); + fireEvent.press(memberRow, { + nativeEvent: {}, + type: 'press', + target: memberRow, + currentTarget: memberRow, + }); + await waitForBatchedUpdatesWithAct(); - const makeMemberMenuItem = screen.getByText(TestHelper.translateLocal('workspace.people.makeMember', {count: 1})); - fireEvent.press(makeMemberMenuItem, { + const saveButton = screen.getByText(TestHelper.translateLocal('common.save')); + fireEvent.press(saveButton, { nativeEvent: {}, type: 'press', - target: makeMemberMenuItem, - currentTarget: makeMemberMenuItem, + target: saveButton, + currentTarget: saveButton, }); await waitForBatchedUpdatesWithAct(); + // Then the role change is refused with the agent rules modal await waitFor(() => { expect(screen.getByText(TestHelper.translateLocal('workspace.rules.agentRules.unableToChangeRoleTitle'))).toBeOnTheScreen(); }); @@ -765,19 +650,187 @@ describe('WorkspaceMembers', () => { }; fireEvent.press(getSelectAllCheckbox()); await waitForBatchedUpdatesWithAct(); - expect(screen.getByTestId('WorkspaceMembersPage-header-dropdown-menu-button')).toBeOnTheScreen(); + const selectedLabel = TestHelper.translateLocal('workspace.common.selected', {count: 1}); + expect(screen.getByText(selectedLabel)).toBeOnTheScreen(); // Then clearing the search drops the selection, because it only ever applied to the searched rows fireEvent.changeText(searchInput, ''); await waitForBatchedUpdatesWithAct(); expect(screen.getByText(ADMIN_OPTION)).toBeOnTheScreen(); - expect(screen.queryByTestId('WorkspaceMembersPage-header-dropdown-menu-button')).not.toBeOnTheScreen(); + expect(screen.queryByText(selectedLabel)).not.toBeOnTheScreen(); expect(getSelectAllCheckbox()).not.toBeChecked(); unmount(); }); }); + describe('Office locations and work arrangements', () => { + const officeLocations: Record = { + OFFICE1: {name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}, isDefault: true}, + OFFICE2: {name: 'New Jersey Office', address: {addressStreet: '900 Asbury Ave', city: 'Ocean City', state: 'NJ', zipCode: '08226', country: 'US'}}, + }; + const officeLocationsPolicy = { + ...policy, + officeLocations, + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE}, + employeeList: { + ...policy.employeeList, + [userEmail]: {email: userEmail, role: CONST.POLICY.ROLE.USER, officeID: 'OFFICE2'}, + }, + }; + + const openBulkActionsForMember = async () => { + await screen.findByText(USER_OPTION); + selectCheckboxByMemberName('Member'); + await waitForBatchedUpdatesWithAct(); + await openBulkActions(); + }; + + beforeEach(async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS]); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, officeLocationsPolicy); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should show the office of each member, and the primary office for members without one', async () => { + // Given a workspace with offices where only one member is assigned to an office + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + await screen.findByText(USER_OPTION); + + // Then the office location column shows the assigned office, and the primary office for everyone else + expect(screen.getByText(TestHelper.translateLocal('workspace.people.officeLocation'))).toBeOnTheScreen(); + expect(screen.getByText('New Jersey Office')).toBeOnTheScreen(); + expect(screen.getAllByText('Headquarters')).toHaveLength(Object.keys(policy.employeeList).length - 1); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should show the company address for every member when the workspace has no other office', async () => { + // Given a workspace whose only office is its company address + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + address: {addressStreet: '1 Market St', city: 'San Francisco', state: 'CA', zipCode: '94105', country: 'US'}, + officeLocations: null, + employeeList: {[userEmail]: {officeID: null}}, + }); + }); + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + await screen.findByText(USER_OPTION); + + // Then the office location column shows the company address for every member, as it counts as an office + expect(screen.getByText(TestHelper.translateLocal('workspace.people.officeLocation'))).toBeOnTheScreen(); + expect(screen.getAllByText(TestHelper.translateLocal('common.companyAddress'))).toHaveLength(Object.keys(policy.employeeList).length); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should hand the selected members to the office location picker', async () => { + // Given an admin who selected a member + const navigateSpy = jest.spyOn(Navigation, 'navigate').mockImplementation(() => {}); + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + await openBulkActionsForMember(); + + // When they choose to change the office location + expect(screen.getByText(TestHelper.translateLocal('workspace.people.changeWorkArrangement'))).toBeOnTheScreen(); + const changeOfficeLocationItem = screen.getByText(TestHelper.translateLocal('workspace.people.changeOfficeLocation')); + fireEvent.press(changeOfficeLocationItem, { + nativeEvent: {}, + type: 'press', + target: changeOfficeLocationItem, + currentTarget: changeOfficeLocationItem, + }); + await waitForBatchedUpdatesWithAct(); + + // Then the office location picker opens for that member + await waitFor(() => { + expect(navigateSpy).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBERS_CHANGE_OFFICE_LOCATION.getRoute(policy.id)); + }); + expect(await getOnyxValue(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT)).toEqual({policyID: policy.id, accountIDs: [userAccountID]}); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should clear the selection once a bulk change has been applied', async () => { + // Given an admin who selected a member and opened a bulk change for them + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + await screen.findByText(USER_OPTION); + selectCheckboxByMemberName('Member'); + await act(async () => { + await Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, {policyID: policy.id, accountIDs: [userAccountID]}); + }); + const selectedLabel = TestHelper.translateLocal('workspace.common.selected', {count: 1}); + expect(screen.getByText(selectedLabel)).toBeOnTheScreen(); + + // When the bulk change is applied, which clears the members handed to it + await act(async () => { + await Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, null); + }); + + // Then the members page drops its selection too + await waitFor(() => { + expect(screen.queryByText(selectedLabel)).not.toBeOnTheScreen(); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should hide the office location column and bulk changes without the commuter exclusions arrangements beta', async () => { + // Given a workspace with offices, and an admin with the commuter exclusions beta but not the arrangements beta + await act(async () => { + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.COMMUTER_EXCLUSIONS]); + }); + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + + // When they open the bulk actions for a member + await openBulkActionsForMember(); + + // Then there is no office location column, and neither change is offered + expect(screen.queryByText(TestHelper.translateLocal('workspace.people.officeLocation'))).not.toBeOnTheScreen(); + expect(screen.queryByText('New Jersey Office')).not.toBeOnTheScreen(); + expect(screen.queryByText(TestHelper.translateLocal('workspace.people.changeOfficeLocation'))).not.toBeOnTheScreen(); + expect(screen.queryByText(TestHelper.translateLocal('workspace.people.changeWorkArrangement'))).not.toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should offer office location and work arrangement changes to People Admins', async () => { + // Given a People Admin, who manages members, offices and work arrangements included + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + role: CONST.POLICY.ROLE.PEOPLE_ADMIN, + employeeList: {[selfEmail]: {email: selfEmail, role: CONST.POLICY.ROLE.PEOPLE_ADMIN}}, + }); + }); + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + + // When they open the bulk actions for a member + await openBulkActionsForMember(); + + // Then both changes are offered + expect(screen.getByText(TestHelper.translateLocal('workspace.people.changeOfficeLocation'))).toBeOnTheScreen(); + expect(screen.getByText(TestHelper.translateLocal('workspace.people.changeWorkArrangement'))).toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + }); + describe('Inline role editing', () => { it('lets a just-invited member be role-edited before their account resolves', async () => { // Given a member invited with optimistic personal details, which is how a new invite stays until the diff --git a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx index f36cf9bbf3f9..fdc91f331572 100644 --- a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx +++ b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx @@ -170,6 +170,51 @@ describe('WorkspaceMoreFeaturesPage', () => { jest.clearAllMocks(); }); + describe('Distance rates toggle', () => { + it('locks Distance rates and explains why while home and office commuter exclusions are enabled', async () => { + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.merge( + `${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, + buildPolicy({ + id: POLICY_ID, + areDistanceRatesEnabled: true, + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE}, + }), + ); + }); + + renderPage({policyID: POLICY_ID}); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(await findLockedSwitch('workspace.moreFeatures.distanceRates.subtitle')); + + await waitFor(() => { + expect(screen.getByText(TestHelper.translateLocal('workspace.moreFeatures.distanceRates.commuterExclusionsOnTitle'))).toBeOnTheScreen(); + expect(screen.getByText(TestHelper.translateLocal('workspace.moreFeatures.distanceRates.disableLockedByCommuterExclusionsPrompt'))).toBeOnTheScreen(); + expect(screen.getByText(TestHelper.translateLocal('common.buttonConfirm'))).toBeOnTheScreen(); + }); + }); + + it('keeps Distance rates available to re-enable when commuter exclusions remain active', async () => { + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.merge( + `${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, + buildPolicy({ + id: POLICY_ID, + areDistanceRatesEnabled: false, + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE}, + }), + ); + }); + + renderPage({policyID: POLICY_ID}); + await waitForBatchedUpdatesWithAct(); + + await expect(findUnlockedSwitch('workspace.moreFeatures.distanceRates.subtitle')).resolves.toBeOnTheScreen(); + }); + }); + describe('Workflows toggle', () => { it('locks the Workflows switch when the workspace has a Smart-Limit Expensify Card', async () => { await TestHelper.signInWithTestUser(); diff --git a/tests/ui/WorkspaceNewRoomPageTest.tsx b/tests/ui/WorkspaceNewRoomPageTest.tsx new file mode 100644 index 000000000000..ac76dc1159f5 --- /dev/null +++ b/tests/ui/WorkspaceNewRoomPageTest.tsx @@ -0,0 +1,333 @@ +import {act, cleanup, fireEvent, render, screen, within} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import type * as FormProviderModule from '@components/Form/FormProvider'; +import HTMLEngineProvider from '@components/HTMLEngineProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type {SelectionListProps} from '@components/SelectionList/types'; +import type * as ValuePickerModule from '@components/ValuePicker'; +import ValuePicker from '@components/ValuePicker'; +import type {ValuePickerProps, ValueSelectorModalProps} from '@components/ValuePicker/types'; +import type * as ValueSelectionListModule from '@components/ValuePicker/ValueSelectionList'; + +import type * as ReportActionsModule from '@libs/actions/Report'; +import {addPolicyReport} from '@libs/actions/Report'; + +import WorkspaceNewRoomPage from '@pages/workspace/WorkspaceNewRoomPage'; + +import CONST from '@src/CONST'; +import type * as FormKeysModule from '@src/ONYXKEYS'; +import type Policy from '@src/types/onyx/Policy'; + +import type * as NavigationCore from '@react-navigation/core'; +import type * as NativeNavigation from '@react-navigation/native'; +import type ReactNative from 'react-native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createMock from '../utils/createMock'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const ONYXKEYS = jest.requireActual('@src/ONYXKEYS').default; + +jest.mock('@react-navigation/core', () => ({ + ...jest.requireActual('@react-navigation/core'), + useIsFocused: () => true, +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: () => true, + useRoute: () => ({key: 'new-room', name: 'NewRoom'}), + usePreventRemove: () => {}, +})); +jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({__esModule: true, default: () => ({didScreenTransitionEnd: true})})); +jest.mock('@src/hooks/useResponsiveLayout'); +jest.mock('@libs/actions/Report', () => ({ + ...jest.requireActual('@libs/actions/Report'), + addPolicyReport: jest.fn, Parameters>(), +})); + +let mockRoomForm: FormProviderModule.FormProviderProps | undefined; +const mockRoomPickers = new Map(); + +jest.mock('@components/Form/FormProvider', () => { + const ActualReact = jest.requireActual('react'); + const formKeys = jest.requireActual('@src/ONYXKEYS').default; + const ActualForm = jest.requireActual('@components/Form/FormProvider').default; + function isRoomForm( + props: FormProviderModule.FormProviderProps, + ): props is FormProviderModule.FormProviderProps & FormProviderModule.FormProviderProps { + return props.formID === formKeys.FORMS.NEW_ROOM_FORM; + } + function ObservedForm(props: FormProviderModule.FormProviderProps) { + ActualReact.useEffect(() => { + if (!isRoomForm(props)) { + return; + } + mockRoomForm = props; + }, [props]); + return ; + } + return {__esModule: true, default: ObservedForm}; +}); + +jest.mock('@components/ValuePicker', () => { + const ActualReact = jest.requireActual('react'); + const ActualPicker = jest.requireActual('@components/ValuePicker').default; + function ObservedPicker(props: ValuePickerProps) { + ActualReact.useEffect(() => { + mockRoomPickers.set(props.label ?? '', props); + }, [props]); + return ; + } + return {__esModule: true, default: ObservedPicker}; +}); + +// Modal presentation stays below the real picker. The real list owns mapping and selection order. +jest.mock('@components/ValuePicker/ValueSelectorModal', () => { + const RN = jest.requireActual('react-native'); + const ValueSelectionList = jest.requireActual('@components/ValuePicker/ValueSelectionList').default; + return ({label, ...props}: ValueSelectorModalProps) => + props.isVisible ? ( + + + + ) : null; +}); + +// The presentation boundary receives complete items mapped by ValueSelectionList, including keyForList. +jest.mock('@components/SelectionList', () => { + const RN = jest.requireActual('react-native'); + return ({data, onSelectRow}: SelectionListProps>[0]>) => ( + + {data.map((item) => ( + onSelectRow(item)} + > + {item.text} + + ))} + + ); +}); + +const adminPolicy = createMock({id: 'admin-workspace', name: 'Admin workspace', type: CONST.POLICY.TYPE.TEAM, role: CONST.POLICY.ROLE.ADMIN}); +const memberPolicy = createMock({id: 'member-workspace', name: 'Member workspace', type: CONST.POLICY.TYPE.TEAM, role: CONST.POLICY.ROLE.USER}); + +function renderNewRoomPage(policyID?: string) { + return render( + + + , + ); +} + +function selectRoomOption(page: ReturnType, label: string, value: string) { + const picker = page.UNSAFE_getAllByType(ValuePicker).find((node) => node.props.label === label); + expect(picker).toBeDefined(); + if (!picker) { + throw new Error('Expected the room picker'); + } + const props = mockRoomPickers.get(label); + expect(props).toBeDefined(); + if (!props) { + throw new Error('Expected the observed room picker props'); + } + expect(props.items?.some((item) => item.value === value)).toBe(true); + // The actual ValuePicker owns its modal visibility through the menu item's press event. + fireEvent.press(within(picker).getByRole('button'), createMock({nativeEvent: {}})); + fireEvent.press(screen.getByTestId(`room-option-${value}`)); +} + +describe('WorkspaceNewRoomPage option state and submit', () => { + beforeEach(async () => { + mockRoomForm = undefined; + mockRoomPickers.clear(); + Onyx.init({keys: ONYXKEYS}); + jest.mocked(addPolicyReport).mockClear(); + await act(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + await Onyx.set(ONYXKEYS.SESSION, {email: 'member@example.com', accountID: 123}); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${adminPolicy.id}`, adminPolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${memberPolicy.id}`, memberPolicy); + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, adminPolicy.id); + }); + await waitForBatchedUpdatesWithAct(); + }); + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + it.each(Object.values(CONST.REPORT.WRITE_CAPABILITIES))('submits selected capability %s and each offered visibility', async (capability) => { + // Given an admin workspace, every current option should retain its exact production value. + const page = renderNewRoomPage(); + await waitForBatchedUpdatesWithAct(); + const capabilities = page.UNSAFE_getAllByType(ValuePicker).find((node) => node.props.label === 'Who can post'); + expect(capabilities).toBeDefined(); + selectRoomOption(page, 'Who can post', capability); + const visibilityPicker = page.UNSAFE_getAllByType(ValuePicker).find((node) => node.props.label === 'Visibility'); + expect(visibilityPicker).toBeDefined(); + const visibilityProps = mockRoomPickers.get('Visibility'); + expect(visibilityProps).toBeDefined(); + if (!visibilityProps) { + throw new Error('Expected the visibility picker'); + } + expect(visibilityProps.items?.map((item) => item.value)).toEqual(Object.values(CONST.REPORT.VISIBILITY).filter((value) => value !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE)); + + // When each real picker option reaches the mounted form's submit callback, state and submitted policy must agree. + for (const visibility of Object.values(CONST.REPORT.VISIBILITY).filter((value) => value !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE)) { + selectRoomOption(page, 'Visibility', visibility); + const form = mockRoomForm; + expect(form).toBeDefined(); + if (!form) { + throw new Error('Expected the actual room form'); + } + act(() => form.onSubmit({roomName: '#test-room', reportDescription: '', policyID: adminPolicy.id, writeCapability: capability, visibility})); + + // Then the real optimistic report builder exposes any option-to-state or policy mapping mistake. + expect(addPolicyReport).toHaveBeenCalled(); + const submitted = jest.mocked(addPolicyReport).mock.calls.at(-1); + expect(submitted).toBeDefined(); + expect(submitted?.[0]).toEqual(expect.objectContaining({policyID: adminPolicy.id, writeCapability: capability, visibility})); + } + }); + + it.each(['unmatched-room-option', undefined])('preserves room settings when picker value is %s', async (value) => { + // Given deliberate room settings, unmatched picker values must not replace those choices. + const page = renderNewRoomPage(); + await waitForBatchedUpdatesWithAct(); + selectRoomOption(page, 'Who can post', CONST.REPORT.WRITE_CAPABILITIES.ADMINS); + selectRoomOption(page, 'Visibility', CONST.REPORT.VISIBILITY.PUBLIC); + const capabilityPicker = mockRoomPickers.get('Who can post'); + const visibilityPicker = mockRoomPickers.get('Visibility'); + expect(capabilityPicker?.onInputChange).toBeDefined(); + expect(visibilityPicker?.onInputChange).toBeDefined(); + if (!capabilityPicker?.onInputChange || !visibilityPicker?.onInputChange) { + throw new Error('Expected the mounted room picker callbacks'); + } + + // When a picker reports a missing or unmatched value, the actual selection handlers ignore it. + act(() => { + capabilityPicker.onInputChange?.(value); + visibilityPicker.onInputChange?.(value); + }); + const form = mockRoomForm; + expect(form).toBeDefined(); + if (!form) { + throw new Error('Expected the actual room form'); + } + act(() => + form.onSubmit({ + roomName: '#test-room', + reportDescription: '', + policyID: adminPolicy.id, + writeCapability: CONST.REPORT.WRITE_CAPABILITIES.ADMINS, + visibility: CONST.REPORT.VISIBILITY.PUBLIC, + }), + ); + + // Then the submitted report retains both valid choices rather than resetting or accepting the unknown value. + expect(addPolicyReport).toHaveBeenCalledTimes(1); + expect(jest.mocked(addPolicyReport).mock.calls.at(0)?.[0]).toEqual( + expect.objectContaining({writeCapability: CONST.REPORT.WRITE_CAPABILITIES.ADMINS, visibility: CONST.REPORT.VISIBILITY.PUBLIC}), + ); + }); + + it('switches workspace and resets admin-only capability before submit', async () => { + // Given an admin-only posting choice, switching to a member workspace must remove that capability. + const page = renderNewRoomPage(); + await waitForBatchedUpdatesWithAct(); + selectRoomOption(page, 'Who can post', CONST.REPORT.WRITE_CAPABILITIES.ADMINS); + + // When a real workspace option is selected, the new workspace owns the report and resets restricted posting. + selectRoomOption(page, 'Workspace', memberPolicy.id); + await waitForBatchedUpdatesWithAct(); + const pickers = page.UNSAFE_getAllByType(ValuePicker); + expect(pickers.some((node) => node.props.label === 'Who can post')).toBe(false); + const form = mockRoomForm; + expect(form).toBeDefined(); + if (!form) { + throw new Error('Expected the actual room form'); + } + act(() => + form.onSubmit({ + roomName: '#member-room', + reportDescription: '', + policyID: memberPolicy.id, + visibility: CONST.REPORT.VISIBILITY.RESTRICTED, + writeCapability: CONST.REPORT.WRITE_CAPABILITIES.ALL, + }), + ); + + // Then neither the old policy ID nor its admin capability leaks into the actual report. + expect(addPolicyReport).toHaveBeenCalledTimes(1); + expect(jest.mocked(addPolicyReport).mock.calls.at(0)?.[0]).toEqual(expect.objectContaining({policyID: memberPolicy.id, writeCapability: CONST.REPORT.WRITE_CAPABILITIES.ALL})); + }); + + it('clears a removed workspace and adopts a valid active workspace', async () => { + // Given a selected workspace that becomes unavailable, retaining it would submit stale policy state. + renderNewRoomPage(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText(adminPolicy.name)).toBeOnTheScreen(); + + // When that policy disappears and the active policy changes, the real Onyx subscription must rebuild options. + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${adminPolicy.id}`, null); + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, memberPolicy.id); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the picker and submitted report both use the surviving workspace. + expect(screen.getByText(memberPolicy.name)).toBeOnTheScreen(); + const form = mockRoomForm; + expect(form).toBeDefined(); + if (!form) { + throw new Error('Expected the actual room form'); + } + act(() => + form.onSubmit({ + roomName: '#surviving-room', + reportDescription: '', + policyID: memberPolicy.id, + visibility: CONST.REPORT.VISIBILITY.RESTRICTED, + writeCapability: CONST.REPORT.WRITE_CAPABILITIES.ALL, + }), + ); + expect(addPolicyReport).toHaveBeenCalledTimes(1); + expect(jest.mocked(addPolicyReport).mock.calls.at(0)?.[0].policyID).toBe(memberPolicy.id); + }); + + it('keeps the locked workspace as the submitted policy', async () => { + // Given a locked workspace different from the active policy, selection must stay bound to the page's policy. + const page = renderNewRoomPage(memberPolicy.id); + await waitForBatchedUpdatesWithAct(); + + // When the mounted form submits, no workspace picker can override the locked ID. + expect(page.UNSAFE_getAllByType(ValuePicker).some((node) => node.props.label === 'Workspace')).toBe(false); + const form = mockRoomForm; + expect(form).toBeDefined(); + if (!form) { + throw new Error('Expected the actual room form'); + } + act(() => + form.onSubmit({ + roomName: '#locked-room', + reportDescription: '', + policyID: adminPolicy.id, + visibility: CONST.REPORT.VISIBILITY.RESTRICTED, + writeCapability: CONST.REPORT.WRITE_CAPABILITIES.ALL, + }), + ); + + // Then the real report builder receives the locked policy and default posting capability. + expect(addPolicyReport).toHaveBeenCalledTimes(1); + expect(jest.mocked(addPolicyReport).mock.calls.at(0)?.[0]).toEqual(expect.objectContaining({policyID: memberPolicy.id, writeCapability: CONST.REPORT.WRITE_CAPABILITIES.ALL})); + }); +}); diff --git a/tests/ui/WorkspaceOfficeLocationEditPageTest.tsx b/tests/ui/WorkspaceOfficeLocationEditPageTest.tsx new file mode 100644 index 000000000000..7c8faaabb971 --- /dev/null +++ b/tests/ui/WorkspaceOfficeLocationEditPageTest.tsx @@ -0,0 +1,79 @@ +import {render} from '@testing-library/react-native'; + +import Header from '@components/Header'; + +import usePolicy from '@hooks/usePolicy'; + +import WorkspaceOfficeLocationEditPage from '@pages/workspace/WorkspaceOfficeLocationEditPage'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; +import type {CompanyAddress, OfficeLocation} from '@src/types/onyx/Policy'; + +import type React from 'react'; +import type {PropsWithChildren} from 'react'; + +import createMock from '../utils/createMock'; + +jest.mock('@components/Header', () => ({__esModule: true, default: {IconButton: jest.fn(() => null)}})); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: jest.fn()}))); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: jest.fn(() => ({Trashcan: 'Trashcan'}))})); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => true}))); +jest.mock('@hooks/usePolicy', () => jest.fn()); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); +jest.mock('@userActions/Policy/DistanceRate', () => ({deleteOfficeLocation: jest.fn(), updateOfficeLocation: jest.fn()})); +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: ({children}: PropsWithChildren) => children})); +jest.mock('@pages/workspace/WorkspaceOfficeLocationForm', () => jest.fn(() => null)); + +const companyAddress: CompanyAddress = {addressStreet: '1 Market St', city: 'San Francisco', state: 'CA', zipCode: '94105', country: 'US'}; +const headquarters: OfficeLocation = {name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}, isDefault: true}; +const newJerseyOffice: OfficeLocation = {name: 'New Jersey Office', address: {addressStreet: '900 Asbury Ave', city: 'Ocean City', state: 'NJ', zipCode: '08226', country: 'US'}}; +const homeAndOfficeCommuterExclusions = {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE}; + +describe('WorkspaceOfficeLocationEditPage', () => { + const policyID = 'policy123'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const renderPage = (policy: Partial) => { + jest.mocked(usePolicy).mockReturnValue(createMock({id: policyID, ...policy})); + const props = createMock>({route: {params: {policyID, officeID: 'OFFICE1'}}}); + render(); + }; + const isDeleteShown = () => jest.mocked(Header.IconButton).mock.calls.length > 0; + + it('lets the last office be deleted while the workspace does not exclude commutes by home and office', () => { + // Given a workspace without a company address whose only office is the one being edited + renderPage({officeLocations: {OFFICE1: headquarters}}); + + // When the office is shown for editing + // Then it can be deleted, since nothing needs an office + expect(isDeleteShown()).toBe(true); + }); + + it('keeps the last office while the workspace excludes commutes by home and office', () => { + // Given a workspace without a company address that excludes commutes by home and office with its only office + renderPage({officeLocations: {OFFICE1: headquarters}, commuterExclusions: homeAndOfficeCommuterExclusions}); + + // When the office is shown for editing + // Then it can't be deleted, since excluding commutes by home and office needs an office + expect(isDeleteShown()).toBe(false); + }); + + it.each([ + ['another office', {officeLocations: {OFFICE1: headquarters, OFFICE2: newJerseyOffice}}], + ['the company address', {officeLocations: {OFFICE1: headquarters}, address: companyAddress}], + ])('lets an office be deleted while the workspace excludes commutes by home and office when %s remains', (_description, policy) => { + // Given a workspace that excludes commutes by home and office with another office left after the deletion + renderPage({...policy, commuterExclusions: homeAndOfficeCommuterExclusions}); + + // When the office is shown for editing + // Then it can be deleted, since the workspace keeps an office + expect(isDeleteShown()).toBe(true); + }); +}); diff --git a/tests/ui/WorkspaceOnboarding.tsx b/tests/ui/WorkspaceOnboarding.tsx index 7d0b8c2560ef..c740667fd40b 100644 --- a/tests/ui/WorkspaceOnboarding.tsx +++ b/tests/ui/WorkspaceOnboarding.tsx @@ -556,7 +556,7 @@ describe('OnboardingWorkspaces Page', () => { await waitForBatchedUpdatesWithAct(); }); - it('should continue the Join Workspace flow to personal details after an empty lookup', async () => { + it.each([false, true])('should continue the Join Workspace flow to personal details after an empty lookup (hasDefaultPolicy: %s)', async (hasDefaultPolicy) => { // Given a validated Join Workspace user whose workspace lookup is in progress. const requestID = 'join-accessible-policies-request'; await TestHelper.signInWithTestUser(); @@ -571,6 +571,17 @@ describe('OnboardingWorkspaces Page', () => { validatedDate: '2026-09-12 00:00:00', }, }); + if (hasDefaultPolicy) { + // An existing workspace can remain accessible after merging into a work account. + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, 'existing-policy'); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}existing-policy`, { + id: 'existing-policy', + name: 'Existing workspace', + owner: VALIDATED_EMAIL, + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + }); + } await Onyx.set(ONYXKEYS.JOINABLE_POLICIES, {}); await Onyx.set(ONYXKEYS.VALIDATE_USER_AND_GET_ACCESSIBLE_POLICIES, {loading: true, requestID}); }); @@ -587,6 +598,8 @@ describe('OnboardingWorkspaces Page', () => { await waitFor(() => { expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); }); + expect(mockCompleteOnboarding).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledTimes(1); expect(screen.queryByTestId('BaseOnboardingWorkspaces')).not.toBeOnTheScreen(); unmount(); @@ -677,6 +690,40 @@ describe('OnboardingWorkspaces Page', () => { await waitForBatchedUpdatesWithAct(); }); + it('should complete onboarding with the default policy after an empty lookup outside the Join Workspace flow', async () => { + // Given a user with an existing workspace who is looking around rather than joining a company workspace. + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.LOOKING_AROUND); + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, 'existing-policy'); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}existing-policy`, { + id: 'existing-policy', + name: 'Existing workspace', + owner: VALIDATED_EMAIL, + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + }); + await Onyx.set(ONYXKEYS.JOINABLE_POLICIES, {}); + await Onyx.set(ONYXKEYS.VALIDATE_USER_AND_GET_ACCESSIBLE_POLICIES, {loading: true}); + }); + const {unmount} = renderOnboardingWorkspacesPage(SCREENS.ONBOARDING.WORKSPACES, {backTo: ''}); + await waitForBatchedUpdatesWithAct(); + + // When no joinable workspaces are returned, the existing default workspace is still available. + await act(async () => { + await Onyx.merge(ONYXKEYS.VALIDATE_USER_AND_GET_ACCESSIBLE_POLICIES, {loading: false}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the default-policy fallback still completes onboarding for other intents. + expect(mockCompleteOnboarding).toHaveBeenCalledTimes(1); + expect(mockCompleteOnboarding).toHaveBeenCalledWith(expect.objectContaining({engagementChoice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND})); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should collect a name before completing onboarding after joining a workspace', async () => { const policyID = 'joined-policy-id'; await TestHelper.signInWithTestUser(); @@ -709,6 +756,175 @@ describe('OnboardingWorkspaces Page', () => { await waitForBatchedUpdatesWithAct(); }); + it('should complete onboarding after joining when only the required first name is present', async () => { + // Given a Join Workspace user who supplied the required first name and left the optional last name blank. + const policyID = 'joined-policy-id'; + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM, {firstName: 'Test', lastName: ''}); + await Onyx.set(ONYXKEYS.JOINABLE_POLICIES, { + [policyID]: { + policyID, + policyName: 'Joined workspace', + policyOwner: 'owner@example.com', + employeeCount: 1, + hasPendingAccess: false, + automaticJoiningEnabled: true, + policyType: CONST.POLICY.TYPE.CORPORATE, + }, + }); + }); + const {unmount} = renderOnboardingWorkspacesPage(SCREENS.ONBOARDING.WORKSPACES, {backTo: ''}); + await waitForBatchedUpdatesWithAct(); + + // When the user joins the workspace. + fireEvent.press(screen.getByText(TestHelper.translateLocal('workspace.workspaceList.joinNow'))); + + // Then onboarding completes instead of trying to reopen the personal-details step. + expect(mockCompleteOnboarding).toHaveBeenCalledTimes(1); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should complete onboarding without reopening the name step when joining empties the workspace list', async () => { + // Given a validated Join Workspace user who already saved a name and has a single joinable workspace + const policyID = 'joined-policy-id'; + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM, {firstName: 'Test', lastName: 'User'}); + await Onyx.set(ONYXKEYS.LOGINS, { + [`1_${VALIDATED_EMAIL}`]: { + partnerID: CONST.PARTNER_ID.EXPENSIFY, + partnerUserID: VALIDATED_EMAIL, + validatedDate: '2026-09-12 00:00:00', + }, + }); + await Onyx.set(ONYXKEYS.VALIDATE_USER_AND_GET_ACCESSIBLE_POLICIES, {loading: false}); + await Onyx.set(ONYXKEYS.JOINABLE_POLICIES, { + [policyID]: { + policyID, + policyName: 'Joined workspace', + policyOwner: 'owner@example.com', + employeeCount: 1, + hasPendingAccess: false, + automaticJoiningEnabled: true, + policyType: CONST.POLICY.TYPE.CORPORATE, + }, + }); + }); + const {unmount} = renderOnboardingWorkspacesPage(SCREENS.ONBOARDING.WORKSPACES, {backTo: ''}); + await waitForBatchedUpdatesWithAct(); + + // When Join now is pressed and the join's Onyx update removes the workspace before this screen loses focus + fireEvent.press(screen.getByText(TestHelper.translateLocal('workspace.workspaceList.joinNow'))); + await act(async () => { + await Onyx.set(ONYXKEYS.JOINABLE_POLICIES, {}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the emptied list neither starts another lookup nor sends the user back to the name step + expect(mockGetAccessiblePolicies).not.toHaveBeenCalled(); + expect(mockCompleteOnboarding).toHaveBeenCalledTimes(1); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it.each([false, true])('should complete onboarding with the saved name when a Join Workspace lookup comes back empty (hasDefaultPolicy: %s)', async (hasDefaultPolicy) => { + // Given a validated Join Workspace user who already saved a name, such as one whose onboarding restarted on + // this screen after the joined workspace dropped out of the list + const requestID = 'join-accessible-policies-request'; + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM, {firstName: 'Test', lastName: 'User'}); + await Onyx.set(ONYXKEYS.LOGINS, { + [`1_${VALIDATED_EMAIL}`]: { + partnerID: CONST.PARTNER_ID.EXPENSIFY, + partnerUserID: VALIDATED_EMAIL, + validatedDate: '2026-09-12 00:00:00', + }, + }); + if (hasDefaultPolicy) { + // An existing workspace can remain accessible after merging into a work account. + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, 'existing-policy'); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}existing-policy`, { + id: 'existing-policy', + name: 'Existing workspace', + owner: VALIDATED_EMAIL, + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + }); + } + await Onyx.set(ONYXKEYS.JOINABLE_POLICIES, {}); + await Onyx.set(ONYXKEYS.VALIDATE_USER_AND_GET_ACCESSIBLE_POLICIES, {loading: true, requestID}); + }); + const {unmount} = renderOnboardingWorkspacesPage(SCREENS.ONBOARDING.WORKSPACES, {backTo: ''}); + await waitForBatchedUpdatesWithAct(); + + // When the lookup finishes with no joinable workspaces + await act(async () => { + await Onyx.merge(ONYXKEYS.VALIDATE_USER_AND_GET_ACCESSIBLE_POLICIES, {loading: false, requestID}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then onboarding completes with the saved name, the same as pressing Continue on the name step + expect(mockCompleteOnboarding).toHaveBeenCalledTimes(1); + expect(mockCompleteOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + engagementChoice: CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE, + firstName: 'Test', + lastName: 'User', + }), + ); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should collect a name after joining when the saved personal details have no first name', async () => { + // Given a Join Workspace user whose personal details form holds only a last name + const policyID = 'joined-policy-id'; + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM, {lastName: 'User'}); + await Onyx.set(ONYXKEYS.JOINABLE_POLICIES, { + [policyID]: { + policyID, + policyName: 'Joined workspace', + policyOwner: 'owner@example.com', + employeeCount: 1, + hasPendingAccess: false, + automaticJoiningEnabled: true, + policyType: CONST.POLICY.TYPE.CORPORATE, + }, + }); + }); + const {unmount} = renderOnboardingWorkspacesPage(SCREENS.ONBOARDING.WORKSPACES, {backTo: ''}); + await waitForBatchedUpdatesWithAct(); + + // When the user joins the workspace + fireEvent.press(screen.getByText(TestHelper.translateLocal('workspace.workspaceList.joinNow'))); + + // Then the name step opens to collect the missing first name + expect(mockCompleteOnboarding).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should finish the marked Join Workspace flow when the merged account has a different persisted intent', async () => { // Given a merge resumed from a Join Workspace task while the target account still persists a different intent. const dismissModalWithReport = jest.spyOn(Navigation, 'dismissModalWithReport').mockImplementation(() => {}); @@ -980,6 +1196,71 @@ describe('OnboardingWorkspaces Page', () => { await waitForBatchedUpdatesWithAct(); }); + it('should not set RHP_ADMINS_ROOM when joining a Submit workspace with the Join Workspace intent', async () => { + jest.spyOn(Navigation, 'dismissModal').mockImplementation(({afterTransition} = {}) => afterTransition?.()); + jest.spyOn(Navigation, 'setNavigationActionToMicrotaskQueue').mockImplementation((callback: () => void) => callback()); + + await TestHelper.signInWithTestUser(); + + // Given a joinable auto-join Submit workspace with the JOIN_WORKSPACE purpose (not EMPLOYER) + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + }); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM, { + firstName: 'Test', + lastName: 'User', + }); + await Onyx.set(ONYXKEYS.JOINABLE_POLICIES, { + submitPolicyID: { + policyID: 'submit-policy-id', + policyName: 'Submit Workspace', + policyOwner: 'owner@test.com', + employeeCount: 4, + hasPendingAccess: false, + automaticJoiningEnabled: true, + policyType: CONST.POLICY.TYPE.SUBMIT, + }, + }); + }); + + const onyxSetSpy = jest.spyOn(Onyx, 'set'); + onyxSetSpy.mockClear(); + + const {unmount} = renderOnboardingWorkspacesPage(SCREENS.ONBOARDING.WORKSPACES, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + // When the user presses "Join Now" + fireEvent.press(screen.getByText(TestHelper.translateLocal('workspace.workspaceList.joinNow'))); + + await waitFor(() => { + expect(mockJoinAccessiblePolicy.mock.calls.at(0)?.at(0)).toBe('submit-policy-id'); + }); + + // Then onboarding completes with JOIN_WORKSPACE but does not set the RHP_ADMINS_ROOM variant + await waitFor(() => { + expect(mockCompleteOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + engagementChoice: CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE, + }), + ); + }); + + const rhpVariantCalls = onyxSetSpy.mock.calls.filter(([key]) => key === ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT); + expect(rhpVariantCalls).not.toContainEqual([ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT, CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM]); + + // Then navigation does not go to the Expenses search route + expect(navigate).not.toHaveBeenCalledWith( + ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE}), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES}), + ); + + onyxSetSpy.mockRestore(); + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should complete onboarding without passing the joined workspace policyID and open Spend > Expenses in the admins room', async () => { jest.spyOn(Navigation, 'dismissModal').mockImplementation(({afterTransition} = {}) => afterTransition?.()); jest.spyOn(Navigation, 'setNavigationActionToMicrotaskQueue').mockImplementation((callback: () => void) => callback()); diff --git a/tests/ui/WorkspaceReceiptPartnersTest.tsx b/tests/ui/WorkspaceReceiptPartnersTest.tsx index 3b0a5d24ebd1..9ca34888b6f8 100644 --- a/tests/ui/WorkspaceReceiptPartnersTest.tsx +++ b/tests/ui/WorkspaceReceiptPartnersTest.tsx @@ -1,4 +1,4 @@ -import {act, render, screen, waitFor} from '@testing-library/react-native'; +import {act, render, renderHook, screen, waitFor, within} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; @@ -9,6 +9,8 @@ import PersonalDetailsByLoginProvider from '@components/PersonalDetailsByLoginPr import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; +import * as useSafeAreaInsetsModule from '@hooks/useSafeAreaInsets'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; @@ -26,6 +28,7 @@ import type {PersonalDetails} from '@src/types/onyx'; import {PortalProvider} from '@gorhom/portal'; import {NavigationContainer} from '@react-navigation/native'; import React from 'react'; +import {StyleSheet} from 'react-native'; import Onyx from 'react-native-onyx'; import * as LHNTestUtils from '../utils/LHNTestUtils'; @@ -261,4 +264,85 @@ describe('WorkspaceReceiptPartners', () => { }); expect(screen.getByText(TestHelper.translateLocal('workspace.receiptPartners.uber.centralBillingAccount'))).toBeOnTheScreen(); }); + + describe('bottom safe area padding', () => { + // Larger than any regular spacing on these pages, so a view whose bottom padding reaches it can only be reserving room for the inset + const BOTTOM_INSET = 100; + const SCREEN_TEST_ID = 'DynamicInviteReceiptPartnerPolicyPage'; + let useSafeAreaInsetsSpy: jest.SpyInstance; + let safeAreaPaddingBottom: number; + + beforeEach(() => { + useSafeAreaInsetsSpy = jest.spyOn(useSafeAreaInsetsModule, 'default').mockReturnValue({top: 0, right: 0, bottom: BOTTOM_INSET, left: 0}); + safeAreaPaddingBottom = renderHook(() => useSafeAreaPaddings(true)).result.current.paddingBottom; + }); + + afterEach(() => { + useSafeAreaInsetsSpy.mockRestore(); + }); + + /** Finds every view on the invite page that reserves room for the bottom safe area inset and tells whether it holds the given button. */ + const doViewsWithBottomSafeAreaPaddingHoldButton = (buttonText: string) => + screen + .getByTestId(SCREEN_TEST_ID) + .findAll((node) => { + if (typeof node.type !== 'string') { + return false; + } + const style: unknown = StyleSheet.flatten(node.props.style); + return !!style && typeof style === 'object' && 'paddingBottom' in style && typeof style.paddingBottom === 'number' && style.paddingBottom >= safeAreaPaddingBottom; + }) + .map((view) => within(view).queryByText(buttonText) !== null); + + it('lets only the confirm button footer add the bottom safe area padding on the send invites page', async () => { + // Given a workspace with members that can still be invited to Uber, so the page shows the member list with the confirm button footer + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, basePolicy); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, employeePersonalDetails); + }); + + // When the page is rendered on a device that has a bottom safe area inset + renderInvitePage({policyID, integration: UBER_INTEGRATION}); + await waitForBatchedUpdatesWithAct(); + await waitFor(() => { + expect(screen.getByText(TestHelper.translateLocal('workspace.receiptPartners.uber.sendInvites'))).toBeOnTheScreen(); + }); + + // Then the footer holding the confirm button should be the only view that reserves room for the inset, because ScreenWrapper adding its own spacer below it leaves an oversized gap under the button + expect(doViewsWithBottomSafeAreaPaddingHoldButton(TestHelper.translateLocal('workspace.receiptPartners.uber.confirm'))).toEqual([true]); + }); + + it('lets only ScreenWrapper add the bottom safe area padding on the all set page', async () => { + // Given a workspace where every member is already linked to Uber, so there is nobody left to invite and the page skips to the "All set" confirmation + await TestHelper.signInWithTestUser(); + const linkedEmployee = {status: CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.LINKED}; + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + ...basePolicy, + receiptPartners: { + ...basePolicy.receiptPartners, + uber: { + ...basePolicy.receiptPartners.uber, + employees: { + [EMPLOYEE_EMAIL]: linkedEmployee, + [BILLING_EMAIL]: linkedEmployee, + }, + }, + }, + }); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, employeePersonalDetails); + }); + + // When the page is rendered on a device that has a bottom safe area inset + renderInvitePage({policyID, integration: UBER_INTEGRATION}); + await waitForBatchedUpdatesWithAct(); + await waitFor(() => { + expect(screen.getByText(TestHelper.translateLocal('workspace.receiptPartners.uber.allSet'))).toBeOnTheScreen(); + }); + + // Then the ScreenWrapper spacer below the page should be the only view that reserves room for the inset, because the confirmation footer does not add it and needs the spacer to keep the button above the home indicator + expect(doViewsWithBottomSafeAreaPaddingHoldButton(TestHelper.translateLocal('common.buttonConfirm'))).toEqual([false]); + }); + }); }); diff --git a/tests/ui/WorkspaceTagsTest.tsx b/tests/ui/WorkspaceTagsTest.tsx index 418121ca3041..caa29052772d 100644 --- a/tests/ui/WorkspaceTagsTest.tsx +++ b/tests/ui/WorkspaceTagsTest.tsx @@ -18,6 +18,7 @@ import WorkspaceTagsPage from '@pages/workspace/tags/WorkspaceTagsPage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; +import type Beta from '@src/types/onyx/Beta'; import {PortalProvider} from '@gorhom/portal'; import {NavigationContainer} from '@react-navigation/native'; @@ -250,7 +251,13 @@ describe('WorkspaceTags', () => { expect(screen.getByText(SECOND_TAG)).toBeOnTheScreen(); }); - fireEvent.press(screen.getByLabelText(TestHelper.translateLocal('workspace.common.selectAll'))); + const selectAll = screen.getAllByLabelText(TestHelper.translateLocal('workspace.common.selectAll')).at(0); + + if (!selectAll) { + throw new Error('Select all button not found'); + } + + fireEvent.press(selectAll); const dropdownMenuButtonTestID = 'WorkspaceTagsPage-header-dropdown-menu-button'; @@ -275,6 +282,96 @@ describe('WorkspaceTags', () => { await waitForBatchedUpdatesWithAct(); }); + describe('Append custom tag list', () => { + const connectedPolicy = { + ...LHNTestUtils.getFakePolicy(), + role: CONST.POLICY.ROLE.ADMIN, + areTagsEnabled: true, + connections: { + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: {}, + }, + }; + + const dependentMultiLevelTags = { + ParentList: { + name: 'ParentList', + required: false, + orderWeight: 0, + tags: { + Parent: {name: 'Parent', enabled: true}, + }, + }, + ChildList: { + name: 'ChildList', + required: false, + orderWeight: 1, + tags: { + Child: {name: 'Child', enabled: true, rules: {parentTagsFilter: '^Parent$'}}, + }, + }, + }; + + const renderAndOpenMoreMenu = async (policy: typeof connectedPolicy, policyTags: typeof tags | typeof dependentMultiLevelTags, betas: Beta[]) => { + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.set(ONYXKEYS.BETAS, betas); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy.id}`, policyTags); + }); + + const result = renderPage(SCREENS.WORKSPACE.TAGS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByText(TestHelper.translateLocal('common.more'))).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByText(TestHelper.translateLocal('common.more'))); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByText(TestHelper.translateLocal('spreadsheet.downloadCSV'))).toBeOnTheScreen(); + }); + return result; + }; + + it('should show the option for single-level tags on a workspace with an accounting connection and the indirectTagUploads beta', async () => { + // Given a NetSuite workspace with single-level tags and an admin on the indirectTagUploads beta + // When the admin opens the More menu + const {unmount} = await renderAndOpenMoreMenu(connectedPolicy, tags, [CONST.BETAS.INDIRECT_TAG_UPLOADS]); + + // Then the option to append a custom tag list is offered, the same as Classic, while the regular import stays hidden + expect(screen.getByText(TestHelper.translateLocal('workspace.tags.appendCustomTagList'))).toBeOnTheScreen(); + expect(screen.queryByText(TestHelper.translateLocal('spreadsheet.importSpreadsheet'))).not.toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should not show the option for dependent multi-level tags', async () => { + // Given a NetSuite workspace with dependent multi-level tags and an admin on the indirectTagUploads beta + // When the admin opens the More menu + const {unmount} = await renderAndOpenMoreMenu({...connectedPolicy, hasMultipleTagLists: true}, dependentMultiLevelTags, [CONST.BETAS.INDIRECT_TAG_UPLOADS]); + + // Then the option is hidden because a custom list can only be appended to single-level or independent multi-level tags + expect(screen.queryByText(TestHelper.translateLocal('workspace.tags.appendCustomTagList'))).not.toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should not show the option without the indirectTagUploads beta', async () => { + // Given a NetSuite workspace with single-level tags and an admin who isn't on the indirectTagUploads beta + // When the admin opens the More menu + const {unmount} = await renderAndOpenMoreMenu(connectedPolicy, tags, []); + + // Then the option is hidden because the feature is gated by the beta + expect(screen.queryByText(TestHelper.translateLocal('workspace.tags.appendCustomTagList'))).not.toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + }); + describe('multi-level tags', () => { it('keeps the imported level order and disables sorting on wide layouts', async () => { // Given a wide layout and independent multi-level tags imported as Charlie, Bravo, Alpha diff --git a/tests/ui/WorkspaceTravelBillingSectionTest.tsx b/tests/ui/WorkspaceTravelBillingSectionTest.tsx index e074370cc19c..c489a68fd71d 100644 --- a/tests/ui/WorkspaceTravelBillingSectionTest.tsx +++ b/tests/ui/WorkspaceTravelBillingSectionTest.tsx @@ -27,6 +27,7 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct' // because jest.mock() is hoisted before variable declarations are evaluated const POLICY_ID = 'testPolicy123'; const WORKSPACE_ACCOUNT_ID = 999888; +const DOMAIN_ACCOUNT_ID = 8309850; // jest.mock() factories are hoisted and run before imports/variables are defined. // Therefore, they cannot reference variables like POLICY_ID or WORKSPACE_ACCOUNT_ID. @@ -636,6 +637,37 @@ describe('WorkspaceTravelBillingSection', () => { expect(deactivateTravelBilling).toHaveBeenCalledWith(POLICY_ID, WORKSPACE_ACCOUNT_ID); }); + it('should deactivate on the workspace account when the domain card program has no travel program', async () => { + // Given a domain card program linked to this workspace, and travel billing set up on the workspace account + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy); + await Onyx.merge(getTravelBillingCardSettingsKey(DOMAIN_ACCOUNT_ID), { + US: { + linkedPolicyIDs: [POLICY_ID], + paymentBankAccountID: 67890, + }, + }); + await Onyx.merge(cardSettingsKey, { + TRAVEL_US: { + isEnabled: true, + paymentBankAccountID: 12345, + currentBalance: 0, + pendingSettlementAmount: 0, + }, + }); + await waitForBatchedUpdatesWithAct(); + }); + + // When the admin turns travel billing off + renderWorkspaceTravelBillingSection(); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByRole('switch')); + await waitForBatchedUpdatesWithAct(); + + // Then the toggle was on and the workspace account's travel billing is deactivated + expect(deactivateTravelBilling).toHaveBeenCalledWith(POLICY_ID, WORKSPACE_ACCOUNT_ID); + }); + it('should not deactivate when the confirmation modal is dismissed', async () => { mockShowConfirmModal.mockResolvedValueOnce({action: 'CLOSE'}); diff --git a/tests/ui/WorkspaceWorkflowsPayerRowTest.tsx b/tests/ui/WorkspaceWorkflowsPayerRowTest.tsx index ecd478c049a5..ac65740d612c 100644 --- a/tests/ui/WorkspaceWorkflowsPayerRowTest.tsx +++ b/tests/ui/WorkspaceWorkflowsPayerRowTest.tsx @@ -145,6 +145,33 @@ describe('WorkspaceWorkflowsPageRevamp - Payer row visibility', () => { expect(screen.getByText(TestHelper.translateLocal('workflowsPayerPage.payer'))).toBeOnTheScreen(); }); + it('keeps the Payer row when reimbursementChoice is REIMBURSEMENT_YES after the bank account is disconnected', async () => { + await TestHelper.signInWithTestUser(); + await act(async () => { + // Disconnecting the bank account clears the bank data (no open state, removed from BANK_ACCOUNT_LIST) + // but preserves the reimburser and leaves reimbursementChoice as REIMBURSEMENT_YES. + await Onyx.merge( + `${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, + buildPolicy({ + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + achAccount: { + reimburser: 'test@user.com', + bankAccountID: 0, + accountNumber: '', + routingNumber: '', + addressName: '', + bankName: '', + }, + }), + ); + }); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(TestHelper.translateLocal('workflowsPayerPage.payer'))).toBeOnTheScreen(); + }); + it('shows the Payer row when reimbursementChoice is REIMBURSEMENT_MANUAL', async () => { await TestHelper.signInWithTestUser(); await act(async () => { diff --git a/tests/ui/components/AvatarWithTextCellTest.tsx b/tests/ui/components/AvatarWithTextCellTest.tsx new file mode 100644 index 000000000000..78fb289f379e --- /dev/null +++ b/tests/ui/components/AvatarWithTextCellTest.tsx @@ -0,0 +1,70 @@ +import {render, screen} from '@testing-library/react-native'; + +import AvatarFromIcon from '@components/Avatar/AvatarFromIcon'; +import AvatarWithTextCell from '@components/Search/SearchList/ListItem/AvatarWithTextCell'; + +import CONST from '@src/CONST'; +import type {Icon} from '@src/types/onyx/OnyxCommon'; + +import React from 'react'; + +jest.mock('@components/Avatar/AvatarFromIcon', () => jest.fn(() => null)); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + flexRow: {}, + alignItemsCenter: {}, + pr2: {}, + userSelectNone: {userSelect: 'none'}, + themeTextColor: {}, + textMicroBold: {}, + flexShrink1: {}, + })), +); + +const ICON: Icon = { + source: 'avatar.png', + type: CONST.ICON_TYPE_AVATAR, + name: 'Test user', + id: 1, +}; + +describe('AvatarWithTextCell', () => { + it('marks the text as copyable and excludes the avatar from copied content', () => { + // Given a shared chat value rendered in a copyable search row. + render( + , + ); + + // When the copyable cell is rendered, then only its text participates in row copying. + expect(screen.getByText('Shared chat')).toHaveProp('selectable', true); + expect(screen.getByText('Shared chat')).toHaveProp('dataSet', expect.objectContaining({[CONST.COPYABLE_TEXT_ELEMENT]: true})); + + const avatar = screen.UNSAFE_getByType(AvatarFromIcon); + expect(avatar.parent?.props).toMatchObject({ + style: {userSelect: 'none'}, + dataSet: {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}, + }); + }); + + it('preserves the original non-copyable rendering for other callers', () => { + // Given a shared chat value whose caller did not opt into text selection. + render( + , + ); + + // When the default cell is rendered, then neither the text nor avatar receives copy-specific behavior. + expect(screen.getByText('Shared chat')).not.toHaveProp('selectable', true); + + const avatar = screen.UNSAFE_getByType(AvatarFromIcon); + expect(avatar.parent?.props).not.toMatchObject({dataSet: {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}); + }); +}); diff --git a/tests/ui/components/ConfirmationPageTest.tsx b/tests/ui/components/ConfirmationPageTest.tsx new file mode 100644 index 000000000000..351473ff3455 --- /dev/null +++ b/tests/ui/components/ConfirmationPageTest.tsx @@ -0,0 +1,68 @@ +import {render} from '@testing-library/react-native'; + +import ConfirmationPage from '@components/ConfirmationPage'; +import Lottie from '@components/Lottie'; + +import React from 'react'; +// Use React Native Animated to exercise the native Animated.Value dimension accepted by ViewStyle. +// eslint-disable-next-line no-restricted-imports +import {Animated} from 'react-native'; + +jest.mock('@components/Lottie', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +function renderConfirmationAndGetWebStyle(illustrationStyle?: React.ComponentProps['illustrationStyle']) { + render(); + const lottieProps = jest.mocked(Lottie).mock.calls.at(-1)?.at(0); + expect(lottieProps).toBeDefined(); + if (!lottieProps) { + throw new Error('ConfirmationPage.Illustration did not render Lottie'); + } + return lottieProps.webStyle; +} + +describe('ConfirmationPage Lottie web dimensions', () => { + afterEach(() => { + jest.mocked(Lottie).mockClear(); + }); + + it('preserves compatible dimensions and independently defaults absent dimensions', () => { + // Given the default dimensions remain available for independently missing width and height. + const defaults = renderConfirmationAndGetWebStyle(); + expect(defaults).toBeDefined(); + expect(defaults?.width).toBeDefined(); + expect(defaults?.height).toBeDefined(); + + // When styles provide supported web dimensions, including a zero width and percentages. + const zeroWidthStyle = renderConfirmationAndGetWebStyle({width: 0, height: 96}); + const percentageHeightStyle = renderConfirmationAndGetWebStyle({width: 48, height: '75%'}); + const missingHeightStyle = renderConfirmationAndGetWebStyle({width: '50%'}); + const missingWidthStyle = renderConfirmationAndGetWebStyle({height: 64}); + + // Then supported dimensions survive and each omitted dimension retains its default. + expect(zeroWidthStyle).toMatchObject({width: 0, height: 96}); + expect(percentageHeightStyle).toMatchObject({width: 48, height: '75%'}); + expect(missingHeightStyle).toMatchObject({width: '50%', height: defaults?.height}); + expect(missingWidthStyle).toMatchObject({width: defaults?.width, height: 64}); + }); + + it('defaults an animated width only at the web boundary and preserves the native style', () => { + // Given a native animated width cannot be passed to the web animation renderer. + const defaultWebStyle = renderConfirmationAndGetWebStyle(); + expect(defaultWebStyle).toBeDefined(); + if (!defaultWebStyle) { + throw new Error('ConfirmationPage did not provide default Lottie web dimensions'); + } + const animatedWidth = new Animated.Value(40); + + // When the shared component receives both animated and supported dimensions. + render(); + const lottieProps = jest.mocked(Lottie).mock.calls.at(-1)?.at(0); + expect(lottieProps).toBeDefined(); + // Then only the web width falls back while the native animation keeps its style. + expect(lottieProps?.webStyle).toEqual({width: defaultWebStyle.width, height: '75%'}); + expect(lottieProps?.style).toEqual(expect.arrayContaining([expect.objectContaining({width: animatedWidth, height: '75%'})])); + }); +}); diff --git a/tests/ui/components/DotIndicatorMessageTest.tsx b/tests/ui/components/DotIndicatorMessageTest.tsx new file mode 100644 index 000000000000..cf35f99881a9 --- /dev/null +++ b/tests/ui/components/DotIndicatorMessageTest.tsx @@ -0,0 +1,85 @@ +import {render, screen} from '@testing-library/react-native'; + +import DotIndicatorMessage from '@components/DotIndicatorMessage'; + +import type * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +let mockShouldUseNarrowLayout = false; + +jest.mock('@components/Icon', () => jest.fn(() => null)); +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({DotIndicator: () => null})), +})); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/useResponsiveLayout', () => + jest.fn(() => ({ + shouldUseNarrowLayout: mockShouldUseNarrowLayout, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + })), +); +jest.mock('@hooks/useStyleUtils', () => jest.fn(() => ({getDotIndicatorTextStyles: jest.fn(() => ({}))}))); +jest.mock('@hooks/useTheme', () => jest.fn(() => ({danger: '', success: '', text: ''}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + userSelectText: {userSelect: 'text'}, + userSelectNone: {userSelect: 'none'}, + dotIndicatorMessage: {}, + offlineFeedbackErrorDot: {}, + offlineFeedbackTextContainer: {}, + })), +); +jest.mock('@libs/DeviceCapabilities', () => ({ + ...jest.requireActual('@libs/DeviceCapabilities'), + canUseTouchScreen: jest.fn(), +})); +jest.mock('@libs/getPlatform', () => jest.fn()); + +const mockedCanUseTouchScreen = jest.mocked(canUseTouchScreen); +const mockedGetPlatform = jest.mocked(getPlatform); +const MESSAGE = 'Company card required'; + +function renderMessage() { + return render( + , + ); +} + +describe('DotIndicatorMessage selection override', () => { + beforeEach(() => { + mockShouldUseNarrowLayout = false; + mockedCanUseTouchScreen.mockReturnValue(false); + }); + + it('disables text selection on web when explicitly requested', () => { + // Given a web layout whose default behavior allows text selection. + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + + // When the caller explicitly disables selection. + renderMessage(); + + // Then the web-specific override prevents text selection. + expect(screen.getByText(MESSAGE)).toHaveStyle({userSelect: 'none'}); + }); + + it('preserves the default text selection behavior on native', () => { + // Given a native layout whose default behavior allows text selection. + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.IOS); + + // When the caller passes the web-specific selection override. + renderMessage(); + + // Then native ignores the override and keeps its existing selection behavior. + expect(screen.getByText(MESSAGE)).toHaveStyle({userSelect: 'text'}); + }); +}); diff --git a/tests/ui/components/HeaderViewTest.tsx b/tests/ui/components/HeaderViewTest.tsx index d2a49066a5f9..227411d07ab9 100644 --- a/tests/ui/components/HeaderViewTest.tsx +++ b/tests/ui/components/HeaderViewTest.tsx @@ -149,9 +149,18 @@ describe('HeaderView', () => { const joinButton = screen.getByText('Join'); expect(joinButton).toBeTruthy(); - // Then the joinRoom action should be called when the user presses the Join button + // Then the joinRoom action should be called with the fields it joins by. The header subscribes to a projection without + // the chat heartbeat fields (`last*`), so the call gets that projection rather than the stored object. fireEvent.press(joinButton); - expect(joinRoom).toHaveBeenCalledWith(report, currentUserAccountID); + expect(joinRoom).toHaveBeenCalledWith( + expect.objectContaining({ + reportID: report.reportID, + parentReportID: report.parentReportID, + parentReportActionID: report.parentReportActionID, + participants: report.participants, + }), + currentUserAccountID, + ); }); it('should display correct title for report with CREATED_REPORT_FOR_UNAPPROVED_TRANSACTIONS parent action', async () => { diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index 1bee9556c075..a266452654a3 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -528,9 +528,16 @@ describe('IOURequestStepConfirmationPageTest', () => { await waitForBatchedUpdatesWithAct(); expect(screen.queryByText(translateLocal('common.error.fieldRequired'))).not.toBeOnTheScreen(); + jest.mocked(getCurrentPosition).mockImplementationOnce(async (success) => { + success({ + coords: {latitude: 40.7128, longitude: -74.006, altitude: null, accuracy: null, altitudeAccuracy: null, heading: null, speed: null}, + timestamp: 0, + }); + }); fireEvent.press(screen.getByText(translateLocal('iou.createExpense'))); await waitForBatchedUpdatesWithAct(); expect(screen.queryByText(translateLocal('common.error.fieldRequired'))).not.toBeOnTheScreen(); + await waitFor(() => expect(TrackExpense.requestMoney).toHaveBeenCalledTimes(1)); }); it('drops the "Automatic" label from all three fields as soon as any one of them is entered', async () => { @@ -1995,6 +2002,144 @@ describe('IOURequestStepConfirmationPageTest', () => { expect(screen.getByLabelText(translateLocal('iou.amount'))).toHaveDisplayValue('43.00'); }); + it('keeps the fields of a manually filled receipt expanded when traversing away and back (multi-scan)', async () => { + // Given two scanned drafts confirmed together, on a surface that exposes the scan fields + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}1`, { + ...DEFAULT_SPLIT_TRANSACTION, + transactionID: '1', + isAmountSet: undefined, + iouRequestType: 'scan', + receipt: {filename: 'receipt1.jpg', source: 'path/to/receipt1.jpg', type: ''}, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}2`, { + ...DEFAULT_SPLIT_TRANSACTION, + transactionID: '2', + isAmountSet: undefined, + iouRequestType: 'scan', + receipt: {filename: 'receipt2.jpg', source: 'path/to/receipt2.jpg', type: ''}, + }); + }); + + render( + + + + + + + + + , + ); + + await waitForBatchedUpdatesWithAct(); + + const of = translateLocal('common.of'); + expect(await screen.findByText(`1 ${of} 2`)).toBeOnTheScreen(); + + // When the user reveals the fields of the first receipt and fills in its amount, without confirming + fireEvent.press(screen.getByText(translateLocal('common.showMore'))); + await waitForBatchedUpdatesWithAct(); + fireEvent.changeText(screen.getByLabelText(translateLocal('iou.amount')), '43'); + await waitForBatchedUpdatesWithAct(); + + // And traverses to the second receipt, which has nothing filled in, so it opens collapsed as usual + const [, nextButton] = screen.getAllByRole(CONST.ROLE.BUTTON, {name: CONST.ROLE.BUTTON}); + fireEvent.press(nextButton); + expect(await screen.findByText(`2 ${of} 2`)).toBeOnTheScreen(); + expect(screen.queryByLabelText(translateLocal('iou.amount'))).not.toBeOnTheScreen(); + + // Then coming back to the first receipt shows its filled-in fields right away instead of hiding them behind "Show more" + const [prevButton] = screen.getAllByRole(CONST.ROLE.BUTTON, {name: CONST.ROLE.BUTTON}); + fireEvent.press(prevButton); + expect(await screen.findByText(`1 ${of} 2`)).toBeOnTheScreen(); + expect(screen.getByLabelText(translateLocal('iou.amount'))).toHaveDisplayValue('43.00'); + expect(screen.queryByText(translateLocal('common.showMore'))).not.toBeOnTheScreen(); + }); + + it('keeps a receipt expanded once the user opened it, even with nothing filled in, when traversing away and back (multi-scan)', async () => { + // Given two scanned drafts confirmed together, on a surface that exposes the scan fields + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}1`, { + ...DEFAULT_SPLIT_TRANSACTION, + transactionID: '1', + isAmountSet: undefined, + iouRequestType: 'scan', + receipt: {filename: 'receipt1.jpg', source: 'path/to/receipt1.jpg', type: ''}, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}2`, { + ...DEFAULT_SPLIT_TRANSACTION, + transactionID: '2', + isAmountSet: undefined, + iouRequestType: 'scan', + receipt: {filename: 'receipt2.jpg', source: 'path/to/receipt2.jpg', type: ''}, + }); + }); + + render( + + + + + + + + + , + ); + + await waitForBatchedUpdatesWithAct(); + + const of = translateLocal('common.of'); + expect(await screen.findByText(`1 ${of} 2`)).toBeOnTheScreen(); + + // When the user moves to the second receipt and opens its fields without entering anything + const [, nextButton] = screen.getAllByRole(CONST.ROLE.BUTTON, {name: CONST.ROLE.BUTTON}); + fireEvent.press(nextButton); + expect(await screen.findByText(`2 ${of} 2`)).toBeOnTheScreen(); + fireEvent.press(screen.getByText(translateLocal('common.showMore'))); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByLabelText(translateLocal('iou.amount'))).toBeOnTheScreen(); + + // And goes back to the first receipt, which they never opened, so it is still collapsed + const [prevButton] = screen.getAllByRole(CONST.ROLE.BUTTON, {name: CONST.ROLE.BUTTON}); + fireEvent.press(prevButton); + expect(await screen.findByText(`1 ${of} 2`)).toBeOnTheScreen(); + expect(screen.queryByLabelText(translateLocal('iou.amount'))).not.toBeOnTheScreen(); + + // Then returning to the second receipt shows it still expanded, the way the user left it + const [, nextButtonAgain] = screen.getAllByRole(CONST.ROLE.BUTTON, {name: CONST.ROLE.BUTTON}); + fireEvent.press(nextButtonAgain); + expect(await screen.findByText(`2 ${of} 2`)).toBeOnTheScreen(); + expect(screen.getByLabelText(translateLocal('iou.amount'))).toBeOnTheScreen(); + expect(screen.queryByText(translateLocal('common.showMore'))).not.toBeOnTheScreen(); + }); + it('switches the displayed transaction when pressing the Next and Previous buttons', async () => { // Given two scanned draft transactions, so the confirmation renders in its multi-transaction mode await act(async () => { diff --git a/tests/ui/components/LHNOptionsListTest.tsx b/tests/ui/components/LHNOptionsListTest.tsx index b6c3601e0bfa..71c19e52b9d2 100644 --- a/tests/ui/components/LHNOptionsListTest.tsx +++ b/tests/ui/components/LHNOptionsListTest.tsx @@ -90,7 +90,7 @@ describe('LHNOptionsList', () => { const mockReport = getFakeReport([1, 2], 0, false); const defaultProps: LHNOptionsListProps = { - data: [mockReport], + data: [mockReport.reportID], onSelectRow: jest.fn(), optionMode: CONST.OPTION_MODE.DEFAULT, onFirstItemRendered: jest.fn(), @@ -118,13 +118,17 @@ describe('LHNOptionsList', () => { ); }; - beforeEach(() => { + beforeEach(async () => { act(() => { Onyx.init({ keys: ONYXKEYS, }); }); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`, mockReport); + }); + jest.clearAllMocks(); }); @@ -265,7 +269,7 @@ describe('LHNOptionsList', () => { }); // When the LHNOptionsList is rendered - render(getLHNOptionsListElement({data: [report]})); + render(getLHNOptionsListElement({data: [report.reportID]})); // Then the queued message should be displayed because DEW submissions are processed async and the user needs feedback const reportItem = await waitFor(() => getReportItem(reportID)); @@ -322,7 +326,7 @@ describe('LHNOptionsList', () => { }); // When the LHNOptionsList is rendered - render(getLHNOptionsListElement({data: [report]})); + render(getLHNOptionsListElement({data: [report.reportID]})); // Then the queued message should NOT appear because the server processes DEW submissions immediately when online const reportItem = await waitFor(() => getReportItem(reportID)); @@ -389,7 +393,7 @@ describe('LHNOptionsList', () => { }); // When the LHNOptionsList renders the workspace thread - render(getLHNOptionsListElement({data: [threadReport]})); + render(getLHNOptionsListElement({data: [threadReport.reportID]})); // Then it should render a single avatar, not a diagonal (multiple) avatar await waitFor(() => { @@ -465,7 +469,7 @@ describe('LHNOptionsList', () => { }); // When the LHNOptionsList renders the expense request thread - render(getLHNOptionsListElement({data: [threadReport]})); + render(getLHNOptionsListElement({data: [threadReport.reportID]})); // Then it should render a subscript avatar (workspace expense requests preserve subscript) await waitFor(() => { @@ -530,7 +534,7 @@ describe('LHNOptionsList', () => { }); // When the LHNOptionsList renders the task report - render(getLHNOptionsListElement({data: [taskReport]})); + render(getLHNOptionsListElement({data: [taskReport.reportID]})); // Then it should render the owner alone, without the workspace subscript await waitFor(() => { @@ -603,7 +607,7 @@ describe('LHNOptionsList', () => { }); // When the LHNOptionsList renders the task report - render(getLHNOptionsListElement({data: [taskReport]})); + render(getLHNOptionsListElement({data: [taskReport.reportID]})); // Then the row shows the copilot alone, badged as acting for the owner, like the task header await waitFor(() => { @@ -670,7 +674,7 @@ describe('LHNOptionsList', () => { }); // When the LHNOptionsList renders the invoice report - render(getLHNOptionsListElement({data: [invoiceReport]})); + render(getLHNOptionsListElement({data: [invoiceReport.reportID]})); // Then it should render a subscript avatar (workspace icon + user avatar) await waitFor(() => { @@ -719,7 +723,7 @@ describe('LHNOptionsList', () => { }); // When the LHNOptionsList renders the IOU report - render(getLHNOptionsListElement({data: [report]})); + render(getLHNOptionsListElement({data: [report.reportID]})); // Then it should render diagonal (multiple) avatars await waitFor(() => { diff --git a/tests/ui/components/MenuItem/MenuItemCopyTest.tsx b/tests/ui/components/MenuItem/MenuItemCopyTest.tsx index f3ac1d350868..957901c93c35 100644 --- a/tests/ui/components/MenuItem/MenuItemCopyTest.tsx +++ b/tests/ui/components/MenuItem/MenuItemCopyTest.tsx @@ -3,11 +3,16 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; + +import type * as CopyableTextRowPress from '@hooks/useCopyableTextRowPress'; +import useCopyableTextRowPress from '@hooks/useCopyableTextRowPress'; import Clipboard from '@libs/Clipboard'; import ControlSelection from '@libs/ControlSelection'; import type * as DeviceCapabilities from '@libs/DeviceCapabilities'; import {canUseTouchScreen, hasHoverSupport} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; @@ -26,6 +31,7 @@ let mockShouldUseNarrowLayout = false; jest.mock('@hooks/useLazyAsset', () => ({ useMemoizedLazyExpensifyIcons: jest.fn(() => ({ Copy: () => null, + NewWindow: () => null, })), })); @@ -60,6 +66,16 @@ jest.mock('@hooks/useResponsiveLayout', () => ({ default: () => ({shouldUseNarrowLayout: mockShouldUseNarrowLayout}), })); +jest.mock('@libs/getPlatform', () => jest.fn()); + +jest.mock('@hooks/useCopyableTextRowPress', () => { + const actual = jest.requireActual('../../../../src/hooks/useCopyableTextRowPress/index.ts'); + return {...actual, __esModule: true, default: jest.fn(actual.default)}; +}); + +const actualUseCopyableTextRowPress = jest.requireActual('../../../../src/hooks/useCopyableTextRowPress/index.ts').default; +const mockedUseCopyableTextRowPress = jest.mocked(useCopyableTextRowPress); +const mockedGetPlatform = jest.mocked(getPlatform); const mockedHasHoverSupport = jest.mocked(hasHoverSupport); const mockedCanUseTouchScreen = jest.mocked(canUseTouchScreen); const mockedShowContextMenu = jest.mocked(showContextMenu); @@ -70,13 +86,14 @@ const NAME = 'Confirmation'; const VALUE = 'CONF-12345'; const ROW_TEST_ID = 'copyable-row'; -function Row() { +function Row({isValueSelectable = false}: {isValueSelectable?: boolean}) { return ( @@ -84,7 +101,41 @@ function Row() { ); } -/** A long press reaches the row's handler through `PressableWithSecondaryInteraction`, which calls `preventDefault` first */ +function LegacyRow({isTitleSelectable = false}: {isTitleSelectable?: boolean}) { + const legacyMenuItem = ( + // eslint-disable-next-line @typescript-eslint/no-deprecated -- This verifies backward-compatible selection behavior in the legacy MenuItem implementation. + + ); + + return {legacyMenuItem}; +} + +function mockCopyableTextHitTest(isPressStartOnCopyableText: (event: unknown) => boolean) { + mockedUseCopyableTextRowPress.mockImplementation((options) => { + const hook = actualUseCopyableTextRowPress(options); + const copyableTextElement = document.createElement('span'); + copyableTextElement.setAttribute(`data-${CONST.COPYABLE_TEXT_ELEMENT}`, 'true'); + + return { + ...hook, + isPressStartOnCopyableText, + markTouchStartOnCopyableText: (_event, shouldCheck = true) => hook.markTouchStartOnCopyableText({target: copyableTextElement}, shouldCheck), + }; + }); +} + +/** A long press reaches the row's handler through `PressableWithSecondaryInteraction`. */ function longPressRow() { fireEvent(screen.getByTestId(ROW_TEST_ID), 'longPress', {preventDefault: () => {}}); } @@ -100,6 +151,8 @@ describe('MenuItem.Copy', () => { mockShouldUseNarrowLayout = false; mockedHasHoverSupport.mockReturnValue(true); mockedCanUseTouchScreen.mockReturnValue(false); + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.IOS); + mockedUseCopyableTextRowPress.mockImplementation(actualUseCopyableTextRowPress); }); describe('on a device with hover support', () => { @@ -140,6 +193,56 @@ describe('MenuItem.Copy', () => { mockShouldUseNarrowLayout = true; }); + it.each(['compound', 'legacy'])('preserves native selection and restores the %s row copy action on the next gesture', (implementation) => { + // Given a mobile-web field with a registered copy action and selectable value + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + const isPressStartOnCopyableText = jest.fn().mockReturnValueOnce(true).mockReturnValue(false); + mockCopyableTextHitTest(isPressStartOnCopyableText); + render(implementation === 'legacy' ? : ); + expect(screen.UNSAFE_getByType(PressableWithFeedback).props.onLongPress).toEqual(expect.any(Function)); + + // When a touch starts on the value, the browser must be able to open its native selection menu + fireEvent(screen.getByTestId(ROW_TEST_ID), 'touchStart', {nativeEvent: {}}); + // RN Web normalizes responder coordinates to pageX/pageY rather than clientX/clientY. + fireEvent(screen.getByTestId(ROW_TEST_ID), 'pressIn', {nativeEvent: {pageX: 10, pageY: 10, touches: [{pageX: 10, pageY: 10}]}}); + + // Then no long-press handler or global selection blocker interferes with native selection + expect(screen.UNSAFE_getByType(PressableWithFeedback).props.onLongPress).toBeUndefined(); + expect(mockedBlockSelection).not.toHaveBeenCalled(); + fireEvent(screen.getByTestId(ROW_TEST_ID), 'pressOut', {nativeEvent: {}}); + expect(screen.UNSAFE_getByType(PressableWithFeedback).props.onLongPress).toBeUndefined(); + expect(mockedShowContextMenu).not.toHaveBeenCalled(); + + // When the next gesture starts outside the copyable value + fireEvent(screen.getByTestId(ROW_TEST_ID), 'touchStart', {nativeEvent: {}}); + fireEvent(screen.getByTestId(ROW_TEST_ID), 'pressIn', {nativeEvent: {}}); + longPressRow(); + + // Then the ordinary row selection blocker and copy context menu work again + expect(mockedBlockSelection).toHaveBeenCalledTimes(1); + expect(mockedShowContextMenu).toHaveBeenCalledTimes(1); + expect(mockedShowContextMenu).toHaveBeenCalledWith(expect.objectContaining({selection: VALUE})); + }); + + it.each([ + {platform: CONST.PLATFORM.WEB, isTitleSelectable: false}, + {platform: CONST.PLATFORM.IOS, isTitleSelectable: true}, + {platform: CONST.PLATFORM.ANDROID, isTitleSelectable: true}, + ])('preserves legacy row interactions on $platform with isTitleSelectable=$isTitleSelectable', ({platform, isTitleSelectable}) => { + // Given a native row or a web row that has not opted into text selection + mockedGetPlatform.mockReturnValue(platform); + mockCopyableTextHitTest(() => true); + render(); + + // When the row is touched + fireEvent(screen.getByTestId(ROW_TEST_ID), 'touchStart', {nativeEvent: {}}); + fireEvent(screen.getByTestId(ROW_TEST_ID), 'pressIn', {nativeEvent: {}}); + + // Then browser-only selection handling does not detach its long-press action or skip its blocker + expect(screen.UNSAFE_getByType(PressableWithFeedback).props.onLongPress).toEqual(expect.any(Function)); + expect(mockedBlockSelection).toHaveBeenCalledTimes(1); + }); + it('renders no copy button, as there is no hover to reveal it', () => { mockIsHovered = true; diff --git a/tests/ui/components/MenuItem/MenuItemFieldTest.tsx b/tests/ui/components/MenuItem/MenuItemFieldTest.tsx index 86c36815ec57..7805445194f3 100644 --- a/tests/ui/components/MenuItem/MenuItemFieldTest.tsx +++ b/tests/ui/components/MenuItem/MenuItemFieldTest.tsx @@ -1,12 +1,17 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import MenuItem from '@components/MenuItem'; import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import Text from '@components/Text'; +import getPlatform from '@libs/getPlatform'; + import CONST from '@src/CONST'; +import type {StyleProp, TextStyle, ViewProps} from 'react-native'; + import React from 'react'; jest.mock('@hooks/useLazyAsset', () => ({ @@ -15,16 +20,45 @@ jest.mock('@hooks/useLazyAsset', () => ({ })), })); +jest.mock('@libs/getPlatform', () => jest.fn()); + +const mockedGetPlatform = jest.mocked(getPlatform); const CHEVRON_TEST_ID = 'menu-item-chevron'; const pressEvent = {nativeEvent: {}}; const NAME = 'Legal first name'; const VALUE = 'John'; +type MockFormHelpMessageProps = { + message?: React.ReactNode; + messageStyle?: StyleProp; + dataSet?: ViewProps['dataSet']; +}; + +const mockFormHelpMessage = jest.fn(({message, messageStyle}: MockFormHelpMessageProps) => { + const RN = jest.requireActual< + Record< + string, + React.ComponentType<{ + children?: React.ReactNode; + style?: StyleProp; + }> + > + >('react-native'); + return {message}; +}); + +jest.mock('@components/FormHelpMessage', () => (props: MockFormHelpMessageProps) => mockFormHelpMessage(props)); + function Wrapper({children}: {children: React.ReactNode}) { return {children}; } describe('MenuItemField', () => { + beforeEach(() => { + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockFormHelpMessage.mockClear(); + }); + describe('filled shape', () => { it('renders the name and the value', () => { render( @@ -52,6 +86,121 @@ describe('MenuItemField', () => { expect(await screen.findByLabelText(`${NAME}, ${VALUE}`)).toBeOnTheScreen(); }); + + it('announces all parts of a combined value', async () => { + render( + + + , + ); + + expect(await screen.findByLabelText(`${NAME}, ${VALUE}, Main office`)).toBeOnTheScreen(); + }); + + it('marks only the value as copyable when value selection is enabled', () => { + // Given a field value that is allowed to start native text selection + // When the field is rendered + render( + + + , + ); + + // Then only the value receives the shared copyable-text marker + expect(screen.getByText(VALUE)).toHaveStyle({userSelect: 'text'}); + expect(screen.getByText(VALUE)).toHaveProp( + 'dataSet', + expect.objectContaining({ + [CONST.COPYABLE_TEXT_ELEMENT]: true, + }), + ); + expect(screen.getByText(NAME)).not.toHaveProp( + 'dataSet', + expect.objectContaining({ + [CONST.COPYABLE_TEXT_ELEMENT]: true, + }), + ); + expect(screen.getByText(NAME)).toHaveStyle({userSelect: 'none'}); + expect(screen.getByText(NAME)).toHaveProp( + 'dataSet', + expect.objectContaining({ + [CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, + }), + ); + }); + + it.each([CONST.PLATFORM.IOS, CONST.PLATFORM.ANDROID])('keeps browser value selection disabled on %s', async (platform) => { + // Given a native platform where browser selection handling does not apply + mockedGetPlatform.mockReturnValue(platform); + const onPress = jest.fn(); + + // When a pressable field opts into browser value selection + render( + + + , + ); + + // Then native text stays unmarked and ordinary row presses still work + expect(screen.getByText(VALUE)).not.toHaveStyle({userSelect: 'text'}); + expect(screen.getByText(VALUE)).not.toHaveProp( + 'dataSet', + expect.objectContaining({ + [CONST.COPYABLE_TEXT_ELEMENT]: true, + }), + ); + expect(screen.getByText(NAME)).not.toHaveProp( + 'dataSet', + expect.objectContaining({ + [CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, + }), + ); + fireEvent.press(await screen.findByLabelText(`${NAME}, ${VALUE}`), pressEvent); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('keeps disabled interactive values excluded from selection and copied content', () => { + // Given a disabled interactive field on web + // When browser value selection is requested + render( + + {}} + isDisabled + isValueSelectable + /> + , + ); + + // Then selectable styling and metadata cannot override the disabled restriction + expect(screen.getByText(VALUE)).toHaveStyle({userSelect: 'none'}); + expect(screen.getByText(VALUE)).toHaveProp( + 'dataSet', + expect.objectContaining({ + [CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, + }), + ); + expect(screen.getByText(VALUE)).not.toHaveProp( + 'dataSet', + expect.objectContaining({ + [CONST.COPYABLE_TEXT_ELEMENT]: true, + }), + ); + }); }); describe('value line count', () => { @@ -99,6 +248,34 @@ describe('MenuItemField', () => { }); describe('empty shape', () => { + it('keeps the placeholder label not selectable when web value selection is enabled', () => { + // Given a field without a value to select + // When its row opts into browser value selection + render( + + + , + ); + + // Then the placeholder remains a label rather than a copyable value + expect(screen.getByText(NAME)).toHaveStyle({userSelect: 'none'}); + expect(screen.getByText(NAME)).toHaveProp( + 'dataSet', + expect.objectContaining({ + [CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, + }), + ); + expect(screen.getByText(NAME)).not.toHaveProp( + 'dataSet', + expect.objectContaining({ + [CONST.COPYABLE_TEXT_ELEMENT]: true, + }), + ); + }); + it.each([ ['no value prop', undefined], ['an empty value', ''], @@ -119,6 +296,51 @@ describe('MenuItemField', () => { }); }); + describe('help text selection', () => { + it('excludes help text from selection and copied content when web value selection is enabled', () => { + // Given a help message inside a row with a selectable value + const helpMessage = 'Commuter deduction applied'; + + // When the row is rendered on web + render( + + + + + , + ); + + // Then the help message cannot be highlighted and is excluded by SelectionScraper + expect(screen.getByText(helpMessage)).toHaveStyle({userSelect: 'none'}); + expect(mockFormHelpMessage.mock.calls.at(-1)?.[0].dataSet).toEqual( + expect.objectContaining({ + [CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, + }), + ); + }); + + it('does not change help text selection for an ordinary row', () => { + // Given a help message inside a row that does not enable value selection + const helpMessage = 'Validation failed'; + + // When the row is rendered + render( + + + + + , + ); + + // Then no selection-specific style or scraper marker is added + expect(screen.getByText(helpMessage)).not.toHaveStyle({userSelect: 'none'}); + expect(mockFormHelpMessage.mock.calls.at(-1)?.[0].dataSet).toBeUndefined(); + }); + }); + describe('trailing cell', () => { it('renders no chevron when the row is not pressable', () => { render( @@ -229,7 +451,8 @@ describe('MenuItemField', () => { expect(await screen.findByRole(CONST.ROLE.BUTTON, {name: `${NAME}, ${VALUE}`})).toBeOnTheScreen(); }); - it('calls onPress when pressed', async () => { + it.each([false, true])('calls onPress for a normal press with isValueSelectable=%s', async (isValueSelectable) => { + // Given a pressable field with or without native value selection enabled const onPress = jest.fn(); render( @@ -237,12 +460,15 @@ describe('MenuItemField', () => { name={NAME} value={VALUE} onPress={onPress} + isValueSelectable={isValueSelectable} /> , ); + // When the row is pressed without selecting text fireEvent.press(await screen.findByLabelText(`${NAME}, ${VALUE}`), pressEvent); + // Then the usual row action still runs once expect(onPress).toHaveBeenCalledTimes(1); }); diff --git a/tests/ui/components/MenuItemTest.tsx b/tests/ui/components/MenuItemTest.tsx index 71aaa4b5c858..22eeefce6652 100644 --- a/tests/ui/components/MenuItemTest.tsx +++ b/tests/ui/components/MenuItemTest.tsx @@ -18,6 +18,12 @@ const mockNewWindowIcon: React.FC = () => null; const mockLinkIcon: React.FC = () => null; const mockDownloadIcon: React.FC = () => null; const mockCheckmarkIcon: React.FC = () => null; +const mockRenderHTML = jest.fn(() => null); + +jest.mock('@components/RenderHTML', () => ({ + __esModule: true, + default: (props: {html: string; isSelectable?: boolean}) => mockRenderHTML(props), +})); jest.mock('@hooks/useLazyAsset', () => ({ useMemoizedLazyExpensifyIcons: jest.fn(() => ({ @@ -35,12 +41,58 @@ function Wrapper({children}: {children: React.ReactNode}) { return {children}; } +function SelectableHTMLMenuItem({isTitleSelectable = false}: {isTitleSelectable?: boolean}) { + return ( + // eslint-disable-next-line @typescript-eslint/no-deprecated -- This test file verifies behavior that remains supported by the legacy MenuItem implementation. + + ); +} + describe('MenuItem', () => { beforeEach(() => { + mockRenderHTML.mockClear(); mockedGetPlatform.mockReturnValue(CONST.PLATFORM.ANDROID); mockedGetOperatingSystem.mockReturnValue(CONST.OS.WINDOWS); }); + it('forwards selectable HTML titles to RenderHTML on web', () => { + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + + render( + + + , + ); + + expect(mockRenderHTML).toHaveBeenCalledWith(expect.objectContaining({isSelectable: true})); + }); + + it('does not override HTML title selection when the title is not selectable', () => { + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + + render( + + + , + ); + + expect(mockRenderHTML).toHaveBeenCalledWith(expect.objectContaining({isSelectable: undefined})); + }); + + it('does not override HTML title selection on native', () => { + render( + + + , + ); + + expect(mockRenderHTML).toHaveBeenCalledWith(expect.objectContaining({isSelectable: undefined})); + }); + describe('accessibility label with NewWindow icon', () => { it('appends "Opens in a new tab" to the accessibility label when iconRight is NewWindow', () => { const customLabel = 'Open external link'; diff --git a/tests/ui/components/QuickCreationActionsBarTest.tsx b/tests/ui/components/QuickCreationActionsBarTest.tsx index c6ad54f9d78d..d5c288ddb6f8 100644 --- a/tests/ui/components/QuickCreationActionsBarTest.tsx +++ b/tests/ui/components/QuickCreationActionsBarTest.tsx @@ -6,10 +6,13 @@ import {LocaleContextProvider} from '@components/LocaleContextProvider'; import QuickCreationActionsBar from '@components/Navigation/QuickCreationActionsBar'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; import {openTravelDotLink} from '@libs/openTravelDotLink'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React from 'react'; import Onyx from 'react-native-onyx'; @@ -34,6 +37,12 @@ jest.mock('@libs/Navigation/Navigation', () => ({ jest.mock('@libs/interceptAnonymousUser', () => jest.fn((callback: () => void) => callback())); +const mockCreateNewReport = jest.fn<{reportID: string}, unknown[]>(() => ({reportID: 'mock-report-id'})); +jest.mock('@libs/actions/Report', () => ({ + ...jest.requireActual>('@libs/actions/Report'), + createNewReport: (...args: unknown[]) => mockCreateNewReport(...args), +})); + jest.mock('@libs/openTravelDotLink', () => ({ openTravelDotLink: jest.fn(), shouldOpenTravelDotLinkWeb: jest.fn(() => true), @@ -44,8 +53,16 @@ jest.mock('@hooks/useConfirmModal', () => jest.fn().mockImplementation(() => ({s jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => false); +// Real usePreferredPolicy, with a switch to report the domain security group as still loading +const mockSecurityGroupLoading = {value: false}; +jest.mock('@hooks/usePreferredPolicy', () => { + const {default: actualUsePreferredPolicy} = jest.requireActual<{default: () => Record}>('@hooks/usePreferredPolicy'); + return () => ({...actualUsePreferredPolicy(), ...(mockSecurityGroupLoading.value ? {isLoadingPreferredPolicy: true} : {})}); +}); + const CURRENT_USER_ACCOUNT_ID = 1; const CURRENT_USER_EMAIL = 'user@test.com'; +const CURRENT_USER_DOMAIN = 'test.com'; const MOCK_POLICY_ID = 'policy-123'; function renderComponent() { @@ -280,10 +297,11 @@ describe('QuickCreationActionsBar - button identifiers', () => { }); it('routes a press on the test-ID-targeted travel button to the travel flow', async () => { - // When the travel button is pressed by its test ID + // Given the travel-ready default workspace set up in beforeEach renderComponent(); await waitForBatchedUpdatesWithAct(); + // When the travel button is pressed by its test ID fireEvent.press(screen.getByTestId(CONST.TEST_ID.QUICK_CREATION_ACTIONS_BAR.BOOK_TRAVEL)); await waitForBatchedUpdatesWithAct(); @@ -291,3 +309,73 @@ describe('QuickCreationActionsBar - button identifiers', () => { expect(openTravelDotLink).toHaveBeenCalledWith(TRAVEL_POLICY_ID); }); }); + +describe('QuickCreationActionsBar - domain preferred workspace', () => { + const PREFERRED_POLICY_ID = 'preferred-policy'; + const makeTeamPolicy = (id: string) => ({ + id, + name: `${id} workspace`, + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + pendingAction: null, + owner: 'owner@test.com', + outputCurrency: CONST.CURRENCY.USD, + }); + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + afterEach(async () => { + jest.clearAllMocks(); + mockSecurityGroupLoading.value = false; + await act(async () => { + await Onyx.clear(); + }); + await waitForBatchedUpdatesWithAct(); + }); + + it('keeps the Report button in place but disabled while the domain security group is still loading', async () => { + // Given a user with a workspace whose domain security group has not loaded yet, so a lock would read as "not restricted" + mockSecurityGroupLoading.value = true; + await act(async () => { + await Onyx.merge(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID, email: CURRENT_USER_EMAIL}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`, makeTeamPolicy(MOCK_POLICY_ID)); + await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, MOCK_POLICY_ID); + }); + await waitForBatchedUpdatesWithAct(); + + // When the Home quick actions render + renderComponent(); + await waitForBatchedUpdatesWithAct(); + + // Then the Report button stays in the bar (no reflow) but is disabled, so a press can't create on the wrong workspace + expect(screen.getByTestId(CONST.TEST_ID.QUICK_CREATION_ACTIONS_BAR.REPORT)).toBeDisabled(); + expect(mockCreateNewReport).not.toHaveBeenCalled(); + }); + + it('creates the report on the preferred workspace without opening the selector', async () => { + // Given a member of three workspaces whose domain security group locks them to a workspace that is not their active one + await act(async () => { + await Onyx.merge(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID, email: CURRENT_USER_EMAIL}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`, makeTeamPolicy(MOCK_POLICY_ID)); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}other-policy`, makeTeamPolicy('other-policy')); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${PREFERRED_POLICY_ID}`, makeTeamPolicy(PREFERRED_POLICY_ID)); + await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, MOCK_POLICY_ID); + await Onyx.merge(ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS, {[CURRENT_USER_DOMAIN]: {securityGroupID: 'group-1', ownerAccountID: 42}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_SECURITY_GROUP}group-1_42`, {enableRestrictedPrimaryPolicy: true, restrictedPrimaryPolicyID: PREFERRED_POLICY_ID}); + }); + await waitForBatchedUpdatesWithAct(); + + // When the Home "Report" quick action is pressed + renderComponent(); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByText(translateLocal('common.report'))); + await waitForBatchedUpdatesWithAct(); + + // Then the report goes to the preferred workspace, which the domain made the user's default, and no selector opens + expect(mockCreateNewReport).toHaveBeenCalledTimes(1); + expect(mockCreateNewReport.mock.calls.at(0)?.at(3)).toEqual(expect.objectContaining({id: PREFERRED_POLICY_ID})); + expect(Navigation.navigate).not.toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.NEW_REPORT_WORKSPACE_SELECTION.path)); + }); +}); diff --git a/tests/ui/components/ReceiptAuditTest.tsx b/tests/ui/components/ReceiptAuditTest.tsx new file mode 100644 index 000000000000..65abce7b733c --- /dev/null +++ b/tests/ui/components/ReceiptAuditTest.tsx @@ -0,0 +1,35 @@ +import {render, screen} from '@testing-library/react-native'; + +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import ReceiptAudit from '@components/ReceiptAudit'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +import {translateLocal} from '../../utils/TestHelper'; + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({})), +})); + +describe('ReceiptAudit', () => { + it('excludes the receipt label from browser and scraper selection', () => { + // Given a receipt audit label in an expense details row + + // When the receipt audit is rendered + render( + + + , + ); + + // Then the label cannot be selected by the browser or included by SelectionScraper + const receiptLabel = screen.getByText(translateLocal('common.receipt')); + expect(receiptLabel).toHaveStyle({userSelect: 'none'}); + expect(receiptLabel).toHaveProp('dataSet', {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}); + }); +}); diff --git a/tests/ui/components/ReceiptEmptyStateTest.tsx b/tests/ui/components/ReceiptEmptyStateTest.tsx index 83c492d5c570..9199bfbffc7d 100644 --- a/tests/ui/components/ReceiptEmptyStateTest.tsx +++ b/tests/ui/components/ReceiptEmptyStateTest.tsx @@ -1,4 +1,4 @@ -import {fireEvent, render, screen} from '@testing-library/react-native'; +import {fireEvent, render, screen, within} from '@testing-library/react-native'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import ReceiptEmptyState from '@components/ReceiptEmptyState'; @@ -6,6 +6,7 @@ import ReceiptEmptyState from '@components/ReceiptEmptyState'; import type {FileObject} from '@src/types/utils/Attachment'; import React from 'react'; +import {View as MockView} from 'react-native'; import {translateLocal} from '../../utils/TestHelper'; @@ -24,13 +25,7 @@ jest.mock('@hooks/useFilesValidation', () => (onFilesValidated: (files: FileObje })); // ReceiptAlternativeMethods uses RenderHTML which requires TRenderEngineProvider (buildTTree). Mock to avoid the error. -jest.mock( - '@components/ReceiptAlternativeMethods', - () => - function MockReceiptAlternativeMethods() { - return null; - }, -); +jest.mock('@components/ReceiptAlternativeMethods', () => () => ); function Wrapper({children}: {children: React.ReactNode}) { return {children}; @@ -42,7 +37,24 @@ describe('ReceiptEmptyState', () => { }); describe('wide RHP attachment picker flow', () => { + it('keeps alternative receipt methods outside the upload button', () => { + // Given an editable wide receipt panel with alternative actions. + render( + + + , + ); + + // When the upload button and alternative methods are inspected. + const uploadButton = screen.getByRole('button', {name: translateLocal('receipt.upload')}); + + // Then alternative links cannot become nested interactive button content. + expect(screen.getByTestId('receipt-alternative-methods')).toBeOnTheScreen(); + expect(within(uploadButton).queryByTestId('receipt-alternative-methods')).toBeNull(); + }); + it('calls openPicker when pressed and isDisplayedInWideRHP is true', () => { + // Given a wide receipt panel with its own picker action. render( { , ); - const uploadButton = screen.getByLabelText(translateLocal('receipt.upload')); + const uploadButton = screen.getByRole('button', {name: translateLocal('receipt.upload')}); + // When its upload control is activated. fireEvent.press(uploadButton); + // Then the picker, not a missing caller callback, handles the action. expect(mockOpenPicker).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Jest matcher for callback expect(mockOpenPicker).toHaveBeenCalledWith({onPicked: expect.any(Function)}); }); it('does not call openPicker when isDisplayedInWideRHP is false and calls onPress instead', () => { + // Given an upload control with a caller-owned navigation action. const onPress = jest.fn(); render( @@ -71,9 +86,11 @@ describe('ReceiptEmptyState', () => { , ); - const uploadButton = screen.getByLabelText(translateLocal('receipt.upload')); + const uploadButton = screen.getByRole('button', {name: translateLocal('receipt.upload')}); + // When that control is activated. fireEvent.press(uploadButton); + // Then its callback runs instead of the wide-panel picker. expect(mockOpenPicker).not.toHaveBeenCalled(); expect(onPress).toHaveBeenCalledTimes(1); }); @@ -94,6 +111,7 @@ describe('ReceiptEmptyState', () => { }); it('does not show "Add a receipt" text when isDisplayedInWideRHP is false', () => { + // Given a receipt placeholder without an upload action. render( { , ); + // When its accessible output is inspected. + // Then it remains a static placeholder, not a misleading upload button. expect(screen.queryByText(translateLocal('receipt.addAReceipt.phrase1'))).not.toBeOnTheScreen(); expect(screen.queryByText(translateLocal('receipt.addAReceipt.phrase2'))).not.toBeOnTheScreen(); + expect(screen.getByLabelText(translateLocal('receipt.upload'))).not.toHaveProp('accessibilityRole', 'button'); }); }); }); diff --git a/tests/ui/components/SearchActionsBarCreateButtonTest.tsx b/tests/ui/components/SearchActionsBarCreateButtonTest.tsx index 2b54c1693cc2..79d46e5a0306 100644 --- a/tests/ui/components/SearchActionsBarCreateButtonTest.tsx +++ b/tests/ui/components/SearchActionsBarCreateButtonTest.tsx @@ -6,8 +6,6 @@ import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import SearchActionsBarCreateButton from '@components/Search/SearchPageHeader/SearchActionsBarCreateButton'; -import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; - import {createNewReport} from '@libs/actions/Report'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; @@ -48,8 +46,6 @@ jest.mock('@libs/actions/Link', () => ({ openOldDotLink: jest.fn(), })); -jest.mock('@hooks/usePolicyForMovingExpenses'); - jest.mock('@hooks/usePopoverPosition', () => () => ({ calculatePopoverPosition: jest.fn(() => Promise.resolve({horizontal: 0, vertical: 0})), })); @@ -61,13 +57,20 @@ jest.mock('@hooks/useCreateEmptyReportConfirmation', () => () => ({ jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => true); -const mockUsePolicyForMovingExpenses = jest.mocked(usePolicyForMovingExpenses); +// Real usePreferredPolicy, with a switch to report the domain security group as still loading +const mockSecurityGroupLoading = {value: false}; +jest.mock('@hooks/usePreferredPolicy', () => { + const {default: actualUsePreferredPolicy} = jest.requireActual<{default: () => Record}>('@hooks/usePreferredPolicy'); + return () => ({...actualUsePreferredPolicy(), ...(mockSecurityGroupLoading.value ? {isLoadingPreferredPolicy: true} : {})}); +}); + const mockNavigate = jest.mocked(Navigation.navigate); const mockCreateNewReport = jest.mocked(createNewReport); const mockInterceptAnonymousUser = jest.mocked(interceptAnonymousUser); const CURRENT_USER_ACCOUNT_ID = 1; const CURRENT_USER_EMAIL = 'user@test.com'; +const CURRENT_USER_DOMAIN = 'test.com'; const MOCK_POLICY_ID = 'policy-123'; const MOCK_POLICY = { @@ -108,13 +111,6 @@ describe('SearchActionsBarCreateButton', () => { }); beforeEach(async () => { - mockUsePolicyForMovingExpenses.mockReturnValue({ - policyForMovingExpensesID: undefined, - policyForMovingExpenses: undefined, - shouldSelectPolicy: false, - shouldNavigateToUpgradePath: true, - }); - await act(async () => { await Onyx.merge(ONYXKEYS.SESSION, { accountID: CURRENT_USER_ACCOUNT_ID, @@ -126,14 +122,15 @@ describe('SearchActionsBarCreateButton', () => { afterEach(async () => { jest.clearAllMocks(); + mockSecurityGroupLoading.value = false; await act(async () => { await Onyx.clear(); }); await waitForBatchedUpdatesWithAct(); }); - it('should always show the "Create report" menu item', async () => { - // When component is rendered + it('should show the "Create report" menu item once workspaces and domain settings have loaded', async () => { + // Given workspaces and the domain security group have finished loading renderComponent(); await waitForBatchedUpdatesWithAct(); @@ -146,8 +143,23 @@ describe('SearchActionsBarCreateButton', () => { expect(screen.getByText(translateLocal('report.newReport.createReport'))).toBeOnTheScreen(); }); + it('should hide the "Create report" menu item while the domain security group is still loading', async () => { + // Given the domain security group has not loaded yet, so a preferred-workspace lock would read as "not restricted" + mockSecurityGroupLoading.value = true; + + // When component is rendered and the Create menu is opened + renderComponent(); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByText(translateLocal('common.create'))); + await waitForBatchedUpdatesWithAct(); + + // Then "Create report" is not offered, so it can't create on the wrong workspace or silently do nothing + expect(screen.queryByText(translateLocal('report.newReport.createReport'))).not.toBeOnTheScreen(); + expect(screen.getByText(translateLocal('iou.createExpense'))).toBeOnTheScreen(); + }); + it('should navigate to upgrade path when no valid policy exists', async () => { - // Given no policies exist (shouldNavigateToUpgradePath = true) + // Given the user has no report-eligible workspace in Onyx // When component is rendered renderComponent(); @@ -169,13 +181,6 @@ describe('SearchActionsBarCreateButton', () => { it('should navigate to workspace selector when no default policy and multiple workspaces exist', async () => { // Given user has multiple policies (shouldSelectPolicy = true, but no single default) - mockUsePolicyForMovingExpenses.mockReturnValue({ - policyForMovingExpensesID: 'some-policy', - policyForMovingExpenses: MOCK_POLICY, - shouldSelectPolicy: false, - shouldNavigateToUpgradePath: false, - }); - // Set up multiple eligible group workspaces await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`, MOCK_POLICY); @@ -207,13 +212,6 @@ describe('SearchActionsBarCreateButton', () => { it('should create report directly when a single default workspace exists', async () => { // Given user has a single valid policy - mockUsePolicyForMovingExpenses.mockReturnValue({ - policyForMovingExpensesID: MOCK_POLICY_ID, - policyForMovingExpenses: MOCK_POLICY, - shouldSelectPolicy: false, - shouldNavigateToUpgradePath: false, - }); - await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`, MOCK_POLICY); await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, MOCK_POLICY_ID); @@ -240,12 +238,6 @@ describe('SearchActionsBarCreateButton', () => { it('should open the empty report confirmation when an empty report appears while the menu is closed', async () => { // Given user has a single valid policy and the component is rendered with the menu closed, so the empty report check is skipped - mockUsePolicyForMovingExpenses.mockReturnValue({ - policyForMovingExpensesID: MOCK_POLICY_ID, - policyForMovingExpenses: MOCK_POLICY, - shouldSelectPolicy: false, - shouldNavigateToUpgradePath: false, - }); await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`, MOCK_POLICY); await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, MOCK_POLICY_ID); @@ -315,13 +307,6 @@ describe('SearchActionsBarCreateButton', () => { }); it('should call startMoneyRequest when "Create expense" is pressed', async () => { - mockUsePolicyForMovingExpenses.mockReturnValue({ - policyForMovingExpensesID: MOCK_POLICY_ID, - policyForMovingExpenses: MOCK_POLICY, - shouldSelectPolicy: false, - shouldNavigateToUpgradePath: false, - }); - await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`, MOCK_POLICY); }); @@ -345,13 +330,6 @@ describe('SearchActionsBarCreateButton', () => { // Given the current user owns a workspace that is past due billing with an outstanding amount const pastDueGracePeriod = getUnixTime(subDays(new Date(), 3)); - mockUsePolicyForMovingExpenses.mockReturnValue({ - policyForMovingExpensesID: MOCK_POLICY_ID, - policyForMovingExpenses: MOCK_POLICY, - shouldSelectPolicy: false, - shouldNavigateToUpgradePath: false, - }); - await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`, { ...MOCK_POLICY, @@ -386,13 +364,6 @@ describe('SearchActionsBarCreateButton', () => { }); it('should open confirmation modal when an empty report exists and confirmation is not dismissed', async () => { - mockUsePolicyForMovingExpenses.mockReturnValue({ - policyForMovingExpensesID: MOCK_POLICY_ID, - policyForMovingExpenses: MOCK_POLICY, - shouldSelectPolicy: false, - shouldNavigateToUpgradePath: false, - }); - await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`, MOCK_POLICY); await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, MOCK_POLICY_ID); @@ -425,13 +396,6 @@ describe('SearchActionsBarCreateButton', () => { }); it('should not open confirmation modal when confirmation has been dismissed', async () => { - mockUsePolicyForMovingExpenses.mockReturnValue({ - policyForMovingExpensesID: MOCK_POLICY_ID, - policyForMovingExpenses: MOCK_POLICY, - shouldSelectPolicy: false, - shouldNavigateToUpgradePath: false, - }); - await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`, MOCK_POLICY); await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, MOCK_POLICY_ID); @@ -467,13 +431,6 @@ describe('SearchActionsBarCreateButton', () => { // Given the current user owns a single workspace that is past due billing with an outstanding amount const pastDueGracePeriod = getUnixTime(subDays(new Date(), 3)); - mockUsePolicyForMovingExpenses.mockReturnValue({ - policyForMovingExpensesID: MOCK_POLICY_ID, - policyForMovingExpenses: MOCK_POLICY, - shouldSelectPolicy: false, - shouldNavigateToUpgradePath: false, - }); - await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`, { ...MOCK_POLICY, @@ -500,4 +457,34 @@ describe('SearchActionsBarCreateButton', () => { // Then it navigates to the restricted action page for the single restricted workspace expect(mockNavigate).toHaveBeenCalledWith(ROUTES.RESTRICTED_ACTION.getRoute(MOCK_POLICY_ID)); }); + + it('creates the report on the domain preferred workspace without opening the selector', async () => { + // Given a member of three workspaces whose domain security group locks them to a workspace that is not their active one + const preferredPolicyID = 'preferred-policy'; + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`, MOCK_POLICY); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}other-policy`, {...MOCK_POLICY, id: 'other-policy'}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${preferredPolicyID}`, {...MOCK_POLICY, id: preferredPolicyID, name: 'Preferred Workspace'}); + await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, MOCK_POLICY_ID); + await Onyx.merge(ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS, {[CURRENT_USER_DOMAIN]: {securityGroupID: 'group-1', ownerAccountID: 42}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_SECURITY_GROUP}group-1_42`, {enableRestrictedPrimaryPolicy: true, restrictedPrimaryPolicyID: preferredPolicyID}); + }); + await waitForBatchedUpdatesWithAct(); + + // When "Create report" is pressed from the Search header Create menu + renderComponent(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByText(translateLocal('common.create'))); + await waitForBatchedUpdatesWithAct(); + + const createReportItem = screen.getByText(translateLocal('report.newReport.createReport')); + fireEvent.press(createReportItem, createMockPressEvent(createReportItem)); + await waitForBatchedUpdatesWithAct(); + + // Then the report goes to the preferred workspace, which the domain made the user's default, and no selector opens + expect(mockCreateNewReport).toHaveBeenCalledTimes(1); + expect(mockCreateNewReport.mock.calls.at(0)?.at(3)).toEqual(expect.objectContaining({id: preferredPolicyID})); + expect(mockNavigate).not.toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.NEW_REPORT_WORKSPACE_SELECTION.path)); + }); }); diff --git a/tests/ui/components/SearchAutocompleteInputTest.tsx b/tests/ui/components/SearchAutocompleteInputTest.tsx new file mode 100644 index 000000000000..016653b4ab9b --- /dev/null +++ b/tests/ui/components/SearchAutocompleteInputTest.tsx @@ -0,0 +1,229 @@ +/** Exercises the search input's border worklet and ref/callback behavior with rendering and native runtime dependencies below the component. */ +import {act, render, screen} from '@testing-library/react-native'; + +import SearchAutocompleteInput from '@components/Search/SearchAutocompleteInput'; +import TextInput from '@components/TextInput'; +import type {BaseTextInputProps, BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; + +import useFocusAfterNav from '@hooks/useFocusAfterNav'; + +import {setSearchContext} from '@libs/actions/Search'; + +import type {ThemeColors} from '@styles/theme/types'; + +import type {ForwardedRef} from 'react'; +import type {View as NativeView, ViewProps} from 'react-native'; +import type * as Reanimated from 'react-native-reanimated'; +import type * as ReanimatedColors from 'react-native-reanimated/lib/typescript/common/style/processors/colors'; +import type * as ReanimatedInterpolation from 'react-native-reanimated/lib/typescript/interpolateColor'; + +import React, {createRef} from 'react'; +import {StyleSheet} from 'react-native'; +import {DynamicColorIOS, PlatformColor, useAnimatedStyle} from 'react-native-reanimated'; + +import createMock from '../../utils/createMock'; + +jest.mock('@components/TextInput', () => { + const react = jest.requireActual('react'); + const {View} = jest.requireActual<{View: typeof NativeView}>('react-native'); + return jest.fn((props: BaseTextInputProps) => react.createElement(View, {testID: props.testID})); +}); +jest.mock('@components/FormHelpMessage', () => () => null); +jest.mock('@hooks/useTheme', () => () => jest.requireActual<{defaultTheme: ThemeColors}>('@styles/theme').defaultTheme); +jest.mock('@hooks/useThemeStyles', () => () => ({searchRouterTextInputContainer: {borderWidth: 1}})); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({displayName: 'Current User'})); +jest.mock('@hooks/useCurrencyList', () => ({useCurrencyListState: () => ({currencyList: {}})})); +jest.mock('@hooks/useExportedToFilterOptions', () => () => ({exportedToFilterOptions: []})); +jest.mock('@hooks/useOnyx', () => () => [undefined]); +jest.mock('@hooks/useFocusAfterNav', () => jest.fn(() => false)); +jest.mock('@libs/actions/Search', () => ({setSearchContext: jest.fn()})); + +// Keep installed color processing and interpolation real without initializing the native Reanimated module. +jest.mock('react-native-reanimated', () => { + const mock = jest.requireActual('react-native-reanimated/mock'); + const colors = jest.requireActual('react-native-reanimated/src/common/style/processors/colors'); + const interpolation = jest.requireActual('react-native-reanimated/src/interpolateColor'); + const {View} = jest.requireActual<{View: typeof NativeView}>('react-native'); + const react = jest.requireActual('react'); + return { + ...mock, + ...colors, + interpolateColor: interpolation.interpolateColor, + useAnimatedStyle: jest.fn(() => ({})), + default: {...mock.default, View: ({style, children}: ViewProps) => react.createElement(View, {style, testID: 'autocomplete-wrapper'}, children)}, + }; +}); + +const input = jest.mocked(TextInput); +const animatedStyle = jest.mocked(useAnimatedStyle); +const focusAfterNav = jest.mocked(useFocusAfterNav); + +beforeEach(() => jest.clearAllMocks()); + +describe('SearchAutocompleteInput', () => { + test.each([ + {description: 'string colors', border: 'red', focusedBorder: 'blue', expected: 'rgba(255, 0, 0, 1)', focusedExpected: 'rgba(0, 0, 255, 1)'}, + {description: 'transparent neighbor', border: 'transparent', focusedBorder: 'red', expected: 'rgba(255, 0, 0, 0)', focusedExpected: 'rgba(255, 0, 0, 1)'}, + {description: 'opaque platform color fallback', border: PlatformColor('label'), focusedBorder: 'red', expected: 'rgba(26, 61, 50, 1)', focusedExpected: 'rgba(255, 0, 0, 1)'}, + { + description: 'opaque dynamic color fallback', + border: DynamicColorIOS({light: 'red', dark: 'blue'}), + focusedBorder: 'red', + expected: 'rgba(26, 61, 50, 1)', + focusedExpected: 'rgba(255, 0, 0, 1)', + }, + {description: 'focused opaque color fallback', border: 'red', focusedBorder: PlatformColor('label'), expected: 'rgba(255, 0, 0, 1)', focusedExpected: 'rgba(26, 61, 50, 1)'}, + {description: 'two transparent endpoints', border: 'transparent', focusedBorder: 'transparent', expected: 'rgba(0, 0, 0, 0)', focusedExpected: 'rgba(0, 0, 0, 0)'}, + ])('applies $description inside the border worklet', ({border, focusedBorder, expected, focusedExpected}) => { + // Given string endpoints or opaque colors that must fall back to the theme border. + render( + , + ); + const borderWorklet = animatedStyle.mock.calls.at(1)?.[0]; + const props = input.mock.calls.at(0)?.[0]; + expect(borderWorklet).toBeDefined(); + expect(props).toBeDefined(); + // When the component's actual focus callback changes the shared value. + expect(borderWorklet?.()).toEqual({borderColor: expected}); + act(() => props?.onFocus?.(createMock>[0]>({}))); + // Then installed RGB interpolation preserves string colors and uses the theme for opaque endpoints. + expect(borderWorklet?.()).toEqual({borderColor: focusedExpected}); + }); + + it('preserves style precedence and focus/blur callback timing', () => { + // Given wrapper styles with conflicting widths, and callbacks that observe the same shared-value worklet. + const observed: unknown[] = []; + render( + observed.push(animatedStyle.mock.calls.at(1)?.[0]())} + onBlur={() => observed.push(animatedStyle.mock.calls.at(1)?.[0]())} + />, + ); + const props = input.mock.calls.at(0)?.[0]; + const styleWorklet = animatedStyle.mock.calls.at(0)?.[0]; + const borderWorklet = animatedStyle.mock.calls.at(1)?.[0]; + expect(props).toBeDefined(); + expect(styleWorklet).toBeDefined(); + expect(borderWorklet).toBeDefined(); + // When focus and blur run in their original callback order. + act(() => props?.onFocus?.(createMock>[0]>({}))); + expect(styleWorklet?.()).toEqual({borderWidth: 3}); + act(() => props?.onBlur?.(createMock>[0]>({}))); + // Then focus observes the old color, blur observes the restored color, and the border worklet overrides the wrapper. + expect(observed).toEqual([{borderColor: 'rgba(255, 0, 0, 1)'}, {borderColor: 'rgba(255, 0, 0, 1)'}]); + const renderedStyle: unknown = screen.getByTestId('autocomplete-wrapper').props.style; + if (!Array.isArray(renderedStyle)) { + throw new Error('Expected the wrapper style array'); + } + const styleParts: unknown[] = renderedStyle; + expect(styleParts.at(1)).toBe(animatedStyle.mock.results.at(0)?.value); + expect(styleParts.at(2)).toBe(animatedStyle.mock.results.at(1)?.value); + expect(StyleSheet.flatten([{borderColor: 'red', borderWidth: 2}, styleWorklet?.(), borderWorklet?.()])).toEqual({borderColor: 'rgba(255, 0, 0, 1)', borderWidth: 2}); + }); + + it('uses the theme border when wrapper colors are absent', () => { + // Given no wrapper colors, both endpoints must use the current theme. + const {defaultTheme} = jest.requireActual<{defaultTheme: ThemeColors}>('@styles/theme'); + render( + , + ); + const borderWorklet = animatedStyle.mock.calls.at(1)?.[0]; + const props = input.mock.calls.at(0)?.[0]; + expect(borderWorklet).toBeDefined(); + expect(props).toBeDefined(); + // When the default border is evaluated before and after focus. + expect(borderWorklet?.()).toEqual({borderColor: 'rgba(26, 61, 50, 1)'}); + act(() => props?.onFocus?.(createMock>[0]>({}))); + // Then focus leaves the default theme border unchanged. + expect(defaultTheme.border).toBe('#1A3D32'); + expect(borderWorklet?.()).toEqual({borderColor: 'rgba(26, 61, 50, 1)'}); + }); + + test.each(['callback', 'object', 'absent'])('preserves %s ref forwarding and null detach', (variant) => { + // Given the actual supported ref shapes and the component's consumed focus contract. + const objectRef = createRef(); + const callbackRef = jest.fn(); + let ref: ForwardedRef | undefined; + if (variant === 'callback') { + ref = callbackRef; + } else if (variant === 'object') { + ref = objectRef; + } + const element = document.createElement('form'); + render( + , + ); + const props = input.mock.calls.at(0)?.[0]; + const internalRef = focusAfterNav.mock.calls.at(0)?.[0]; + expect(props).toBeDefined(); + expect(internalRef).toBeDefined(); + expect(typeof props?.ref).toBe('function'); + // When the child attaches and detaches the original element. + callbackRef.mockImplementation((attached) => expect(internalRef?.current).toBe(attached)); + if (typeof props?.ref === 'function') { + props.ref(element); + // Then absent external refs retain the early return while other refs receive the original element. + expect(internalRef?.current).toBe(variant === 'absent' ? null : element); + if (variant === 'callback') { + expect(callbackRef).toHaveBeenCalledWith(element); + } else if (variant === 'object') { + expect(objectRef.current).toBe(element); + } + props.ref(null); + } + expect(internalRef?.current).toBeNull(); + if (variant === 'callback') { + expect(callbackRef.mock.calls).toEqual([[element], [null]]); + } + expect(objectRef.current).toBeNull(); + expect(props?.autoFocus).toBe(false); + }); + + it('clears the query before resetting search context', () => { + // Given an existing query whose clear callback and context reset have observable ordering. + const changes = jest.fn(); + render( + , + ); + const props = input.mock.calls.at(0)?.[0]; + expect(props).toBeDefined(); + // When the real clear callback runs. + props?.onClearInput?.(); + // Then the query change precedes context reset and the default autofocus remains enabled. + expect(changes).toHaveBeenCalledWith(''); + expect(setSearchContext).toHaveBeenCalledWith(false); + const contextCallOrder = jest.mocked(setSearchContext).mock.invocationCallOrder.at(0); + if (contextCallOrder === undefined) { + throw new Error('Expected search context reset'); + } + expect(changes.mock.invocationCallOrder.at(0)).toBeLessThan(contextCallOrder); + expect(props?.autoFocus).toBe(true); + }); +}); diff --git a/tests/ui/components/SettlementButtonTest.tsx b/tests/ui/components/SettlementButtonTest.tsx index de2b135ec238..b9b57415b48c 100644 --- a/tests/ui/components/SettlementButtonTest.tsx +++ b/tests/ui/components/SettlementButtonTest.tsx @@ -12,6 +12,7 @@ import type SettlementButtonProps from '@components/SettlementButton/types'; import {createWorkspace} from '@libs/actions/Policy/Policy'; import {navigateToBankAccountRoute} from '@libs/actions/ReimbursementAccount'; +import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -24,7 +25,7 @@ import type {ValueOf} from 'type-fest'; import React from 'react'; import Onyx from 'react-native-onyx'; -import {translateLocal} from '../../utils/TestHelper'; +import {getOnyxData, translateLocal} from '../../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; jest.mock('@libs/Navigation/navigationRef', () => ({ @@ -582,6 +583,167 @@ describe('SettlementButton', () => { }); }); + describe('expense payment funding source', () => { + const workspaceBankAccount = { + bankAccountID: BANK_ACCOUNT_ID + 1, + accountNumber: 'XXXX1111', + routingNumber: '123456789', + addressName: 'Workspace account', + bankName: 'Workspace bank', + reimburser: 'payer@test.com', + state: CONST.BANK_ACCOUNT.STATE.OPEN, + }; + + async function selectExpensePaymentOption(policy: Policy, optionText: string, bankAccountList?: BankAccountList) { + const expenseReport = createExpenseReport(); + await setupOnyxState({report: expenseReport, chatReport: createChatReport(), policy, bankAccountList}); + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByText(translateLocal('iou.settlePayment', '$100.00'))); + await waitForBatchedUpdatesWithAct(); + const menuItem = screen.getByTestId(`PopoverMenuItem-${optionText}`); + fireEvent.press(menuItem, {nativeEvent: {}, type: 'press', target: menuItem, currentTarget: menuItem}); + await waitForBatchedUpdatesWithAct(); + } + + it.each([CONST.BANK_ACCOUNT.STATE.OPEN, CONST.BANK_ACCOUNT.STATE.LOCKED])('adds a standalone account when the inaccessible workspace account is %s', async (state) => { + // Given an existing workspace account and stale setup data, neither may be used for a new funding source. + const policy = createTestPolicy({achAccount: {...workspaceBankAccount, state}}); + await Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {bankAccountID: workspaceBankAccount.bankAccountID, policyID: POLICY_ID}}); + await Onyx.set(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, {accountNumber: '1111'}); + + // When an admin without that account chooses business-bank payment. + await selectExpensePaymentOption(policy, translateLocal('iou.settleBusiness', '')); + + // Then setup receives no policyID and cannot replace the workspace account or reuse stale account details. + expect(navigateToBankAccountRoute).toHaveBeenCalledWith({backTo: ''}); + expect(Navigation.navigate).not.toHaveBeenCalledWith(ROUTES.BANK_ACCOUNT_CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT.getRoute(POLICY_ID)); + await getOnyxData({ + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + callback: (setup) => { + expect(setup?.achData?.policyID).toBeFalsy(); + expect(setup?.achData?.bankAccountID).toBeFalsy(); + }, + }); + await getOnyxData({key: `${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, callback: (workspace) => expect(workspace?.achAccount).toEqual(policy.achAccount)}); + }); + + it('resumes the admin’s partially configured business account without linking it to the workspace', async () => { + // Given an existing workspace account and another bank account that the admin has not finished setting up. + const policy = createTestPolicy({achAccount: workspaceBankAccount}); + const bankAccountList = createBankAccountList(); + bankAccountList[BANK_ACCOUNT_ID].accountData = {...bankAccountList[BANK_ACCOUNT_ID].accountData, state: CONST.BANK_ACCOUNT.STATE.SETUP}; + + await Onyx.set(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, {accountNumber: '5678'}); + + // When business-bank payment would otherwise open connect-existing setup. + await selectExpensePaymentOption(policy, translateLocal('iou.settleBusiness', ''), bankAccountList); + + // Then the admin resumes their account and the workspace keeps its existing account. + await getOnyxData({key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, callback: (draft) => expect(draft?.accountNumber).toBe('5678')}); + expect(navigateToBankAccountRoute).toHaveBeenCalledWith({bankAccountID: BANK_ACCOUNT_ID, backTo: ''}); + expect(Navigation.navigate).not.toHaveBeenCalledWith(ROUTES.BANK_ACCOUNT_CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT.getRoute(POLICY_ID)); + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, + callback: (workspace) => expect(workspace?.achAccount).toEqual(workspaceBankAccount), + }); + }); + + it.each([ + {bankCurrency: CONST.CURRENCY.USD, policyIDs: ['another-workspace']}, + {bankCurrency: CONST.CURRENCY.GBP, policyIDs: []}, + ])('starts fresh instead of resuming a partial account with currency $bankCurrency and policies $policyIDs', async ({bankCurrency, policyIDs}) => { + // Given a partial business account that belongs to another workspace or uses a different currency. + const policy = createTestPolicy({achAccount: workspaceBankAccount}); + const bankAccountList = createBankAccountList(); + bankAccountList[BANK_ACCOUNT_ID].bankCurrency = bankCurrency; + bankAccountList[BANK_ACCOUNT_ID].accountData = { + ...bankAccountList[BANK_ACCOUNT_ID].accountData, + state: CONST.BANK_ACCOUNT.STATE.SETUP, + policyIDs, + }; + await Onyx.set(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, { + accountNumber: '5678', + }); + + // When the admin adds a funding source for this USD report. + await selectExpensePaymentOption(policy, translateLocal('iou.settleBusiness', ''), bankAccountList); + + // Then a fresh standalone setup avoids resuming an unsuitable account and preserves the workspace account. + expect(navigateToBankAccountRoute).toHaveBeenCalledWith({backTo: ''}); + await getOnyxData({ + key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, + callback: (draft) => expect(draft?.accountNumber).toBeFalsy(), + }); + await getOnyxData({key: `${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, callback: (workspace) => expect(workspace?.achAccount).toEqual(workspaceBankAccount)}); + }); + + it.each([CONST.BANK_ACCOUNT.STATE.SETUP, CONST.BANK_ACCOUNT.STATE.PENDING])('resumes an accessible workspace account in %s with policy context', async (state) => { + // Given the workspace account itself is shared with the admin and still needs setup. + const bankAccountList = createBankAccountList(); + bankAccountList[BANK_ACCOUNT_ID].accountData = {...bankAccountList[BANK_ACCOUNT_ID].accountData, state}; + const policy = createTestPolicy({achAccount: {...workspaceBankAccount, bankAccountID: BANK_ACCOUNT_ID, state}}); + + // When the admin chooses business-bank payment. + await selectExpensePaymentOption(policy, translateLocal('iou.settleBusiness', ''), bankAccountList); + + // Then continue this account rather than creating or connecting a replacement. + expect(navigateToBankAccountRoute).toHaveBeenCalledWith({policyID: POLICY_ID, backTo: ''}); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('pays from another accessible business account without connecting it to the workspace', async () => { + // Given the admin owns a usable account different from the workspace account. + const policy = createTestPolicy({achAccount: workspaceBankAccount}); + const bankAccountList = createBankAccountList('5678'); + + // When the admin selects that account directly in the pay menu. + await selectExpensePaymentOption(policy, 'Test Bank Account', bankAccountList); + + // Then the selected account funds payment without entering workspace bank setup. + expect(defaultProps.onPress).toHaveBeenCalledWith({ + paymentType: CONST.IOU.PAYMENT_TYPE.VBBA, + payAsBusiness: true, + methodID: BANK_ACCOUNT_ID, + paymentMethod: CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT, + }); + expect(navigateToBankAccountRoute).not.toHaveBeenCalled(); + await getOnyxData({key: `${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, callback: (workspace) => expect(workspace?.achAccount).toEqual(workspaceBankAccount)}); + }); + + it('keeps first-account setup linked to a workspace with no bank account', async () => { + // Given the workspace has no bank account and the admin has no existing accounts. + const policy = createTestPolicy(); + + // When the admin chooses business-bank payment. + await selectExpensePaymentOption(policy, translateLocal('iou.settleBusiness', '')); + + // Then the normal setup may establish the workspace's first account. + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute({policyID: POLICY_ID, backTo: ''})); + expect(navigateToBankAccountRoute).not.toHaveBeenCalled(); + }); + + it('marks a report as paid without entering bank setup', async () => { + // Given the admin cannot access the workspace bank account. + const policy = createTestPolicy({achAccount: workspaceBankAccount}); + + // When the admin chooses pay elsewhere. + await selectExpensePaymentOption(policy, translateLocal('iou.payElsewhere', '')); + + // Then manual payment proceeds independently of bank-account setup. + expect(defaultProps.onPress).toHaveBeenCalledWith({paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, payAsBusiness: false}); + expect(navigateToBankAccountRoute).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + }); + describe('invoice business add bank account workspace reuse', () => { const ACTIVE_POLICY_ID = 'active-admin-policy-id'; const PERSONAL_POLICY_ID = 'personal-policy-id'; diff --git a/tests/ui/components/TaskHeaderActionButtonTest.tsx b/tests/ui/components/TaskHeaderActionButtonTest.tsx new file mode 100644 index 000000000000..7e5f91b9a88c --- /dev/null +++ b/tests/ui/components/TaskHeaderActionButtonTest.tsx @@ -0,0 +1,67 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; + +import {completeTask} from '@userActions/Task'; +import type * as TaskType from '@userActions/Task'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import {createRandomReport} from '../../utils/collections/reports'; +import {translateLocal} from '../../utils/TestHelper'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@userActions/Task', () => ({ + ...jest.requireActual('@userActions/Task'), + canActionTask: jest.fn(() => true), + completeTask: jest.fn(), + reopenTask: jest.fn(), +})); + +describe('TaskHeaderActionButton', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('should complete the task with the full stored report', async () => { + // Given an open task report stored with a read position, because completing the task restores lastReadTime from + // that report if the request fails, and the header's own report projection drops it + const taskReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.TASK, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + permissions: [CONST.REPORT.PERMISSIONS.READ, CONST.REPORT.PERMISSIONS.WRITE], + lastReadTime: '2026-10-08 12:00:00.000', + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${taskReport.reportID}`, taskReport); + await waitForBatchedUpdates(); + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // When the user marks the task as complete + fireEvent.press(screen.getByText(translateLocal('task.markAsComplete'))); + + // Then the action gets the stored report, including the read position it needs for its failure data + expect(jest.mocked(completeTask).mock.calls.at(0)?.at(0)).toEqual(expect.objectContaining({reportID: taskReport.reportID, lastReadTime: taskReport.lastReadTime})); + }); +}); diff --git a/tests/ui/components/TaskListItemSelectionTest.tsx b/tests/ui/components/TaskListItemSelectionTest.tsx new file mode 100644 index 000000000000..82849277d5f9 --- /dev/null +++ b/tests/ui/components/TaskListItemSelectionTest.tsx @@ -0,0 +1,107 @@ +import {render} from '@testing-library/react-native'; + +import UserAvatar from '@components/Avatar/UserAvatar'; +import AvatarWithTextCell from '@components/Search/SearchList/ListItem/AvatarWithTextCell'; +import TaskListItem from '@components/Search/SearchList/ListItem/TaskListItem'; +import type {TaskListItemType} from '@components/Search/SearchList/ListItem/types'; +import ListItemComposed from '@components/SelectionList/ListItemComposed'; +import TextWithTooltip from '@components/TextWithTooltip'; + +import CONST from '@src/CONST'; + +import type {PropsWithChildren} from 'react'; + +import React from 'react'; + +let mockIsLargeScreenWidth = true; + +jest.mock('@components/Avatar/UserAvatar', () => jest.fn(() => null)); +jest.mock('@components/Badge', () => jest.fn(() => null)); +jest.mock('@components/Icon', () => jest.fn(() => null)); +jest.mock('@components/Search/SearchList/ListItem/AvatarWithTextCell', () => jest.fn(() => null)); +jest.mock('@components/Search/SearchList/ListItem/DateCell', () => jest.fn(() => null)); +jest.mock('@components/Search/SearchList/ListItem/UserInfoCell', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItemComposed', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/TextWithTooltip', () => jest.fn(() => null)); + +jest.mock('@components/OnyxListItemProvider', () => ({useSession: () => ({accountID: 1})})); +jest.mock('@components/Search/SearchSelectionProvider', () => ({useRowSelection: () => ({isSelected: false})})); +jest.mock('@hooks/useHasOutstandingChildTask', () => jest.fn(() => false)); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({ArrowRightLong: 'arrow'})})); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined])); +jest.mock('@hooks/useParentReport', () => jest.fn(() => undefined)); +jest.mock('@hooks/useParentReportAction', () => jest.fn(() => undefined)); +jest.mock('@hooks/useReportIsArchived', () => jest.fn(() => false)); +jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({isLargeScreenWidth: mockIsLargeScreenWidth}))); +jest.mock('@hooks/useStyleUtils', () => jest.fn(() => new Proxy({}, {get: () => () => ({})}))); +jest.mock('@hooks/useTheme', () => jest.fn(() => ({icon: '', reportStatusBadge: {paid: {backgroundColor: '', textColor: ''}}}))); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); +jest.mock('@libs/actions/Task', () => ({canActionTask: () => false, completeTask: jest.fn()})); +jest.mock('@libs/Fullstory', () => ({__esModule: true, default: {getChatFSClass: () => ''}})); + +const TASK_ITEM = { + keyForList: 'task-1', + type: CONST.SEARCH.DATA_TYPES.TASK, + accountID: 1, + created: '2026-10-02 12:00:00', + description: 'Review the expense', + managerID: 2, + parentReportID: 'parent-1', + reportID: 'task-1', + reportName: 'Review expense', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + assignee: {accountID: 2, avatar: 'assignee.png'}, + createdBy: {accountID: 1, avatar: 'creator.png'}, + formattedAssignee: 'Assignee', + formattedCreatedBy: 'Creator', + parentReportName: 'Shared chat', + parentReportIcon: {source: 'chat.png', type: CONST.ICON_TYPE_AVATAR, name: 'Shared chat', id: 3}, + report: {reportID: 'task-1'}, + shouldShowYear: false, +} as TaskListItemType; + +const mockedListItemComposed = jest.mocked(ListItemComposed); +const mockedTextWithTooltip = jest.mocked(TextWithTooltip); +const mockedAvatarWithTextCell = jest.mocked(AvatarWithTextCell); + +function renderTaskItem() { + return render( + , + ); +} + +describe('TaskListItem text selection', () => { + beforeEach(() => { + mockIsLargeScreenWidth = true; + jest.clearAllMocks(); + }); + + it('enables copyable-row handling and marks the task title, description, and shared chat as copyable', () => { + // Given a task displayed in the wide search table. + renderTaskItem(); + + // When the row is rendered, then its press handling allows native selection for each copyable value. + expect(mockedListItemComposed).toHaveBeenCalledWith(expect.objectContaining({shouldAllowTextSelection: true}), undefined); + expect(mockedTextWithTooltip).toHaveBeenCalledWith(expect.objectContaining({text: 'Review expense', isSelectable: true}), undefined); + expect(mockedTextWithTooltip).toHaveBeenCalledWith(expect.objectContaining({text: 'Review the expense', isSelectable: true}), undefined); + expect(mockedAvatarWithTextCell).toHaveBeenCalledWith(expect.objectContaining({reportName: 'Shared chat', isSelectable: true}), undefined); + }); + + it('excludes the compact assignee avatar from copied content', () => { + // Given a task displayed in the narrow search layout. + mockIsLargeScreenWidth = false; + + // When the row is rendered, then its avatar is hidden from copied text. + const renderResult = renderTaskItem(); + const avatar = renderResult.UNSAFE_getByType(UserAvatar); + expect(avatar.parent?.props).toMatchObject({ + dataSet: {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}, + }); + }); +}); diff --git a/tests/ui/components/TimePickerTest.tsx b/tests/ui/components/TimePickerTest.tsx index 47b162d66d10..ede337599b00 100644 --- a/tests/ui/components/TimePickerTest.tsx +++ b/tests/ui/components/TimePickerTest.tsx @@ -1,7 +1,12 @@ -import {fireEvent, render, screen} from '@testing-library/react-native'; +import {fireEvent, render, screen, within} from '@testing-library/react-native'; + +import ScrollView from '@components/ScrollView'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import TimePicker from '@src/components/TimePicker/TimePicker'; import type {TimePickerProps} from '@src/components/TimePicker/TimePicker'; +import CONST from '@src/CONST'; import type {ComponentRef, Ref} from 'react'; import type {TextInput, TextInputProps} from 'react-native'; @@ -11,6 +16,10 @@ import React, {act} from 'react'; import mockCreateMock from '../../utils/createMock'; +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); + +const mockUseResponsiveLayout = jest.mocked(useResponsiveLayout); + // Store mocked inputs by testID so we can access them in tests const mockInputs: Record> = {}; @@ -73,6 +82,10 @@ describe('TimePicker Component', () => { , ); + beforeEach(() => { + mockUseResponsiveLayout.mockReturnValue(CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE); + }); + afterEach(() => { jest.clearAllMocks(); // Clear stored mock inputs @@ -145,4 +158,18 @@ describe('TimePicker Component', () => { pressBackspaceAndExpect({hours: '00', minutes: '00'}); }); }); + + it('keeps the Save button outside the scrollable area in landscape mode', () => { + // Given a landscape screen, which is too short to stack the inputs, number pad and Save button + mockUseResponsiveLayout.mockReturnValue({...CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE, isInLandscapeMode: true}); + + // When the time picker is rendered + renderTimePicker({defaultValue: '2025-01-01 12:34 AM'}); + + // Then the number pad sits in a scrollable area and the Save button stays pinned outside it, so it is never pushed off-screen + const scrollView = screen.UNSAFE_getByType(ScrollView); + expect(within(scrollView).getByTestId('button_<')).toBeOnTheScreen(); + // The Save button is the only button rendered outside the scrollable area + expect(screen.getAllByRole('button')).toHaveLength(within(scrollView).getAllByRole('button').length + 1); + }); }); diff --git a/tests/ui/hooks/useHtmlPasteTest/index.tsx b/tests/ui/hooks/useHtmlPasteTest/index.tsx index b5a4f2e755e6..d8aec6b570a2 100644 --- a/tests/ui/hooks/useHtmlPasteTest/index.tsx +++ b/tests/ui/hooks/useHtmlPasteTest/index.tsx @@ -6,6 +6,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; import type {RefObject} from 'react'; +import type {TextInput} from 'react-native'; import Onyx from 'react-native-onyx'; @@ -20,20 +21,68 @@ jest.mock('@src/hooks/useHtmlPaste', (): typeof useHtmlPaste => { return jest.requireActual('@hooks/useHtmlPaste/index.ts'); }); +const originalRangeGeometryDescriptor = Object.getOwnPropertyDescriptor(Range.prototype, 'getBoundingClientRect'); +let didInstallRangeGeometry = false; + +beforeAll(() => { + if (typeof Range.prototype.getBoundingClientRect === 'function') { + return; + } + + Object.defineProperty(Range.prototype, 'getBoundingClientRect', { + configurable: true, + writable: true, + value: (): DOMRect => ({ + top: 0, + left: 0, + width: 0, + height: 0, + right: 0, + bottom: 0, + x: 0, + y: 0, + toJSON: () => {}, + }), + }); + didInstallRangeGeometry = true; +}); + +afterAll(() => { + if (!didInstallRangeGeometry) { + return; + } + + if (originalRangeGeometryDescriptor) { + Object.defineProperty(Range.prototype, 'getBoundingClientRect', originalRangeGeometryDescriptor); + } else { + Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect'); + } +}); + describe('useHtmlPaste - handlePastePlainText', () => { - let textInputRef: RefObject; + let textInputRef: RefObject<(HTMLDivElement & Pick) | null>; let textInputElement: HTMLDivElement; - const createMockClipboardEvent = (text: string): ClipboardEvent => { - return createMock({ - clipboardData: { - getData: (type: string) => (type === 'text/plain' ? text : ''), - files: [], - items: [], - types: ['text/plain'], + /** + * Creates a paste event with plain-text and optional HTML clipboard data. + * + * @param text Plain-text clipboard content. + * @param html HTML clipboard content. + * @returns A paste event containing the requested clipboard data. + */ + const createMockClipboardEvent = (text: string, html = ''): ClipboardEvent => { + const clipboardData = createMock({ + getData: (type: string) => { + if (type === 'text/html') { + return html; + } + return type === 'text/plain' ? text : ''; }, - preventDefault: jest.fn(), + files: [], + items: [], + types: html ? ['text/html', 'text/plain'] : ['text/plain'], }); + return Object.assign(new Event('paste', {bubbles: true, cancelable: true}), {clipboardData}); }; const mockWindowSelection = (selectedText: string) => { @@ -55,22 +104,7 @@ describe('useHtmlPaste - handlePastePlainText', () => { textInputElement.setAttribute('contenteditable', 'true'); textInputElement.textContent = ''; document.body.appendChild(textInputElement); - textInputRef = {current: textInputElement}; - - if (!Range.prototype.getBoundingClientRect) { - Range.prototype.getBoundingClientRect = () => - ({ - top: 0, - left: 0, - width: 0, - height: 0, - right: 0, - bottom: 0, - x: 0, - y: 0, - toJSON: () => {}, - }) as DOMRect; - } + textInputRef = {current: Object.assign(textInputElement, {isFocused: () => true})}; }); afterEach(() => { @@ -85,7 +119,6 @@ describe('useHtmlPaste - handlePastePlainText', () => { mockWindowSelection(selectedText); const event = createMockClipboardEvent(url); - // @ts-expect-error -- this web test intentionally passes a contenteditable DOM ref to the shared hybrid hook. const {result} = renderHook(() => useHtmlPaste(textInputRef)); await waitForBatchedUpdatesWithAct(); @@ -105,7 +138,6 @@ describe('useHtmlPaste - handlePastePlainText', () => { mockWindowSelection(''); const event = createMockClipboardEvent(url); - // @ts-expect-error -- this web test intentionally passes a contenteditable DOM ref to the shared hybrid hook. const {result} = renderHook(() => useHtmlPaste(textInputRef)); await waitForBatchedUpdatesWithAct(); @@ -125,7 +157,6 @@ describe('useHtmlPaste - handlePastePlainText', () => { mockWindowSelection('what up'); const event = createMockClipboardEvent(plainText); - // @ts-expect-error -- this web test intentionally passes a contenteditable DOM ref to the shared hybrid hook. const {result} = renderHook(() => useHtmlPaste(textInputRef)); await waitForBatchedUpdatesWithAct(); @@ -145,7 +176,6 @@ describe('useHtmlPaste - handlePastePlainText', () => { mockWindowSelection(''); const event = createMockClipboardEvent(textWithTrailingWhitespace); - // @ts-expect-error -- this web test intentionally passes a contenteditable DOM ref to the shared hybrid hook. const {result} = renderHook(() => useHtmlPaste(textInputRef)); await waitForBatchedUpdatesWithAct(); @@ -160,6 +190,137 @@ describe('useHtmlPaste - handlePastePlainText', () => { expect(textInputElement.textContent?.endsWith(' ')).toBe(true); } }); + + it('uses the Slack emoji shortcode metadata while preserving surrounding HTML formatting', async () => { + // Given a Slack emoji image is followed by bold text, so changing the image must not remove the surrounding formatting. + const html = '

Normal Text. :smile: Bold

'; + const event = createMockClipboardEvent('Normal Text. :tada: Bold', html); + mockWindowSelection(''); + + renderHook(() => useHtmlPaste(textInputRef, undefined, true)); + await waitForBatchedUpdatesWithAct(); + + // When the Slack HTML is pasted into the active composer. + act(() => document.dispatchEvent(event)); + + // Then the shortcode and bold Markdown remain because only the recognized emoji image should change. + expect(textInputRef.current?.textContent).toBe('Normal Text. :tada: *Bold*'); + }); + + it('preserves multiple Slack emoji shortcodes, spacing, and surrounding formatting', async () => { + // Given Slack HTML mixes supported and custom emoji images with formatting, so removing the Safari fallback must preserve the desktop paste behavior. + const html = + '

:tada: :thankyou_pink::smile: Bold Italic Link :smile:

:tada:
'; + const event = createMockClipboardEvent(':tada: :thankyou_pink::smile: Bold Italic Link :smile:\n:tada:', html); + mockWindowSelection(''); + + renderHook(() => useHtmlPaste(textInputRef, undefined, true)); + await waitForBatchedUpdatesWithAct(); + + // When the complete Slack message is pasted into the active composer. + act(() => document.dispatchEvent(event)); + + // Then every emoji image becomes its exact shortcode and the existing spacing and Markdown survive for the Composer pipeline to process. + expect(textInputRef.current?.textContent).toBe(':tada: :thankyou_pink::smile: *Bold* _Italic_ [Link](https://example.com) `:smile:`\n```\n:tada:\n```'); + }); + + it('preserves the original Safari formatting HTML when there are no emoji images', async () => { + // Given Safari HTML has nested formatting and non-breaking spaces but no emoji images, so the emoji handling must leave it alone. + const html = + 'Bold text,\u00A0Bold and Italic text,\u00A0Normal Italic text.'; + const event = createMockClipboardEvent('Bold text,\u00A0Bold and Italic text,\u00A0Normal Italic text.', html); + mockWindowSelection(''); + + renderHook(() => useHtmlPaste(textInputRef, undefined, true)); + await waitForBatchedUpdatesWithAct(); + + // When the Safari HTML is pasted into the active composer. + act(() => document.dispatchEvent(event)); + + // Then its Markdown stays unchanged because parsing a paste without emoji images must not create a new formatting regression. + expect(textInputRef.current?.textContent).toBe('*Bold text,**_Bold and Italic text,\u00A0_*_Normal Italic text._'); + }); + + it('replaces a Slack emoji without changing surrounding Safari formatting HTML', async () => { + // Given a Slack emoji appears beside Safari-specific formatting, so replacing the image must not serialize and alter the rest of the HTML. + const html = + ':tada: Bold text,\u00A0Italic text.'; + const event = createMockClipboardEvent(':tada: Bold text,\u00A0Italic text.', html); + mockWindowSelection(''); + + renderHook(() => useHtmlPaste(textInputRef, undefined, true)); + await waitForBatchedUpdatesWithAct(); + + // When the mixed Slack and Safari HTML is pasted into the active composer. + act(() => document.dispatchEvent(event)); + + // Then the emoji becomes a shortcode while the original formatting boundaries stay intact. + expect(textInputRef.current?.textContent).toBe(':tada: *Bold text,*_Italic text._'); + }); + + it('replaces only the recognized emoji when a normal image appears first', async () => { + // Given a normal photo comes before a Slack emoji image, so Slack detection must not turn the photo into an emoji. + const html = '

Photo :tada:

'; + const event = createMockClipboardEvent('Photo :tada:', html); + mockWindowSelection(''); + + renderHook(() => useHtmlPaste(textInputRef, undefined, true)); + await waitForBatchedUpdatesWithAct(); + + // When the mixed image HTML is pasted into the active composer. + act(() => document.dispatchEvent(event)); + + // Then the photo remains Markdown image content and only the Slack emoji becomes a shortcode. + expect(textInputRef.current?.textContent).toBe('![Photo](https://example.com/photo.png) :tada:'); + }); + + it('falls back to image alt when only the emoji type metadata is available', async () => { + // Given a legacy Slack emoji image has its type metadata but no explicit shortcode metadata, so the alt text is the only safe fallback. + const html = '

Normal Text. :tada: Bold

'; + const event = createMockClipboardEvent('Normal Text. :tada: Bold', html); + mockWindowSelection(''); + + renderHook(() => useHtmlPaste(textInputRef, undefined, true)); + await waitForBatchedUpdatesWithAct(); + + // When the legacy Slack HTML is pasted into the active composer. + act(() => document.dispatchEvent(event)); + + // Then the fallback shortcode is used because older clipboard data still needs to avoid broken image Markdown. + expect(textInputRef.current?.textContent).toBe('Normal Text. :tada: *Bold*'); + }); + + it('does not replace blob images without Slack emoji metadata', async () => { + // Given a blob image has an emoji-shaped filename but no Slack metadata, so its filename must not classify it as an emoji. + const html = '

Normal Text. 1f389@2x.png Bold

'; + const event = createMockClipboardEvent('Normal Text. :tada: Bold', html); + mockWindowSelection(''); + + renderHook(() => useHtmlPaste(textInputRef, undefined, true)); + await waitForBatchedUpdatesWithAct(); + + // When the blob image is pasted into the active composer. + act(() => document.dispatchEvent(event)); + + // Then it remains an image because neither the blob URL nor the emoji-shaped alt proves it is a Slack emoji. + expect(textInputRef.current?.textContent).toBe('Normal Text. ![1f389@2x.png](blob:https://new.expensify.com/123) Bold'); + }); + + it('does not replace normal images whose alt text is an emoji shortcode', async () => { + // Given a normal web image has an emoji-like alt value but no Slack metadata, so alt text alone must not classify it as a Slack emoji. + const html = '

Copy image below:

:smile:'; + const event = createMockClipboardEvent('Copy image below:', html); + mockWindowSelection(''); + + renderHook(() => useHtmlPaste(textInputRef, undefined, true)); + await waitForBatchedUpdatesWithAct(); + + // When the normal image is pasted into the active composer. + act(() => document.dispatchEvent(event)); + + // Then it stays an image because converting it would corrupt ordinary image pastes with emoji-looking alt text. + expect(textInputRef.current?.textContent).toBe('Copy image below:\n![:smile:](https://example.com/image.png)'); + }); }); describe('useHtmlPaste - handlePastedHTML', () => { @@ -167,7 +328,7 @@ describe('useHtmlPaste - handlePastedHTML', () => { const ROOM_NAME = '#general'; const PASTED_HTML = `Hey team`; - let textInputRef: RefObject; + let textInputRef: RefObject<(HTMLDivElement & Pick) | null>; let textInputElement: HTMLDivElement; /** The HTML flavour of the clipboard, which is what Clipboard.setHtml writes when copying a message. */ @@ -185,7 +346,6 @@ describe('useHtmlPaste - handlePastedHTML', () => { }; const pasteHtmlIntoActiveInput = async (html: string) => { - // @ts-expect-error -- this web test intentionally passes a contenteditable DOM ref to the shared hybrid hook. renderHook(() => useHtmlPaste(textInputRef, undefined, true)); await waitForBatchedUpdatesWithAct(); await act(async () => { @@ -207,8 +367,7 @@ describe('useHtmlPaste - handlePastedHTML', () => { textInputElement.textContent = ''; document.body.appendChild(textInputElement); // The hook ignores a paste when the input is not focused, and a bare DOM node has no isFocused(). - Object.defineProperty(textInputElement, 'isFocused', {value: () => true, configurable: true}); - textInputRef = {current: textInputElement}; + textInputRef = {current: Object.assign(textInputElement, {isFocused: () => true})}; const range = document.createRange(); range.selectNodeContents(textInputElement); @@ -223,7 +382,7 @@ describe('useHtmlPaste - handlePastedHTML', () => { it('resolves a pasted report mention to the room name', async () => { // Given the mentioned report is in Onyx, as it is when copying from a chat you can see - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${MENTIONED_REPORT_ID}`, {reportID: MENTIONED_REPORT_ID, reportName: ROOM_NAME} as Report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${MENTIONED_REPORT_ID}`, {reportID: MENTIONED_REPORT_ID, reportName: ROOM_NAME} satisfies Report); // When the HTML flavour of the clipboard is pasted into the input await pasteHtmlIntoActiveInput(PASTED_HTML); @@ -243,3 +402,106 @@ describe('useHtmlPaste - handlePastedHTML', () => { expect(textInputElement.textContent).toBe('Hey #Hidden team'); }); }); + +describe('useHtmlPaste DOM insertion boundaries', () => { + afterEach(() => { + jest.restoreAllMocks(); + document.body.replaceChildren(); + }); + + it.each(['input', 'textarea'] as const)('uses %s setRangeText with the existing length arithmetic and focusin dispatch', (tagName) => { + // Given the actual RN Web host category, augmented with its isFocused command. + const input = Object.assign(document.createElement(tagName), {isFocused: () => true}); + input.value = 'abcd'; + input.setSelectionRange(1, 3); + const focusIn = jest.fn(); + input.addEventListener('focusin', focusIn); + const setRangeText = jest.spyOn(input, 'setRangeText'); + const frames = jest.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(() => 1); + window.getSelection()?.removeAllRanges(); + const {result} = renderHook(() => useHtmlPaste({current: input}, undefined, false, 6)); + const paste = result.current?.handlePastePlainText; + if (!paste) { + throw new Error('Expected plaintext paste interface'); + } + const event = createMock({clipboardData: {getData: () => 'WORD'}}); + + // When a long paste replaces the selected span. + act(() => paste(event)); + + // Then truncation uses the pre-existing whole-value length and retains focus/scroll scheduling. + expect(setRangeText).toHaveBeenCalledWith('WO'); + expect(input.value).toBe('aWOd'); + expect(focusIn).toHaveBeenCalledTimes(1); + expect(frames).toHaveBeenCalledTimes(1); + }); + + it('inserts a contenteditable Range, retains the caret, dispatches input and scrolls on the scheduled frame', () => { + // Given the markdown div producer with a real selection and overflow geometry. + const input = Object.assign(document.createElement('div'), {isFocused: () => true}); + input.setAttribute('contenteditable', 'true'); + input.textContent = 'ab'; + document.body.appendChild(input); + const range = document.createRange(); + range.selectNodeContents(input); + range.collapse(false); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + Object.defineProperties(input, {clientWidth: {value: 20}, clientHeight: {value: 20}}); + jest.spyOn(input, 'getBoundingClientRect').mockReturnValue(createMock({left: 0, top: 0})); + jest.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(createMock({left: 100, top: 200})); + const frames = jest.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(() => 1); + const inputEvent = jest.fn(); + const focusIn = jest.fn(); + input.addEventListener('input', inputEvent); + input.addEventListener('focusin', focusIn); + const {result} = renderHook(() => useHtmlPaste({current: input}, undefined, false, 5)); + const paste = result.current?.handlePastePlainText; + if (!paste) { + throw new Error('Expected plaintext paste interface'); + } + + // When only three characters fit in the div's current length budget. + act(() => paste(createMock({clipboardData: {getData: () => 'WORD'}}))); + const frame = frames.mock.calls.at(0); + if (!frame) { + throw new Error('Expected scheduled caret scroll'); + } + act(() => frame[0](0)); + + // Then Range insertion and collapsed caret precede the browser scrolling/focus notifications. + expect(input.textContent).toBe('abWOR'); + expect(selection?.anchorOffset).toBe(3); + expect(selection?.isCollapsed).toBe(true); + expect(inputEvent).toHaveBeenCalledTimes(1); + expect(focusIn).toHaveBeenCalledTimes(1); + expect(input.scrollLeft).toBe(90); + expect(input.scrollTop).toBe(190); + }); + + it('does not attempt DOM insertion for absent, native or non-editable div refs', () => { + // Given a native partial provides focus but does not have DOM identity. + const nativeInput = createMock({isFocused: () => true}); + const input = Object.assign(document.createElement('div'), {isFocused: () => true}); + const frames = jest.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(() => 1); + const ref: Parameters[0] = {current: null}; + const {result} = renderHook(() => useHtmlPaste(ref)); + const paste = result.current?.handlePastePlainText; + if (!paste) { + throw new Error('Expected plaintext paste interface'); + } + const event = createMock({clipboardData: {getData: () => 'hello'}}); + + // When each category reaches the existing insertion boundary. + act(() => paste(event)); + ref.current = nativeInput; + act(() => paste(event)); + ref.current = input; + act(() => paste(event)); + + // Then no browser mutation or scheduled scroll is attempted. + expect(input.textContent).toBe(''); + expect(frames).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/AddVendorPageTest.ts b/tests/unit/AddVendorPageTest.ts index ade2877e4058..33faef33e276 100644 --- a/tests/unit/AddVendorPageTest.ts +++ b/tests/unit/AddVendorPageTest.ts @@ -1,4 +1,4 @@ -import {getVendorRuleDisplayValue, hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; +import {getVendorRuleDisplayValue, hasVendorFeature} from '@libs/PolicyUtils'; import {getSelectedVendorItem, getVendorSelectionItems} from '@pages/workspace/rules/MerchantRules/AddVendorPage'; @@ -36,7 +36,7 @@ const buildIntacctPolicy = (vendors: Array<{id: string; name: string; value: str }), }); -/** Xero policy whose supplier list scopes vendor matching to Xero (label flips vendor -> supplier). */ +/** Xero policy whose supplier list scopes vendor matching to Xero. */ const buildXeroPolicy = (contacts: Record): Policy => createMock({ ...createRandomPolicy(0), @@ -178,11 +178,6 @@ describe('AddVendorPage', () => { expect(hasVendorFeature(qboPolicy, true)).toBe(true); }); - it('labels the row "vendor" on QBO and flips to "supplier" on Xero', () => { - expect(isXeroActiveMatchingSource(qboPolicy)).toBe(false); - expect(isXeroActiveMatchingSource(xeroPolicy)).toBe(true); - }); - it('uses the shared vendor display resolver for the row title', () => { expect(getVendorRuleDisplayValue(qboPolicy, 'v-1', vendorUnavailable)).toBe('Acme Co'); expect(getVendorRuleDisplayValue(createRandomPolicy(0), 'v-disconnected', vendorUnavailable)).toBe(vendorUnavailable); diff --git a/tests/unit/AgentActionTest.ts b/tests/unit/AgentActionTest.ts index 38ed33042097..4ea9c96021e8 100644 --- a/tests/unit/AgentActionTest.ts +++ b/tests/unit/AgentActionTest.ts @@ -3,7 +3,7 @@ import {WRITE_COMMANDS} from '@libs/API/types'; import Navigation from '@libs/Navigation/Navigation'; import {isRecord} from '@libs/ObjectUtils'; -import {clearAgentAvatarUpdateError, clearAgentUpdateError, createAgent, deleteAgent, updateAgentAvatar, updateAgentName, updateAgentPrompt} from '@userActions/Agent'; +import {clearAgentAvatarUpdateError, clearAgentUpdateError, createAgent, createCompanyAgent, deleteAgent, updateAgentAvatar, updateAgentName, updateAgentPrompt} from '@userActions/Agent'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -152,7 +152,7 @@ describe('createAgent', () => { const {optimisticData} = getWriteOptions(); const accountID = getOptimisticAccountID(); - const promptUpdate = findUpdate(optimisticData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); + const promptUpdate = findUpdate(optimisticData, `${ONYXKEYS.COLLECTION.AGENT}${accountID}`); expect(promptUpdate?.value).toEqual({ prompt: 'My prompt', @@ -227,9 +227,16 @@ describe('createAgent', () => { }); it('forwards policyID in the write params when provided', () => { - createAgent('Bot', 'My prompt', OWNER_ACCOUNT_ID, OWNER_LOGIN, undefined, undefined, undefined, 'POLICY_42'); - - expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_AGENT, expect.objectContaining({firstName: 'Bot', prompt: 'My prompt', policyID: 'POLICY_42'}), expect.any(Object)); + const policy = { + ...createRandomPolicy(0), + id: 'POLICY_42', + }; + createCompanyAgent(OWNER_ACCOUNT_ID, OWNER_LOGIN, 'My prompt', policy, CONST.POLICY.ROLE.ADMIN, 'Bot'); + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.CREATE_COMPANY_AGENT, + expect.objectContaining({firstName: 'Bot', prompt: 'My prompt', policyID: 'POLICY_42'}), + expect.any(Object), + ); }); it('returns the optimistic accountID and avatarURI so callers can chain follow-up navigation', () => { @@ -326,7 +333,7 @@ describe('createAgent', () => { }); it('omits login on the optimistic personal detail entry — the real email is server-assigned', () => { - createAgent('Bot', 'My prompt', OWNER_ACCOUNT_ID, OWNER_LOGIN, undefined, undefined, undefined, 'POLICY_42'); + createAgent('Bot', 'My prompt', OWNER_ACCOUNT_ID, OWNER_LOGIN, undefined, undefined, undefined); const accountID = getOptimisticAccountID(); const entry = getOptimisticPersonalDetailEntry(accountID); @@ -351,7 +358,7 @@ describe('createAgent', () => { const accountID = getOptimisticAccountID(); expect(findUpdate(successData, ONYXKEYS.PERSONAL_DETAILS_LIST)).toBeUndefined(); - expect(findUpdate(successData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`)).toBeUndefined(); + expect(findUpdate(successData, `${ONYXKEYS.COLLECTION.AGENT}${accountID}`)).toBeUndefined(); }); it('passes the optimistic accountID through to the server so it can echo a real-ID mapping', () => { @@ -360,7 +367,7 @@ describe('createAgent', () => { expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_AGENT, expect.objectContaining({optimisticAccountID: String(result.optimisticAccountID)}), expect.any(Object)); }); - it('failure data preserves optimistic personal detail and merges errors onto the prompt entry', () => { + it('failure data merges errors onto the prompt entry', () => { createAgent('Bot', 'My prompt', OWNER_ACCOUNT_ID, OWNER_LOGIN); const {failureData} = getWriteOptions(); @@ -371,12 +378,7 @@ describe('createAgent', () => { displayName: 'Bot', isOptimisticPersonalDetail: true, }); - const promptValue = getUpdateRecord(failureData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); - - expect(promptValue).toMatchObject({ - prompt: 'My prompt', - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }); + const promptValue = getUpdateRecord(failureData, `${ONYXKEYS.COLLECTION.AGENT}${accountID}`); expect(promptValue.errors).toBeTruthy(); }); }); @@ -405,7 +407,7 @@ describe('updateAgentName', () => { updateAgentName(TEST_ACCOUNT_ID, 'New Name', 'Old Name'); const {optimisticData} = getWriteOptions(); - const promptUpdate = findUpdate(optimisticData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`); + const promptUpdate = findUpdate(optimisticData, `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`); expect(promptUpdate?.value).toMatchObject({pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: null}); }); @@ -414,7 +416,7 @@ describe('updateAgentName', () => { updateAgentName(TEST_ACCOUNT_ID, 'New Name', 'Old Name'); const {successData} = getWriteOptions(); - expect(getUpdateRecord(successData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`).pendingAction).toBeNull(); + expect(getUpdateRecord(successData, `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`).pendingAction).toBeNull(); }); it('failure data reverts displayName to originalFirstName in PERSONAL_DETAILS_LIST', () => { @@ -428,7 +430,7 @@ describe('updateAgentName', () => { updateAgentName(TEST_ACCOUNT_ID, 'New Name', 'Old Name'); const {failureData} = getWriteOptions(); - const promptValue = getUpdateRecord(failureData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`); + const promptValue = getUpdateRecord(failureData, `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`); expect(promptValue.nameErrors).toBeTruthy(); expect(promptValue.pendingAction).toBeNull(); @@ -460,7 +462,7 @@ describe('updateAgentPrompt', () => { updateAgentPrompt(TEST_ACCOUNT_ID, 'New prompt', 'Old prompt'); const {optimisticData} = getWriteOptions(); - const promptUpdate = findUpdate(optimisticData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`); + const promptUpdate = findUpdate(optimisticData, `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`); expect(promptUpdate?.value).toMatchObject({prompt: 'New prompt', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: null}); }); @@ -469,21 +471,21 @@ describe('updateAgentPrompt', () => { updateAgentPrompt(TEST_ACCOUNT_ID, 'New prompt', 'Old prompt'); const {successData} = getWriteOptions(); - expect(getUpdateRecord(successData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`).pendingAction).toBeNull(); + expect(getUpdateRecord(successData, `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`).pendingAction).toBeNull(); }); it('failure data reverts prompt to originalPrompt on the prompt key', () => { updateAgentPrompt(TEST_ACCOUNT_ID, 'New prompt', 'Old prompt'); const {failureData} = getWriteOptions(); - expect(getUpdateRecord(failureData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`).prompt).toBe('Old prompt'); + expect(getUpdateRecord(failureData, `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`).prompt).toBe('Old prompt'); }); it('failure data sets promptErrors (truthy) and pendingAction null on the prompt key', () => { updateAgentPrompt(TEST_ACCOUNT_ID, 'New prompt', 'Old prompt'); const {failureData} = getWriteOptions(); - const promptValue = getUpdateRecord(failureData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`); + const promptValue = getUpdateRecord(failureData, `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`); expect(promptValue.promptErrors).toBeTruthy(); expect(promptValue.pendingAction).toBeNull(); @@ -515,7 +517,7 @@ describe('deleteAgent', () => { deleteAgent(TEST_ACCOUNT_ID); const {optimisticData} = getWriteOptions(); - const promptUpdate = findUpdate(optimisticData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`); + const promptUpdate = findUpdate(optimisticData, `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`); expect(promptUpdate?.onyxMethod).toBe('merge'); expect(promptUpdate?.value).toMatchObject({pendingAction: 'delete'}); @@ -525,7 +527,7 @@ describe('deleteAgent', () => { deleteAgent(TEST_ACCOUNT_ID); const {successData} = getWriteOptions(); - const promptUpdate = findUpdate(successData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`); + const promptUpdate = findUpdate(successData, `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`); expect(promptUpdate?.onyxMethod).toBe('set'); expect(promptUpdate?.value).toBeNull(); @@ -542,11 +544,11 @@ describe('deleteAgent', () => { deleteAgent(TEST_ACCOUNT_ID); const {failureData} = getWriteOptions(); - const promptUpdate = findUpdate(failureData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`); + const promptUpdate = findUpdate(failureData, `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`); expect(promptUpdate?.onyxMethod).toBe('merge'); expect(promptUpdate?.value).toMatchObject({pendingAction: null}); - expect(getUpdateRecord(failureData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`).errors).toBeTruthy(); + expect(getUpdateRecord(failureData, `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`).errors).toBeTruthy(); }); it('calls Navigation.goBack after issuing the write', () => { @@ -648,7 +650,7 @@ describe('clearAgentUpdateError', () => { it('calls Onyx.merge on the correct prompt key with errors null', () => { clearAgentUpdateError(TEST_ACCOUNT_ID); - expect(mergeSpy).toHaveBeenCalledWith(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`, {avatarErrors: null, errors: null, nameErrors: null, promptErrors: null}); + expect(mergeSpy).toHaveBeenCalledWith(`${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`, {avatarErrors: null, errors: null, nameErrors: null, promptErrors: null}); }); }); @@ -684,7 +686,7 @@ describe('updateAgentAvatar (file upload)', () => { updateAgentAvatar(TEST_ACCOUNT_ID, {file: mockFile, uri: 'file://photo.jpg'}, currentAvatar); const {optimisticData} = getWriteOptions(); - const promptUpdate = findUpdate(optimisticData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`); + const promptUpdate = findUpdate(optimisticData, `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`); expect(promptUpdate?.value).toMatchObject({pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: null, avatarErrors: null}); }); @@ -708,7 +710,7 @@ describe('updateAgentAvatar (file upload)', () => { const value = getPersonalDetailEntry(failureData, TEST_ACCOUNT_ID); expect(value.avatar).toBe(currentAvatar); expect(value.avatarThumbnail).toBe(currentAvatar); - const promptValue = getUpdateRecord(failureData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`); + const promptValue = getUpdateRecord(failureData, `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`); expect(promptValue.avatarErrors).toBeTruthy(); expect(promptValue.pendingAction).toBeNull(); }); @@ -747,7 +749,7 @@ describe('updateAgentAvatar (bot avatar)', () => { const {failureData} = getWriteOptions(); const value = getPersonalDetailEntry(failureData, TEST_ACCOUNT_ID); expect(value.avatar).toBe(currentAvatar); - expect(getUpdateRecord(failureData, `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`).avatarErrors).toBeTruthy(); + expect(getUpdateRecord(failureData, `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`).avatarErrors).toBeTruthy(); }); }); @@ -766,6 +768,6 @@ describe('clearAgentAvatarUpdateError', () => { it('calls Onyx.merge on the correct prompt key with avatarErrors null', () => { clearAgentAvatarUpdateError(TEST_ACCOUNT_ID); - expect(mergeSpy).toHaveBeenCalledWith(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`, {avatarErrors: null}); + expect(mergeSpy).toHaveBeenCalledWith(`${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`, {avatarErrors: null}); }); }); diff --git a/tests/unit/AttachmentCarouselViewTest.tsx b/tests/unit/AttachmentCarouselViewTest.tsx new file mode 100644 index 000000000000..27d9a2beb652 --- /dev/null +++ b/tests/unit/AttachmentCarouselViewTest.tsx @@ -0,0 +1,300 @@ +/** Exercises the web carousel's viewability, focus, arrow bounds and shared gesture ref through its real component. */ +import {act, render, screen} from '@testing-library/react-native'; + +import type AttachmentCarouselViewComponent from '@components/Attachments/AttachmentCarousel/AttachmentCarouselView/index'; +import type AttachmentCarouselViewProps from '@components/Attachments/AttachmentCarousel/AttachmentCarouselView/types'; +import CarouselButtons from '@components/Attachments/AttachmentCarousel/CarouselButtons'; +import CarouselItem from '@components/Attachments/AttachmentCarousel/CarouselItem'; +import {AttachmentCarouselPagerStateContext} from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; +import type {AttachmentCarouselPagerStateContextType} from '@components/Attachments/AttachmentCarousel/Pager/types'; +import type {Attachment, AttachmentSource} from '@components/Attachments/types'; +import Text from '@components/Text'; +import {useFullScreenState} from '@components/VideoPlayerContexts/FullScreenContextProvider'; + +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; + +import type {FlatListProps, FlatList, ListRenderItemInfo} from 'react-native'; +// eslint-disable-next-line no-restricted-imports -- This type-only import describes the real Gesture Handler module retained by the mock. +import type * as GestureHandler from 'react-native-gesture-handler'; +import type * as Reanimated from 'react-native-reanimated'; + +import React, {useContext} from 'react'; +import {Keyboard} from 'react-native'; +import {GestureDetector} from 'react-native-gesture-handler'; + +import createMock from '../utils/createMock'; + +jest.mock('@components/Attachments/AttachmentCarousel/CarouselButtons', () => jest.fn(() => null)); +jest.mock('@components/Attachments/AttachmentCarousel/CarouselActions', () => jest.fn(() => null)); +jest.mock('@components/Attachments/AttachmentCarousel/CarouselItem', () => jest.fn(() => null)); +jest.mock('@components/VideoPlayerContexts/FullScreenContextProvider', () => ({useFullScreenState: jest.fn()})); +jest.mock('@libs/DeviceCapabilities', () => ({canUseTouchScreen: jest.fn()})); +jest.mock('@hooks/useResponsiveLayout'); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('react-native-gesture-handler', () => ({ + ...jest.requireActual('react-native-gesture-handler'), + GestureDetector: jest.fn(({children}: React.ComponentProps) => children), +})); + +const AttachmentCarouselView = jest.requireActual<{default: typeof AttachmentCarouselViewComponent}>( + '../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx', +).default; + +const mockScrollToIndex = jest.fn(); +const mockScrollRef = Object.assign(jest.fn(), {current: createMock>({scrollToIndex: mockScrollToIndex})}); +let pagerState: AttachmentCarouselPagerStateContextType | null = null; +const mockFlatList = jest.fn(({data, renderItem}: FlatListProps) => { + pagerState = useContext(AttachmentCarouselPagerStateContext); + return ( + <> + {Array.from(data ?? []).map((item, index) => ( + {renderItem?.(createMock>({item, index}))} + ))} + + ); +}); +jest.mock('react-native-reanimated', () => { + const animated = jest.requireActual('react-native-reanimated/mock'); + return { + ...animated, + default: {...animated.default, FlatList: (props: FlatListProps) => mockFlatList(props)}, + useAnimatedRef: () => mockScrollRef, + }; +}); + +function createOnNavigateMock() { + return jest.fn>, Parameters>>(); +} + +function createSvgAttachmentSourceMock() { + return jest.fn>, Parameters>>(() => null); +} + +function renderCarousel(overrides: Partial = {}) { + const props: AttachmentCarouselViewProps = { + page: 0, + attachments: [{source: 'first.png'}, {source: 'second.png'}], + source: 'first.png', + shouldShowArrows: true, + autoHideArrows: jest.fn(), + cancelAutoHideArrow: jest.fn(), + setShouldShowArrows: jest.fn(), + setPage: jest.fn(), + onNavigate: jest.fn(), + ...overrides, + }; + const view = render(); + return {props, ...view}; +} + +describe('AttachmentCarouselView web', () => { + beforeEach(() => { + jest.clearAllMocks(); + pagerState = null; + jest.mocked(canUseTouchScreen).mockReturnValue(true); + jest.mocked(useFullScreenState).mockReturnValue(createMock>({isFullScreen: false, isFullScreenRef: {current: false}})); + jest.mocked(CarouselItem).mockImplementation(({item, isFocused}) => { + const sourceLabel = typeof item.source === 'object' ? JSON.stringify(item.source) : String(item.source); + return {`${item.attachmentID ?? sourceLabel}:${String(isFocused)}`}; + }); + }); + + it.each([{source: 'image.png', attachmentID: 'report_2'}, {source: 'image.png'}, {source: 123}])( + 'keeps attachment identity and focus for source $source and id $attachmentID', + (attachment) => { + // Given a typed attachment from the carousel producer, including supported source variants + const other: Attachment = {source: attachment.source, attachmentID: 'different-id'}; + const onNavigate = createOnNavigateMock(); + const {props} = renderCarousel({attachments: [other, attachment], source: other.source, attachmentID: other.attachmentID, onNavigate}); + const viewability = mockFlatList.mock.calls.at(-1)?.[0].onViewableItemsChanged; + expect(viewability).toBeDefined(); + if (!viewability) { + throw new Error('Expected the web FlatList viewability callback'); + } + + // When FlatList reports the second attachment as visible + act(() => viewability({viewableItems: [{item: attachment, key: 'selected', index: 1, isViewable: true}], changed: []})); + + // Then navigation receives the exact item and focus follows attachmentID before source + expect(onNavigate.mock.calls.at(0)?.at(0)).toBe(attachment); + expect(props.setPage).toHaveBeenCalledWith(1); + expect( + screen.getByText(`${attachment.attachmentID ?? (typeof attachment.source === 'object' ? JSON.stringify(attachment.source) : String(attachment.source))}:true`), + ).toBeOnTheScreen(); + expect(screen.getByText('different-id:false')).toBeOnTheScreen(); + }, + ); + + it('invokes a function source as the initial state value and renders it unfocused without an id', () => { + // Given a supported SVG source without an attachment id + const source = createSvgAttachmentSourceMock(); + const attachment: Attachment = {source}; + + // When the unchanged useState initializer receives that function + renderCarousel({attachments: [attachment], source}); + + // Then React invokes it and stores its null return rather than the source function + expect(source).toHaveBeenCalled(); + expect(screen.getByText(`${String(source)}:false`)).toBeOnTheScreen(); + }); + + it('invokes a function source as a state updater while retaining exact navigation and page effects', () => { + // Given a function source without an id and a carousel initialized from a string + const source = createSvgAttachmentSourceMock(); + const attachment: Attachment = {source}; + const onNavigate = createOnNavigateMock(); + const {props} = renderCarousel({attachments: [{source: 'first.png'}, attachment], onNavigate}); + expect(source).not.toHaveBeenCalled(); + const viewability = mockFlatList.mock.calls.at(-1)?.[0].onViewableItemsChanged; + expect(viewability).toBeDefined(); + if (!viewability) { + throw new Error('Expected the web FlatList viewability callback'); + } + + // When the unchanged state setter receives that function from viewability + act(() => viewability({viewableItems: [{item: attachment, key: 'function', index: 1, isViewable: true}], changed: []})); + + // Then React invokes the updater, leaves the item unfocused and still navigates to the original attachment + expect(source).toHaveBeenCalled(); + expect(screen.getByText(`${String(source)}:false`)).toBeOnTheScreen(); + expect(props.setPage).toHaveBeenCalledWith(1); + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate.mock.calls.at(0)?.at(0)).toBe(attachment); + }); + + it('prefers attachment ids over function sources during initialization and viewability', () => { + // Given two SVG sources whose ids supply their focus identities + const source = createSvgAttachmentSourceMock(); + const first: Attachment = {source, attachmentID: 'first-id'}; + const second: Attachment = {source, attachmentID: 'second-id'}; + const onNavigate = createOnNavigateMock(); + const {props} = renderCarousel({attachments: [first, second], source, attachmentID: first.attachmentID, onNavigate}); + expect(source).not.toHaveBeenCalled(); + expect(screen.getByText('first-id:true')).toBeOnTheScreen(); + const viewability = mockFlatList.mock.calls.at(-1)?.[0].onViewableItemsChanged; + expect(viewability).toBeDefined(); + if (!viewability) { + throw new Error('Expected the web FlatList viewability callback'); + } + + // When the second item becomes visible, its id also takes precedence over the function updater + act(() => viewability({viewableItems: [{item: second, key: 'second', index: 1, isViewable: true}], changed: []})); + + // Then neither state call invokes the source and exact navigation and focus follow the selected id + expect(source).not.toHaveBeenCalled(); + expect(screen.getByText('first-id:false')).toBeOnTheScreen(); + expect(screen.getByText('second-id:true')).toBeOnTheScreen(); + expect(props.setPage).toHaveBeenCalledWith(1); + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate.mock.calls.at(0)?.at(0)).toBe(second); + }); + + it('clears focus for empty tokens and navigates a null-index token without changing page or focus', () => { + // Given a real callback installed by the web target + const {props} = renderCarousel(); + const viewability = mockFlatList.mock.calls.at(-1)?.[0].onViewableItemsChanged; + expect(viewability).toBeDefined(); + if (!viewability) { + throw new Error('Expected viewability callback'); + } + const attachment: Attachment = {source: 'second.png'}; + + // When a token has no index, navigation still receives it, then an empty token set clears focus + act(() => viewability({viewableItems: [{item: attachment, key: 'second', index: null, isViewable: true}], changed: []})); + expect(props.onNavigate).toHaveBeenCalledWith(attachment); + expect(props.setPage).not.toHaveBeenCalled(); + expect(screen.getByText('first.png:true')).toBeOnTheScreen(); + act(() => viewability({viewableItems: [], changed: []})); + + // Then empty input removes active focus without another navigation + expect(screen.getByText('first.png:false')).toBeOnTheScreen(); + expect(props.onNavigate).toHaveBeenCalledTimes(1); + }); + + it('returns before keyboard, page or navigation effects during fullscreen', () => { + // Given a fullscreen media item + jest.mocked(useFullScreenState).mockReturnValue(createMock>({isFullScreen: true, isFullScreenRef: {current: true}})); + const dismiss = jest.spyOn(Keyboard, 'dismiss'); + const {props} = renderCarousel(); + const viewability = mockFlatList.mock.calls.at(-1)?.[0].onViewableItemsChanged; + expect(viewability).toBeDefined(); + if (!viewability) { + throw new Error('Expected viewability callback'); + } + + // When viewability changes behind fullscreen playback + act(() => viewability({viewableItems: [{item: {source: 'second.png'}, key: 'second', index: 1, isViewable: true}], changed: []})); + + // Then none of the ordinary paging effects happen + expect(dismiss).not.toHaveBeenCalled(); + expect(props.setPage).not.toHaveBeenCalled(); + expect(props.onNavigate).not.toHaveBeenCalled(); + dismiss.mockRestore(); + }); + + it('shares the exact undefined-initialized ref with the real pan gesture and retains it on rerender', () => { + // Given the production gesture passed to the detector, with only detector rendering replaced + const {props, rerender} = renderCarousel(); + const detectorProps = jest.mocked(GestureDetector).mock.calls.at(-1)?.[0]; + expect(detectorProps).toBeDefined(); + if (!detectorProps || !pagerState || !('toGestureArray' in detectorProps.gesture)) { + throw new Error('Expected the real detector gesture and pager context'); + } + const gestures = detectorProps.gesture.toGestureArray(); + expect(gestures).toHaveLength(1); + const gesture = gestures.at(0); + expect(gesture).toBeDefined(); + if (!gesture) { + throw new Error('Expected the single pan gesture'); + } + expect(gesture.handlerName).toBe('PanGestureHandler'); + const contextRef = pagerState.pagerRef; + expect(gesture.config.ref).toBe(contextRef); + expect(gesture.config.ref?.current).toBeUndefined(); + + // When Gesture Handler initializes the real gesture it writes the same ref exposed to pager consumers + gesture.initialize(); + expect(gesture.config.ref?.current).toBe(gesture); + rerender( + , + ); + + // Then the provider keeps the original ref object rather than a snapshot or a second ref + expect(pagerState.pagerRef).toBe(contextRef); + }); + + it('enforces arrow bounds and bypasses the detector on a mouse device', () => { + // Given a non-touch device at the first page + jest.mocked(canUseTouchScreen).mockReturnValue(false); + const {props, rerender} = renderCarousel(); + mockScrollToIndex.mockClear(); + const buttons = jest.mocked(CarouselButtons).mock.calls.at(-1)?.[0]; + expect(buttons).toBeDefined(); + if (!buttons) { + throw new Error('Expected carousel arrow controls'); + } + + // When the user cycles backward then forward and tries to pass the last page + act(() => buttons.onBack()); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + act(() => buttons.onForward()); + expect(mockScrollToIndex).toHaveBeenCalledWith({index: 1, animated: false}); + rerender( + , + ); + mockScrollToIndex.mockClear(); + const lastButtons = jest.mocked(CarouselButtons).mock.calls.at(-1)?.[0]; + expect(lastButtons).toBeDefined(); + act(() => lastButtons?.onForward()); + + // Then out-of-range navigation does not scroll and pointer capture has no gesture detector + expect(mockScrollToIndex).not.toHaveBeenCalled(); + expect(GestureDetector).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/AttachmentModalBaseContentTest.tsx b/tests/unit/AttachmentModalBaseContentTest.tsx new file mode 100644 index 000000000000..aefb403b0b69 --- /dev/null +++ b/tests/unit/AttachmentModalBaseContentTest.tsx @@ -0,0 +1,220 @@ +/** Exercises file selection and submission on the attachment modal, with media rendering replaced below it. */ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import AttachmentCarousel from '@components/Attachments/AttachmentCarousel'; +import AttachmentView from '@components/Attachments/AttachmentView'; +import type * as AttachmentViewModule from '@components/Attachments/AttachmentView'; +import type {ButtonProps} from '@components/Button'; +// eslint-disable-next-line no-restricted-imports -- The subject uses this child, so the test must observe that exact dependency. +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Text from '@components/Text'; + +import getPlatform from '@libs/getPlatform'; + +import AttachmentModalBaseContent from '@pages/media/AttachmentModalScreen/AttachmentModalBaseContent'; +import type {AttachmentContentProps, AttachmentModalBaseContentProps} from '@pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types'; + +import CONST from '@src/CONST'; +import type {Report} from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; + +// eslint-disable-next-line no-restricted-imports -- This type-only import describes the native host module used by the button mock. +import type * as ReactNative from 'react-native'; + +import React from 'react'; + +import createMock from '../utils/createMock'; + +jest.mock('@components/Attachments/AttachmentCarousel', () => jest.fn(() => null)); +jest.mock('@components/Attachments/AttachmentView', () => ({ + __esModule: true, + default: jest.fn(() => null), + checkIsFileImage: jest.requireActual<{default: typeof AttachmentView; checkIsFileImage: typeof AttachmentViewModule.checkIsFileImage}>('@components/Attachments/AttachmentView') + .checkIsFileImage, +})); +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/Button', () => { + const Native = jest.requireActual('react-native'); + return { + __esModule: true, + default: Object.assign( + ({children, onPress, isDisabled}: ButtonProps) => ( + { + onPress?.(event); + }} + disabled={isDisabled} + > + {children} + + ), + {Text: Native.Text, KeyboardShortcut: () => null}, + ), + }; +}); +jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined])); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/useResponsiveLayout'); +jest.mock('@hooks/useKeyboardShortcut', () => jest.fn()); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/useBottomSafeSafeAreaPaddingStyle', () => jest.fn(() => ({}))); +jest.mock('@libs/getPlatform', () => jest.fn()); + +const FileSelection = jest.fn(({fileToDisplay}: AttachmentContentProps) => {fileToDisplay?.name ?? 'no file'}); + +function createOnConfirmMock(implementation?: NonNullable) { + return jest.fn>, Parameters>>(implementation); +} + +function renderAttachmentModal(props: AttachmentModalBaseContentProps = {}) { + return render(); +} + +describe('AttachmentModalBaseContent files', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(getPlatform).mockReturnValue(CONST.PLATFORM.WEB); + }); + + it.each([0, 1, -1, 4])('retains the original array and uses at(%s) for selection', (index) => { + // Given the mutable file array produced by the attachment upload flow + const files: FileObject[] = [{name: 'first.png'}, {name: 'second.png'}]; + renderAttachmentModal({file: files, fileToDisplayIndex: index, AttachmentContent: FileSelection}); + + // When the real modal passes its selection to attachment content + const selection = FileSelection.mock.calls.at(-1)?.[0]; + + // Then the container and selected element retain their original identities, including negative and missing indices + expect(selection).toBeDefined(); + expect(selection?.files).toBe(files); + expect(selection?.fileToDisplay).toBe(files.at(index)); + expect(screen.getByText(files.at(index)?.name ?? 'no file')).toBeOnTheScreen(); + }); + + it('preserves absence, filename fallback, scalar identity, empty arrays and prop replacement', () => { + // Given no supplied file, the content sees absence rather than an invented file + const view = renderAttachmentModal({AttachmentContent: FileSelection}); + expect(FileSelection.mock.calls.at(-1)?.[0]).toEqual({files: undefined, fileToDisplay: undefined}); + + // When a filename, scalar, empty array and replacement array arrive through props + view.rerender( + , + ); + const fallback = FileSelection.mock.calls.at(-1)?.[0]; + expect(fallback?.files).toEqual({name: 'receipt.png'}); + expect(fallback?.fileToDisplay).toBe(fallback?.files); + const scalar: FileObject = {name: 'scalar.png'}; + view.rerender( + , + ); + expect(FileSelection.mock.calls.at(-1)?.[0].fileToDisplay).toBe(scalar); + const empty: FileObject[] = []; + view.rerender( + , + ); + expect(FileSelection.mock.calls.at(-1)?.[0]).toEqual({files: empty, fileToDisplay: undefined}); + const replacement: FileObject[] = [{name: 'replacement.png'}]; + view.rerender( + , + ); + + // Then selection tracks the replacement without copying it + expect(FileSelection.mock.calls.at(-1)?.[0].files).toBe(replacement); + expect(FileSelection.mock.calls.at(-1)?.[0].fileToDisplay).toBe(replacement.at(0)); + }); + + it.each([CONST.PLATFORM.IOS, CONST.PLATFORM.ANDROID, CONST.PLATFORM.WEB])('mutates and submits the original array with platform close ordering on %s', (platform) => { + // Given a confirmation that navigates off the modal and an array whose elements must stay untouched + jest.mocked(getPlatform).mockReturnValue(platform); + const files: FileObject[] = [{name: 'first.png'}, {name: 'second.png'}]; + const events: string[] = []; + const onConfirm = createOnConfirmMock(() => events.push('confirm')); + const onClose = jest.fn(() => events.push('close')); + renderAttachmentModal({file: files, source: 'https://example.com/receipt.png', onConfirm, onClose, confirmLeavesScreen: true, AttachmentContent: FileSelection}); + + // When the rendered send control is pressed + fireEvent.press(screen.getByTestId('send')); + + // Then source is added to the original container, never to its elements, in the existing platform order + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm.mock.calls.at(0)?.at(0)).toBe(files); + expect(files).toHaveProperty('source', 'https://example.com/receipt.png'); + expect(files.at(0)).not.toHaveProperty('source'); + expect(files.at(1)).not.toHaveProperty('source'); + expect(events).toEqual(platform === CONST.PLATFORM.WEB ? ['confirm', 'close'] : ['close', 'confirm']); + expect(onClose.mock.calls.at(0)).toEqual(platform === CONST.PLATFORM.WEB ? [] : [{shouldCallDirectly: true}]); + }); + + it('closes before deferred scalar confirmation and follows a real carousel navigation', () => { + // Given the real carousel callback owned by the modal and a controlled animation frame + const frame = jest.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(() => 1); + const onConfirm = createOnConfirmMock(); + const onClose = jest.fn(); + const scalar: FileObject = {name: 'navigated.png'}; + renderAttachmentModal({report: createMock({reportID: '1'}), source: 'initial.png', file: {name: 'initial.png'}, onConfirm, onClose}); + const carousel = jest.mocked(AttachmentCarousel).mock.calls.at(-1)?.[0]; + expect(carousel?.onNavigate).toBeDefined(); + if (!carousel?.onNavigate) { + throw new Error('Expected the modal to install carousel navigation'); + } + + // When carousel selection replaces the file and the user sends it + act(() => carousel.onNavigate?.({source: 'navigated.png', file: scalar})); + fireEvent.press(screen.getByTestId('send')); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onConfirm).not.toHaveBeenCalled(); + const confirm = frame.mock.calls.at(-1)?.[0]; + expect(confirm).toBeDefined(); + if (!confirm) { + throw new Error('Expected confirmation to be deferred to the next frame'); + } + act(() => confirm(0)); + + // Then confirmation mutates and receives the exact replacement scalar + expect(onConfirm.mock.calls.at(0)?.at(0)).toBe(scalar); + expect(scalar).toHaveProperty('source', 'navigated.png'); + frame.mockRestore(); + }); + + it('hides send while the attachment keyboard is active and disables explicitly blocked sending', () => { + // Given the attachment view callback controlling send visibility + const onConfirm = createOnConfirmMock(); + const view = renderAttachmentModal({source: 'receipt.png', onConfirm}); + const attachment = jest.mocked(AttachmentView).mock.calls.at(-1)?.[0]; + expect(attachment?.onToggleKeyboard).toBeDefined(); + if (!attachment?.onToggleKeyboard) { + throw new Error('Expected the attachment keyboard callback'); + } + + // When the keyboard is shown, send disappears, and an explicit disabled prop also blocks presses + act(() => attachment.onToggleKeyboard?.(true)); + expect(screen.queryByTestId('send')).not.toBeOnTheScreen(); + act(() => attachment.onToggleKeyboard?.(false)); + view.rerender( + , + ); + expect(screen.getByTestId('send')).toBeDisabled(); + fireEvent.press(screen.getByTestId('send')); + + // Then neither route confirms a blocked send + expect(onConfirm).not.toHaveBeenCalled(); + expect(jest.mocked(HeaderWithBackButton)).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/BankAccountUtilsTest.ts b/tests/unit/BankAccountUtilsTest.ts index 23036f167f60..0ae2198202af 100644 --- a/tests/unit/BankAccountUtilsTest.ts +++ b/tests/unit/BankAccountUtilsTest.ts @@ -5,10 +5,13 @@ import { getDefaultCompanyWebsite, getLastFourDigits, getRequiredKYBDocuments, + getWiseGlobalReimbursementsBannerBankAccountID, + getWorkspaceWiseGlobalReimbursementsBannerBankAccountID, hasBankAccountAllowDebit, hasPartiallySetupBankAccount, hasPersonalBankAccountMissingInfo, isBankAccountPartiallySetup, + isBankAccountEligibleForGlobalReimbursements, isPersonalBankAccountMissingInfo, isUserAddressVerificationRequired, isUserDOBVerificationRequired, @@ -18,7 +21,7 @@ import type {KYBVerificationResponses} from '@libs/BankAccountUtils'; import CONST from '@src/CONST'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import type {Account, BankAccountList, Session} from '@src/types/onyx'; +import type {Account, BankAccountList, Policy, Session} from '@src/types/onyx'; import type AccountData from '@src/types/onyx/AccountData'; import createMock from '../utils/createMock'; @@ -222,6 +225,73 @@ describe('BankAccountUtils', () => { }); }); + describe('isBankAccountEligibleForGlobalReimbursements', () => { + const eligibleAccountData: AccountData = { + type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + additionalData: {currency: CONST.CURRENCY.USD}, + }; + + it('returns true for a verified USD business account', () => { + // Given a verified USD business bank account + // When checking whether it can start the Wise global reimbursements flow + // Then it is eligible, since that is the only kind of account Wise onboarding supports + expect(isBankAccountEligibleForGlobalReimbursements(eligibleAccountData)).toBe(true); + }); + + it('returns false when the account data is missing', () => { + // Given no account data, e.g. an unknown bankAccountID in the URL + // When checking eligibility + // Then it is not eligible, so the intro page shows not-found instead of crashing + expect(isBankAccountEligibleForGlobalReimbursements(undefined)).toBe(false); + }); + + it('returns false for a personal account', () => { + // Given a personal account that is otherwise eligible + // When checking eligibility + // Then it is not eligible, because global reimbursements are paid from business accounts only + expect(isBankAccountEligibleForGlobalReimbursements({...eligibleAccountData, type: CONST.BANK_ACCOUNT.TYPE.PERSONAL})).toBe(false); + }); + + it('returns false for a non-USD account', () => { + // Given a business account in a currency other than USD + // When checking eligibility + // Then it is not eligible, because Release 1 only supports U.S. accounts + expect(isBankAccountEligibleForGlobalReimbursements({...eligibleAccountData, additionalData: {currency: CONST.CURRENCY.EUR}})).toBe(false); + }); + + it('returns false when the currency is missing', () => { + // Given a business account without a currency + // When checking eligibility + // Then it is not eligible, matching the existing Corpay condition that requires an explicit USD currency + expect(isBankAccountEligibleForGlobalReimbursements({...eligibleAccountData, additionalData: {}})).toBe(false); + }); + + it('returns true when only the top-level bank currency is USD', () => { + // Given a business account whose nested currency is missing but whose record carries bankCurrency USD + // When checking eligibility with that bankCurrency + // Then it is eligible, because bankCurrency is the required field and the nested currency is optional + expect(isBankAccountEligibleForGlobalReimbursements({...eligibleAccountData, additionalData: {}}, CONST.CURRENCY.USD)).toBe(true); + }); + + it('returns false when the account is already set up with Corpay', () => { + // Given a verified USD business account that already has a Corpay verification + // When checking eligibility + // Then it is not eligible, because global reimbursements are already enabled for it, matching the Wallet menu condition + expect(isBankAccountEligibleForGlobalReimbursements({...eligibleAccountData, additionalData: {currency: CONST.CURRENCY.USD, verifications: {corpay: {}}}})).toBe(false); + }); + + it.each([CONST.BANK_ACCOUNT.STATE.SETUP, CONST.BANK_ACCOUNT.STATE.PENDING, CONST.BANK_ACCOUNT.STATE.VERIFYING, CONST.BANK_ACCOUNT.STATE.LOCKED])( + 'returns false for an account in the %s state', + (state) => { + // Given a USD business account that is not verified yet or is locked + // When checking eligibility + // Then it is not eligible, because only OPEN accounts can send reimbursements + expect(isBankAccountEligibleForGlobalReimbursements({...eligibleAccountData, state})).toBe(false); + }, + ); + }); + describe('getLastFourDigits', () => { it.each([ {input: '123456789012', expected: '9012', description: 'long account number'}, @@ -753,4 +823,140 @@ describe('BankAccountUtils', () => { ]); }); }); + + describe('getWiseGlobalReimbursementsBannerBankAccountID', () => { + const buildBankAccount = (bankAccountID: number, created: string, overrides: Partial = {}) => ({ + accountData: { + bankAccountID, + created, + type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + additionalData: {currency: CONST.CURRENCY.USD}, + ...overrides, + }, + }); + + it('returns the most recently added eligible account', () => { + // Given two eligible accounts where the lower ID was added later + const bankAccountList = createMock({ + accountOne: buildBankAccount(1, '2026-05-01 10:00:00'), + accountTwo: buildBankAccount(2, '2026-03-01 10:00:00'), + }); + + // When picking the account for the Wallet banner + // Then the newest one by creation date wins, since the spec defaults the banner to the most recently added account + expect(getWiseGlobalReimbursementsBannerBankAccountID(bankAccountList)).toBe(1); + }); + + it('skips accounts that are not eligible', () => { + // Given a newer personal account and an older eligible business account + const bankAccountList = createMock({ + accountOne: buildBankAccount(1, '2026-03-01 10:00:00'), + accountTwo: buildBankAccount(2, '2026-05-01 10:00:00', {type: CONST.BANK_ACCOUNT.TYPE.PERSONAL}), + }); + + // When picking the account for the Wallet banner + // Then only the eligible account is considered, so the banner never opens an intro page that would show not-found + expect(getWiseGlobalReimbursementsBannerBankAccountID(bankAccountList)).toBe(1); + }); + + it('skips accounts pending deletion', () => { + // Given a newer eligible account deleted offline and an older eligible account + const bankAccountList = createMock({ + accountOne: buildBankAccount(1, '2026-03-01 10:00:00'), + accountTwo: {...buildBankAccount(2, '2026-05-01 10:00:00'), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }); + + // When picking the account for the Wallet banner + // Then the pending-delete account is ignored, so the banner never points to an account queued for removal + expect(getWiseGlobalReimbursementsBannerBankAccountID(bankAccountList)).toBe(1); + }); + + it('falls back to the higher ID when creation dates match', () => { + // Given two eligible accounts created at the same time + const bankAccountList = createMock({ + accountOne: buildBankAccount(1, '2026-05-01 10:00:00'), + accountTwo: buildBankAccount(2, '2026-05-01 10:00:00'), + }); + + // When picking the account for the Wallet banner + // Then the higher ID wins, since IDs grow with each newly added account + expect(getWiseGlobalReimbursementsBannerBankAccountID(bankAccountList)).toBe(2); + }); + + it('returns undefined when no account is eligible', () => { + // Given only an account already set up for global reimbursements + const bankAccountList = createMock({ + accountOne: buildBankAccount(1, '2026-05-01 10:00:00', {additionalData: {currency: CONST.CURRENCY.USD, verifications: {corpay: {}}}}), + }); + + // When picking the account for the Wallet banner + // Then nothing is returned, so the banner stays hidden + expect(getWiseGlobalReimbursementsBannerBankAccountID(bankAccountList)).toBeUndefined(); + }); + }); + + describe('getWorkspaceWiseGlobalReimbursementsBannerBankAccountID', () => { + const bankAccountID = 1; + const eligibleAccountData: AccountData = { + bankAccountID, + type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + additionalData: {currency: CONST.CURRENCY.USD}, + }; + const policy = createMock({ + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + achAccount: {bankAccountID, accountNumber: '', routingNumber: '', addressName: '', bankName: '', reimburser: ''}, + }); + + it('returns the connected account when it is eligible', () => { + // Given a workspace reimbursing via Expensify from an eligible account the admin holds a copy of + const bankAccountList = createMock({[bankAccountID]: {accountData: eligibleAccountData}}); + + // When picking the account for the Workspace banner + // Then the connected account is returned, so the banner opens the intro page for it + expect(getWorkspaceWiseGlobalReimbursementsBannerBankAccountID(policy, bankAccountList)).toBe(bankAccountID); + }); + + it('returns undefined when the workspace does not reimburse via Expensify', () => { + // Given a workspace that tracks payments made elsewhere + const manualPolicy = createMock({...policy, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL}); + const bankAccountList = createMock({[bankAccountID]: {accountData: eligibleAccountData}}); + + // When picking the account for the Workspace banner + // Then nothing is returned, since the connected account is not used to pay anyone + expect(getWorkspaceWiseGlobalReimbursementsBannerBankAccountID(manualPolicy, bankAccountList)).toBeUndefined(); + }); + + it('returns undefined when the admin does not hold a copy of the account', () => { + // Given an eligible connected account that is missing from the admin's bank account list + const bankAccountList = createMock({}); + + // When picking the account for the Workspace banner + // Then nothing is returned, since eligibility can't be checked without the account data + expect(getWorkspaceWiseGlobalReimbursementsBannerBankAccountID(policy, bankAccountList)).toBeUndefined(); + }); + + it('returns undefined when the connected account is not eligible', () => { + // Given a connected account that is already set up with Corpay + const bankAccountList = createMock({ + [bankAccountID]: {accountData: {...eligibleAccountData, additionalData: {currency: CONST.CURRENCY.USD, verifications: {corpay: {}}}}}, + }); + + // When picking the account for the Workspace banner + // Then nothing is returned, so the banner never opens an intro page that would show not-found + expect(getWorkspaceWiseGlobalReimbursementsBannerBankAccountID(policy, bankAccountList)).toBeUndefined(); + }); + + it('returns undefined when the connected account is pending deletion', () => { + // Given an eligible connected account deleted while offline + const bankAccountList = createMock({ + [bankAccountID]: {accountData: eligibleAccountData, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }); + + // When picking the account for the Workspace banner + // Then nothing is returned, so the banner never points to an account queued for removal + expect(getWorkspaceWiseGlobalReimbursementsBannerBankAccountID(policy, bankAccountList)).toBeUndefined(); + }); + }); }); diff --git a/tests/unit/CarouselActionsTest.tsx b/tests/unit/CarouselActionsTest.tsx new file mode 100644 index 000000000000..d440a0aee8e2 --- /dev/null +++ b/tests/unit/CarouselActionsTest.tsx @@ -0,0 +1,65 @@ +import {render} from '@testing-library/react-native'; + +import CarouselActions from '@components/Attachments/AttachmentCarousel/CarouselActions'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +// Captures registered shortcuts so tests can fire them with a keyboard event targeting a chosen element +const registeredShortcuts: Record) => void> = {}; +jest.mock('@hooks/useKeyboardShortcut', () => { + return (shortcut: {shortcutKey: string}, callback: (event?: Partial) => void) => { + registeredShortcuts[shortcut.shortcutKey] = callback; + }; +}); + +/** Mirrors the PDF scroller that PDFView focuses so arrow keys scroll the PDF */ +function focusPDFScroller(): HTMLDivElement { + const scroller = document.createElement('div'); + scroller.tabIndex = -1; + document.body.appendChild(scroller); + scroller.focus(); + return scroller; +} + +function pressArrowKey(shortcutKey: string, target: HTMLElement) { + registeredShortcuts[shortcutKey]?.({target}); +} + +describe('CarouselActions', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it.each([CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT.shortcutKey, CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT.shortcutKey])( + 'keeps the PDF scroller focused when %s cannot move the carousel', + (shortcutKey) => { + // Given a single PDF attachment whose scroller has focus, so the carousel has nowhere to move + const onCycleThroughAttachments = jest.fn(() => false); + render(); + const scroller = focusPDFScroller(); + + // When the user presses an arrow key + pressArrowKey(shortcutKey, scroller); + + // Then the scroller stays focused, because blurring it would stop the Down key from scrolling the PDF + expect(onCycleThroughAttachments).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(scroller); + }, + ); + + it.each([CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT.shortcutKey, CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT.shortcutKey])('blurs the focused element when %s moves the carousel', (shortcutKey) => { + // Given an attachment with other attachments next to it, whose PDF scroller has focus + const onCycleThroughAttachments = jest.fn(() => true); + render(); + const scroller = focusPDFScroller(); + + // When the user presses an arrow key and the carousel moves to another attachment + pressArrowKey(shortcutKey, scroller); + + // Then focus leaves the scroller, so no focus ring is left highlighting the modal + expect(onCycleThroughAttachments).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(document.body); + }); +}); diff --git a/tests/unit/CategoryOptionListUtilsTest.ts b/tests/unit/CategoryOptionListUtilsTest.ts index a426a9988bce..76d337267fa2 100644 --- a/tests/unit/CategoryOptionListUtilsTest.ts +++ b/tests/unit/CategoryOptionListUtilsTest.ts @@ -1458,4 +1458,21 @@ describe('CategoryOptionListUtils', () => { expect(rows.every((row) => row.alternateText === undefined)).toBe(true); }); + + it('groups categories into Recent and All sections once the count reaches a custom searchInputThreshold', () => { + // Given fewer enabled categories than the default limit, but as many as the custom threshold + const categories: PolicyCategories = { + Food: makeCategory('Food', '5000'), + Travel: makeCategory('Travel', '6000'), + Lodging: makeCategory('Lodging', '7000'), + }; + + // When building sections with the default threshold and with a threshold matching the category count + const defaultSections = getCategoryListSections({categories, recentlyUsedCategories: ['Food'], localeCompare, translate: translateLocal}); + const customSections = getCategoryListSections({categories, recentlyUsedCategories: ['Food'], localeCompare, translate: translateLocal, searchInputThreshold: 3}); + + // Then the default keeps a single untitled list, while the custom threshold splits it so the list matches the visible search input + expect(defaultSections.map((section) => section.title)).toEqual(['']); + expect(customSections.map((section) => section.title)).toEqual([translateLocal('common.recent'), translateLocal('common.all')]); + }); }); diff --git a/tests/unit/CategoryShortcutBarTest.tsx b/tests/unit/CategoryShortcutBarTest.tsx new file mode 100644 index 000000000000..9d3b12636d02 --- /dev/null +++ b/tests/unit/CategoryShortcutBarTest.tsx @@ -0,0 +1,166 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import emojis, {categoryFrequentlyUsed} from '@assets/emojis'; + +import CategoryShortcutBar from '@components/EmojiPicker/CategoryShortcutBar'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import Tooltip from '@components/Tooltip'; +import type {TooltipExtendedProps} from '@components/Tooltip/types'; + +import {getHeaderEmojis} from '@libs/EmojiUtils'; +import Log from '@libs/Log'; + +import IntlStore from '@src/languages/IntlStore'; +import type {TranslationPaths} from '@src/languages/types'; +import {defaultTheme} from '@src/styles/theme'; + +import React from 'react'; + +import {translateLocal} from '../utils/TestHelper'; + +jest.mock('@components/Tooltip', () => ({ + __esModule: true, + default: jest.fn(({children}: TooltipExtendedProps) => children), +})); + +const productionHeaders = getHeaderEmojis([categoryFrequentlyUsed, ...emojis]); +const expectedTranslationPaths: TranslationPaths[] = [ + 'emojiPicker.headers.frequentlyUsed', + 'emojiPicker.headers.smileysAndEmotion', + 'emojiPicker.headers.animalsAndNature', + 'emojiPicker.headers.foodAndDrink', + 'emojiPicker.headers.travelAndPlaces', + 'emojiPicker.headers.activities', + 'emojiPicker.headers.objects', + 'emojiPicker.headers.symbols', + 'emojiPicker.headers.flags', +]; + +describe('CategoryShortcutBar', () => { + const alertSpy = jest.spyOn(Log, 'alert').mockImplementation(() => {}); + + beforeEach(async () => { + await IntlStore.load('en'); + jest.mocked(Tooltip).mockClear(); + alertSpy.mockClear(); + }); + + afterAll(() => { + alertSpy.mockRestore(); + }); + + it('uses the production translation for every category tooltip and accessible label', () => { + // Given the production headers are the source of every category shortcut. + const onPress = jest.fn(); + + // When every production category appears in the shortcut bar. + render( + + + , + ); + + // Then every header has its own translation and none takes the unmapped fallback. + const expectedLabels = expectedTranslationPaths.map((path) => translateLocal(path)); + const tooltipLabels = jest + .mocked(Tooltip) + .mock.calls.slice(-productionHeaders.length) + .map(([props]) => props.text); + expect(tooltipLabels).toEqual(expectedLabels); + expect(screen.getAllByRole('button')).toHaveLength(expectedLabels.length); + for (const label of expectedLabels) { + expect(screen.getByRole('button', {name: label})).toBeVisible(); + } + expect(alertSpy).not.toHaveBeenCalled(); + }); + + it('alerts for an unmapped category while providing the frequently-used fallback label', () => { + // Given a future category can reach the bar before a translation is mapped. + const unknownHeader = {...categoryFrequentlyUsed, code: 'unknownCategory', index: -1}; + + // When that unmapped category is rendered. + render( + + + , + ); + + // Then the fallback remains usable and the missing mapping cannot silently ship. + const fallbackLabel = translateLocal('emojiPicker.headers.frequentlyUsed'); + expect(screen.getByRole('button', {name: fallbackLabel})).not.toBeSelected(); + expect(jest.mocked(Tooltip).mock.calls.at(-1)?.[0].text).toBe(fallbackLabel); + expect(alertSpy).toHaveBeenCalledWith('No emoji category translation mapped for code: unknownCategory'); + }); + + it('exposes the selected category through its accessible state', () => { + // Given a category is selected by its production header index. + const selectedHeader = productionHeaders.at(1); + if (!selectedHeader) { + throw new Error('The smileys emoji header is missing'); + } + + // When the bar receives that selected index. + render( + + + , + ); + + // Then assistive technology can distinguish selected and unselected shortcuts. + expect(screen.getByRole('button', {name: translateLocal('emojiPicker.headers.smileysAndEmotion'), selected: true})).toBeSelected(); + expect(screen.getByRole('button', {name: translateLocal('emojiPicker.headers.frequentlyUsed'), selected: false})).not.toBeSelected(); + }); + + it('passes the category index to the press handler', () => { + // Given shortcut presses must select the matching category in the emoji list. + const onPress = jest.fn(); + const header = {...categoryFrequentlyUsed, index: 42}; + render( + + + , + ); + + // When the user presses that category's shortcut. + fireEvent.press(screen.getByRole('button', {name: translateLocal('emojiPicker.headers.frequentlyUsed')})); + + // Then the bar reports the header index rather than its position in the shortcut array. + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledWith(header.index); + }); + + it('highlights a shortcut only while it is hovered', () => { + // Given a shortcut starts without hover highlighting. + render( + + + , + ); + const button = screen.getByRole('button', {name: translateLocal('emojiPicker.headers.frequentlyUsed')}); + const highlight = {backgroundColor: defaultTheme.buttonDefaultBG}; + expect(button).not.toHaveStyle(highlight); + + // When the pointer enters and then leaves the shortcut. + fireEvent(button, 'hoverIn'); + // Then the hover style appears while the pointer is inside and clears on exit. + expect(button).toHaveStyle(highlight); + fireEvent(button, 'hoverOut'); + expect(button).not.toHaveStyle(highlight); + }); +}); diff --git a/tests/unit/CategoryTagPickerAutoFocusTest.tsx b/tests/unit/CategoryTagPickerAutoFocusTest.tsx index 94a4039ee2fe..cda4c8ece815 100644 --- a/tests/unit/CategoryTagPickerAutoFocusTest.tsx +++ b/tests/unit/CategoryTagPickerAutoFocusTest.tsx @@ -23,6 +23,8 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct' * runs through `useFocusEffect` + a `setTimeout` racing the popover animation, which is verified on device. */ +jest.mock('@libs/API'); + // Capture the props the pickers pass down to SelectionList so we can assert on `textInputOptions.disableAutoFocus`. // Prefix `mock` so jest allows the variable inside the factory below. const mockSelectionList = jest.fn]>(() => null); diff --git a/tests/unit/CategoryUtilsTest.ts b/tests/unit/CategoryUtilsTest.ts index 988cfe4659d4..67535d533fa3 100644 --- a/tests/unit/CategoryUtilsTest.ts +++ b/tests/unit/CategoryUtilsTest.ts @@ -7,6 +7,7 @@ import { getCategoryNameError, getDecodedFullCategoryName, getDecodedLeafCategoryName, + hasAnyCategoryGLCode, hasAnyCategoryRules, isCategoryDescriptionRequired, isCategoryMissing, @@ -521,6 +522,51 @@ describe('getCategoryGLCode', () => { }); }); +describe('hasAnyCategoryGLCode', () => { + it('returns false when the workspace has no categories', () => { + // Given no categories, there is no GL code to pick, so the Category GL code cell must stay read-only + // When / Then + expect(hasAnyCategoryGLCode(undefined)).toBe(false); + expect(hasAnyCategoryGLCode({})).toBe(false); + }); + + it('returns false when every GL code is missing or blank', () => { + // Given categories whose GL codes are missing, empty, whitespace or only quotes + const categories: PolicyCategories = { + Meals: {enabled: true, name: 'Meals', pendingAction: null}, + Travel: {enabled: true, name: 'Travel', pendingAction: null, 'GL Code': ''}, // eslint-disable-line @typescript-eslint/naming-convention + Fuel: {enabled: true, name: 'Fuel', pendingAction: null, 'GL Code': ' '}, // eslint-disable-line @typescript-eslint/naming-convention + Rent: {enabled: true, name: 'Rent', pendingAction: null, 'GL Code': '""'}, // eslint-disable-line @typescript-eslint/naming-convention + }; + + // When / Then a blank code does not count as a GL code + expect(hasAnyCategoryGLCode(categories)).toBe(false); + }); + + it('returns true when at least one category has a GL code', () => { + // Given a mix of categories with and without a GL code + const categories: PolicyCategories = { + Meals: {enabled: true, name: 'Meals', pendingAction: null}, + Travel: {enabled: true, name: 'Travel', pendingAction: null, 'GL Code': '1200'}, // eslint-disable-line @typescript-eslint/naming-convention + }; + + // When / Then one code is enough to keep the cell editable + expect(hasAnyCategoryGLCode(categories)).toBe(true); + }); + + it('returns false when the only GL codes are on disabled or pending-deletion categories', () => { + // Given GL codes only on categories the category picker won't let the user select + const categories: PolicyCategories = { + Meals: {enabled: true, name: 'Meals', pendingAction: null}, + Travel: {enabled: false, name: 'Travel', pendingAction: null, 'GL Code': '1200'}, // eslint-disable-line @typescript-eslint/naming-convention + Fuel: {enabled: true, name: 'Fuel', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, 'GL Code': '1300'}, // eslint-disable-line @typescript-eslint/naming-convention + }; + + // When / Then the cell stays read-only, because the picker would offer no GL code to pick + expect(hasAnyCategoryGLCode(categories)).toBe(false); + }); +}); + describe('getCategoryNameError', () => { const encodedFoodAndDrink = 'Food & Drink'; const categories: PolicyCategories = { diff --git a/tests/unit/ChronosTimerActionTest.ts b/tests/unit/ChronosTimerActionTest.ts index 7bc5698c1fbd..992a76ee06b2 100644 --- a/tests/unit/ChronosTimerActionTest.ts +++ b/tests/unit/ChronosTimerActionTest.ts @@ -2,12 +2,17 @@ import {startOrStopChronosTimer} from '@libs/actions/Chronos'; import {write} from '@libs/API'; import {WRITE_COMMANDS} from '@libs/API/types'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report} from '@src/types/onyx'; +import type {Report, ReportAction} from '@src/types/onyx'; import type {OnyxData} from '@src/types/onyx/Request'; import type {OnyxKey, OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + jest.mock('@libs/API'); const mockWrite = jest.mocked(write); @@ -24,6 +29,19 @@ function getWriteOptions(): OnyxData { return options; } +/** Reads back the last-visible-action fields the report update restores, narrowing instead of asserting the Onyx value's type. */ +function getRestoredLastAction(updates: Array> | undefined, reportID: string) { + const update = updates?.find((u) => u.key === `${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + if (!update || !('value' in update) || typeof update.value !== 'object' || update.value === null) { + return undefined; + } + const {value} = update; + return { + lastVisibleActionCreated: 'lastVisibleActionCreated' in value && typeof value.lastVisibleActionCreated === 'string' ? value.lastVisibleActionCreated : undefined, + lastActorAccountID: 'lastActorAccountID' in value && typeof value.lastActorAccountID === 'number' ? value.lastActorAccountID : undefined, + }; +} + function getChronosNVPStartTime(updates: Array> | undefined): string | undefined { const update = updates?.find((u) => u.key === ONYXKEYS.NVP_CHRONOS_TIME_TRACKING); if (!update || !('value' in update) || typeof update.value !== 'object' || update.value === null) { @@ -60,6 +78,56 @@ describe('startOrStopChronosTimer', () => { expect(getChronosNVPStartTime(failureData)).toBe(''); }); + it('restores the last visible action that the acting user could see when the send fails', async () => { + const WHISPER_TARGET_ACCOUNT_ID = 777; + const COMMENT_ACTOR_ACCOUNT_ID = 888; + + const comment: ReportAction = { + reportActionID: 'chronosComment', + reportID: TEST_REPORT.reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: COMMENT_ACTOR_ACCOUNT_ID, + created: '2026-07-13 09:00:00.000', + message: [{type: 'COMMENT', html: 'Older comment', text: 'Older comment'}], + originalMessage: {html: 'Older comment'}, + }; + + /** Newer than `comment`, and only the whispered-to account can see it. */ + const whisper: ReportAction = { + reportActionID: 'chronosWhisper', + reportID: TEST_REPORT.reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + actorAccountID: WHISPER_TARGET_ACCOUNT_ID, + created: '2026-07-13 09:30:00.000', + message: [{type: 'COMMENT', html: 'changed the amount', text: 'changed the amount', whisperedTo: [WHISPER_TARGET_ACCOUNT_ID]}], + originalMessage: {whisperedTo: [WHISPER_TARGET_ACCOUNT_ID]}, + }; + + // Given a Chronos report whose newest action is a whisper aimed at one account + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${TEST_REPORT.reportID}`, {[comment.reportActionID]: comment, [whisper.reportActionID]: whisper}); + await waitForBatchedUpdates(); + + // When the whispered-to account starts the timer + startOrStopChronosTimer(TEST_REPORT, WHISPER_TARGET_ACCOUNT_ID, null); + + // Then the failure data restores the whisper, which is the newest action that account can see + expect(getRestoredLastAction(getWriteOptions().failureData, TEST_REPORT.reportID)).toEqual({ + lastVisibleActionCreated: whisper.created, + lastActorAccountID: WHISPER_TARGET_ACCOUNT_ID, + }); + + jest.clearAllMocks(); + + // When somebody the whisper does not target starts the timer + startOrStopChronosTimer(TEST_REPORT, COMMENT_ACTOR_ACCOUNT_ID, null); + + // Then the whisper is invisible to them, so the older comment is restored instead + expect(getRestoredLastAction(getWriteOptions().failureData, TEST_REPORT.reportID)).toEqual({ + lastVisibleActionCreated: comment.created, + lastActorAccountID: COMMENT_ACTOR_ACCOUNT_ID, + }); + }); + it('optimistically stops the timer when one is running', () => { // When stopping a running timer (previous startTime present) startOrStopChronosTimer(TEST_REPORT, TEST_ACCOUNT_ID, TEST_START_TIME); diff --git a/tests/unit/ClearReportActionErrorsTest.ts b/tests/unit/ClearReportActionErrorsTest.ts index 1a931fa54d6a..81e895210001 100644 --- a/tests/unit/ClearReportActionErrorsTest.ts +++ b/tests/unit/ClearReportActionErrorsTest.ts @@ -414,6 +414,51 @@ describe('ClearReportActionErrors', () => { expect(parentReportActions?.[PARENT_REPORT_ACTION_ID]?.errors).toEqual({}); }); + it('should clear child errors when the reports parameter contains only the ancestor chain', async () => { + // Given an action with both a parent and a child thread, where the reports parameter holds only the + // ancestor link of the starting report (no entry for the child report, and the REPORT collection is not + // seeded), so a caller may narrow `reports` to ancestors without losing the downward walk + const reportAction = getFakeReportAction(Number(REPORT_ACTION_ID), { + childReportID: CHILD_REPORT_ID, + errors: {sharedError: 'Error message'}, + }); + const parentReportAction = getFakeReportAction(Number(PARENT_REPORT_ACTION_ID), { + errors: {sharedError: 'Parent error message'}, + }); + const childReportAction = getFakeReportAction(Number(CHILD_REPORT_ACTION_ID), { + errors: {sharedError: 'Child error message'}, + }); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [REPORT_ACTION_ID]: reportAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT_ID}`, { + [PARENT_REPORT_ACTION_ID]: parentReportAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CHILD_REPORT_ID}`, { + [CHILD_REPORT_ACTION_ID]: childReportAction, + }); + await waitForBatchedUpdates(); + + // When clearAllRelatedReportActionErrors is called with an ancestor-only reports collection + clearAllRelatedReportActionErrors({ + reportID: REPORT_ID, + reportAction, + originalReportID: REPORT_ID, + isOffline: false, + reports: { + [`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`]: {parentReportID: PARENT_REPORT_ID, parentReportActionID: PARENT_REPORT_ACTION_ID}, + }, + }); + await waitForBatchedUpdates(); + + // Then the matching errors are cleared on both the parent and the child actions + const parentReportActions = await getReportActionsFromOnyx(PARENT_REPORT_ID); + expect(parentReportActions?.[PARENT_REPORT_ACTION_ID]?.errors).toEqual({}); + const childReportActions = await getReportActionsFromOnyx(CHILD_REPORT_ID); + expect(childReportActions?.[CHILD_REPORT_ACTION_ID]?.errors).toEqual({}); + }); + it('should clear errors across a multi-level ancestor chain supplied via the reports parameter', async () => { // Given a three-level chain (report -> parent -> grandparent) whose hierarchy exists ONLY in the // reports parameter (the REPORT collection is not seeded), so the walk can only reach the diff --git a/tests/unit/ComposerSelectionNotificationTest.tsx b/tests/unit/ComposerSelectionNotificationTest.tsx new file mode 100644 index 000000000000..0edfdd2d327d --- /dev/null +++ b/tests/unit/ComposerSelectionNotificationTest.tsx @@ -0,0 +1,102 @@ +/** Covers the real Suggestions forwarding interface for clear and caret notifications. */ +import {act, render} from '@testing-library/react-native'; + +import type {CustomSelectionChangeEvent} from '@components/Composer/types'; + +import type {SuggestionsRef} from '@pages/inbox/report/ReportActionCompose/ComposerContext'; +import Suggestions from '@pages/inbox/report/ReportActionCompose/Suggestions'; + +import type {TextInputSelectionChangeEvent} from 'react-native'; + +import React from 'react'; + +import createMock from '../utils/createMock'; + +const mockEmojiSelection = jest.fn>, Parameters>>(); +const mockMentionSelection = jest.fn>, Parameters>>(); + +jest.mock('@components/DragAndDrop/Provider', () => ({useDragAndDropState: () => ({isDraggingOver: false})})); +jest.mock('@pages/inbox/report/ReportActionCompose/SuggestionEmoji', () => { + const ReactModule = jest.requireActual('react'); + return ({ref}: {ref: React.Ref}) => { + ReactModule.useImperativeHandle(ref, () => ({ + onSelectionChange: mockEmojiSelection, + resetSuggestions: jest.fn, Parameters>(), + triggerHotkeyActions: jest.fn, Parameters>(), + updateShouldShowSuggestionMenuToFalse: jest.fn< + ReturnType, + Parameters + >(), + setShouldBlockSuggestionCalc: jest.fn, Parameters>(), + getSuggestions: () => [], + getIsSuggestionsMenuVisible: () => false, + })); + return null; + }; +}); +jest.mock('@pages/inbox/report/ReportActionCompose/SuggestionMention', () => { + const ReactModule = jest.requireActual('react'); + return ({ref}: {ref: React.Ref}) => { + ReactModule.useImperativeHandle(ref, () => ({ + onSelectionChange: mockMentionSelection, + resetSuggestions: jest.fn, Parameters>(), + triggerHotkeyActions: jest.fn, Parameters>(), + updateShouldShowSuggestionMenuToFalse: jest.fn< + ReturnType, + Parameters + >(), + setShouldBlockSuggestionCalc: jest.fn, Parameters>(), + getSuggestions: () => [], + getIsSuggestionsMenuVisible: () => false, + })); + return null; + }; +}); + +describe('Suggestions selection notifications', () => { + beforeEach(() => jest.clearAllMocks()); + + it.each([ + {nativeEvent: {selection: {start: 0, end: 0}}} satisfies CustomSelectionChangeEvent, + createMock({nativeEvent: {selection: {start: 2, end: 4}}}), + {nativeEvent: {selection: {start: 3, end: 3, positionX: 10, positionY: 20}}} satisfies CustomSelectionChangeEvent, + ])('forwards selection $nativeEvent.selection in emoji then mention order', (notification) => { + // Given clear supplies a minimal notification while real input/caret notifications retain their fields. + const ref = React.createRef(); + render( + {}} + updateComment={() => {}} + measureParentContainerAndReportCursor={() => {}} + isGroupPolicyReport={false} + />, + ); + const selectionChanged = ref.current?.onSelectionChange; + if (!selectionChanged) { + throw new Error('Expected Suggestions selection interface'); + } + + // When the existing imperative interface receives the producer's notification. + act(() => selectionChanged(notification)); + + // Then both consumers receive the same object, preserving caret data and forwarding order. + expect(mockEmojiSelection).toHaveBeenCalledWith(notification); + expect(mockMentionSelection).toHaveBeenCalledWith(notification); + const emojiCall = mockEmojiSelection.mock.calls.at(0); + const mentionCall = mockMentionSelection.mock.calls.at(0); + if (!emojiCall || !mentionCall) { + throw new Error('Expected both selection notification calls'); + } + expect(emojiCall[0]).toBe(notification); + expect(mentionCall[0]).toBe(notification); + const earlierCallOrder1 = mockEmojiSelection.mock.invocationCallOrder.at(0); + const laterCallOrder1 = mockMentionSelection.mock.invocationCallOrder.at(0); + if (earlierCallOrder1 === undefined || laterCallOrder1 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder1).toBeLessThan(laterCallOrder1); + }); +}); diff --git a/tests/unit/ContextMenuActionsCopyMessageTest.ts b/tests/unit/ContextMenuActionsCopyMessageTest.ts index 1d8a6f6f14e9..1470c27421b1 100644 --- a/tests/unit/ContextMenuActionsCopyMessageTest.ts +++ b/tests/unit/ContextMenuActionsCopyMessageTest.ts @@ -10,13 +10,9 @@ import type {TranslationParameters, TranslationPaths} from '@src/languages/types import createMock from '../utils/createMock'; import {formatPhoneNumber} from '../utils/TestHelper'; -jest.mock( - 'expo-web-browser', - () => ({ - openAuthSessionAsync: jest.fn(), - }), - {virtual: true}, -); +jest.mock('expo-web-browser', () => ({ + openAuthSessionAsync: jest.fn(), +})); jest.mock('@components/Reactions/MiniQuickEmojiReactions', () => 'MiniQuickEmojiReactions'); jest.mock('@components/Reactions/QuickEmojiReactions', () => 'QuickEmojiReactions'); @@ -260,6 +256,75 @@ describe('ContextMenuActions copy message', () => { expect(mockSetString).toHaveBeenCalledWith('updated work arrangement'); }); + it('copies the localized message for a default work arrangement update action', () => { + // Given a default work arrangement change log, whose stored text is Auth's English message + mockCanSetHtml.mockReturnValue(false); + mockGetClipboardText.mockReturnValue('changed the default work arrangement'); + + if (!copyMessageAction?.onPress) { + throw new Error('Copy message context menu action was not found'); + } + + // When its message is copied + copyMessageAction.onPress( + false, + createReportActionPayload( + createMock({ + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_POLICY_WORK_ARRANGEMENT, + message: [{text: 'stored message', html: 'stored message'}], + originalMessage: {newValue: true, oldValue: false}, + }), + ), + ); + + // Then the translated change log text is copied instead of the stored text + expect(mockGetClipboardText).toHaveBeenCalledWith('workspaceActions.workArrangement.changed', undefined); + expect(mockSetString).toHaveBeenCalledWith('changed the default work arrangement'); + }); + + it.each([ + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION, + {officeID: 'OFFICE1', name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}}, + 'workspaceActions.officeLocation.added', + ], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION, + {officeID: 'OFFICE1', name: 'HQ', updatedField: CONST.POLICY.OFFICE_LOCATION_FIELD.NAME, newValue: 'HQ', oldValue: 'Headquarters'}, + 'workspaceActions.officeLocation.renamed', + ], + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION, {officeID: 'OFFICE1', name: 'Headquarters'}, 'workspaceActions.officeLocation.removed'], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION, + {accountID: 1, email: 'member@test.com', name: 'Member One', officeID: 'OFFICE2', officeName: 'New Jersey Office', oldOfficeID: '', oldOfficeName: ''}, + 'workspaceActions.officeLocation.changedMemberOfficeLocation', + ], + ] as const)('copies the localized message for a %s action', (actionName, originalMessage, translationKey) => { + // Given an office location change log, whose stored text is Auth's English message + mockCanSetHtml.mockReturnValue(false); + mockGetClipboardText.mockReturnValue('office change'); + + if (!copyMessageAction?.onPress) { + throw new Error('Copy message context menu action was not found'); + } + + // When its message is copied + copyMessageAction.onPress( + false, + createReportActionPayload( + createMock({ + actionName, + message: [{text: 'stored message', html: 'stored message'}], + originalMessage, + }), + ), + ); + + // Then the translated change log text is copied instead of the stored text + expect(mockGetClipboardText).toHaveBeenCalledWith(translationKey, undefined); + expect(mockSetString).toHaveBeenCalledWith('office change'); + }); + it('copies the localized member work arrangement message as html when supported', () => { // Given the clipboard supports HTML, when the member arrangement message is copied, then the shared rich clipboard path is used. mockCanSetHtml.mockReturnValue(true); diff --git a/tests/unit/ContextMenuActionsDeleteTest.ts b/tests/unit/ContextMenuActionsDeleteTest.ts index b2e060fa2a63..437919edd5d7 100644 --- a/tests/unit/ContextMenuActionsDeleteTest.ts +++ b/tests/unit/ContextMenuActionsDeleteTest.ts @@ -10,13 +10,9 @@ import createRandomReportAction from '../utils/collections/reportActions'; import createRandomTransaction from '../utils/collections/transaction'; import createMock from '../utils/createMock'; -jest.mock( - 'expo-web-browser', - () => ({ - openAuthSessionAsync: jest.fn(), - }), - {virtual: true}, -); +jest.mock('expo-web-browser', () => ({ + openAuthSessionAsync: jest.fn(), +})); jest.mock('@components/Reactions/MiniQuickEmojiReactions', () => 'MiniQuickEmojiReactions'); jest.mock('@components/Reactions/QuickEmojiReactions', () => 'QuickEmojiReactions'); diff --git a/tests/unit/ContextMenuActionsEditActionTest.ts b/tests/unit/ContextMenuActionsEditActionTest.ts index 5f7f73ba133b..8520c69f8552 100644 --- a/tests/unit/ContextMenuActionsEditActionTest.ts +++ b/tests/unit/ContextMenuActionsEditActionTest.ts @@ -7,13 +7,9 @@ import type {ReportAction, ReportActions} from '@src/types/onyx'; import createMock from '../utils/createMock'; -jest.mock( - 'expo-web-browser', - () => ({ - openAuthSessionAsync: jest.fn(), - }), - {virtual: true}, -); +jest.mock('expo-web-browser', () => ({ + openAuthSessionAsync: jest.fn(), +})); jest.mock('@components/Reactions/MiniQuickEmojiReactions', () => 'MiniQuickEmojiReactions'); jest.mock('@components/Reactions/QuickEmojiReactions', () => 'QuickEmojiReactions'); const {default: ContextMenuActions} = jest.requireActual('@pages/inbox/report/ContextMenu/ContextMenuActions'); diff --git a/tests/unit/ContextMenuActionsSupportTicketTest.ts b/tests/unit/ContextMenuActionsSupportTicketTest.ts index 936d9854e1c2..18ab70e518b0 100644 --- a/tests/unit/ContextMenuActionsSupportTicketTest.ts +++ b/tests/unit/ContextMenuActionsSupportTicketTest.ts @@ -4,13 +4,9 @@ import CONST from '@src/CONST'; import createMock from '../utils/createMock'; -jest.mock( - 'expo-web-browser', - () => ({ - openAuthSessionAsync: jest.fn(), - }), - {virtual: true}, -); +jest.mock('expo-web-browser', () => ({ + openAuthSessionAsync: jest.fn(), +})); jest.mock('@components/Reactions/MiniQuickEmojiReactions', () => 'MiniQuickEmojiReactions'); jest.mock('@components/Reactions/QuickEmojiReactions', () => 'QuickEmojiReactions'); diff --git a/tests/unit/CreateReportMenuItemTest.tsx b/tests/unit/CreateReportMenuItemTest.tsx index c5cdad8abd48..a669d6b86af8 100644 --- a/tests/unit/CreateReportMenuItemTest.tsx +++ b/tests/unit/CreateReportMenuItemTest.tsx @@ -45,8 +45,9 @@ jest.mock('@hooks/useResponsiveLayout', () => ({ default: () => ({shouldUseNarrowLayout: false}), })); +const mockCreateNewReport = jest.fn<{reportID: string}, unknown[]>(() => ({reportID: 'report-1'})); jest.mock('@libs/actions/Report', () => ({ - createNewReport: jest.fn(() => ({reportID: 'report-1'})), + createNewReport: (...args: unknown[]) => mockCreateNewReport(...args), })); jest.mock('@libs/Navigation/Navigation', () => ({ @@ -64,22 +65,6 @@ jest.mock('@libs/Navigation/helpers/getCreateReportRoute', () => ({ navigateToCreateReportWorkspaceSelection: jest.fn(), })); -jest.mock('@libs/PolicyUtils', () => { - const CONSTANTS = jest.requireActual<{default: typeof CONST}>('@src/CONST').default; - - return { - getDefaultChatEnabledPolicy: jest.fn((policies: Policy[]) => policies.at(0)), - getGroupPoliciesWhereReportCanBeCreated: jest.fn((policies: Record | undefined) => - Object.values(policies ?? {}).filter( - (policy): policy is Policy => - !!policy && - !policy.isJoinRequestPending && - (policy.type === CONSTANTS.POLICY.TYPE.TEAM || policy.type === CONSTANTS.POLICY.TYPE.CORPORATE || policy.type === CONSTANTS.POLICY.TYPE.SUBMIT), - ), - ), - }; -}); - jest.mock('@navigation/helpers/isOnSearchMoneyRequestReportPage', () => ({ __esModule: true, default: () => false, @@ -102,20 +87,7 @@ function makePolicy(id: string, type: Policy['type']): Policy { } function setupUseOnyx() { - const personalPolicy = makePolicy('personal-1', CONST.POLICY.TYPE.PERSONAL); - const groupPolicy = makePolicy('team-1', CONST.POLICY.TYPE.TEAM); - const submitPolicy = makePolicy('submit-1', CONST.POLICY.TYPE.SUBMIT); const values = new Map([ - [ONYXKEYS.NVP_ACTIVE_POLICY_ID, personalPolicy.id], - [`${ONYXKEYS.COLLECTION.POLICY}${personalPolicy.id}`, personalPolicy], - [ - ONYXKEYS.COLLECTION.POLICY, - { - [`${ONYXKEYS.COLLECTION.POLICY}${personalPolicy.id}`]: personalPolicy, - [`${ONYXKEYS.COLLECTION.POLICY}${groupPolicy.id}`]: groupPolicy, - [`${ONYXKEYS.COLLECTION.POLICY}${submitPolicy.id}`]: submitPolicy, - }, - ], [ONYXKEYS.SESSION, {accountID: 1, email: 'user@test.com'}], [ONYXKEYS.BETAS, []], [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {}], @@ -134,14 +106,31 @@ describe('CreateReportMenuItem', () => { setupUseOnyx(); }); - it('passes only report-creation workspaces to useCreateReport', () => { + it('lets useCreateReport resolve the workspace instead of handing it a pre-filtered list', () => { + // Given the FAB item renders for a user with several report-eligible workspaces render(); + // When it wires up useCreateReport const params = mockUseCreateReport.mock.calls.at(0)?.at(0); - expect(params?.groupPoliciesWithChatEnabled).toHaveLength(2); - expect(params?.groupPoliciesWithChatEnabled).toEqual([ - expect.objectContaining({id: 'team-1', type: CONST.POLICY.TYPE.TEAM}), - expect.objectContaining({id: 'submit-1', type: CONST.POLICY.TYPE.SUBMIT}), - ]); + + // Then it passes no candidate list, so a truncated or unfiltered list can never decide the default (the old `.slice(0, 2)` bug) + expect(Object.keys(params ?? {}).sort()).toEqual(['onCreateReport', 'onNavigateToWorkspaceSelection', 'shouldHandleNavigationBack']); + expect(params?.shouldHandleNavigationBack).toBe(false); + }); + + it.each([ + ['the active workspace', 'team-1'], + ['the domain preferred workspace', 'corporate-1'], + ])('creates the report on %s that useCreateReport resolved', (_description, policyID) => { + // Given the FAB item is rendered and useCreateReport has resolved a workspace + render(); + const policy = makePolicy(policyID, CONST.POLICY.TYPE.TEAM); + + // When useCreateReport asks the item to create the report + mockUseCreateReport.mock.calls.at(0)?.at(0)?.onCreateReport(policy, false); + + // Then the report is created on exactly that workspace, not on one the item looked up itself + expect(mockCreateNewReport).toHaveBeenCalledTimes(1); + expect(mockCreateNewReport.mock.calls.at(0)?.at(3)).toBe(policy); }); }); diff --git a/tests/unit/DebugUtilsTest.ts b/tests/unit/DebugUtilsTest.ts index eae25780cc59..ac76c3a2ba87 100644 --- a/tests/unit/DebugUtilsTest.ts +++ b/tests/unit/DebugUtilsTest.ts @@ -1184,7 +1184,7 @@ describe('DebugUtils', () => { Onyx.clear(); }); it('returns undefined reason when report is not defined', () => { - const {reason} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(undefined, RORY_EMAIL, RORY_ACCOUNT_ID) ?? {}; + const {reason} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(undefined, RORY_EMAIL, RORY_ACCOUNT_ID, undefined) ?? {}; expect(reason).toBeUndefined(); }); it('returns correct reason when report has a join request', async () => { @@ -1209,6 +1209,7 @@ describe('DebugUtils', () => { }, RORY_EMAIL, RORY_ACCOUNT_ID, + undefined, ) ?? {}; expect(reason).toBe('debug.reasonGBR.hasJoinRequest'); }); @@ -1222,6 +1223,7 @@ describe('DebugUtils', () => { }, RORY_EMAIL, RORY_ACCOUNT_ID, + undefined, ) ?? {}; expect(reason).toBe('debug.reasonGBR.isUnreadWithMention'); }); @@ -1239,6 +1241,7 @@ describe('DebugUtils', () => { }, RORY_EMAIL, RORY_ACCOUNT_ID, + undefined, ) ?? {}; expect(reason).toBe('debug.reasonGBR.isWaitingForAssigneeToCompleteAction'); }); @@ -1251,6 +1254,7 @@ describe('DebugUtils', () => { }, RORY_EMAIL, RORY_ACCOUNT_ID, + undefined, ) ?? {}; expect(reason).toBe('debug.reasonGBR.hasChildReportAwaitingAction'); }); @@ -1262,11 +1266,12 @@ describe('DebugUtils', () => { }, RORY_EMAIL, RORY_ACCOUNT_ID, + undefined, ) ?? {}; expect(reason).toBeUndefined(); }); it('returns undefined reportAction when report is not defined', () => { - const {reportAction} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(undefined, RORY_EMAIL, RORY_ACCOUNT_ID) ?? {}; + const {reportAction} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(undefined, RORY_EMAIL, RORY_ACCOUNT_ID, undefined) ?? {}; expect(reportAction).toBeUndefined(); }); it('returns the report action which is a join request', async () => { @@ -1297,6 +1302,7 @@ describe('DebugUtils', () => { }, RORY_EMAIL, RORY_ACCOUNT_ID, + undefined, ) ?? {}; expect(reportAction).toMatchObject(MOCK_REPORT_ACTIONS['1']); }); @@ -1368,7 +1374,7 @@ describe('DebugUtils', () => { if (!report) { throw new Error('Expected the report fixture to be present'); } - const {reportAction} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(report, RORY_EMAIL, 12345) ?? {}; + const {reportAction} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(report, RORY_EMAIL, 12345, undefined) ?? {}; expect(reportAction).toMatchObject(MOCK_REPORT_ACTIONS['1']); }); it('returns undefined report action when report has no GBR', () => { @@ -1379,6 +1385,7 @@ describe('DebugUtils', () => { }, RORY_EMAIL, RORY_ACCOUNT_ID, + undefined, ) ?? {}; expect(reportAction).toBeUndefined(); }); @@ -1387,7 +1394,7 @@ describe('DebugUtils', () => { const {chatReport, transactionViolations} = await setUpAutoRejectedOpenReport(); // When the debug page asks why the chat shows a GBR, passing the violations like the LHN derived value does - const {reason} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(chatReport, RORY_EMAIL, RORY_ACCOUNT_ID, false, transactionViolations) ?? {}; + const {reason} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(chatReport, RORY_EMAIL, RORY_ACCOUNT_ID, undefined, false, transactionViolations) ?? {}; // Then no GBR reason is reported, because the SUBMIT badge is suppressed when nothing is submittable, matching the LHN row expect(reason).toBeUndefined(); diff --git a/tests/unit/DeepLinkHandlerTest.tsx b/tests/unit/DeepLinkHandlerTest.tsx index 79c776e0ff90..a5c74464cffe 100644 --- a/tests/unit/DeepLinkHandlerTest.tsx +++ b/tests/unit/DeepLinkHandlerTest.tsx @@ -182,7 +182,7 @@ describe('DeepLinkHandler', () => { urlChangeHandler?.({url: `new-expensify://r/${PUBLIC_ROOM_ID}`}); }); - expect(jest.mocked(openReportFromDeepLink).mock.calls.at(-1)?.[7]).toEqual({ + expect(jest.mocked(openReportFromDeepLink).mock.calls.at(-1)?.[8]).toEqual({ [reportNameValuePairsKey]: {private_isArchived: archivedAt}, }); addEventListenerSpy.mockRestore(); diff --git a/tests/unit/DistanceRateTest.ts b/tests/unit/DistanceRateTest.ts index 8bcf3189b86d..d3e6cce00ce3 100644 --- a/tests/unit/DistanceRateTest.ts +++ b/tests/unit/DistanceRateTest.ts @@ -1,14 +1,21 @@ -import type {LocalizedTranslate} from '@components/LocaleContextProvider'; - -import {deletePolicyDistanceRates, enablePolicyDistanceRates, setEmployeeWorkArrangement, setWorkspaceDistanceAutoUpdate} from '@libs/actions/Policy/DistanceRate'; +import { + deletePolicyDistanceRates, + enablePolicyDistanceRates, + setEmployeeWorkArrangement, + setPolicyDistanceRatesUnit, + setWorkspaceDistanceAutoUpdate, + updateEmployeeOfficeLocation, +} from '@libs/actions/Policy/DistanceRate'; import * as API from '@libs/API'; import {pause, resetQueue} from '@libs/Network/SequentialQueue'; import {isGovernmentRateUnmodified} from '@libs/PolicyDistanceRatesUtils'; +import {getEffectiveWorkArrangement} from '@libs/WorkArrangementUtils'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type {GovernmentMileageRate, Policy, Report, ReportActions, Transaction, TransactionViolations} from '@src/types/onyx'; -import type {CustomUnit, Rate, Unit} from '@src/types/onyx/Policy'; +import type {CustomUnit, OfficeLocation, Rate, Unit} from '@src/types/onyx/Policy'; import Onyx from 'react-native-onyx'; @@ -17,29 +24,42 @@ import createRandomTransaction from '../utils/collections/transaction'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -const translate: LocalizedTranslate = (phrase, ...parameters) => { - if (phrase === 'workspace.people.officeBased') { - return 'Office-based'; - } - if (phrase === 'workspace.people.noRegularWorkspace') { - return 'Remote or mobile'; - } - if (phrase === 'workspaceActions.updatedMemberWorkArrangement') { - const arrangement = parameters[0]; - if (typeof arrangement === 'object' && arrangement !== null && 'displayName' in arrangement && 'newArrangement' in arrangement && 'oldArrangement' in arrangement) { - return `changed ${String(arrangement.displayName)}'s work arrangement to ${String(arrangement.newArrangement)} (previously ${String(arrangement.oldArrangement)})`; - } - return String(phrase); - } - return String(phrase); -}; - describe('DistanceRate', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); return waitForBatchedUpdates(); }); + function getPolicyFromOnyx(policyID: string): Promise { + return new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const, + // eslint-disable-next-line rulesdir/prefer-early-return + callback: (value) => { + if (value !== undefined) { + Onyx.disconnect(connection); + resolve(value); + } + }, + }); + }); + } + + function getReportActionsFromOnyx(reportID: string) { + return new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` as const, + // eslint-disable-next-line rulesdir/prefer-early-return + callback: (value) => { + if (value !== undefined) { + Onyx.disconnect(connection); + resolve(value); + } + }, + }); + }); + } + describe('deletePolicyDistanceRates', () => { it('should set customUnitOutOfPolicy violation only for transactions that have the deleted custom unit rate', async () => { const customUnitID = '5A55C2B68DDCB'; @@ -247,21 +267,6 @@ describe('DistanceRate', () => { return distanceCustomUnit; } - function getPolicyFromOnyx(policyID: string): Promise { - return new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const, - // eslint-disable-next-line rulesdir/prefer-early-return - callback: (value) => { - if (value !== undefined) { - Onyx.disconnect(connection); - resolve(value); - } - }, - }); - }); - } - afterEach(() => { resetQueue(); return Onyx.clear(); @@ -484,6 +489,168 @@ describe('DistanceRate', () => { }); }); + describe('setPolicyDistanceRatesUnit', () => { + const customUnitID = '5A55C2B68DDCB'; + + afterEach(() => { + resetQueue(); + return Onyx.clear(); + }); + + it('optimistically updates the commuter exclusion unit without converting its distance', async () => { + // Given a fixed-distance commuter exclusion saved in miles + const customUnit: CustomUnit = { + customUnitID, + name: 'Distance', + enabled: true, + rates: {}, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }; + const policy: Policy = { + ...createRandomPolicy(5), + customUnits: {[customUnitID]: customUnit}, + commuterExclusions: { + method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + fixedDistance: 5, + fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + // When the workspace unit changes while the request is queued offline + pause(); + setPolicyDistanceRatesUnit(policy.id, customUnit, {...customUnit, attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}}, policy.commuterExclusions); + await waitForBatchedUpdates(); + + // Then both displayed units update immediately while the configured distance stays unchanged + const optimisticPolicy = await getPolicyFromOnyx(policy.id); + expect(optimisticPolicy.customUnits?.[customUnitID].attributes?.unit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + expect(optimisticPolicy.commuterExclusions).toMatchObject({ + method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + fixedDistance: 5, + fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + }); + expect(optimisticPolicy.pendingFields?.commuterExclusions).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + }); + + it('clears the commuter exclusion pending state when changing the unit succeeds', async () => { + const customUnit: CustomUnit = { + customUnitID, + name: 'Distance', + enabled: true, + rates: {}, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }; + const commuterExclusions = { + method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + fixedDistance: 5, + fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }; + const policy: Policy = {...createRandomPolicy(5), customUnits: {[customUnitID]: customUnit}, commuterExclusions}; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + const mockFetch = TestHelper.createGlobalFetchMock(); + const originalFetch = global.fetch; + global.fetch = mockFetch; + try { + setPolicyDistanceRatesUnit(policy.id, customUnit, {...customUnit, attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}}, commuterExclusions); + await waitForBatchedUpdates(); + } finally { + global.fetch = originalFetch; + } + + const updatedPolicy = await getPolicyFromOnyx(policy.id); + expect(updatedPolicy.commuterExclusions).toMatchObject({fixedDistance: 5, fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}); + expect(updatedPolicy.pendingFields?.commuterExclusions).toBeUndefined(); + expect(updatedPolicy.errorFields?.commuterExclusions).toBeUndefined(); + }); + + it('restores the commuter exclusion unit and sets an error when changing the unit fails', async () => { + const customUnit: CustomUnit = { + customUnitID, + name: 'Distance', + enabled: true, + rates: {}, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }; + const commuterExclusions = { + method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + fixedDistance: 5, + fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }; + const policy: Policy = {...createRandomPolicy(5), customUnits: {[customUnitID]: customUnit}, commuterExclusions}; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + const mockFetch = TestHelper.createGlobalFetchMock(); + const originalFetch = global.fetch; + global.fetch = mockFetch; + mockFetch.fail(); + try { + setPolicyDistanceRatesUnit(policy.id, customUnit, {...customUnit, attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}}, commuterExclusions); + await waitForBatchedUpdates(); + } finally { + global.fetch = originalFetch; + } + + const revertedPolicy = await getPolicyFromOnyx(policy.id); + expect(revertedPolicy.commuterExclusions).toMatchObject({fixedDistance: 5, fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}); + expect(revertedPolicy.pendingFields?.commuterExclusions).toBeUndefined(); + expect(revertedPolicy.errorFields?.commuterExclusions).toBeDefined(); + }); + + it('removes the optimistic commuter exclusion unit when the original unit was unset', async () => { + const customUnit: CustomUnit = { + customUnitID, + name: 'Distance', + enabled: true, + rates: {}, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }; + const commuterExclusions = { + method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + fixedDistance: 5, + }; + const policy: Policy = {...createRandomPolicy(5), customUnits: {[customUnitID]: customUnit}, commuterExclusions}; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + const mockFetch = TestHelper.createGlobalFetchMock(); + const originalFetch = global.fetch; + global.fetch = mockFetch; + mockFetch.fail(); + try { + setPolicyDistanceRatesUnit(policy.id, customUnit, {...customUnit, attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}}, commuterExclusions); + await waitForBatchedUpdates(); + } finally { + global.fetch = originalFetch; + } + + const revertedPolicy = await getPolicyFromOnyx(policy.id); + expect(revertedPolicy.commuterExclusions).toMatchObject({fixedDistance: 5}); + expect(revertedPolicy.commuterExclusions?.fixedDistanceUnit).toBeUndefined(); + expect(revertedPolicy.errorFields?.commuterExclusions).toBeDefined(); + }); + + it('does not update commuter exclusions when none are configured', async () => { + const customUnit: CustomUnit = { + customUnitID, + name: 'Distance', + enabled: true, + rates: {}, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }; + const policy: Policy = {...createRandomPolicy(5), customUnits: {[customUnitID]: customUnit}}; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + pause(); + setPolicyDistanceRatesUnit(policy.id, customUnit, {...customUnit, attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}}); + await waitForBatchedUpdates(); + + const updatedPolicy = await getPolicyFromOnyx(policy.id); + expect(updatedPolicy.commuterExclusions).toBeUndefined(); + expect(updatedPolicy.pendingFields?.commuterExclusions).toBeUndefined(); + }); + }); + describe('setEmployeeWorkArrangement', () => { const member1AccountID = 11; const member2AccountID = 12; @@ -498,44 +665,6 @@ describe('DistanceRate', () => { jest.restoreAllMocks(); }); - function getPolicyFromOnyx(policyID: string): Promise { - return new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const, - // eslint-disable-next-line rulesdir/prefer-early-return - callback: (value) => { - if (value !== undefined) { - Onyx.disconnect(connection); - resolve(value); - } - }, - }); - }); - } - - function getReportActionsFromOnyx(reportID: string) { - return new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` as const, - // eslint-disable-next-line rulesdir/prefer-early-return - callback: (value) => { - if (value !== undefined) { - Onyx.disconnect(connection); - resolve(value); - } - }, - }); - }); - } - - function getSingleUpdateValue(value: unknown): unknown { - if (typeof value !== 'object' || value === null) { - return undefined; - } - const values = Object.values(value); - return values.length === 1 ? values.at(0) : undefined; - } - async function seedWorkArrangementPolicy(policy: Policy) { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); @@ -544,7 +673,7 @@ describe('DistanceRate', () => { await waitForBatchedUpdates(); } - it('should optimistically update the member and create one changelog action with pending ADD', async () => { + it('should optimistically update only the member that changes, without adding a change log', async () => { // Given two members in the policy and only one needs an arrangement change const policy: Policy = { ...createRandomPolicy(20), @@ -557,31 +686,45 @@ describe('DistanceRate', () => { pause(); // When both members are assigned the office-based arrangement - setEmployeeWorkArrangement(policy, [member1AccountID, member2AccountID], true, personalDetails, translate); + setEmployeeWorkArrangement(policy, [member1AccountID, member2AccountID], true, personalDetails); await waitForBatchedUpdates(); - // Then only the changed member and one pending changelog entry are updated + // Then only the changed member is updated const onyxPolicy = await getPolicyFromOnyx(policy.id); expect(onyxPolicy.employeeList?.[member1Email]).toEqual({email: member1Email, hasOfficeWorkArrangement: true, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}); // Member2 already matches, so it is skipped without any optimistic footprint expect(onyxPolicy.employeeList?.[member2Email]).toEqual({email: member2Email, hasOfficeWorkArrangement: true}); - const reportActions = await getReportActionsFromOnyx('1'); - const actions = Object.values(reportActions); - expect(actions).toHaveLength(1); - expect(actions.at(0)).toMatchObject({ - actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - message: [ - { - text: translate('workspaceActions.updatedMemberWorkArrangement', { - displayName: 'Member One', - newArrangement: translate('workspace.people.officeBased'), - oldArrangement: translate('workspace.people.noRegularWorkspace'), - }), + // And the admins room gets no optimistic change log, since the server can thread the real one + expect(await getReportActionsFromOnyx('1')).toEqual({}); + + resetQueue(); + }); + + it('should preserve a pending invite when changing its work arrangement offline', async () => { + // Given a newly invited member whose add action is still pending + const policy: Policy = { + ...createRandomPolicy(27), + employeeList: { + [member1Email]: { + email: member1Email, + hasOfficeWorkArrangement: false, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, - ], - originalMessage: {accountID: member1AccountID, email: member1Email, name: 'Member One', newValue: true, oldValue: false}, + }, + }; + await seedWorkArrangementPolicy(policy); + + pause(); + // When the member's arrangement is changed before the invite finishes syncing + setEmployeeWorkArrangement(policy, [member1AccountID], true, personalDetails); + await waitForBatchedUpdates(); + + // Then the arrangement updates while the invite remains visibly pending + const onyxPolicy = await getPolicyFromOnyx(policy.id); + expect(onyxPolicy.employeeList?.[member1Email]).toMatchObject({ + hasOfficeWorkArrangement: true, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }); resetQueue(); @@ -597,7 +740,7 @@ describe('DistanceRate', () => { pause(); // When the matching member and an unknown account are passed - setEmployeeWorkArrangement(policy, [member1AccountID, 999999001], false, personalDetails, translate); + setEmployeeWorkArrangement(policy, [member1AccountID, 999999001], false, personalDetails); await waitForBatchedUpdates(); // Then no member data or changelog entry is changed @@ -619,20 +762,29 @@ describe('DistanceRate', () => { await seedWorkArrangementPolicy(policy); const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); - // When the member's work arrangement is updated - setEmployeeWorkArrangement(policy, [member1AccountID], true, personalDetails, translate); + // When the work-arrangement change is queued with the temporary accountID assigned to an offline invite + const temporaryAccountID = 999999003; + const personalDetailsWithTemporaryAccountID = { + ...personalDetails, + [temporaryAccountID]: {accountID: temporaryAccountID, login: member1Email, displayName: 'Member One'}, + }; + setEmployeeWorkArrangement(policy, [temporaryAccountID], true, personalDetailsWithTemporaryAccountID); + + // Then the request carries the stable login with the temporary accountID so it still applies after an offline invite syncs + expect(writeSpy.mock.calls.at(0)?.[1]).toMatchObject({employeeAccountIDList: String(temporaryAccountID), employeeLoginList: member1Email}); - // Then success updates clear the pending state for both the member and changelog action + // And success updates clear the member's pending state const onyxData = writeSpy.mock.calls.at(0)?.[2]; - expect(onyxData?.successData?.[0]).toEqual({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, - value: {employeeList: {[member1Email]: {pendingAction: null}}}, - }); - expect(getSingleUpdateValue(onyxData?.successData?.[1]?.value)).toEqual({pendingAction: null}); + expect(onyxData?.successData).toEqual([ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + value: {employeeList: {[member1Email]: {pendingAction: null}}}, + }, + ]); }); - it('should revert the member and drop the changelog entry on failure', async () => { + it('should revert the member on failure', async () => { // Given the member and admins room are in Onyx const policy: Policy = { ...createRandomPolicy(23), @@ -643,9 +795,9 @@ describe('DistanceRate', () => { const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); // When the member's work arrangement is updated - setEmployeeWorkArrangement(policy, [member1AccountID], true, personalDetails, translate); + setEmployeeWorkArrangement(policy, [member1AccountID], true, personalDetails); - // Then failure data restores the member value, retains UPDATE so dismissing the error preserves the member, and removes the changelog action + // Then failure data restores the member value and retains UPDATE so dismissing the error preserves the member const onyxData = writeSpy.mock.calls.at(0)?.[2]; expect(onyxData?.failureData?.[0]).toMatchObject({ onyxMethod: Onyx.METHOD.MERGE, @@ -684,12 +836,8 @@ describe('DistanceRate', () => { await Onyx.merge(policyKey, {employeeList: {[member1Email]: {hasOfficeWorkArrangement: failureArrangementPatch}}}); await waitForBatchedUpdates(); expect((await getPolicyFromOnyx(policy.id)).employeeList?.[member1Email]?.hasOfficeWorkArrangement).toBeUndefined(); - - expect(onyxData?.failureData?.[1]).toMatchObject({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`, - }); - expect(getSingleUpdateValue(onyxData?.failureData?.[1]?.value)).toBeNull(); + expect(getEffectiveWorkArrangement((await getPolicyFromOnyx(policy.id)).employeeList?.[member1Email]?.hasOfficeWorkArrangement, false)).toBe(false); + expect(onyxData?.failureData).toHaveLength(1); }); it('should update members without changelog actions when there is no admins room', async () => { @@ -707,7 +855,7 @@ describe('DistanceRate', () => { pause(); // When the member's work arrangement is updated - setEmployeeWorkArrangement(policy, [member1AccountID], true, personalDetails, translate); + setEmployeeWorkArrangement(policy, [member1AccountID], true, personalDetails); await waitForBatchedUpdates(); // Then the member is updated without creating a changelog action @@ -738,7 +886,7 @@ describe('DistanceRate', () => { await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetailsWithGhost); await waitForBatchedUpdates(); // When the member missing from the policy employee list is passed - setEmployeeWorkArrangement(policy, [missingMemberAccountID], true, personalDetailsWithGhost, translate); + setEmployeeWorkArrangement(policy, [missingMemberAccountID], true, personalDetailsWithGhost); await waitForBatchedUpdates(); // Then no member data or changelog action is created @@ -761,7 +909,7 @@ describe('DistanceRate', () => { pause(); // When the member is assigned an arrangement without a policy ID - setEmployeeWorkArrangement({...policy, id: ''}, [member1AccountID], true, personalDetails, translate); + setEmployeeWorkArrangement({...policy, id: ''}, [member1AccountID], true, personalDetails); await waitForBatchedUpdates(); // Then neither the policy nor the admins room changelog is changed @@ -774,4 +922,164 @@ describe('DistanceRate', () => { resetQueue(); }); }); + + describe('updateEmployeeOfficeLocation', () => { + const member1AccountID = 31; + const member2AccountID = 32; + const member1Email = 'office1@test.com'; + const member2Email = 'office2@test.com'; + const personalDetails = { + [member1AccountID]: {accountID: member1AccountID, login: member1Email, displayName: 'Member One'}, + [member2AccountID]: {accountID: member2AccountID, login: member2Email, displayName: 'Member Two'}, + }; + const officeLocations: Record = { + OFFICE1: {name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}, isDefault: true}, + OFFICE2: {name: 'New Jersey Office', address: {addressStreet: '900 Asbury Ave', city: 'Ocean City', state: 'NJ', zipCode: '08226', country: 'US'}}, + }; + + beforeAll(() => IntlStore.load(CONST.LOCALES.DEFAULT)); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + async function seedOfficeLocationPolicy(policy: Policy) { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}2`, {reportID: '2', policyID: policy.id, chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS} as Report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`, {}); + await waitForBatchedUpdates(); + } + + it('should optimistically assign only the member that changes, without adding a change log', async () => { + // Given one member following the primary office and one already assigned to the New Jersey office + const policy: Policy = { + ...createRandomPolicy(30), + officeLocations, + employeeList: { + [member1Email]: {email: member1Email}, + [member2Email]: {email: member2Email, officeID: 'OFFICE2'}, + }, + }; + await seedOfficeLocationPolicy(policy); + + pause(); + // When both members are assigned to the New Jersey office + updateEmployeeOfficeLocation(policy, [member1AccountID, member2AccountID], 'OFFICE2', personalDetails); + await waitForBatchedUpdates(); + + // Then only the member who changed is updated + const onyxPolicy = await getPolicyFromOnyx(policy.id); + expect(onyxPolicy.employeeList?.[member1Email]).toEqual({email: member1Email, officeID: 'OFFICE2', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}); + expect(onyxPolicy.employeeList?.[member2Email]).toEqual({email: member2Email, officeID: 'OFFICE2'}); + + // And the admins room gets no optimistic change log, since the server can thread the real one + expect(await getReportActionsFromOnyx('2')).toEqual({}); + + resetQueue(); + }); + + it('should preserve a pending invite when changing its office offline', async () => { + // Given a newly invited member whose add action is still pending + const policy: Policy = { + ...createRandomPolicy(34), + officeLocations, + employeeList: {[member1Email]: {email: member1Email, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}}, + }; + await seedOfficeLocationPolicy(policy); + + pause(); + // When the member's office is changed before the invite finishes syncing + updateEmployeeOfficeLocation(policy, [member1AccountID], 'OFFICE2', personalDetails); + await waitForBatchedUpdates(); + + // Then the office updates while the invite remains visibly pending + const onyxPolicy = await getPolicyFromOnyx(policy.id); + expect(onyxPolicy.employeeList?.[member1Email]).toMatchObject({officeID: 'OFFICE2', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); + + resetQueue(); + }); + + it('should send every member that changed in one request, by login', async () => { + // Given two members following the primary office + const policy: Policy = { + ...createRandomPolicy(31), + officeLocations, + employeeList: {[member1Email]: {email: member1Email}, [member2Email]: {email: member2Email}}, + }; + await seedOfficeLocationPolicy(policy); + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- The API spy records the request, which the paused queue keeps from being sent. + const writeSpy = jest.spyOn(API, 'write'); + + pause(); + // When both are assigned to the company address + updateEmployeeOfficeLocation(policy, [member1AccountID, member2AccountID], CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, personalDetails); + await waitForBatchedUpdates(); + + // Then one request carries both members by login, which still matches them if an offline invite replaces their accountIDs + expect(writeSpy).toHaveBeenCalledTimes(1); + expect(writeSpy.mock.calls.at(0)?.[1]).toEqual({ + policyID: policy.id, + employeeLoginList: `${member1Email},${member2Email}`, + officeID: CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, + }); + + resetQueue(); + }); + + it('should clear pending states on success', async () => { + // Given a member following the primary office + const policy: Policy = { + ...createRandomPolicy(32), + officeLocations, + employeeList: {[member1Email]: {email: member1Email}}, + }; + await seedOfficeLocationPolicy(policy); + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- The API spy captures the action updates without sending a request. + const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); + + // When the member is assigned to an office + updateEmployeeOfficeLocation(policy, [member1AccountID], 'OFFICE2', personalDetails); + + // Then success clears the member's pending state + const onyxData = writeSpy.mock.calls.at(0)?.[2]; + expect(onyxData?.successData).toEqual([ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + value: {employeeList: {[member1Email]: {pendingAction: null}}}, + }, + ]); + }); + + it('should restore the previous office of each member on failure', async () => { + // Given one member assigned to the New Jersey office and one following the primary office + const policy: Policy = { + ...createRandomPolicy(33), + officeLocations, + employeeList: {[member1Email]: {email: member1Email, officeID: 'OFFICE2'}, [member2Email]: {email: member2Email}}, + }; + await seedOfficeLocationPolicy(policy); + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- The API spy captures the action updates without sending a request. + const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); + + // When both are assigned to the company address + updateEmployeeOfficeLocation(policy, [member1AccountID, member2AccountID], CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, personalDetails); + + // Then failure puts back the New Jersey office, removes the assignment the other member never had, and keeps + // UPDATE so dismissing the error keeps the members + const onyxData = writeSpy.mock.calls.at(0)?.[2]; + expect(onyxData?.failureData?.[0]).toMatchObject({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + value: { + employeeList: { + [member1Email]: {officeID: 'OFFICE2', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + [member2Email]: {officeID: null, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + }); + expect(onyxData?.failureData).toHaveLength(1); + }); + }); }); diff --git a/tests/unit/DistanceRequestUtilsTest.ts b/tests/unit/DistanceRequestUtilsTest.ts index ee5d2b38f413..1ce4f792604b 100644 --- a/tests/unit/DistanceRequestUtilsTest.ts +++ b/tests/unit/DistanceRequestUtilsTest.ts @@ -1184,4 +1184,155 @@ describe('DistanceRequestUtils', () => { expect(result?.customUnitRateID).toBe('MATCHING_2025_RATE_ID'); }); }); + + describe('getRateDisplayName', () => { + const baseName = 'Cars & Vans (Diesel/Hybrid) - Up to 1600cc'; + const governmentAttributes = {governmentRate: {sourceRateID: 'GB_2026-06-01', rate: 14, startDate: '2026-06-01'}}; + + it('strips a trailing date range suffix from an ended government band', () => { + // Given an ended government band whose name carries the date range the backend appends for uniqueness + const rate = {name: `${baseName} - 6/1/26 - 8/31/26`, startDate: '2026-06-01', endDate: '2026-08-31', attributes: governmentAttributes}; + + // When getting its display name + const result = DistanceRequestUtils.getRateDisplayName(rate); + + // Then the suffix is removed, because the date range is already shown next to the name + expect(result).toBe(baseName); + }); + + it('strips a trailing "From" suffix from a government band', () => { + // Given an open-ended government band whose name carries a "From" date suffix + const rate = {name: `${baseName} - From 9/1/26`, startDate: '2026-09-01', attributes: governmentAttributes}; + + // When getting its display name + const result = DistanceRequestUtils.getRateDisplayName(rate); + + // Then the suffix is removed + expect(result).toBe(baseName); + }); + + it('keeps the suffix on a custom dated rate', () => { + // Given a custom rate with dates that a user named with a date on purpose, so it has no government rate snapshot + const rate = {name: 'Summer - 6/1/26 - 8/31/26', startDate: '2026-06-01', endDate: '2026-08-31'}; + + // When getting its display name + const result = DistanceRequestUtils.getRateDisplayName(rate); + + // Then the name is unchanged, because the backend only adds the suffix to government rates + expect(result).toBe(rate.name); + }); + + it('leaves names that start with a date unchanged', () => { + // Given a government rate whose name puts the date range first + const rate = {name: '4/6/26 - 4/5/27 United Kingdom', startDate: '2026-04-06', endDate: '2027-04-05', attributes: governmentAttributes}; + + // When getting its display name + const result = DistanceRequestUtils.getRateDisplayName(rate); + + // Then the name is unchanged, because only a trailing suffix is stripped + expect(result).toBe(rate.name); + }); + + it('leaves names without a suffix unchanged', () => { + // Given a government rate whose name has no date suffix + const rate = {name: baseName, startDate: '2026-09-01', attributes: governmentAttributes}; + + // When getting its display name + const result = DistanceRequestUtils.getRateDisplayName(rate); + + // Then the name is unchanged + expect(result).toBe(baseName); + }); + + it('ignores a date in the middle of a name', () => { + // Given a government rate whose name has a date that isn't at the end + const rate = {name: `${baseName} - 6/1/26 - 8/31/26 (Legacy)`, startDate: '2026-06-01', endDate: '2026-08-31', attributes: governmentAttributes}; + + // When getting its display name + const result = DistanceRequestUtils.getRateDisplayName(rate); + + // Then the name is unchanged + expect(result).toBe(rate.name); + }); + + it('returns the raw name when stripping would leave it empty', () => { + // Given a government rate whose whole name is a date suffix + const rate = {name: ' - 6/1/26 - 8/31/26', startDate: '2026-06-01', endDate: '2026-08-31', attributes: governmentAttributes}; + + // When getting its display name + const result = DistanceRequestUtils.getRateDisplayName(rate); + + // Then the raw name is kept so the rate still has a visible name + expect(result).toBe(rate.name); + }); + }); + + describe('getRateNameWithDateSuffix', () => { + const governmentAttributes = {governmentRate: {sourceRateID: 'GB_2026-09-01', rate: 14, startDate: '2026-09-01'}}; + + it('appends the hidden date suffix to the edited name', () => { + // Given a government band whose date suffix is hidden in the editors + const rate = {name: 'Cars - From 9/1/26', startDate: '2026-09-01', attributes: governmentAttributes}; + + // When the user renames it from "Cars" to "Vans" + const result = DistanceRequestUtils.getRateNameWithDateSuffix(rate, 'Vans'); + + // Then the saved name keeps the original suffix, so Onyx and the API still get the full raw name + expect(result).toBe('Vans - From 9/1/26'); + }); + + it('returns the edited name as is when no suffix is hidden', () => { + // Given a custom dated rate that keeps its date in the editors, because it has no government rate snapshot + const rate = {name: 'Summer - 6/1/26 - 8/31/26', startDate: '2026-06-01', endDate: '2026-08-31'}; + + // When the user renames it + const result = DistanceRequestUtils.getRateNameWithDateSuffix(rate, 'Winter'); + + // Then nothing is appended, because the editors showed the full name + expect(result).toBe('Winter'); + }); + + it('keeps an empty name empty', () => { + // Given a government band whose date suffix is hidden in the editors + const rate = {name: 'Cars - From 9/1/26', startDate: '2026-09-01', attributes: governmentAttributes}; + + // When the user clears the name + const result = DistanceRequestUtils.getRateNameWithDateSuffix(rate, ''); + + // Then the name stays empty, so the required-name check fails instead of saving only the suffix + expect(result).toBe(''); + }); + }); + + describe('compareRatesByDisplayName', () => { + const localeCompare: LocaleContextProps['localeCompare'] = (a, b) => a.localeCompare(b); + const baseName = 'Cars & Vans (Diesel/Hybrid) - Up to 1600cc'; + const governmentAttributes = {governmentRate: {sourceRateID: 'GB_2026-09-01', rate: 14, startDate: '2026-09-01'}}; + const currentBand = {name: `${baseName} - From 9/1/26`, startDate: '2026-09-01', attributes: governmentAttributes}; + const endedBand = {name: `${baseName} - 6/1/26 - 8/31/26`, startDate: '2026-06-01', endDate: '2026-08-31', attributes: governmentAttributes}; + const undatedRate = {name: baseName}; + const otherRate = {name: 'Bicycle', startDate: '2026-01-01'}; + + it('sorts by display name, then newest start date first, then rates without a start date last', () => { + // Given rates where several share the same display name + const rates = [undatedRate, endedBand, otherRate, currentBand]; + + // When sorting them by display name + const result = [...rates].sort((a, b) => DistanceRequestUtils.compareRatesByDisplayName(a, b, localeCompare)); + + // Then the current band sits above its ended band, and the undated rate with the same display name sorts last + expect(result).toEqual([otherRate, currentBand, endedBand, undatedRate]); + }); + + it('keeps the start date tie-break when the display name order is reversed', () => { + // Given the same rates + const rates = [undatedRate, endedBand, otherRate, currentBand]; + + // When sorting them by display name in descending order + const result = [...rates].sort((a, b) => DistanceRequestUtils.compareRatesByDisplayName(a, b, localeCompare, -1)); + + // Then only the display name order flips, so the current band still sits above its ended band + expect(result).toEqual([currentBand, endedBand, undatedRate, otherRate]); + }); + }); }); diff --git a/tests/unit/EmojiPickerAnchorTest.tsx b/tests/unit/EmojiPickerAnchorTest.tsx new file mode 100644 index 000000000000..181c7b8c11f3 --- /dev/null +++ b/tests/unit/EmojiPickerAnchorTest.tsx @@ -0,0 +1,234 @@ +/** Exercises anchor identity, real measurement, resize and composer ordering through the exported emoji picker. */ +import {act, render} from '@testing-library/react-native'; + +import type {Emoji} from '@assets/emojis/types'; + +import EmojiPicker from '@components/EmojiPicker/EmojiPicker'; +import EmojiPickerMenu from '@components/EmojiPicker/EmojiPickerMenu'; +import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; +import type PopoverWithMeasuredContentProps from '@components/PopoverWithMeasuredContent/types'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useResponsiveLayoutMock from '@hooks/useResponsiveLayout/__mocks__'; + +import type {EmojiPickerRef, EmojiPopoverAnchor} from '@libs/actions/EmojiPickerAction'; +import refocusComposerAfterPreventFirstResponder from '@libs/refocusComposerAfterPreventFirstResponder'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; + +import {close} from '@userActions/Modal'; + +import CONST from '@src/CONST'; + +import type {TextInput, View} from 'react-native'; + +import React, {createRef} from 'react'; +import {Dimensions} from 'react-native'; + +import createMock from '../utils/createMock'; + +jest.mock('@components/PopoverWithMeasuredContent', () => jest.fn(({children}: PopoverWithMeasuredContentProps) => children)); +jest.mock('@components/EmojiPicker/EmojiPickerMenu', () => jest.fn(() => null)); +jest.mock('@components/FocusTrap/FocusTrapForModal', () => ({__esModule: true, default: ({children}: {children: React.ReactNode}) => children})); +jest.mock('@components/ActionSheetAwareScrollView'); +jest.mock('@hooks/useResponsiveLayout', () => { + const createLayoutMock = jest.requireActual<{default: typeof createMock}>('../utils/createMock').default; + const baseline = jest.requireActual<{default: typeof useResponsiveLayoutMock}>('@hooks/useResponsiveLayout/__mocks__'); + return { + __esModule: true, + default: jest.fn, Parameters>(() => createLayoutMock>(baseline.default())), + }; +}); +jest.mock('@hooks/useKeyboardState', () => jest.fn(() => ({isKeyboardActive: false}))); +jest.mock('@hooks/useBottomSafeSafeAreaPaddingStyle', () => jest.fn(() => ({}))); +jest.mock('@src/utils/keyboard', () => ({__esModule: true, default: {dismiss: jest.fn(() => Promise.resolve())}})); +jest.mock('@userActions/Modal', () => ({close: jest.fn()})); +jest.mock('@libs/refocusComposerAfterPreventFirstResponder', () => jest.fn(() => Promise.resolve())); +jest.mock('@libs/ReportActionComposeFocusManager', () => ({ + __esModule: true, + default: { + preventComposerFocusOnFirstResponderOnce: jest.fn(), + preventEditComposerFocusOnFirstResponderOnce: jest.fn(), + }, +})); + +function getRenderedPopover() { + const props = jest.mocked(PopoverWithMeasuredContent).mock.calls.at(-1)?.[0]; + expect(props).toBeDefined(); + if (!props) { + throw new Error('Expected the real emoji picker to render its popover'); + } + return props; +} + +function renderEmojiPicker() { + const ref = createRef(); + const view = render(); + expect(ref.current).not.toBeNull(); + if (!ref.current) { + throw new Error('Expected the exported picker imperative API'); + } + return {ref, picker: ref.current, ...view}; +} + +describe('EmojiPicker anchors', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useResponsiveLayout).mockImplementation(() => createMock>(useResponsiveLayoutMock())); + jest.mocked(close).mockImplementation((callback) => { + callback?.(); + }); + }); + + it.each(['view', 'input'])('preserves the supplied %s ref and measures before a closing menu destroys the anchor', async (kind) => { + // Given a native anchor whose measurement is available only while its context menu exists + const order: string[] = []; + const measureInWindow = jest.fn((callback: Parameters[0]) => { + order.push('measure'); + callback(10, 20, 30, 40); + }); + const blur = jest.fn(() => order.push('blur')); + const anchor: EmojiPopoverAnchor = {current: kind === 'view' ? createMock({blur, measureInWindow}) : createMock({blur, measureInWindow})}; + jest.mocked(close).mockImplementation((callback) => { + order.push('close'); + anchor.current = null; + callback?.(); + }); + const {picker} = renderEmojiPicker(); + + // When the real picker opens at a shifted top-left origin + await act(async () => { + picker.showEmojiPicker({ + emojiPopoverAnchor: anchor, + anchorOrigin: {horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, shiftVertical: 5}, + onModalHide: jest.fn(), + onEmojiSelected: jest.fn(), + }); + }); + + // Then the supplied ref identity and measured geometry survive destruction of the original anchor + expect(getRenderedPopover().anchorRef).toBe(anchor); + expect(getRenderedPopover().anchorPosition).toEqual({horizontal: 10, vertical: 65}); + expect(getRenderedPopover().anchorDimensions).toEqual({width: 30, height: 40}); + expect(order).toEqual(['blur', 'measure', 'close']); + expect(getRenderedPopover().isVisible).toBe(true); + }); + + it('uses the same null fallback before opening, after reset and after hide, and keeps DOM zero measurement real', async () => { + // Given an actual DOM div, which has blur but no React Native measurement method + const anchor: EmojiPopoverAnchor = {current: document.createElement('div')}; + const {ref, picker, rerender} = renderEmojiPicker(); + const emptyAnchor = getRenderedPopover().anchorRef; + expect(emptyAnchor.current).toBeNull(); + rerender(); + expect(getRenderedPopover().anchorRef).toBe(emptyAnchor); + + // When the DOM anchor opens and the picker resets its holder + await act(async () => { + picker.showEmojiPicker({emojiPopoverAnchor: anchor, onModalHide: jest.fn(), onEmojiSelected: jest.fn()}); + }); + expect(getRenderedPopover().anchorRef).toBe(anchor); + expect(getRenderedPopover().anchorPosition).toEqual({horizontal: 0, vertical: 0}); + expect(getRenderedPopover().anchorDimensions).toEqual({width: 0, height: 0}); + act(() => ref.current?.resetEmojiPopoverAnchor()); + rerender(); + expect(getRenderedPopover().anchorRef).toBe(emptyAnchor); + act(() => ref.current?.hideEmojiPicker(true)); + await act(async () => { + getRenderedPopover().onModalHide?.(); + }); + rerender(); + + // Then the fallback remains the same object with a null current + expect(getRenderedPopover().anchorRef).toBe(emptyAnchor); + expect(emptyAnchor.current).toBeNull(); + }); + + it('remeasures on resize, hides with no anchor on wide layout, and keeps narrow-layout keyboard resizes open', async () => { + // Given a measurable anchor and the picker dimensions listener + const dimensions = jest.spyOn(Dimensions, 'addEventListener'); + let x = 10; + const anchor: EmojiPopoverAnchor = {current: createMock({blur: jest.fn(), measureInWindow: (callback) => callback(x, 20, 30, 40)})}; + const {ref, picker, rerender} = renderEmojiPicker(); + await act(async () => { + picker.showEmojiPicker({emojiPopoverAnchor: anchor, onModalHide: jest.fn(), onEmojiSelected: jest.fn()}); + }); + x = 50; + const resize = dimensions.mock.calls.at(-1)?.[1]; + expect(resize).toBeDefined(); + if (!resize) { + throw new Error('Expected an installed dimensions listener'); + } + + // When resize occurs with a live anchor, the real measurement updates the default bottom-right position + await act(async () => resize({window: Dimensions.get('window'), screen: Dimensions.get('screen')})); + expect(getRenderedPopover().anchorPosition).toEqual({horizontal: 80, vertical: 20}); + jest.mocked(useResponsiveLayout).mockReturnValue(createMock>({shouldUseNarrowLayout: true})); + rerender(); + const liveNarrowResize = dimensions.mock.calls.at(-1)?.[1]; + expect(liveNarrowResize).toBeDefined(); + if (!liveNarrowResize) { + throw new Error('Expected an installed dimensions listener'); + } + x = 90; + await act(async () => liveNarrowResize({window: Dimensions.get('window'), screen: Dimensions.get('screen')})); + expect(getRenderedPopover().anchorPosition).toEqual({horizontal: 120, vertical: 20}); + expect(getRenderedPopover().isVisible).toBe(true); + act(() => ref.current?.resetEmojiPopoverAnchor()); + jest.mocked(useResponsiveLayout).mockReturnValue(createMock>({shouldUseNarrowLayout: true})); + rerender(); + const narrowResize = dimensions.mock.calls.at(-1)?.[1]; + expect(narrowResize).toBeDefined(); + if (!narrowResize) { + throw new Error('Expected an installed dimensions listener'); + } + act(() => narrowResize({window: Dimensions.get('window'), screen: Dimensions.get('screen')})); + expect(getRenderedPopover().isVisible).toBe(true); + jest.mocked(useResponsiveLayout).mockReturnValue(createMock>({shouldUseNarrowLayout: false})); + rerender(); + const wideResize = dimensions.mock.calls.at(-1)?.[1]; + expect(wideResize).toBeDefined(); + if (!wideResize) { + throw new Error('Expected an installed dimensions listener'); + } + act(() => wideResize({window: Dimensions.get('window'), screen: Dimensions.get('screen')})); + + // Then losing an anchor closes the wide picker, while the narrow picker survived the keyboard resize + expect(getRenderedPopover().isVisible).toBe(false); + dimensions.mockRestore(); + }); + + it.each(['main', 'edit'] as const)('hides before selecting once and returns focus to the %s composer after hide', async (composer) => { + // Given a real picker opened for one composer with a producer-shaped emoji + const {picker, ref} = renderEmojiPicker(); + const hidden = jest.fn(); + + const selected = jest.fn(); + await act(async () => { + picker.showEmojiPicker({emojiPopoverAnchor: {current: null}, onModalHide: hidden, onEmojiSelected: selected, composerToRefocusOnClose: composer}); + }); + const menu = jest.mocked(EmojiPickerMenu).mock.calls.at(-1)?.[0]; + expect(menu).toBeDefined(); + if (!menu) { + throw new Error('Expected the visible picker menu'); + } + const emoji = createMock({code: '😀'}); + + // When selection hides the picker, the owner callback runs and the next rendered handler rejects another selection + act(() => menu.onEmojiSelected('😀', emoji, 0)); + const hiddenMenu = jest.mocked(EmojiPickerMenu).mock.calls.at(-1)?.[0]; + expect(hiddenMenu).toBeDefined(); + act(() => hiddenMenu?.onEmojiSelected('😀', emoji, 0)); + await act(async () => { + getRenderedPopover().onModalHide?.(); + }); + + // Then only one selection reaches the owner and focus returns after the hide callback + expect(selected).toHaveBeenCalledTimes(1); + expect(hidden).toHaveBeenCalledWith(false); + expect(refocusComposerAfterPreventFirstResponder).toHaveBeenCalledWith(composer); + expect( + composer === 'main' ? ReportActionComposeFocusManager.preventComposerFocusOnFirstResponderOnce : ReportActionComposeFocusManager.preventEditComposerFocusOnFirstResponderOnce, + ).toHaveBeenCalledTimes(1); + expect(ref.current?.isEmojiPickerVisible).toBe(false); + }); +}); diff --git a/tests/unit/ExactOnlineConnectionListingTest.tsx b/tests/unit/ExactOnlineConnectionListingTest.tsx new file mode 100644 index 000000000000..c2e852ab1567 --- /dev/null +++ b/tests/unit/ExactOnlineConnectionListingTest.tsx @@ -0,0 +1,107 @@ +import {act, fireEvent, render, renderHook, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import ConnectionsGrid from '@pages/workspace/connections/ConnectionsGrid'; +import useAccountingConnectionListings from '@pages/workspace/connections/useAccountingConnectionListings'; + +import {clearExactOnlineWaitlistError, joinExactOnlineWaitlist} from '@userActions/User'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import type * as MockUseConfirmModalUtil from '../utils/mockUseConfirmModal'; + +import createRandomPolicy from '../utils/collections/policies'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +let mockEnabledBetas: string[] = []; +const mockStartIntegrationFlow = jest.fn(); +const mockConnect = jest.fn(); + +jest.mock('@userActions/User', () => ({clearExactOnlineWaitlistError: jest.fn(), joinExactOnlineWaitlist: jest.fn()})); +jest.mock('@hooks/useConfirmModal', () => { + const {default: mockUseConfirmModal} = jest.requireActual('../utils/mockUseConfirmModal'); + return mockUseConfirmModal; +}); +jest.mock('@hooks/usePermissions', () => ({ + __esModule: true, + default: () => ({isBetaEnabled: (beta: string) => mockEnabledBetas.includes(beta)}), +})); +jest.mock('@hooks/useIsUnifiedConnectionsBetaEnabled', () => () => true); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ + __esModule: true, + default: () => ({canWrite: true, showReadOnlyModal: jest.fn()}), +})); +jest.mock('@pages/workspace/accounting/AccountingContext', () => ({ + useAccountingActions: () => ({startIntegrationFlow: mockStartIntegrationFlow}), + useAccountingState: () => ({activeIntegration: undefined, popoverAnchorRefs: {current: {}}}), +})); + +function TestWrapper({children}: {children: React.ReactNode}) { + return {children}; +} + +describe('Exact Online in unified Connections', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + return IntlStore.load(CONST.LOCALES.EN); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it.each([{enabledBetas: []}, {enabledBetas: [CONST.BETAS.EXACT_ONLINE]}])('keeps signup available offline with Exact beta enrollment $enabledBetas', async ({enabledBetas}) => { + // Given unified Connections is enabled, the account is offline, and the Exact beta may be enabled + mockEnabledBetas = enabledBetas; + await Onyx.set(ONYXKEYS.NETWORK, {shouldForceOffline: true}); + const policy = createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Test workspace'); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + const {result} = renderHook(() => useAccountingConnectionListings(policy), {wrapper: TestWrapper}); + await waitForBatchedUpdatesWithAct(); + const listing = result.current.find((item) => item.key === CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE); + if (!listing) { + throw new Error('Exact Online waitlist is missing from Connections'); + } + + // When the connection cards render and the user chooses the waitlist + render( + , + {wrapper: TestWrapper}, + ); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByRole('button', {name: 'Join waitlist'})); + fireEvent.press(screen.getByRole('button', {name: 'Existing connector', disabled: true})); + + // Then Exact offers coming-soon signup offline while live connectors retain their offline restriction + expect(screen.getByText('Coming soon')).toBeOnTheScreen(); + expect(joinExactOnlineWaitlist).toHaveBeenCalledTimes(1); + expect(mockStartIntegrationFlow).not.toHaveBeenCalled(); + expect(mockConnect).not.toHaveBeenCalled(); + expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen(); + + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-15 10:00:00')); + expect(screen.getByLabelText('Exact Online, Coming soon, You’re on the waitlist')).toBeOnTheScreen(); + + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, null)); + expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen(); + + await act(() => Onyx.merge(ONYXKEYS.ACCOUNT, {errorFields: {exactOnlineWaitlist: {[Date.now()]: 'Signup failed. Please try again.'}}})); + expect(screen.getByText('Signup failed. Please try again.')).toBeOnTheScreen(); + fireEvent.press(screen.getByRole('button', {name: 'Dismiss'})); + expect(clearExactOnlineWaitlistError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/ExactOnlineWaitlistButtonTest.tsx b/tests/unit/ExactOnlineWaitlistButtonTest.tsx new file mode 100644 index 000000000000..3899f9108ff1 --- /dev/null +++ b/tests/unit/ExactOnlineWaitlistButtonTest.tsx @@ -0,0 +1,148 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import ExactOnlineWaitlistButton from '@pages/workspace/accounting/ExactOnlineWaitlistButton'; + +import {joinExactOnlineWaitlist} from '@userActions/User'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import type * as MockUseConfirmModalUtil from '../utils/mockUseConfirmModal'; + +import createRandomPolicy from '../utils/collections/policies'; +import {getShowConfirmModalOption, mockShowConfirmModal, resetMockConfirmModal} from '../utils/mockUseConfirmModal'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const mockShowReadOnlyModal = jest.fn(); +let mockCanWriteAccounting = true; +const policy = createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'First workspace'); +const otherPolicy = createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE, 'Second workspace'); + +jest.mock('@userActions/User', () => ({joinExactOnlineWaitlist: jest.fn()})); +jest.mock('@hooks/useConfirmModal', () => { + const {default: mockUseConfirmModal} = jest.requireActual('../utils/mockUseConfirmModal'); + return mockUseConfirmModal; +}); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ + __esModule: true, + default: () => ({canWrite: mockCanWriteAccounting, showReadOnlyModal: mockShowReadOnlyModal}), +})); + +function TestWrapper({children}: {children: React.ReactNode}) { + return {children}; +} + +describe('Exact Online waitlist signup', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + return IntlStore.load(CONST.LOCALES.EN); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + resetMockConfirmModal(); + mockCanWriteAccounting = true; + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('queues signup offline, confirms success once, and shares signup status across workspaces', async () => { + // Given an account that has not joined the waitlist and is currently offline + await Onyx.set(ONYXKEYS.NETWORK, {shouldForceOffline: true}); + const {rerender} = render(, {wrapper: TestWrapper}); + await waitForBatchedUpdatesWithAct(); + + // When the user presses Join waitlist twice before the optimistic value renders + const button = screen.getByRole('button', {name: 'Join waitlist'}); + fireEvent.press(button); + fireEvent.press(button); + + // Then signup is queued once without claiming success while offline + expect(joinExactOnlineWaitlist).toHaveBeenCalledTimes(1); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + + await act(() => Onyx.merge(ONYXKEYS.ACCOUNT, {pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}})); + rerender(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByRole('button', {name: 'Join waitlist', disabled: true})).toBeOnTheScreen(); + + // When the request fails and its optimistic account value is rolled back + await act(() => + Onyx.update([ + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: ''}, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: {pendingFields: {exactOnlineWaitlist: null}, errorFields: {exactOnlineWaitlist: {[Date.now()]: 'Signup failed'}}}, + }, + ]), + ); + await waitForBatchedUpdatesWithAct(); + + // Then signup is offered again + expect(screen.getByRole('button', {name: 'Join waitlist', disabled: false})).toBeOnTheScreen(); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + + // When the retried signup succeeds and the user switches workspaces + fireEvent.press(screen.getByRole('button', {name: 'Join waitlist'})); + await act(() => Onyx.merge(ONYXKEYS.ACCOUNT, {pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, errorFields: {exactOnlineWaitlist: null}})); + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-14 12:00:00')); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + await act(() => Onyx.set(ONYXKEYS.NETWORK, {shouldForceOffline: false})); + await act(() => Onyx.merge(ONYXKEYS.ACCOUNT, {pendingFields: {exactOnlineWaitlist: null}})); + rerender(); + await waitForBatchedUpdatesWithAct(); + + // Then the same account signup is shown without another action in the new workspace + expect(screen.getByText('You’re on the waitlist')).toBeTruthy(); + expect(screen.queryByRole('button', {name: 'Join waitlist'})).toBeNull(); + expect(joinExactOnlineWaitlist).toHaveBeenCalledTimes(2); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + expect(getShowConfirmModalOption('title')).toBe('You’re on the waitlist'); + expect(getShowConfirmModalOption('prompt')).toBe('Concierge will send you a message to let you know when this integration is ready and how to connect.'); + expect(getShowConfirmModalOption('shouldShowCancelButton')).toBe(false); + }); + + it('loads an existing account signup without offering another signup', async () => { + // Given an account that joined the waitlist in an earlier session + await Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-13 12:00:00'); + await Onyx.merge(ONYXKEYS.ACCOUNT, {pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}}); + + // When the user opens accounting in another workspace + render(, {wrapper: TestWrapper}); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByRole('button', {name: 'Join waitlist', disabled: true})).toBeOnTheScreen(); + await act(() => Onyx.merge(ONYXKEYS.ACCOUNT, {pendingFields: {exactOnlineWaitlist: null}})); + await waitForBatchedUpdatesWithAct(); + + // Then their stored signup replaces the action without opening an acknowledgement + expect(screen.getByText('You’re on the waitlist')).toBeTruthy(); + expect(screen.queryByRole('button', {name: 'Join waitlist'})).toBeNull(); + expect(joinExactOnlineWaitlist).not.toHaveBeenCalled(); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); + + it('shows the read-only explanation without signing up when accounting write access is denied', async () => { + // Given a workspace where the user has read-only accounting access + mockCanWriteAccounting = false; + render(, {wrapper: TestWrapper}); + await waitForBatchedUpdatesWithAct(); + + // When the user presses Join waitlist + fireEvent.press(screen.getByRole('button', {name: 'Join waitlist'})); + + // Then the standard read-only explanation appears and the account stays off the waitlist + expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1); + expect(joinExactOnlineWaitlist).not.toHaveBeenCalled(); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/FocusPDFScrollerWhenReadyTest.ts b/tests/unit/FocusPDFScrollerWhenReadyTest.ts new file mode 100644 index 000000000000..3f6c9df24da6 --- /dev/null +++ b/tests/unit/FocusPDFScrollerWhenReadyTest.ts @@ -0,0 +1,174 @@ +import focusPDFScrollerWhenReady from '@components/PDFView/focusPDFScrollerWhenReady'; + +const PDF_FILE_NAME = 'Receipt.pdf'; + +/** Mirrors the DOM react-fast-pdf renders once a PDF loads: react-window's focusable outer div inside react-pdf's Document. */ +function createPDFDocument(): {document: HTMLDivElement; scroller: HTMLDivElement; page: HTMLDivElement} { + const pdfDocument = document.createElement('div'); + pdfDocument.className = 'react-pdf__Document'; + const scroller = document.createElement('div'); + scroller.tabIndex = -1; + const page = document.createElement('div'); + page.tabIndex = 0; + scroller.appendChild(page); + pdfDocument.appendChild(scroller); + return {document: pdfDocument, scroller, page}; +} + +/** Mirrors the password form, whose wrapper is also focusable with tabIndex=-1 but renders outside the Document. */ +function createPasswordForm(): {form: HTMLDivElement; input: HTMLInputElement} { + const form = document.createElement('div'); + form.tabIndex = -1; + const input = document.createElement('input'); + form.appendChild(input); + return {form, input}; +} + +/** MutationObserver callbacks run as microtasks, so let them flush before asserting. */ +async function flushMutationObservers() { + await Promise.resolve(); +} + +describe('focusPDFScrollerWhenReady', () => { + let container: HTMLDivElement; + let headerButton: HTMLButtonElement; + let disconnectSpy: jest.SpyInstance; + + beforeEach(() => { + headerButton = document.createElement('button'); + container = document.createElement('div'); + document.body.append(headerButton, container); + disconnectSpy = jest.spyOn(MutationObserver.prototype, 'disconnect'); + }); + + afterEach(() => { + disconnectSpy.mockRestore(); + document.body.innerHTML = ''; + }); + + it('focuses the scroller right away when the PDF has already rendered', () => { + // Given an attachment modal reopened on a cached PDF, so the scroller is already rendered and focus fell back to the body + const {document: pdfDocument, scroller} = createPDFDocument(); + container.appendChild(pdfDocument); + expect(document.activeElement).toBe(document.body); + + // When the PDF view tries to focus the scroller + focusPDFScrollerWhenReady(container, PDF_FILE_NAME); + + // Then the scroller is focused so arrow keys scroll the PDF instead of the chat behind the modal + expect(document.activeElement).toBe(scroller); + + // And it is named with the file name, so screen readers announce the PDF instead of only "group" + expect(scroller.getAttribute('role')).toBe('group'); + expect(scroller.getAttribute('aria-label')).toBe(PDF_FILE_NAME); + }); + + it('focuses the scroller once it renders after the PDF loads, then stops watching', async () => { + // Given a PDF that is still loading, so only the loading indicator is rendered + container.appendChild(document.createElement('div')); + focusPDFScrollerWhenReady(container, PDF_FILE_NAME); + expect(disconnectSpy).not.toHaveBeenCalled(); + + // When the PDF finishes loading and the scroller renders + const {document: pdfDocument, scroller} = createPDFDocument(); + container.appendChild(pdfDocument); + await flushMutationObservers(); + + // Then the scroller is focused and the observer disconnects, because there is nothing left to wait for + expect(document.activeElement).toBe(scroller); + expect(disconnectSpy).toHaveBeenCalledTimes(1); + }); + + it('does not focus the password form wrapper and focuses the scroller after a wrong-password retry', async () => { + // Given a password-protected PDF, whose password form wrapper also has tabIndex=-1 + const {form, input} = createPasswordForm(); + container.appendChild(form); + focusPDFScrollerWhenReady(container, PDF_FILE_NAME); + + // Then the wrapper is not mistaken for the scroller, because it renders outside the Document + expect(document.activeElement).toBe(document.body); + + // When the user types a wrong password and the form shows an error + input.focus(); + form.appendChild(document.createElement('span')); + await flushMutationObservers(); + + // Then focus stays in the password input and the observer keeps watching, because the PDF is still locked + expect(document.activeElement).toBe(input); + expect(disconnectSpy).not.toHaveBeenCalled(); + + // When the user enters the right password and the PDF renders in place of the form + form.remove(); + const {document: pdfDocument, scroller} = createPDFDocument(); + container.appendChild(pdfDocument); + await flushMutationObservers(); + + // Then the scroller is focused, because focus was inside the PDF view and can move to the scroller + expect(document.activeElement).toBe(scroller); + expect(disconnectSpy).toHaveBeenCalledTimes(1); + + // And the scroller that renders after unlocking is named with the file name + expect(scroller.getAttribute('aria-label')).toBe(PDF_FILE_NAME); + expect(form.hasAttribute('aria-label')).toBe(false); + }); + + it('does not take focus from elements outside the PDF view', async () => { + // Given the user moved focus to a modal header button while the PDF was loading + focusPDFScrollerWhenReady(container, PDF_FILE_NAME); + headerButton.focus(); + + // When the PDF finishes loading and the scroller renders + const {document: pdfDocument, scroller} = createPDFDocument(); + container.appendChild(pdfDocument); + await flushMutationObservers(); + + // Then focus stays on the header button so a keyboard user is not pulled away from it, and the observer still stops + expect(document.activeElement).toBe(headerButton); + expect(disconnectSpy).toHaveBeenCalledTimes(1); + + // And the scroller is still named, so it is announced correctly when the user moves focus to it later + expect(scroller.getAttribute('aria-label')).toBe(PDF_FILE_NAME); + }); + + it('does not move focus that is already inside the scroller', () => { + // Given focus is already on an element inside the scroller, like a PDF page + const {document: pdfDocument, page} = createPDFDocument(); + container.appendChild(pdfDocument); + page.focus(); + + // When the PDF view tries to focus the scroller again, like after the carousel switches back to this PDF + focusPDFScrollerWhenReady(container, PDF_FILE_NAME); + + // Then focus stays where the user put it + expect(document.activeElement).toBe(page); + }); + + it('stops watching when cleaned up before the PDF renders', async () => { + // Given a PDF that is still loading + const cleanup = focusPDFScrollerWhenReady(container, PDF_FILE_NAME); + + // When the PDF view unmounts or loses focus in the carousel before the PDF loads + cleanup(); + expect(disconnectSpy).toHaveBeenCalledTimes(1); + + // Then a scroller that renders later is not focused, because the observer no longer runs + container.appendChild(createPDFDocument().document); + await flushMutationObservers(); + expect(document.activeElement).toBe(document.body); + }); + + it('does not create an observer when the scroller is already rendered', () => { + // Given a scroller that is already rendered + container.appendChild(createPDFDocument().document); + const observeSpy = jest.spyOn(MutationObserver.prototype, 'observe'); + + // When the PDF view focuses the scroller and is then cleaned up + const cleanup = focusPDFScrollerWhenReady(container, PDF_FILE_NAME); + cleanup(); + + // Then no observer was started, so there is nothing left running after cleanup + expect(observeSpy).not.toHaveBeenCalled(); + expect(disconnectSpy).not.toHaveBeenCalled(); + observeSpy.mockRestore(); + }); +}); diff --git a/tests/unit/HttpUtilsTest.ts b/tests/unit/HttpUtilsTest.ts index 0b13894cc75a..ab6d4f2409e6 100644 --- a/tests/unit/HttpUtilsTest.ts +++ b/tests/unit/HttpUtilsTest.ts @@ -7,6 +7,28 @@ import Onyx from 'react-native-onyx'; import HttpUtils from '../../src/libs/HttpUtils'; +const mockAlertUser = jest.fn(); +const mockPreparePrefetchRequest = jest.fn(() => ({prefetchKey: undefined as string | undefined, prefetchHeaders: undefined as Record | undefined})); + +jest.mock('@libs/actions/UpdateRequired', () => ({ + alertUser: () => mockAlertUser(), +})); +jest.mock('@libs/Prefetch/preparePrefetchRequest', () => ({ + __esModule: true, + default: () => mockPreparePrefetchRequest(), +})); + +const RECONNECT_APP_PREFETCH_KEY = `${WRITE_COMMANDS.RECONNECT_APP}:1`; + +function mockUpdateRequiredResponse(isPrefetched: boolean) { + return { + ok: true, + status: 200, + headers: {get: (name: string) => (name === 'nitroPrefetched' && isPrefetched ? 'true' : null)}, + json: () => Promise.resolve({jsonCode: CONST.JSON_CODE.UPDATE_REQUIRED}), + }; +} + function mockFetchResponse(message: string, jsonCode: number = CONST.JSON_CODE.EXP_ERROR) { global.fetch = jest.fn().mockResolvedValue({ ok: true, @@ -24,6 +46,8 @@ beforeAll(() => { afterEach(() => { jest.restoreAllMocks(); + mockAlertUser.mockClear(); + mockPreparePrefetchRequest.mockClear(); }); describe('HttpUtils', () => { @@ -67,4 +91,39 @@ describe('HttpUtils', () => { title: CONST.ERROR_TITLE.DUPLICATE_RECORD, }); }); + + it('retries a prefetched 426 without the prefetchKey instead of showing Update Required', async () => { + // Given a startup prefetch that was sent with a body saved by the previous build, so the server answered 426, + // and a fresh request that the server accepts + mockPreparePrefetchRequest.mockReturnValueOnce({prefetchKey: RECONNECT_APP_PREFETCH_KEY, prefetchHeaders: {prefetchKey: RECONNECT_APP_PREFETCH_KEY}}); + const responses = [mockUpdateRequiredResponse(true), {ok: true, status: 200, headers: {get: () => null}, json: () => Promise.resolve({jsonCode: CONST.JSON_CODE.SUCCESS})}]; + const requestHeaders: Array = []; + global.fetch = jest.fn().mockImplementation((_url: string, init: RequestInit) => { + requestHeaders.push(init.headers); + return Promise.resolve(responses.shift()); + }); + + // When the new build sends ReconnectApp and gets the prefetched 426 + const response = await HttpUtils.xhr(WRITE_COMMANDS.RECONNECT_APP, {}); + + // Then the request is sent again without the prefetchKey, so nitro-fetch can't serve the stale cached 426, + // and the app isn't blocked because the server accepted the current build + expect(requestHeaders).toEqual([{prefetchKey: RECONNECT_APP_PREFETCH_KEY}, undefined]); + expect(mockAlertUser).not.toHaveBeenCalled(); + expect(response).toMatchObject({jsonCode: CONST.JSON_CODE.SUCCESS}); + }); + + it('shows Update Required when the retry of a prefetched 426 also gets a 426', async () => { + // Given the installed build itself is below the server's minimum version, so both the prefetch and the retry get a 426 + mockPreparePrefetchRequest.mockReturnValueOnce({prefetchKey: RECONNECT_APP_PREFETCH_KEY, prefetchHeaders: {prefetchKey: RECONNECT_APP_PREFETCH_KEY}}); + const fetchMock = jest.fn().mockResolvedValueOnce(mockUpdateRequiredResponse(true)).mockResolvedValueOnce(mockUpdateRequiredResponse(false)); + global.fetch = fetchMock; + + // When ReconnectApp is sent + await HttpUtils.xhr(WRITE_COMMANDS.RECONNECT_APP, {}); + + // Then it is retried only once, because the second 426 came from the server, and the user is asked to update + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mockAlertUser).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index 1f4b4841748f..86e0cb0ef781 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -1273,6 +1273,19 @@ describe('formatCurrentUserToAttendee', () => { }); }); +describe('isP2PRecipient', () => { + const CURRENT_USER_ACCOUNT_ID = 1; + + it('returns true for another user', () => { + // Given a participant that is another user + const participant = {accountID: 2, isPolicyExpenseChat: false}; + + // When checking whether the expense goes 1:1 to that user + // Then it does, so the expense can't hold a negative amount + expect(IOUUtils.isP2PRecipient(participant, CURRENT_USER_ACCOUNT_ID)).toBe(true); + }); +}); + describe('isParticipantP2P', () => { it('should return true for P2P participant with accountID and isPolicyExpenseChat false', () => { const participant = { @@ -1487,3 +1500,120 @@ describe('isLookingAroundSearchRoutingActive', () => { expect(IOUUtils.isLookingAroundSearchRoutingActive(false, true)).toBe(false); }); }); + +describe('getDefaultReimbursableForPolicy', () => { + // These cases pin down what an ABSENT `defaultReimbursable` means at expense creation time. It has to + // agree with getCashExpenseReimbursableMode on the Rules page, otherwise the page claims a rule is + // enforced while newly created expenses quietly ignore it. + it('treats an absent defaultReimbursable as reimbursable', () => { + // Given a group policy that has no stored defaultReimbursable + const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: undefined}; + + // When a new expense is created in its policy expense chat + const result = IOUUtils.getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: true, isCreatingTrackExpense: false}); + + // Then it defaults to reimbursable, which is what the backend assumes for an absent value + expect(result).toBe(true); + }); + + it('honors a locked non-reimbursable policy in its policy expense chat', () => { + // Given a workspace locked to "cash expenses are always non-reimbursable" + const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: false, disabledFields: {reimbursable: true}}; + + // When a new expense is created in its policy expense chat + const result = IOUUtils.getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: true, isCreatingTrackExpense: false}); + + // Then the lock applies, because the expense goes to that workspace + expect(result).toBe(false); + }); + + it('ignores a locked non-reimbursable policy when the expense does not go to that workspace', () => { + // Given a workspace locked to "cash expenses are always non-reimbursable" + const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: false, disabledFields: {reimbursable: true}}; + + // When a new expense is created outside its policy expense chat, such as a P2P expense + const result = IOUUtils.getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: false, isCreatingTrackExpense: false}); + + // Then it stays reimbursable, because that workspace's rules do not apply to the expense + expect(result).toBe(true); + }); + + it('still reports reimbursable for a locked policy whose defaultReimbursable is absent', () => { + // Given a workspace with a locked reimbursable field but no stored default + const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: undefined, disabledFields: {reimbursable: true}}; + + // When a new expense is created in its policy expense chat + const result = IOUUtils.getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: true, isCreatingTrackExpense: false}); + + // Then it is reimbursable - the same answer the Rules page now shows, instead of the two disagreeing + expect(result).toBe(true); + }); + + it('uses the stored default for an unlocked policy', () => { + // Given a workspace defaulting to non-reimbursable without locking the field + const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: false, disabledFields: {reimbursable: false}}; + + // When a new expense is created in its policy expense chat + const result = IOUUtils.getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: true, isCreatingTrackExpense: false}); + + // Then the stored default applies + expect(result).toBe(false); + }); + + it('stays reimbursable for a P2P expense with no policy', () => { + // Given no policy at all, which is the P2P case + // When a new expense is created + const result = IOUUtils.getDefaultReimbursableForPolicy({policy: undefined, isPolicyExpenseChat: false, isCreatingTrackExpense: false}); + + // Then it is reimbursable, the long-standing behavior for P2P + expect(result).toBe(true); + }); +}); + +describe('calculateDefaultReimbursable', () => { + it('applies a locked non-reimbursable workspace rule to a submitted expense', () => { + // Given a workspace locked to "cash expenses are always non-reimbursable" + const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: false, disabledFields: {reimbursable: true}}; + + // When submitting a new expense to its policy expense chat + const result = IOUUtils.calculateDefaultReimbursable({ + iouType: CONST.IOU.TYPE.SUBMIT, + policy, + participant: {isPolicyExpenseChat: true}, + }); + + // Then the expense is created non-reimbursable + expect(result).toBe(false); + }); + + it('defaults to reimbursable when the workspace has no stored defaultReimbursable', () => { + // Given a workspace whose policy blob is missing defaultReimbursable + const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: undefined}; + + // When submitting a new expense to its policy expense chat + const result = IOUUtils.calculateDefaultReimbursable({ + iouType: CONST.IOU.TYPE.SUBMIT, + policy, + participant: {isPolicyExpenseChat: true}, + }); + + // Then the expense is reimbursable + expect(result).toBe(true); + }); + + it('does not apply a locked workspace rule when an unreported expense is submitted to a P2P participant', () => { + // Given the policy for moving expenses is locked to "cash expenses are always non-reimbursable" + const policyForMovingExpenses: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: false, disabledFields: {reimbursable: true}}; + + // When an unreported expense is submitted to a P2P participant + const result = IOUUtils.calculateDefaultReimbursable({ + iouType: CONST.IOU.TYPE.SUBMIT, + policyForMovingExpenses, + participant: {isPolicyExpenseChat: false}, + transactionReportID: CONST.REPORT.UNREPORTED_REPORT_ID, + }); + + // Then the expense stays reimbursable, because the unrelated workspace's lock does not reach P2P expenses + expect(result).toBe(true); + }); +}); diff --git a/tests/unit/ImageRecyclingKeyTest.tsx b/tests/unit/ImageRecyclingKeyTest.tsx new file mode 100644 index 000000000000..58e66f02ef50 --- /dev/null +++ b/tests/unit/ImageRecyclingKeyTest.tsx @@ -0,0 +1,267 @@ +import {render} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import ImageRenderer from '@components/HTMLEngineProvider/HTMLRenderers/ImageRenderer'; +import Image from '@components/Image'; +import BaseImageNative from '@components/Image/BaseImage.native'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {ShowContextMenuActionsContext, ShowContextMenuStateContext} from '@components/ShowContextMenuContext'; +import ThemeProvider from '@components/ThemeProvider'; +import ThemeStylesProvider from '@components/ThemeStylesContextProvider'; +import ThumbnailImage from '@components/ThumbnailImage'; + +import CONST from '@src/CONST'; + +import type {ImageProps as ExpoImageProps} from 'expo-image'; + +import React from 'react'; + +// Jest resolves the bare module name to the .native file, so the web file is loaded by its full name +const BaseImageWeb = jest.requireActual<{default: typeof BaseImageNative}>('@components/Image/BaseImage.tsx').default; + +const mockExpoImage = jest.fn(() => null); + +jest.mock('expo-image', () => ({ + get Image() { + return mockExpoImage; + }, +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + getActiveRouteWithoutParams: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), + navigate: jest.fn(), +})); + +// A freshly sent attachment renders the same picture from three URIs in a row +const PICKER_URI = 'file:///data/user/0/com.expensify.chat/files/picked.jpg'; +const CACHE_URI = 'file:///data/user/0/com.expensify.chat/cache/attachments/1234.jpg'; +const REMOTE_URI = 'https://www.expensify.com/chat-attachments/1234/picked.jpg.1024.jpg'; +const ATTACHMENT_ID = '1234'; +// A markdown image points anywhere; it never carries the attachment source attribute +const MARKDOWN_URI = 'https://example.com/photo.jpg'; + +function ThemeProviderWithLight({children}: {children: React.ReactNode}) { + return {children}; +} + +function renderWithProviders(element: React.ReactElement) { + return render({element}); +} + +function sourceUri(props: ExpoImageProps): string | undefined { + const {source} = props; + if (typeof source === 'object' && source !== null && !Array.isArray(source) && 'uri' in source) { + return source.uri; + } + return undefined; +} + +function expoImageProps(): ExpoImageProps[] { + return mockExpoImage.mock.calls.map(([props]) => props); +} + +function lastExpoImageProps(): ExpoImageProps { + const props = expoImageProps().at(-1); + if (!props) { + throw new Error('expo-image was not rendered'); + } + return props; +} + +describe('Image recyclingKey', () => { + beforeEach(() => { + mockExpoImage.mockClear(); + }); + + describe('BaseImage on native', () => { + it('keys the view on the source URI when no recyclingKey is given', () => { + // Given an image with no explicit key, the behaviour every existing caller relies on + // When it renders + render(); + + // Then the URI stays the key, so nothing outside the attachment path changes + expect(lastExpoImageProps().recyclingKey).toBe(PICKER_URI); + }); + + it('keeps the given recyclingKey while the same picture moves from the picker path to the cache copy to the remote URL', () => { + // Given a freshly sent attachment keyed on its ID, which goes through all three URIs in one session + const {rerender} = render( + , + ); + rerender( + , + ); + rerender( + , + ); + + // When the source has changed twice + const rendered = expoImageProps(); + expect(rendered.map(sourceUri)).toEqual([PICKER_URI, CACHE_URI, REMOTE_URI]); + + // Then expo-image saw one key throughout, so it never cleared the view between sources + expect(new Set(rendered.map((props) => props.recyclingKey))).toEqual(new Set([ATTACHMENT_ID])); + }); + + it('falls back to the source URI when recyclingKey is passed as undefined', () => { + // Given a caller that passes the prop through without a value, as ThumbnailImage does for non-attachments + // When it renders + render( + , + ); + + // Then the explicit undefined does not wipe the derived key + expect(lastExpoImageProps().recyclingKey).toBe(PICKER_URI); + }); + }); + + describe('BaseImage on web', () => { + it('ignores recyclingKey and keeps the key derived from the source', () => { + // Given the web image gets the same prop, because the renderer does not know the platform + // When it renders + render( + , + ); + + // Then web keeps its URI key, so this change stays native only + expect(lastExpoImageProps().recyclingKey).toBe(REMOTE_URI); + }); + }); + + describe('Image', () => { + it('re-renders the view when only recyclingKey changes and the source object is reused', () => { + // Given Image is memoised on its source, so a key change alone could be skipped + const source = {uri: PICKER_URI}; + const {rerender} = renderWithProviders( + // eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. + , + ); + // When only the key changes + rerender( + + {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. */} + + , + ); + + // Then the new key reaches the view + expect(lastExpoImageProps().recyclingKey).toBe('second'); + }); + }); + + describe('ThumbnailImage', () => { + it('passes recyclingKey down to the image view', () => { + // Given the renderer hands ThumbnailImage a key for an attachment + // When it renders + renderWithProviders( + , + ); + + // Then the key arrives at the view with the source untouched + expect(lastExpoImageProps().recyclingKey).toBe(ATTACHMENT_ID); + expect(sourceUri(lastExpoImageProps())).toBe(PICKER_URI); + }); + }); + + describe('ImageRenderer', () => { + const contextMenuState = { + anchor: null, + report: undefined, + action: undefined, + transactionThreadReport: undefined, + isDisabled: true, + }; + const contextMenuActions = { + checkIfContextMenuActive: () => {}, + onShowContextMenu: (callback: () => void) => callback(), + }; + + function renderImage(attributes: Record) { + return renderWithProviders( + + + {/* @ts-expect-error - only the attributes are needed for this test */} + + + , + ); + } + + it('keys an attachment thumbnail on its attachment ID', () => { + // Given an uploaded attachment, which carries the source attribute and its ID + // When it renders + renderImage({ + src: PICKER_URI, + [CONST.ATTACHMENT_SOURCE_ATTRIBUTE]: PICKER_URI, + [CONST.ATTACHMENT_ID_ATTRIBUTE]: ATTACHMENT_ID, + }); + + // Then the ID is the key, so the three URI swaps after send share one view + expect(lastExpoImageProps().recyclingKey).toBe(ATTACHMENT_ID); + }); + + it('keeps the URI key for an image with no attachment ID', () => { + // Given a plain markdown image + // When it renders + renderImage({src: MARKDOWN_URI}); + + // Then nothing changes for it + expect(lastExpoImageProps().recyclingKey).toBe(MARKDOWN_URI); + }); + + it('keeps the URI key for a markdown image that only carries a positional attachment ID', () => { + // Given a markdown image with the `_` ID getHtmlWithAttachmentID adds, which an edit can + // reuse for a different picture, so it is not a picture identity + // When it renders + renderImage({ + src: MARKDOWN_URI, + [CONST.ATTACHMENT_ID_ATTRIBUTE]: '4321_1', + }); + + // Then the URI stays the key, so an edit still clears the old picture + expect(lastExpoImageProps().recyclingKey).toBe(MARKDOWN_URI); + }); + + it('keeps the URI key for an Expensify-hosted markdown image that only carries a positional attachment ID', () => { + // Given a markdown image whose receipt URL makes the renderer treat it as an attachment for auth, while its ID + // is still positional + // When it renders + renderImage({ + src: 'https://www.expensify.com/receipts/A.jpg', + [CONST.ATTACHMENT_ID_ATTRIBUTE]: '4321_1', + }); + + // Then auth alone does not earn the ID key + const props = lastExpoImageProps(); + expect(props.recyclingKey).not.toBe('4321_1'); + expect(props.recyclingKey).toBe(sourceUri(props)); + }); + }); +}); diff --git a/tests/unit/ImageViewTest.tsx b/tests/unit/ImageViewTest.tsx new file mode 100644 index 000000000000..984bc575286b --- /dev/null +++ b/tests/unit/ImageViewTest.tsx @@ -0,0 +1,175 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import type ImageViewProps from '@components/ImageView/types'; + +import CONST from '@src/CONST'; + +import type ReactNative from 'react-native'; + +import React from 'react'; + +// Load the web implementation explicitly, since Jest resolves the native one by default +const ImageView = jest.requireActual<{default: React.ComponentType}>('@components/ImageView/index.tsx').default; + +const IMAGE_URL = 'https://example.com/receipt.jpg'; + +let mockIsOffline = false; + +jest.mock('@hooks/useNetwork', () => () => ({isOffline: mockIsOffline})); + +jest.mock('@libs/DeviceCapabilities', () => ({canUseTouchScreen: () => false})); + +jest.mock('@components/Image', () => { + const MockReact = jest.requireActual('react'); + const {View} = jest.requireActual('react-native'); + return {__esModule: true, default: (props: Record) => MockReact.createElement(View, {testID: 'full-image', ...props})}; +}); + +jest.mock('@components/LoadingIndicator', () => { + const MockReact = jest.requireActual('react'); + const {View} = jest.requireActual('react-native'); + return {__esModule: true, default: () => MockReact.createElement(View, {testID: 'loading-indicator'})}; +}); + +jest.mock('@components/ImageLoadTimeoutNotice', () => { + const MockReact = jest.requireActual('react'); + const {View, Pressable} = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({onRetry}: {onRetry: () => void}) => + MockReact.createElement(View, {testID: 'load-timeout-notice'}, MockReact.createElement(Pressable, {testID: 'retry-button', onPress: onRetry})), + }; +}); + +function renderImageView() { + const onError = jest.fn(); + render( + , + ); + return {onError}; +} + +describe('ImageView (web)', () => { + beforeEach(() => { + jest.useFakeTimers(); + mockIsOffline = false; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('stops the spinner and offers a retry when the load runs past the timeout', () => { + // Given a full-size image that never emits onLoad or onError + const {onError} = renderImageView(); + expect(screen.getByTestId('loading-indicator')).toBeTruthy(); + + // When the spinner has been on screen for the whole timeout + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // Then the spinner is replaced by a retry option, and the parent is not told the load failed + expect(screen.queryByTestId('loading-indicator')).toBeNull(); + expect(screen.getByTestId('retry-button')).toBeTruthy(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('still shows an image that loads after the timeout', () => { + // Given a load that already timed out + const {onError} = renderImageView(); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + expect(screen.getByTestId('retry-button')).toBeTruthy(); + + // When the slow load finally finishes + fireEvent(screen.getByTestId('full-image'), 'load', {nativeEvent: {width: 100, height: 100}}); + + // Then the timeout notice goes away and no failure is reported + expect(screen.queryByTestId('retry-button')).toBeNull(); + expect(screen.queryByTestId('loading-indicator')).toBeNull(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('restarts the load and the timer when retry is pressed', () => { + // Given a load that already timed out + const {onError} = renderImageView(); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // When the user presses retry + fireEvent.press(screen.getByTestId('retry-button')); + + // Then the spinner shows again on a remounted image and gets a fresh timeout + expect(screen.getByTestId('loading-indicator')).toBeTruthy(); + expect(screen.queryByTestId('retry-button')).toBeNull(); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + expect(screen.getByTestId('retry-button')).toBeTruthy(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('keeps the timeout notice when a stalled load later errors', () => { + // Given a load that already timed out + const {onError} = renderImageView(); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // When the transport finally gives up and the image reports an error + fireEvent(screen.getByTestId('full-image'), 'error'); + + // Then the user still sees the timeout notice, since a slow load is not a missing attachment + expect(screen.getByTestId('retry-button')).toBeTruthy(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('hides the spinner when the image fails to load', () => { + // Given a full-size image that is loading + const {onError} = renderImageView(); + + // When the image reports an error + fireEvent(screen.getByTestId('full-image'), 'error'); + + // Then the spinner goes away instead of spinning over the parent's error UI + expect(onError).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('loading-indicator')).toBeNull(); + }); + + it('does not report a timeout for an image that loads before the timeout', () => { + // Given a full-size image that is loading + const {onError} = renderImageView(); + + // When the image loads and the timeout then passes + fireEvent(screen.getByTestId('full-image'), 'load', {nativeEvent: {width: 100, height: 100}}); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // Then the timer was cleared with the spinner and never reports an error + expect(screen.queryByTestId('loading-indicator')).toBeNull(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('does not report a timeout while offline', () => { + // Given the device is offline while the image is loading + mockIsOffline = true; + const {onError} = renderImageView(); + + // When the timeout passes + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // Then no timeout notice is offered, so the image can still load once the device is back online + expect(screen.queryByTestId('retry-button')).toBeNull(); + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/ImageWithLoadingTest.tsx b/tests/unit/ImageWithLoadingTest.tsx new file mode 100644 index 000000000000..79d07c4567b7 --- /dev/null +++ b/tests/unit/ImageWithLoadingTest.tsx @@ -0,0 +1,292 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ImageWithLoading from '@components/ImageWithLoading'; + +import CONST from '@src/CONST'; + +import type ReactNative from 'react-native'; + +import React from 'react'; + +const FULL_IMAGE_URI = 'https://example.com/receipt.jpg'; +const PREVIEW_URI = 'https://example.com/receipt-preview.jpg'; + +let mockIsOffline = false; + +jest.mock('@hooks/useNetwork', () => () => ({isOffline: mockIsOffline})); + +jest.mock('@components/Image', () => { + const MockReact = jest.requireActual('react'); + const {View} = jest.requireActual('react-native'); + function MockImage({source, ...props}: {source?: {uri?: string}}) { + return MockReact.createElement(View, {testID: source?.uri === PREVIEW_URI ? 'preview-image' : 'full-image', ...props}); + } + return {__esModule: true, default: MockImage}; +}); + +jest.mock('@components/LoadingIndicator', () => { + const MockReact = jest.requireActual('react'); + const {View} = jest.requireActual('react-native'); + return {__esModule: true, default: () => MockReact.createElement(View, {testID: 'loading-indicator'})}; +}); + +jest.mock('@components/ImageLoadTimeoutNotice', () => { + const MockReact = jest.requireActual('react'); + const {View, Pressable} = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({onRetry}: {onRetry: () => void}) => + MockReact.createElement(View, {testID: 'load-timeout-notice'}, MockReact.createElement(Pressable, {testID: 'retry-button', onPress: onRetry})), + }; +}); + +function renderImage(props: Partial> = {}) { + const onError = jest.fn(); + const onLoad = jest.fn(); + render( + , + ); + return {onError, onLoad}; +} + +function startLoading() { + fireEvent(screen.getByTestId('full-image'), 'loadStart'); +} + +describe('ImageWithLoading', () => { + beforeEach(() => { + jest.useFakeTimers(); + mockIsOffline = false; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('stops the spinner and offers a retry when the load runs past the timeout', () => { + // Given an image whose full version starts loading and never emits onLoad or onError + const {onError} = renderImage(); + startLoading(); + act(() => { + jest.advanceTimersByTime(200); + }); + expect(screen.getByTestId('loading-indicator')).toBeTruthy(); + + // When the spinner has been on screen for the whole timeout + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // Then the spinner is replaced by a retry option, and the parent is not told the load failed + expect(screen.queryByTestId('loading-indicator')).toBeNull(); + expect(screen.getByTestId('retry-button')).toBeTruthy(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('keeps the preview mounted when a load times out over it', () => { + // Given a preview is shown while the full image loads, so the spinner shows right away + const {onError} = renderImage({previewUri: PREVIEW_URI}); + startLoading(); + expect(screen.getByTestId('loading-indicator')).toBeTruthy(); + + // When the full image never finishes within the timeout + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // Then only the spinner goes away: the preview that had already loaded stays visible with a retry + expect(screen.queryByTestId('loading-indicator')).toBeNull(); + expect(screen.getByTestId('preview-image')).toBeTruthy(); + expect(screen.getByTestId('retry-button')).toBeTruthy(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('still shows an image that loads after the timeout', () => { + // Given a load that already timed out over its preview + const {onError, onLoad} = renderImage({previewUri: PREVIEW_URI}); + startLoading(); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // When the slow load finally finishes + fireEvent(screen.getByTestId('full-image'), 'load', {nativeEvent: {width: 100, height: 100}}); + + // Then the timeout notice goes away and the load reports as a success + expect(screen.queryByTestId('retry-button')).toBeNull(); + expect(onLoad).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + + it('restarts the load and the timer when retry is pressed', () => { + // Given a load that already timed out + const {onError} = renderImage(); + startLoading(); + act(() => { + jest.advanceTimersByTime(200); + }); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + expect(screen.getByTestId('retry-button')).toBeTruthy(); + + // When the user presses retry + fireEvent.press(screen.getByTestId('retry-button')); + + // Then the spinner shows again on a remounted image and gets a fresh timeout + expect(screen.getByTestId('loading-indicator')).toBeTruthy(); + expect(screen.queryByTestId('retry-button')).toBeNull(); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + expect(screen.getByTestId('retry-button')).toBeTruthy(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('keeps the timeout notice when a stalled load later errors', () => { + // Given a load that already timed out + const {onError} = renderImage(); + startLoading(); + act(() => { + jest.advanceTimersByTime(200); + }); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // When the transport finally gives up and the image reports an error + fireEvent(screen.getByTestId('full-image'), 'error'); + + // Then the user still sees the timeout notice, since a slow load is not a missing attachment + expect(screen.getByTestId('retry-button')).toBeTruthy(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('reports a real load error to the parent', () => { + // Given an image that is loading + const {onError} = renderImage(); + startLoading(); + act(() => { + jest.advanceTimersByTime(200); + }); + + // When the image emits a real error + fireEvent(screen.getByTestId('full-image'), 'error'); + + // Then the parent is told and the spinner goes away + expect(onError).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('loading-indicator')).toBeNull(); + }); + + it('keeps the spinner on a slow load that keeps sending bytes', () => { + // Given a load that is past the no-progress ceiling but still reporting download progress + const {onError} = renderImage({previewUri: PREVIEW_URI}); + startLoading(); + + // When progress arrives repeatedly, each step shorter than the stall window, for far longer + // than the ceiling + for (let i = 0; i < 4; i++) { + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT - 1000); + }); + fireEvent(screen.getByTestId('full-image'), 'progress', {loaded: (i + 1) * 1000, total: 10000}); + } + + // Then the spinner keeps spinning and nothing is reported + expect(screen.getByTestId('loading-indicator')).toBeTruthy(); + expect(screen.queryByTestId('retry-button')).toBeNull(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('does not report a timeout for an image that loads before the timeout', () => { + // Given an image that is loading behind a spinner + const {onError, onLoad} = renderImage(); + startLoading(); + act(() => { + jest.advanceTimersByTime(200); + }); + + // When the image loads and the timeout then passes + fireEvent(screen.getByTestId('full-image'), 'load', {nativeEvent: {width: 100, height: 100}}); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // Then the timer was cleared with the spinner and never reports an error + expect(onLoad).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + + it('does not report a timeout for a cached image that never shows the spinner', () => { + // Given a cached image with no preview, which loads within the 200 ms spinner delay + const {onError} = renderImage(); + startLoading(); + fireEvent(screen.getByTestId('full-image'), 'load', {nativeEvent: {width: 100, height: 100}}); + + // When the timeout passes + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // Then no spinner was shown, so no timer ran and nothing was reported + expect(screen.queryByTestId('loading-indicator')).toBeNull(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('hides the notice in a thumbnail-sized image that stalls', () => { + // Given a caller that opts out of the notice, as small thumbnails do + const {onError} = renderImage({previewUri: PREVIEW_URI, shouldShowTimeoutNotice: false}); + startLoading(); + expect(screen.getByTestId('loading-indicator')).toBeTruthy(); + + // When the load runs past the ceiling without finishing + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // Then the spinner still stops and the preview stays mounted, but no notice is offered + expect(screen.queryByTestId('loading-indicator')).toBeNull(); + expect(screen.getByTestId('preview-image')).toBeTruthy(); + expect(screen.queryByTestId('load-timeout-notice')).toBeNull(); + expect(screen.queryByTestId('retry-button')).toBeNull(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('still reports an error to a thumbnail that hides the notice', () => { + // Given a thumbnail-sized image that stalled with no notice to show + const {onError} = renderImage({previewUri: PREVIEW_URI, shouldShowTimeoutNotice: false}); + startLoading(); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // When the stalled load later errors + fireEvent(screen.getByTestId('full-image'), 'error'); + + // Then the parent is told, since nothing else on screen explains the missing image + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('does not report a timeout while offline', () => { + // Given the device is offline, where the offline indicator owns the waiting state + mockIsOffline = true; + const {onError} = renderImage(); + startLoading(); + + // When the timeout passes + act(() => { + jest.advanceTimersByTime(200 + CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // Then no timeout notice is offered, so the image can still load once the device is back online + expect(screen.queryByTestId('loading-indicator')).toBeNull(); + expect(screen.queryByTestId('retry-button')).toBeNull(); + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/ImageWithSizeCalculationTest.tsx b/tests/unit/ImageWithSizeCalculationTest.tsx new file mode 100644 index 000000000000..36476804a12f --- /dev/null +++ b/tests/unit/ImageWithSizeCalculationTest.tsx @@ -0,0 +1,112 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ImageWithSizeCalculation from '@components/ImageWithSizeCalculation'; + +import CONST from '@src/CONST'; + +import type ReactNative from 'react-native'; + +import React from 'react'; + +const IMAGE_URI = 'https://example.com/receipt.jpg'; + +let mockIsOffline = false; + +jest.mock('@libs/Log', () => ({ + __esModule: true, + default: {hmmm: jest.fn()}, +})); + +const mockLogHmmm = jest.requireMock<{default: {hmmm: jest.Mock}}>('@libs/Log').default.hmmm; + +jest.mock('@hooks/useNetwork', () => () => ({isOffline: mockIsOffline})); + +jest.mock('@hooks/useThemeStyles', () => () => new Proxy({}, {get: () => ({})})); + +// The real ImageWithLoading runs the timeout; only its leaf components are stubbed. +jest.mock('@components/Image', () => { + const MockReact = jest.requireActual('react'); + const {View} = jest.requireActual('react-native'); + return {__esModule: true, default: (props: Record) => MockReact.createElement(View, {testID: 'image', ...props})}; +}); + +jest.mock('@components/LoadingIndicator', () => { + const MockReact = jest.requireActual('react'); + const {View} = jest.requireActual('react-native'); + return {__esModule: true, default: () => MockReact.createElement(View, {testID: 'loading-indicator'})}; +}); + +jest.mock('@components/AttachmentOfflineIndicator', () => { + const MockReact = jest.requireActual('react'); + const {View} = jest.requireActual('react-native'); + return {__esModule: true, default: () => MockReact.createElement(View, {testID: 'offline-indicator'})}; +}); + +jest.mock('@components/ImageLoadTimeoutNotice', () => { + const MockReact = jest.requireActual('react'); + const {Pressable} = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({onRetry}: {onRetry: () => void}) => MockReact.createElement(Pressable, {testID: 'retry-button', onPress: onRetry}), + }; +}); + +function renderImage() { + const onMeasure = jest.fn(); + const onLoadFailure = jest.fn(); + render( + , + ); + return {onMeasure, onLoadFailure}; +} + +describe('ImageWithSizeCalculation', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockIsOffline = false; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('does not report a slow load that runs past the spinner timeout', () => { + // Given an image whose load never emits onLoad or onError + const {onLoadFailure} = renderImage(); + fireEvent(screen.getByTestId('image'), 'loadStart'); + act(() => { + jest.advanceTimersByTime(200); + }); + expect(screen.getByTestId('loading-indicator')).toBeTruthy(); + + // When the spinner timeout elapses + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // Then the load is not logged as a size-fetch failure and the parent keeps its image instead of a fallback. + // The Retry notice is suppressed: this renders inside a thumbnail, which is too small to show it. + expect(mockLogHmmm).not.toHaveBeenCalled(); + expect(onLoadFailure).not.toHaveBeenCalled(); + expect(screen.queryByTestId('retry-button')).toBeNull(); + }); + + it('logs and reports a load that errors', () => { + // Given an image that is loading + const {onLoadFailure} = renderImage(); + fireEvent(screen.getByTestId('image'), 'loadStart'); + + // When the image emits a real error + fireEvent(screen.getByTestId('image'), 'error'); + + // Then the size fetch is reported as failed + expect(mockLogHmmm).toHaveBeenCalledTimes(1); + expect(onLoadFailure).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/InboxTabButtonTest.tsx b/tests/unit/InboxTabButtonTest.tsx index 9391613c69c6..8ec1a0319d58 100644 --- a/tests/unit/InboxTabButtonTest.tsx +++ b/tests/unit/InboxTabButtonTest.tsx @@ -53,7 +53,7 @@ jest.mock('@components/Navigation/NavigationTabBar/TabBarItem', () => () => null jest.mock('@components/Navigation/NavigationTabBar/getLastRoute', () => () => mockLastRoute); jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({Inbox: 'inbox-icon'})})); jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); -jest.mock('@hooks/useSidebarOrderedReports', () => ({useSidebarOrderedReportsState: () => ({chatTabBrickRoad: undefined})})); +jest.mock('@hooks/useSidebarOrderedReports', () => ({useChatTabBrickRoad: () => undefined})); jest.mock('@hooks/useTheme', () => () => ({danger: 'danger', iconSuccessFill: 'success'})); jest.mock('@hooks/useThemeStyles', () => () => ({ flex1: {}, diff --git a/tests/unit/InputUtilsTest.ts b/tests/unit/InputUtilsTest.ts new file mode 100644 index 000000000000..46fd8c6e2e55 --- /dev/null +++ b/tests/unit/InputUtilsTest.ts @@ -0,0 +1,86 @@ +/** Exercises writable DOM scrolling and the native input boundary. */ +import type * as InputUtilsModule from '@libs/InputUtils/index'; +import {scrollToBottom as scrollNativeToBottom, scrollToRight as scrollNativeToRight} from '@libs/InputUtils/index.native'; +import type {ScrollInput} from '@libs/InputUtils/types'; + +import type {TextInput} from 'react-native'; + +import createMock from '../utils/createMock'; + +const {moveSelectionToEnd, scrollToBottom, scrollToRight} = jest.requireActual('@libs/InputUtils/index.ts'); + +describe('InputUtils', () => { + it.each(['input', 'textarea', 'div'] as const)('scrolls an actual %s to its bottom and right edges', (tagName) => { + // Given RN Web inputs/textarea elements and live-markdown divs expose writable scroll offsets. + const input = document.createElement(tagName); + Object.defineProperties(input, {scrollHeight: {value: 120}, scrollWidth: {value: 230}}); + + // When both existing scrolling operations execute against the host node. + scrollToBottom(input); + scrollToRight(input); + + // Then each operation uses its own dimension, including the markdown and multiline producers. + expect(input.scrollTop).toBe(120); + expect(input.scrollLeft).toBe(230); + }); + + it('assigns zero dimensions instead of retaining a prior scroll offset', () => { + // Given an emptied input still has offsets from its previous content. + const input = document.createElement('input'); + input.scrollTop = 12; + input.scrollLeft = 34; + + // When the current dimensions are zero. + scrollToBottom(input); + scrollToRight(input); + + // Then zero is a real assignment, not an absent dimension. + expect(input.scrollTop).toBe(0); + expect(input.scrollLeft).toBe(0); + }); + + it('does not write getter-only native scroll fields or scroll in the native sibling', () => { + // Given ReactNativeElement inherits getter-only scroll fields from ReadOnlyElement. + const nativeInput = createMock({ + get scrollTop() { + return 11; + }, + get scrollLeft() { + return 22; + }, + scrollHeight: 120, + scrollWidth: 230, + }); + const input: Parameters[0] = nativeInput; + + // When property presence could otherwise admit a native ref into DOM scrolling. + expect(() => { + scrollToBottom(input); + scrollToRight(input); + scrollNativeToBottom(input); + scrollNativeToRight(input); + }).not.toThrow(); + + // Then no native write occurs despite the fields being present. + expect(nativeInput.scrollTop).toBe(11); + expect(nativeInput.scrollLeft).toBe(22); + }); + + it('moves an input selection to its end and leaves native selection alone', () => { + // Given the existing selection operation owns only setSelectionRange inputs. + const input = document.createElement('input'); + input.value = 'hello'; + input.setSelectionRange(0, 2); + const setSelection = jest.fn, Parameters>(); + const nativeInput = createMock({setSelection}); + + // When selection-to-end runs on each supported category. + moveSelectionToEnd(input); + moveSelectionToEnd(nativeInput); + + // Then the DOM caret moves to the exact text length without a native dispatch. + expect(input.selectionStart).toBe(5); + expect(input.selectionEnd).toBe(5); + expect(setSelection).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/LhnSearchPreviewParityTest.ts b/tests/unit/LhnSearchPreviewParityTest.ts index da619e66777b..965bfbf5ebc6 100644 --- a/tests/unit/LhnSearchPreviewParityTest.ts +++ b/tests/unit/LhnSearchPreviewParityTest.ts @@ -405,6 +405,7 @@ async function computeBothSurfaces({ convertToDisplayString, conciergeReportID: CONCIERGE_REPORT_ID, isSearching: true, + formatPhoneNumber, }, undefined, undefined, diff --git a/tests/unit/LightboxTest.tsx b/tests/unit/LightboxTest.tsx index 5f96ebbb9252..b62416572a21 100644 --- a/tests/unit/LightboxTest.tsx +++ b/tests/unit/LightboxTest.tsx @@ -1,4 +1,4 @@ -import {fireEvent, render, screen} from '@testing-library/react-native'; +import {act, fireEvent, render, screen} from '@testing-library/react-native'; import {AttachmentCarouselPagerActionsContext, AttachmentCarouselPagerStateContext} from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import type {AttachmentCarouselPagerActionsContextType, AttachmentCarouselPagerStateContextType} from '@components/Attachments/AttachmentCarousel/Pager/types'; @@ -29,6 +29,26 @@ jest.mock('@components/Image', () => { }; }); +jest.mock('@components/ActivityIndicator', () => { + const MockReact = jest.requireActual('react'); + const {View} = jest.requireActual('react-native'); + return {__esModule: true, default: () => MockReact.createElement(View, {testID: 'activity-indicator'})}; +}); + +jest.mock('@components/ImageLoadTimeoutNotice', () => { + const MockReact = jest.requireActual('react'); + const {View, Pressable} = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({onRetry}: {onRetry: () => void}) => + MockReact.createElement(View, {testID: 'load-timeout-notice'}, MockReact.createElement(Pressable, {testID: 'retry-button', onPress: onRetry})), + }; +}); + +let mockIsOffline = false; + +jest.mock('@hooks/useNetwork', () => () => ({isOffline: mockIsOffline})); + jest.mock('@components/Lightbox/numberOfConcurrentLightboxes', () => ({ __esModule: true, default: 3, @@ -208,4 +228,138 @@ describe('Lightbox', () => { } }); }); + + describe('image that never finishes loading', () => { + beforeEach(() => { + jest.useFakeTimers(); + mockIsOffline = false; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + function renderStandaloneLightbox() { + const onError = jest.fn(); + render( + , + ); + fireEvent(screen.getByTestId('lightbox-wrapper'), 'onLayout', { + nativeEvent: {layout: {width: 400, height: 800}}, + }); + return {onError}; + } + + it('stops the spinner and offers a retry when the load runs past the timeout', () => { + // Given a lightbox whose image never emits onLoad or onError + const {onError} = renderStandaloneLightbox(); + expect(screen.getByTestId('activity-indicator')).toBeTruthy(); + + // When the spinner has been on screen for the whole timeout + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // Then the spinner is replaced by a retry option, and the parent is not told the load failed + expect(screen.queryByTestId('activity-indicator')).toBeNull(); + expect(screen.getByTestId('retry-button')).toBeTruthy(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('still shows an image that loads after the timeout', () => { + // Given a load that already timed out + const {onError} = renderStandaloneLightbox(); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + expect(screen.getByTestId('retry-button')).toBeTruthy(); + + // When the slow load finally finishes + fireEvent(screen.getByTestId('image'), 'load', {nativeEvent: {width: 100, height: 100}}); + + // Then the timeout notice goes away and no failure is reported + expect(screen.queryByTestId('retry-button')).toBeNull(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('restarts the load and the timer when retry is pressed', () => { + // Given a load that already timed out + const {onError} = renderStandaloneLightbox(); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // When the user presses retry + fireEvent.press(screen.getByTestId('retry-button')); + + // Then the spinner shows again on a remounted image and gets a fresh timeout + expect(screen.getByTestId('activity-indicator')).toBeTruthy(); + expect(screen.queryByTestId('retry-button')).toBeNull(); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + expect(screen.getByTestId('retry-button')).toBeTruthy(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('keeps the timeout notice when a stalled load later errors', () => { + // Given a load that already timed out + const {onError} = renderStandaloneLightbox(); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // When the transport finally gives up and the image reports an error + fireEvent(screen.getByTestId('image'), 'error'); + + // Then the user still sees the timeout notice, since a slow load is not a missing attachment + expect(screen.getByTestId('retry-button')).toBeTruthy(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('hides the spinner when the image fails to load', () => { + // Given a lightbox whose image is loading + const {onError} = renderStandaloneLightbox(); + + // When the image reports an error + fireEvent(screen.getByTestId('image'), 'error'); + + // Then the spinner goes away instead of spinning over the parent's error UI + expect(onError).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('activity-indicator')).toBeNull(); + }); + + it('does not report a timeout for an image that loads before the timeout', () => { + // Given a lightbox whose image is loading + const {onError} = renderStandaloneLightbox(); + + // When the image loads and the timeout then passes + fireEvent(screen.getByTestId('image'), 'load', {nativeEvent: {width: 100, height: 100}}); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // Then the timer was cleared with the spinner and never reports an error + expect(screen.queryByTestId('activity-indicator')).toBeNull(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('does not report a timeout while offline', () => { + // Given the device is offline while the lightbox image is loading + mockIsOffline = true; + const {onError} = renderStandaloneLightbox(); + + // When the timeout passes + act(() => { + jest.advanceTimersByTime(CONST.TIMING.IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // Then no timeout notice is offered, so the image can still load once the device is back online + expect(screen.queryByTestId('retry-button')).toBeNull(); + expect(onError).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/LinkOpenReportFromDeepLinkTest.ts b/tests/unit/LinkOpenReportFromDeepLinkTest.ts new file mode 100644 index 000000000000..86a5ff354bde --- /dev/null +++ b/tests/unit/LinkOpenReportFromDeepLinkTest.ts @@ -0,0 +1,203 @@ +import {navigateToConciergeChat} from '@libs/actions/Report'; +import {setOnboardingErrorMessage} from '@libs/actions/Welcome'; +import {setDeepLinkToOpenAfterOnboarding} from '@libs/navigateAfterOnboarding'; +import Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@libs/Navigation/navigationRef'; + +import {openReportFromDeepLink} from '@src/libs/actions/Link'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationState} from '@react-navigation/native'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.mock('@libs/Navigation/navigationRef', () => ({ + __esModule: true, + default: {getRootState: jest.fn()}, +})); +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + navigate: jest.fn(), + isActiveRoute: jest.fn(() => false), + getTopmostReportId: jest.fn(), + waitForProtectedRoutes: jest.fn(() => Promise.resolve()), + }, +})); +jest.mock('@libs/actions/Session', () => ({ + canAnonymousUserAccessRoute: jest.fn(() => true), + isAnonymousUser: jest.fn(() => false), + signOutAndRedirectToSignIn: jest.fn(), + waitForUserSignIn: jest.fn(() => Promise.resolve()), +})); +jest.mock('@libs/actions/Report', () => ({ + doneCheckingPublicRoom: jest.fn(), + navigateToConciergeChat: jest.fn(), + openReport: jest.fn(), +})); +jest.mock('@libs/actions/Welcome', () => ({ + setOnboardingErrorMessage: jest.fn(), +})); +jest.mock('@libs/navigateAfterOnboarding', () => ({ + setDeepLinkToOpenAfterOnboarding: jest.fn(), +})); + +const mockedNavigation = jest.mocked(Navigation); +const mockedNavigateToConciergeChat = jest.mocked(navigateToConciergeChat); +const mockedSetDeepLinkToOpenAfterOnboarding = jest.mocked(setDeepLinkToOpenAfterOnboarding); +const mockedNavigationRef = jest.mocked(navigationRef); +const mockedSetOnboardingErrorMessage = jest.mocked(setOnboardingErrorMessage); + +const APP_URL = 'https://new.expensify.com'; + +function focusOn(screenName: string) { + const state: NavigationState = { + stale: false, + type: 'stack', + key: 'root', + index: 0, + routeNames: [screenName], + routes: [{key: screenName, name: screenName}], + }; + mockedNavigationRef.getRootState.mockReturnValue(state); +} + +async function handleLinkCapturedBeforeOnboarding(route: string) { + // The link arrives while signed out, then the new account signs up and still has onboarding ahead of it + openReportFromDeepLink(`${APP_URL}/${route}`, {}, false, undefined, undefined, undefined, undefined, 0, {}, undefined); + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await waitForBatchedUpdates(); + + // Completing onboarding marks it done before its screens close, which is when the link is handled + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true}); + await waitForBatchedUpdates(); +} + +function replayParkedLink() { + const openParkedLink = mockedSetDeepLinkToOpenAfterOnboarding.mock.calls.at(-1)?.[0]; + return openParkedLink?.(); +} + +describe('openReportFromDeepLink before onboarding', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await Onyx.set(ONYXKEYS.IS_LOADING_REPORT_DATA, true); + }); + + it('parks the link instead of opening it under the onboarding modal', async () => { + // Given onboarding is on screen when the link is handled + focusOn(SCREENS.ONBOARDING.PURPOSE); + + // When a link captured before sign-up is processed + await handleLinkCapturedBeforeOnboarding(ROUTES.SETTINGS_PROFILE.route); + + // Then it is handed to the post-onboarding navigation rather than opened now, which would flash the "Not here" page + expect(mockedSetDeepLinkToOpenAfterOnboarding).toHaveBeenCalledTimes(1); + expect(mockedNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('opens a parked link only once report data has loaded', async () => { + // Given a link parked while onboarding was on screen + focusOn(SCREENS.ONBOARDING.PURPOSE); + await handleLinkCapturedBeforeOnboarding(ROUTES.SETTINGS_PROFILE.route); + + // When onboarding finishes while the new account's data is still loading + expect(replayParkedLink()).toBe(true); + await waitForBatchedUpdates(); + + // Then nothing opens yet, because the route would sit on its loading skeleton + expect(mockedNavigation.navigate).not.toHaveBeenCalled(); + + // When the data finishes loading + await Onyx.set(ONYXKEYS.IS_LOADING_REPORT_DATA, false); + await waitForBatchedUpdates(); + + // Then the link opens + expect(mockedNavigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_PROFILE.route, expect.objectContaining({waitForTransition: true})); + }); + + it('opens a parked Concierge link in the chat instead of through the Concierge page', async () => { + // Given a Concierge link parked while onboarding was on screen + focusOn(SCREENS.ONBOARDING.PURPOSE); + await handleLinkCapturedBeforeOnboarding(ROUTES.CONCIERGE); + + // When onboarding finishes and the data has loaded + await Onyx.set(ONYXKEYS.IS_LOADING_REPORT_DATA, false); + replayParkedLink(); + await waitForBatchedUpdates(); + + // Then the chat opens directly, since the Concierge page is a URL entry page that shows a skeleton and redirects itself + expect(mockedNavigateToConciergeChat).toHaveBeenCalledTimes(1); + expect(mockedNavigation.navigate).not.toHaveBeenCalled(); + }); + + it.each([`${ROUTES.CONCIERGE}/`, `${ROUTES.CONCIERGE}?referrer=email`])('opens a parked Concierge link written as %s in the chat as well', async (route) => { + // Given a Concierge link with a trailing slash or a query string, parked while onboarding was on screen + focusOn(SCREENS.ONBOARDING.PURPOSE); + await handleLinkCapturedBeforeOnboarding(route); + + // When onboarding finishes and the data has loaded + await Onyx.set(ONYXKEYS.IS_LOADING_REPORT_DATA, false); + replayParkedLink(); + await waitForBatchedUpdates(); + + // Then it still skips the Concierge page, which would slide in as a panel before redirecting + expect(mockedNavigateToConciergeChat).toHaveBeenCalledTimes(1); + expect(mockedNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('also parks the link when onboarding is no longer the focused screen', async () => { + // Given the onboarding screens already closed by the time the link is handled + focusOn(NAVIGATORS.TAB_NAVIGATOR); + + // When a link captured before sign-up is processed + await handleLinkCapturedBeforeOnboarding(ROUTES.SETTINGS_PROFILE.route); + + // Then it is still parked, because the account had to onboard when the link was captured + expect(mockedSetDeepLinkToOpenAfterOnboarding).toHaveBeenCalledTimes(1); + expect(mockedNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('opens a parked report link once the report itself has arrived', async () => { + // Given a link to a report the new account has not loaded yet, parked while onboarding was on screen + focusOn(SCREENS.ONBOARDING.PURPOSE); + await handleLinkCapturedBeforeOnboarding(ROUTES.REPORT_WITH_ID.getRoute('123')); + await Onyx.set(ONYXKEYS.IS_LOADING_REPORT_DATA, false); + + // When onboarding finishes before the report is in Onyx + replayParkedLink(); + await waitForBatchedUpdates(); + + // Then it waits, because a missing report would be treated as not found and sent elsewhere + expect(mockedNavigation.navigate).not.toHaveBeenCalled(); + + // When the report arrives + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}123`, {reportID: '123'}); + await waitForBatchedUpdates(); + + // Then the report opens + expect(mockedNavigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('123'), expect.objectContaining({waitForTransition: true})); + }); + + it('does not park a link the app never replays', async () => { + // Given onboarding is on screen + focusOn(SCREENS.ONBOARDING.PURPOSE); + + // When the captured link is the test tools modal, which is app state in the URL rather than a destination + await handleLinkCapturedBeforeOnboarding(ROUTES.TEST_TOOLS_MODAL.route); + + // Then it is dropped, without the back-button error, since onboarding is already complete when the link is handled + expect(mockedSetDeepLinkToOpenAfterOnboarding).not.toHaveBeenCalled(); + expect(mockedSetOnboardingErrorMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/LinkTest.ts b/tests/unit/LinkTest.ts index 033e62271aa7..0e7843f23a7a 100644 --- a/tests/unit/LinkTest.ts +++ b/tests/unit/LinkTest.ts @@ -421,7 +421,7 @@ describe('Link.openReportFromDeepLink', () => { it('does not show an unfinished-setup error for a completed user on an onboarding task screen', async () => { await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true}); - openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/onboarding/work-email-validation?isJoinWorkspaceTask=true`, {}, true, undefined, undefined, false, 1, {}, {}); + openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/onboarding/work-email-validation?isJoinWorkspaceTask=true`, {}, true, undefined, undefined, false, true, 1, {}, {}); await waitForBatchedUpdates(); expect(mockedNavigation.waitForProtectedRoutes).toHaveBeenCalled(); diff --git a/tests/unit/ListUtilsTest.tsx b/tests/unit/ListUtilsTest.tsx index 41647fb142f7..612fcb551196 100644 --- a/tests/unit/ListUtilsTest.tsx +++ b/tests/unit/ListUtilsTest.tsx @@ -1,6 +1,6 @@ import type {ListItem} from '@components/SelectionList/types'; -import {sortDefaultToTop} from '@libs/ListUtils'; +import {clearTrailingDefaultSeparator, sortDefaultToTop, withClearRow} from '@libs/ListUtils'; import type {ThemeStyles} from '@styles/index'; @@ -66,3 +66,80 @@ describe('sortDefaultToTop', () => { ]); }); }); + +describe('withClearRow', () => { + const clearRow: ListItem = {keyForList: 'none', text: 'None'}; + const vendors: ListItem[] = [ + {keyForList: 'v1', text: 'Vendor 1', isSelected: true}, + {keyForList: 'v2', text: 'Vendor 2'}, + ]; + + it('puts the clear row below the selection when it is pinned to the top', () => { + // Given a list whose first row is the pinned selection + // When the clear row is inserted with isSelectionPinnedToTop = true + // Then it sits just below the pinned selection, not above it + expect(withClearRow(vendors, clearRow, true)).toStrictEqual([ + {keyForList: 'v1', text: 'Vendor 1', isSelected: true}, + {keyForList: 'none', text: 'None'}, + {keyForList: 'v2', text: 'Vendor 2'}, + ]); + }); + + it('puts the clear row first when nothing is pinned to the top', () => { + // Given a short/unpinned list + // When the clear row is inserted with isSelectionPinnedToTop = false + // Then it stays at the very top (natural order is retained below it) + expect(withClearRow(vendors, clearRow, false)).toStrictEqual([{keyForList: 'none', text: 'None'}, ...vendors]); + }); +}); + +describe('clearTrailingDefaultSeparator', () => { + const separator = ; + const isDefault = (item: ListItem) => item.keyForList === 'default'; + + it('removes the separator when the default row is last in the list', () => { + // Given a list whose last row is the default account carrying a baked-in separator (e.g. search left only the default result) + const items: ListItem[] = [ + {keyForList: 'other', text: 'Other'}, + {keyForList: 'default', text: 'Default', footerContent: separator}, + ]; + + // When the trailing default separator is cleared + // Then the default row keeps its place but loses the dangling separator below it + expect(clearTrailingDefaultSeparator(items, isDefault)).toStrictEqual([ + {keyForList: 'other', text: 'Other'}, + {keyForList: 'default', text: 'Default', footerContent: undefined}, + ]); + }); + + it('keeps the separator when the default row is not last', () => { + // Given a list where the default row (with separator) is followed by other rows + const items: ListItem[] = [ + {keyForList: 'default', text: 'Default', footerContent: separator}, + {keyForList: 'other', text: 'Other'}, + ]; + + // When the trailing default separator is cleared + // Then nothing changes because the separator still divides the default row from the rows below it + expect(clearTrailingDefaultSeparator(items, isDefault)).toStrictEqual(items); + }); + + it('does nothing when the last row is not the default', () => { + // Given a list whose last row is a non-default account without a separator + const items: ListItem[] = [ + {keyForList: 'default', text: 'Default', footerContent: separator}, + {keyForList: 'other', text: 'Other'}, + ]; + + // When the trailing default separator is cleared + // Then the list is returned untouched + expect(clearTrailingDefaultSeparator(items, isDefault)).toStrictEqual(items); + }); + + it('does nothing for an empty list', () => { + // Given an empty list + // When the trailing default separator is cleared + // Then it stays empty without throwing + expect(clearTrailingDefaultSeparator([], isDefault)).toStrictEqual([]); + }); +}); diff --git a/tests/unit/LoadTestMiddlewareTest.ts b/tests/unit/LoadTestMiddlewareTest.ts index 4f28c61760ee..9b1fe12a149b 100644 --- a/tests/unit/LoadTestMiddlewareTest.ts +++ b/tests/unit/LoadTestMiddlewareTest.ts @@ -1,3 +1,5 @@ +import type {ApiRequestCommandParameters} from '@libs/API/types'; + import HttpUtils from '@src/libs/HttpUtils'; import LoadTest from '@src/libs/Middleware/LoadTest'; import {setLoadTestParameters} from '@src/libs/Network/LoadTestState'; @@ -39,7 +41,7 @@ beforeEach(async () => { setLoadTestParameters(null); }); -function getMockCallsForTestUrl(): Array> { +function getMockCallsForTestUrl() { const calls = fetchMock.mock.calls; return calls.filter((call) => call[0] === TEST_URL); } @@ -222,3 +224,208 @@ describe('LoadTest middleware', () => { expect(getMockCallsForTestUrl()).toHaveLength(4); }); }); + +// Jest's jsdom has no Request global. whatwg-fetch/fetch.js converts string inputs to a string url, +// but defaults mode and referrer to null. Keep only its source-backed url view here. +// createMock deliberately accepts this incomplete native fixture for identity and non-string handling. +const {Request: FetchRequest} = jest.requireActual<{Request: new (input: string) => Pick}>('whatwg-fetch'); + +describe.each([ + {name: 'createGlobalFetchMock', createFetchMock: TestHelper.createGlobalFetchMock}, + {name: 'getGlobalFetchMock', createFetchMock: TestHelper.getGlobalFetchMock}, +])('$name fetch contract', ({createFetchMock}) => { + it('captures native string, Request and URL inputs with optional request options', async () => { + // Given the actual polyfill instance is an incomplete Request fixture and options stay native + expect(typeof FetchRequest).toBe('function'); + const polyfillRequest = new FetchRequest(TEST_URL); + const request = createMock(polyfillRequest); + const url = new URL(TEST_URL); + const options: RequestInit = {method: 'POST', body: new FormData()}; + const mockFetch = createFetchMock(); + + // When native inputs are passed with omitted, explicit undefined and present options + const stringResponse: Promise = mockFetch(TEST_URL); + const requestResponse: Promise = mockFetch(request, undefined); + const urlResponse: Promise = mockFetch(url, options); + await mockFetch(TEST_URL, options); + await mockFetch(request, options); + await mockFetch(url); + + // Then the original inputs and options remain in call order and responses retain the success JSON + expect(request).toBe(polyfillRequest); + expect(mockFetch.mock.calls).toEqual([[TEST_URL], [request, undefined], [url, options], [TEST_URL, options], [request, options], [url]]); + expect(mockFetch.mock.calls.at(1)?.[0]).toBe(request); + expect(mockFetch.mock.calls.at(2)?.[0]).toBe(url); + expect(mockFetch.mock.calls.at(2)?.[1]).toBe(options); + for (const response of await Promise.all([stringResponse, requestResponse, urlResponse])) { + expect(response.ok).toBe(true); + await expect(response.json()).resolves.toEqual({jsonCode: 200}); + } + }); + + it('matches API commands only for string inputs and extracts their FormData', async () => { + // Given the existing command handler reads native OpenReport parameters + const mockFetch = createFetchMock(); + const responseHandler = jest.fn((params: ApiRequestCommandParameters[typeof TEST_COMMAND]) => ({jsonCode: 201, message: params.reportID})); + mockFetch.mockAPICommand(TEST_COMMAND, responseHandler); + const body = new FormData(); + body.append('reportID', '99'); + const options: RequestInit = {method: 'POST', body}; + + // When the same API URL is supplied as a string, Request and URL + const stringResponse = await mockFetch(TEST_URL, options); + const requestResponse = await mockFetch(createMock(new FetchRequest(TEST_URL)), options); + const urlResponse = await mockFetch(new URL(TEST_URL), options); + + // Then only the string uses the command handler and its JSON fields override the default success code + await expect(stringResponse.json()).resolves.toEqual({jsonCode: 201, message: '99'}); + await expect(requestResponse.json()).resolves.toEqual({jsonCode: 200}); + await expect(urlResponse.json()).resolves.toEqual({jsonCode: 200}); + expect(responseHandler).toHaveBeenCalledTimes(1); + expect(responseHandler).toHaveBeenCalledWith({reportID: '99'}); + }); + + it('preserves response override precedence and fail then succeed controls', async () => { + // Given response overrides replace success fields but failure has its own response + const json = jest.fn(() => Promise.resolve({jsonCode: 202})); + const mockFetch = createFetchMock({ok: false, json}); + + // When the mock succeeds, fails and succeeds again + const successResponse = await mockFetch(TEST_URL); + mockFetch.fail(); + const failureResponse = await mockFetch(TEST_URL); + mockFetch.succeed(); + const restoredResponse = await mockFetch(TEST_URL); + + // Then overrides win only on success and the failure JSON remains unchanged + expect(successResponse.ok).toBe(false); + await expect(successResponse.json()).resolves.toEqual({jsonCode: 202}); + expect(failureResponse.ok).toBe(true); + await expect(failureResponse.json()).resolves.toEqual({jsonCode: 400}); + expect(restoredResponse.ok).toBe(false); + await expect(restoredResponse.json()).resolves.toEqual({jsonCode: 202}); + expect(json).toHaveBeenCalledTimes(2); + }); + + it('creates independent mock identities and state', async () => { + // Given each factory call starts with its own history and failure state + const firstMock = createFetchMock(); + const secondMock = createFetchMock(); + firstMock.fail(); + + // When both mocks handle the same input + const firstResponse = await firstMock(TEST_URL); + const secondResponse = await secondMock(TEST_URL); + firstMock.mockClear(); + + // Then controls and clearing one mock cannot alter the other + expect(firstMock).not.toBe(secondMock); + await expect(firstResponse.json()).resolves.toEqual({jsonCode: 400}); + await expect(secondResponse.json()).resolves.toEqual({jsonCode: 200}); + expect(firstMock.mock.calls).toHaveLength(0); + expect(secondMock.mock.calls).toEqual([[TEST_URL]]); + }); + + it('resumes paused inputs in order while retaining the existing omission of queued options', async () => { + // Given command responses need FormData and pause delays every native input + const mockFetch = createFetchMock(); + const responseHandler = jest.fn((params: ApiRequestCommandParameters[typeof TEST_COMMAND]) => ({message: params.reportID ?? 'missing'})); + mockFetch.mockAPICommand(TEST_COMMAND, responseHandler); + const body = new FormData(); + body.append('reportID', '99'); + const options: RequestInit = {method: 'POST', body}; + const request = createMock(new FetchRequest(TEST_URL)); + const url = new URL(TEST_URL); + const resolvedInputs: Array[0]> = []; + mockFetch.pause(); + + // When the inputs queue and the real batched-update helper runs before and during resume + const stringResponse = mockFetch(TEST_URL, options).then((response) => { + resolvedInputs.push(TEST_URL); + return response; + }); + const requestResponse = mockFetch(request, options).then((response) => { + resolvedInputs.push(request); + return response; + }); + const urlResponse = mockFetch(url, options).then((response) => { + resolvedInputs.push(url); + return response; + }); + await waitForBatchedUpdates(); + expect(resolvedInputs).toEqual([]); + await mockFetch.resume(); + + // Then resolution keeps queue order and string matching receives no queued FormData + expect(resolvedInputs).toEqual([TEST_URL, request, url]); + expect(mockFetch.mock.calls).toEqual([ + [TEST_URL, options], + [request, options], + [url, options], + ]); + await expect((await stringResponse).json()).resolves.toEqual({jsonCode: 200, message: 'missing'}); + await expect((await requestResponse).json()).resolves.toEqual({jsonCode: 200}); + await expect((await urlResponse).json()).resolves.toEqual({jsonCode: 200}); + expect(responseHandler).toHaveBeenCalledWith({}); + + // When a new call follows resume it receives its present options normally + await expect((await mockFetch(TEST_URL, options)).json()).resolves.toEqual({jsonCode: 200, message: '99'}); + expect(responseHandler).toHaveBeenLastCalledWith({reportID: '99'}); + }); + + it('retains the existing queue when resume is called again', async () => { + // Given a response getter that observes each response built from the existing queue + let responseCount = 0; + const mockFetch = createFetchMock({ + get ok() { + responseCount += 1; + return true; + }, + }); + mockFetch.pause(); + const response = mockFetch(TEST_URL); + + // When resume runs twice without resetting the queue + await mockFetch.resume(); + expect(responseCount).toBe(1); + await mockFetch.resume(); + + // Then the retained queue rebuilds a response without adding a fetch call or resolving twice + expect(responseCount).toBe(2); + expect((await response).ok).toBe(true); + expect(mockFetch.mock.calls).toEqual([[TEST_URL]]); + }); + + it('resets calls, implementation, queued requests, handlers and state flags', async () => { + // Given failure and pause are active and a registered command has a queued request + const mockFetch = createFetchMock(); + const originalImplementation = mockFetch.getMockImplementation(); + expect(originalImplementation).toBeDefined(); + const responseHandler = jest.fn(() => ({message: 'registered'})); + mockFetch.mockAPICommand(TEST_COMMAND, responseHandler); + mockFetch.fail(); + mockFetch.pause(); + let queuedRequestResolved = false; + const queuedRequest = mockFetch(TEST_URL).then(() => { + queuedRequestResolved = true; + }); + expect(queuedRequest).toBeInstanceOf(Promise); + + // When reset clears the implementation as Jest specifies + expect(mockFetch.mockReset()).toBe(mockFetch); + expect(mockFetch.mock.calls).toEqual([]); + expect(mockFetch.getMockImplementation()).toBeUndefined(); + + // Then restoring the actual original implementation exposes cleared flags, queue and handlers + if (!originalImplementation) { + throw new Error('Expected the original fetch mock implementation'); + } + mockFetch.mockImplementation(originalImplementation); + const response = await mockFetch(TEST_URL); + await expect(response.json()).resolves.toEqual({jsonCode: 200}); + await mockFetch.resume(); + expect(queuedRequestResolved).toBe(false); + expect(responseHandler).not.toHaveBeenCalled(); + expect(mockFetch.mock.calls).toEqual([[TEST_URL]]); + }); +}); diff --git a/tests/unit/MapViewUtilsTest.ts b/tests/unit/MapViewUtilsTest.ts index c5d61d429390..678dc5351b90 100644 --- a/tests/unit/MapViewUtilsTest.ts +++ b/tests/unit/MapViewUtilsTest.ts @@ -1,4 +1,4 @@ -import type {AlternateDirection, Coordinate} from '@components/MapView/MapViewTypes'; +import type {AlternateDirection, Coordinate, WayPoint} from '@components/MapView/MapViewTypes'; import utils from '@components/MapView/utils'; const SINGLE_SEGMENT: Coordinate[] = [ @@ -187,6 +187,100 @@ describe('MapView utils', () => { }); }); + describe('getBoundsCenter', () => { + it('returns the center of the bounds as longitude and latitude', () => { + // Given bounds spanning longitudes 10 to 30 and latitudes 40 to 50 + // When resolving their center + const center = utils.getBoundsCenter({southWest: [10, 40], northEast: [30, 50]}); + + // Then the center keeps each axis in its own field + expect(center).toEqual({lng: 20, lat: 45}); + }); + }); + + describe('findClosestCoordinateOnLineFromCenter', () => { + it('measures the distance to the route with longitude and latitude in their own roles', () => { + // Given a center at the equator near longitude 90, with a route that passes 0.1° east of it and then 0.2° north + // of it. Reading the [longitude, latitude] coordinates as [latitude, longitude] would scale the north/south gap + // by cos(90°) and make the farther northern part of the route look the closest. + const route: Coordinate[] = [ + [89.6, -1], + [89.6, 0.2], + [88, 0.2], + ]; + + // When looking for the point of the route closest to the center + const closest = utils.findClosestCoordinateOnLineFromCenter({lng: 89.5, lat: 0}, route); + + // Then it is the point 0.1° east of the center, not the one 0.2° north of it + expect(closest?.at(0)).toBeCloseTo(89.6); + expect(closest?.at(1)).toBeCloseTo(0); + }); + }); + + describe('getCenterTargets', () => { + const START: WayPoint = {id: 'start', coordinate: [10, 10], markerType: 'START_WAYPOINT'}; + const STOP: WayPoint = {id: 'stop', coordinate: [20, 20], markerType: 'STOP_WAYPOINT'}; + + it('offers the route when there is no real user location to alternate with', () => { + // Given a route with two waypoints and no user location (e.g. location access was never granted) + // When resolving what the center button can target + const targets = utils.getCenterTargets([START, STOP], undefined, undefined); + + // Then the route is a target, so the first press frames it instead of zooming into the initialState fallback + expect(targets.hasWaypointsTarget).toBe(true); + expect(targets.singleWaypointCoordinate).toBeUndefined(); + }); + + it('offers a lone waypoint when there is no real user location', () => { + // Given a single waypoint and no user location + // When resolving what the center button can target + const targets = utils.getCenterTargets([START], undefined, undefined); + + // Then the lone waypoint is the target the button centers on + expect(targets.hasWaypointsTarget).toBe(true); + expect(targets.singleWaypointCoordinate).toEqual(START.coordinate); + }); + + it('does not offer a lone waypoint that sits on the user location', () => { + // Given a single waypoint placed exactly at the user's location + // When resolving what the center button can target + const targets = utils.getCenterTargets([START], undefined, START.coordinate); + + // Then there is nothing to alternate with, as both targets are the same spot + expect(targets.hasWaypointsTarget).toBe(false); + }); + + it('offers a lone waypoint that is north of the user location near longitude -90', () => { + // Given a single waypoint ~1 km north of the user near longitude -90 (Memphis), where reading the + // [longitude, latitude] coordinates as [latitude, longitude] shrinks north/south distances to almost nothing + const waypoint: WayPoint = {id: 'start', coordinate: [-90.05, 35.159], markerType: 'START_WAYPOINT'}; + + // When resolving what the center button can target + const targets = utils.getCenterTargets([waypoint], undefined, [-90.05, 35.15]); + + // Then the waypoint is still a separate target the button can alternate with + expect(targets.hasWaypointsTarget).toBe(true); + expect(targets.singleWaypointCoordinate).toEqual(waypoint.coordinate); + }); + }); + + describe('areSameCoordinate', () => { + it('treats points ~10 m apart as the same coordinate', () => { + // Given two [longitude, latitude] points ~10 m apart north/south + // When comparing them + // Then they are the same coordinate, as they are within the 20 m threshold + expect(utils.areSameCoordinate([-90.05, 35.15], [-90.05, 35.15009])).toBe(true); + }); + + it('treats points ~300 m apart north/south as different coordinates', () => { + // Given two [longitude, latitude] points ~300 m apart north/south near longitude -87.6 (Chicago) + // When comparing them + // Then they are different coordinates, as the order is not mistaken for [latitude, longitude] + expect(utils.areSameCoordinate([-87.63, 41.88], [-87.63, 41.8827])).toBe(false); + }); + }); + describe('isSingleSegmentRoute', () => { it('detects single segment, segmented and empty routes', () => { expect(utils.isSingleSegmentRoute(SINGLE_SEGMENT)).toBe(true); @@ -194,4 +288,90 @@ describe('MapView utils', () => { expect(utils.isSingleSegmentRoute([])).toBe(true); }); }); + + describe('getMapboxLanguage', () => { + it('passes locales that are already valid Mapbox codes through unchanged', () => { + // Given app locales whose values are already BCP-47 codes that Mapbox can localize labels to + + // When each locale is mapped to the language the map labels are rendered in + const languages = [utils.getMapboxLanguage('en'), utils.getMapboxLanguage('es'), utils.getMapboxLanguage('fr'), utils.getMapboxLanguage('de')]; + + // Then every locale is passed through as is, because Mapbox already understands it and the labels should match the app's language + expect(languages).toEqual(['en', 'es', 'fr', 'de']); + }); + + it('remaps locales whose value differs from the Mapbox code', () => { + // Given app locales whose values are not the codes Mapbox expects, so Mapbox would not recognize them as is + + // When each locale is mapped to the language the map labels are rendered in + const languages = [utils.getMapboxLanguage('pt-BR'), utils.getMapboxLanguage('zh-hans')]; + + // Then each locale is converted to the matching Mapbox code, so the labels are still shown in the user's language + expect(languages).toEqual(['pt', 'zh-Hans']); + }); + + it('returns undefined when there is no locale', () => { + // Given a user whose preferred locale has not loaded yet + + // When the missing locale is mapped to a Mapbox language + const language = utils.getMapboxLanguage(undefined); + + // Then no language is returned, so the map keeps its default labels instead of being set to an invalid language + expect(language).toBeUndefined(); + }); + }); + + describe('getNativeMapboxLanguage', () => { + it('returns the Mapbox language for locales the native SDKs can localize labels to', () => { + // Given app locales whose Mapbox language is one of the Mapbox Streets languages the native SDKs support + + // When each locale is mapped to the language the native map labels are rendered in + const languages = [utils.getNativeMapboxLanguage('en'), utils.getNativeMapboxLanguage('ja'), utils.getNativeMapboxLanguage('pt-BR'), utils.getNativeMapboxLanguage('zh-hans')]; + + // Then the same language as on web is returned, so the native labels match the app's language + expect(languages).toEqual(['en', 'ja', 'pt', 'zh-Hans']); + }); + + it('returns undefined for locales the native SDKs reject', () => { + // Given app locales whose language the native Mapbox SDKs don't support, which makes iOS throw and Android log a warning + + // When each locale is mapped to the language the native map labels are rendered in + const languages = [utils.getNativeMapboxLanguage('nl'), utils.getNativeMapboxLanguage('pl'), utils.getNativeMapboxLanguage('el')]; + + // Then no language is returned, so the native map keeps its default labels without raising an error + expect(languages).toEqual([undefined, undefined, undefined]); + }); + }); + + describe('getMapboxWorldview', () => { + it('passes country codes Mapbox defines a worldview for through unchanged', () => { + // Given countries that Mapbox draws disputed borders for from their own point of view + + // When each country is mapped to the worldview the map's borders are drawn from + const worldviews = [utils.getMapboxWorldview('US'), utils.getMapboxWorldview('CN'), utils.getMapboxWorldview('IN'), utils.getMapboxWorldview('JP')]; + + // Then every country is passed through as is, so users see the borders as their own country recognizes them + expect(worldviews).toEqual(['US', 'CN', 'IN', 'JP']); + }); + + it('passes countries without a dedicated worldview through so Mapbox falls back to the style default', () => { + // Given countries that Mapbox has no dedicated worldview for + + // When each country is mapped to the worldview the map's borders are drawn from + const worldviews = [utils.getMapboxWorldview('DE'), utils.getMapboxWorldview('AU')]; + + // Then the countries are still passed through, because Mapbox falls back to the style's default worldview and a hardcoded list of supported countries would go stale + expect(worldviews).toEqual(['DE', 'AU']); + }); + + it('drops anything that is not a country code, which Mapbox would reject', () => { + // Given values that are not two-letter uppercase country codes, such as a missing country or a three-letter code + + // When each value is mapped to the worldview the map's borders are drawn from + const worldviews = [utils.getMapboxWorldview(undefined), utils.getMapboxWorldview(''), utils.getMapboxWorldview('USA'), utils.getMapboxWorldview('us')]; + + // Then no worldview is returned for any of them, because Mapbox raises an error for codes it can't parse + expect(worldviews).toEqual([undefined, undefined, undefined, undefined]); + }); + }); }); diff --git a/tests/unit/MentionUserRendererTest.tsx b/tests/unit/MentionUserRendererTest.tsx index 3455700feef3..7f8d061eef1e 100644 --- a/tests/unit/MentionUserRendererTest.tsx +++ b/tests/unit/MentionUserRendererTest.tsx @@ -83,7 +83,7 @@ jest.mock('@libs/PersonalDetailsUtils', () => { if (!target) { return ''; } - // Keep SMS domain as is here, Str.removeSMSDomain in component will strip it later + // Keep SMS domain as is here, the component will format it later if (target.endsWith('@expensify.sms')) { return target; } @@ -221,10 +221,10 @@ describe('MentionUserRenderer', () => { expect(screen.getByText('@alex@other.com')).toBeVisible(); }); - test('strips SMS domain from phone number mentions', () => { + test('formats phone number for phone number mentions', () => { const tnode = buildTNode({data: '@+12018675309@expensify.sms'}); renderMention({tnode}); - expect(screen.getByText('@+12018675309')).toBeVisible(); + expect(screen.getByText('@(201) 867-5309')).toBeVisible(); expect(screen.queryByText('expensify.sms')).not.toBeVisible(); }); diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index d16e1dfdc493..4ab10ce667d2 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -19,6 +19,7 @@ import {isFromCreditCardImport} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, ReportAction, SearchResults, Transaction} from '@src/types/onyx'; +import type {Attendee} from '@src/types/onyx/IOU'; import type {OnyxCollection} from 'react-native-onyx'; @@ -29,7 +30,7 @@ import createRandomPolicy from '../utils/collections/policies'; import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction, {createRandomDistanceRequestTransaction} from '../utils/collections/transaction'; -import {convertToDisplayString, translateLocal} from '../utils/TestHelper'; +import {convertToDisplayString, formatPhoneNumber, translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; // Mock localeCompare function for tests @@ -714,6 +715,93 @@ describe('MergeTransactionUtils', () => { expect(result.conflictFields).toContain('attendees'); }); + describe('attendee combinations', () => { + const submitter = { + email: 'submitter@example.com', + displayName: 'Submitter', + avatarUrl: '', + login: 'submitter', + }; + const alice = { + email: 'alice@example.com', + displayName: 'Alice', + avatarUrl: '', + login: 'alice', + }; + const bob = { + email: 'bob@example.com', + displayName: 'Bob', + avatarUrl: '', + login: 'bob', + }; + + const merge = (targetAttendees: Attendee[], sourceAttendees: Attendee[]) => { + const targetTransaction = { + ...createRandomTransaction(0), + comment: { + ...createRandomTransaction(0).comment, + attendees: targetAttendees, + }, + }; + const sourceTransaction = { + ...createRandomTransaction(1), + comment: { + ...createRandomTransaction(1).comment, + attendees: sourceAttendees, + }, + }; + return getMergeableDataAndConflictFields(targetTransaction, sourceTransaction, mockLocaleCompare, mockGetCurrencyDecimals); + }; + + it('should not conflict when neither expense has attendees', () => { + const result = merge([], []); + + expect(result.conflictFields).not.toContain('attendees'); + }); + + it('should keep the attendee when only the source expense has one', () => { + const result = merge([], [submitter]); + + expect(result.conflictFields).not.toContain('attendees'); + expect(result.mergeableData).toMatchObject({ + attendees: [submitter], + }); + }); + + it('should keep the attendee when only the target expense has one', () => { + const result = merge([submitter], []); + + expect(result.conflictFields).not.toContain('attendees'); + expect(result.mergeableData).toMatchObject({ + attendees: [submitter], + }); + }); + + it('should not conflict when both expenses have the same single attendee', () => { + const result = merge([submitter], [submitter]); + + expect(result.conflictFields).not.toContain('attendees'); + }); + + it('should conflict when the expenses have different single attendees', () => { + const result = merge([alice], [bob]); + + expect(result.conflictFields).toContain('attendees'); + }); + + it('should not conflict when both expenses have the same two attendees', () => { + const result = merge([alice, bob], [bob, alice]); + + expect(result.conflictFields).not.toContain('attendees'); + }); + + it('should conflict when one expense has two attendees and the other has a different one', () => { + const result = merge([alice, bob], [submitter]); + + expect(result.conflictFields).toContain('attendees'); + }); + }); + it('should automatically merge login-only attendees', () => { const targetTransaction = { ...createRandomTransaction(0), @@ -1018,7 +1106,16 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for merchant - const result = getDisplayValue('merchant', transaction, getTransactionDetails(transaction), undefined, translateLocal, convertToDisplayString, mockLocaleCompare); + const result = getDisplayValue( + 'merchant', + transaction, + getTransactionDetails(transaction), + undefined, + translateLocal, + convertToDisplayString, + mockLocaleCompare, + formatPhoneNumber, + ); // Then it should return empty string expect(result).toBe(''); @@ -1033,8 +1130,26 @@ describe('MergeTransactionUtils', () => { }; // When we get display values for boolean fields - const reimbursableResult = getDisplayValue('reimbursable', transaction, getTransactionDetails(transaction), undefined, translateLocal, convertToDisplayString, mockLocaleCompare); - const billableResult = getDisplayValue('billable', transaction, getTransactionDetails(transaction), undefined, translateLocal, convertToDisplayString, mockLocaleCompare); + const reimbursableResult = getDisplayValue( + 'reimbursable', + transaction, + getTransactionDetails(transaction), + undefined, + translateLocal, + convertToDisplayString, + mockLocaleCompare, + formatPhoneNumber, + ); + const billableResult = getDisplayValue( + 'billable', + transaction, + getTransactionDetails(transaction), + undefined, + translateLocal, + convertToDisplayString, + mockLocaleCompare, + formatPhoneNumber, + ); // Then it should return translated Yes/No values expect(reimbursableResult).toBe('common.yes'); @@ -1050,7 +1165,16 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for amount - const result = getDisplayValue('amount', transaction, getTransactionDetails(transaction), undefined, translateLocal, convertToDisplayString, mockLocaleCompare); + const result = getDisplayValue( + 'amount', + transaction, + getTransactionDetails(transaction), + undefined, + translateLocal, + convertToDisplayString, + mockLocaleCompare, + formatPhoneNumber, + ); // Then it should return formatted currency string expect(result).toBe('$10.00'); @@ -1066,7 +1190,16 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for description - const result = getDisplayValue('description', transaction, getTransactionDetails(transaction), undefined, translateLocal, convertToDisplayString, mockLocaleCompare); + const result = getDisplayValue( + 'description', + transaction, + getTransactionDetails(transaction), + undefined, + translateLocal, + convertToDisplayString, + mockLocaleCompare, + formatPhoneNumber, + ); // Then it should return cleaned text without HTML and with spaces instead of line breaks expect(result).toBe('This is a test description with line breaks and more text'); @@ -1080,7 +1213,7 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for tag - const result = getDisplayValue('tag', transaction, getTransactionDetails(transaction), undefined, translateLocal, convertToDisplayString, mockLocaleCompare); + const result = getDisplayValue('tag', transaction, getTransactionDetails(transaction), undefined, translateLocal, convertToDisplayString, mockLocaleCompare, formatPhoneNumber); // Then it should return sanitized tag names separated by commas expect(result).toBe('Department, Engineering, Frontend'); @@ -1097,7 +1230,16 @@ describe('MergeTransactionUtils', () => { ], }, }; - const result = getDisplayValue('attendees', transaction, getTransactionDetails(transaction), undefined, translateLocal, convertToDisplayString, mockLocaleCompare); + const result = getDisplayValue( + 'attendees', + transaction, + getTransactionDetails(transaction), + undefined, + translateLocal, + convertToDisplayString, + mockLocaleCompare, + formatPhoneNumber, + ); expect(result).toBe('Test User 1, Test User 2'); }); @@ -1112,8 +1254,26 @@ describe('MergeTransactionUtils', () => { }; // When we get display values for string fields - const merchantResult = getDisplayValue('merchant', transaction, getTransactionDetails(transaction), undefined, translateLocal, convertToDisplayString, mockLocaleCompare); - const categoryResult = getDisplayValue('category', transaction, getTransactionDetails(transaction), undefined, translateLocal, convertToDisplayString, mockLocaleCompare); + const merchantResult = getDisplayValue( + 'merchant', + transaction, + getTransactionDetails(transaction), + undefined, + translateLocal, + convertToDisplayString, + mockLocaleCompare, + formatPhoneNumber, + ); + const categoryResult = getDisplayValue( + 'category', + transaction, + getTransactionDetails(transaction), + undefined, + translateLocal, + convertToDisplayString, + mockLocaleCompare, + formatPhoneNumber, + ); // Then it should return the string values expect(merchantResult).toBe('Starbucks Coffee'); @@ -1128,7 +1288,16 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for reportID - const result = getDisplayValue('reportID', transaction, getTransactionDetails(transaction), undefined, translateLocal, convertToDisplayString, mockLocaleCompare); + const result = getDisplayValue( + 'reportID', + transaction, + getTransactionDetails(transaction), + undefined, + translateLocal, + convertToDisplayString, + mockLocaleCompare, + formatPhoneNumber, + ); // Then it should return translated "None" expect(result).toBe('common.none'); @@ -1143,7 +1312,16 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for reportID - const result = getDisplayValue('reportID', transaction, getTransactionDetails(transaction), undefined, translateLocal, convertToDisplayString, mockLocaleCompare); + const result = getDisplayValue( + 'reportID', + transaction, + getTransactionDetails(transaction), + undefined, + translateLocal, + convertToDisplayString, + mockLocaleCompare, + formatPhoneNumber, + ); // Then it should return the reportName expect(result).toBe('Test Report Name'); @@ -1169,7 +1347,16 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for reportID - const result = getDisplayValue('reportID', transaction, getTransactionDetails(transaction), undefined, translateLocal, convertToDisplayString, mockLocaleCompare); + const result = getDisplayValue( + 'reportID', + transaction, + getTransactionDetails(transaction), + undefined, + translateLocal, + convertToDisplayString, + mockLocaleCompare, + formatPhoneNumber, + ); // Then it should return the report's name from Onyx expect(result).toBe(report.reportName); diff --git a/tests/unit/MergeUtilsTest.ts b/tests/unit/MergeUtilsTest.ts index 1eef25bf677d..93dd52b92240 100644 --- a/tests/unit/MergeUtilsTest.ts +++ b/tests/unit/MergeUtilsTest.ts @@ -230,13 +230,13 @@ describe('MergeUtils', () => { [login]: {accountID: 42, login, ...overrides}, }); - it('returns "not set" when there is no final approver', () => { + it('returns undefined when there is no final approver', () => { // Given a connection with no final approver configured // When the approver's display name is resolved - // Then the missing approver is called out rather than left blank - expect(getMergeFinalApproverDisplayName(undefined, {}, translateLocal, formatPhoneNumber)).toBe('workspace.merge.notSet'); - expect(getMergeFinalApproverDisplayName(null, {}, translateLocal, formatPhoneNumber)).toBe('workspace.merge.notSet'); - expect(getMergeFinalApproverDisplayName('', {}, translateLocal, formatPhoneNumber)).toBe('workspace.merge.notSet'); + // Then the approver is undefined + expect(getMergeFinalApproverDisplayName(undefined, {}, translateLocal, formatPhoneNumber)).toBeUndefined(); + expect(getMergeFinalApproverDisplayName(null, {}, translateLocal, formatPhoneNumber)).toBeUndefined(); + expect(getMergeFinalApproverDisplayName('', {}, translateLocal, formatPhoneNumber)).toBeUndefined(); }); it('returns the display name when the approver is a known workspace member', () => { diff --git a/tests/unit/MiddlewareTest.ts b/tests/unit/MiddlewareTest.ts index d6a6d79da16d..772aea316f2d 100644 --- a/tests/unit/MiddlewareTest.ts +++ b/tests/unit/MiddlewareTest.ts @@ -1,7 +1,8 @@ -import {READ_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import SaveResponseInOnyx from '@libs/Middleware/SaveResponseInOnyx'; import CONST from '@src/CONST'; +import * as App from '@src/libs/actions/App'; import * as PersistedRequests from '@src/libs/actions/PersistedRequests'; import type MoveIOUReportToExistingPolicyParams from '@src/libs/API/parameters/MoveIOUReportToExistingPolicyParams'; import HttpUtils from '@src/libs/HttpUtils'; @@ -120,6 +121,75 @@ describe('Middleware', () => { expect(await getOnyxValue(ONYXKEYS.ONYX_UPDATES_FROM_SERVER)).toBeUndefined(); expect(await getOnyxValue(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT)).toBe(clientLastUpdateID); }); + + describe('when the server no longer knows the client lastUpdateID', () => { + beforeEach(() => { + jest.spyOn(App, 'reconnectApp').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test.each([ + ['AddComment', 'AddComment'], + // DeleteMoneyRequest skips the OnyxUpdates flow but still moves lastUpdateID forward, so it can leave the same gap + ['DeleteMoneyRequest', WRITE_COMMANDS.DELETE_MONEY_REQUEST], + ])('applies the response and runs a full ReconnectApp to cover the gap for %s', async (_case, command) => { + // Given a device that was backgrounded for a long time, so the server no longer has its lastUpdateIDAppliedToClient + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 100); + await waitForBatchedUpdates(); + + Request.addMiddleware(SaveResponseInOnyx); + + // When a write returns a lastUpdateID but no previousUpdateID + jest.spyOn(HttpUtils, 'xhr').mockResolvedValueOnce({jsonCode: 200, lastUpdateID: 5001}); + const result = await Request.processWithMiddleware({ + command, + data: {apiRequestType: CONST.API_REQUEST_TYPE.WRITE}, + successData: [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.IS_LOADING_APP, value: false}], + }); + await waitForBatchedUpdates(); + + // Then the response is applied without pausing the queue + expect(result?.shouldPauseQueue).toBeUndefined(); + expect(await getOnyxValue(ONYXKEYS.ONYX_UPDATES_FROM_SERVER)).toBeUndefined(); + + // And a full ReconnectApp runs, because an incremental one would start after the skipped updates + expect(App.reconnectApp).toHaveBeenCalledTimes(1); + expect(App.reconnectApp).toHaveBeenCalledWith(); + }); + + test.each([ + ['the response has a previousUpdateID', 100, 'AddComment', {lastUpdateID: 101, previousUpdateID: 100}], + ['the client has no lastUpdateID yet', undefined, 'AddComment', {lastUpdateID: 5001}], + ['the response is not newer than the client', 100, 'AddComment', {lastUpdateID: 100}], + ['the request is a ReconnectApp', 100, SIDE_EFFECT_REQUEST_COMMANDS.RECONNECT_APP, {lastUpdateID: 5001}], + ['the request is an OpenApp', 100, WRITE_COMMANDS.OPEN_APP, {lastUpdateID: 5001}], + ['the request is a GetMissingOnyxMessages', 100, SIDE_EFFECT_REQUEST_COMMANDS.GET_MISSING_ONYX_MESSAGES, {lastUpdateID: 5001}], + ['the request is a CloseAccount', 100, WRITE_COMMANDS.CLOSE_ACCOUNT, {lastUpdateID: 5001}], + ])('does not run a full ReconnectApp when %s', async (_case, clientLastUpdateID, command, updateIDs) => { + // Given a client whose lastUpdateID is either still known to the server or has nothing to lose + if (clientLastUpdateID) { + await Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, clientLastUpdateID); + } + await waitForBatchedUpdates(); + + Request.addMiddleware(SaveResponseInOnyx); + jest.spyOn(HttpUtils, 'xhr').mockResolvedValueOnce({jsonCode: 200, ...updateIDs}); + + // When the response comes back + await Request.processWithMiddleware({ + command, + data: {apiRequestType: CONST.API_REQUEST_TYPE.WRITE}, + successData: [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.IS_LOADING_APP, value: false}], + }); + await waitForBatchedUpdates(); + + // Then no extra full ReconnectApp runs, since no updates were skipped or the request already returns full data + expect(App.reconnectApp).not.toHaveBeenCalled(); + }); + }); }); describe('HandleUnusedOptimisticID', () => { diff --git a/tests/unit/ModifiedExpenseMessageTest.ts b/tests/unit/ModifiedExpenseMessageTest.ts index eb2f4d538054..7fe1be3313e8 100644 --- a/tests/unit/ModifiedExpenseMessageTest.ts +++ b/tests/unit/ModifiedExpenseMessageTest.ts @@ -2932,10 +2932,9 @@ describe('ModifiedExpenseMessage', () => { }); }); - describe('Xero supplier changes (R4)', () => { + describe('Xero vendor changes (R4)', () => { // Xero policy with two named supplier contacts. The resolver reads - // `connections.xero.data.contacts` (keyed Record), and the label switches to - // "supplier" instead of "vendor" because the policy is on Xero. + // `connections.xero.data.contacts` (keyed Record). const policyWithXeroSuppliers = createMock({ id: 'p-1', name: 'My Workspace', @@ -2956,7 +2955,8 @@ describe('ModifiedExpenseMessage', () => { }, }); - it('renders "set the supplier to X" for a Xero workspace when the supplier is set for the first time', () => { + it('renders "set the vendor to X" for a Xero workspace when the vendor is set for the first time', () => { + // Given a Xero expense whose vendor is set for the first time const reportAction = { ...createRandomReportAction(1), actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, @@ -2964,6 +2964,7 @@ describe('ModifiedExpenseMessage', () => { vendor: {externalID: 'xcAcme', wasManuallySet: true}, }, }; + // When the modified expense message is built const result = getForReportAction({ convertToDisplayString, translate: translateLocal, @@ -2975,10 +2976,12 @@ describe('ModifiedExpenseMessage', () => { formatPhoneNumber, movedFromReportName: undefined, }); - expect(result).toEqual('set the supplier to "Acme Xero"'); + // Then it says "vendor" like every other integration + expect(result).toEqual('set the vendor to "Acme Xero"'); }); - it('renders "changed the supplier to Y (previously X)" when the supplier is changed on a Xero workspace', () => { + it('renders "changed the vendor to Y (previously X)" when the vendor is changed on a Xero workspace', () => { + // Given a Xero expense whose vendor is changed from one contact to another const reportAction = { ...createRandomReportAction(1), actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, @@ -2987,6 +2990,7 @@ describe('ModifiedExpenseMessage', () => { vendor: {externalID: 'xcOffice', wasManuallySet: true}, }, }; + // When the modified expense message is built const result = getForReportAction({ convertToDisplayString, translate: translateLocal, @@ -2998,10 +3002,12 @@ describe('ModifiedExpenseMessage', () => { formatPhoneNumber, movedFromReportName: undefined, }); - expect(result).toEqual('changed the supplier to "Office Supplies Xero" (previously "Acme Xero")'); + // Then it says "vendor" like every other integration + expect(result).toEqual('changed the vendor to "Office Supplies Xero" (previously "Acme Xero")'); }); - it('falls back to rendering the externalID for a Xero supplier no longer in the contacts list', () => { + it('falls back to rendering the externalID for a Xero vendor no longer in the contacts list', () => { + // Given a Xero expense whose vendor was removed from the synced contacts const reportAction = { ...createRandomReportAction(1), actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, @@ -3009,6 +3015,7 @@ describe('ModifiedExpenseMessage', () => { vendor: {externalID: 'xcDeleted', wasManuallySet: false}, }, }; + // When the modified expense message is built const result = getForReportAction({ convertToDisplayString, translate: translateLocal, @@ -3020,7 +3027,8 @@ describe('ModifiedExpenseMessage', () => { formatPhoneNumber, movedFromReportName: undefined, }); - expect(result).toEqual('set the supplier to "xcDeleted"'); + // Then it shows the externalID under the "vendor" wording + expect(result).toEqual('set the vendor to "xcDeleted"'); }); }); }); diff --git a/tests/unit/MoveFilesOutOfDocumentsTest.ts b/tests/unit/MoveFilesOutOfDocumentsTest.ts index 043d35137e66..437cac4ca083 100644 --- a/tests/unit/MoveFilesOutOfDocumentsTest.ts +++ b/tests/unit/MoveFilesOutOfDocumentsTest.ts @@ -17,11 +17,6 @@ jest.mock('@libs/Log', () => ({ default: {info: jest.fn(), warn: jest.fn(), alert: jest.fn()}, })); -jest.mock('@libs/getPlatform', () => ({ - __esModule: true, - default: jest.fn(() => 'ios'), -})); - jest.mock('react-native-fs', () => ({ DocumentDirectoryPath: '/mock/documents', CachesDirectoryPath: '/mock/caches', @@ -38,7 +33,8 @@ const mockRNFS: { readDir: jest.Mock; } = jest.requireMock('react-native-fs'); -const {default: mockGetPlatform}: {default: jest.Mock} = jest.requireMock('@libs/getPlatform'); +// Jest resolves the module to its iOS implementation, so the no-op used on other platforms is loaded by its full file name +const removeStaleExportStagingDirShim = jest.requireActual<{default: () => Promise}>('@libs/migrations/MoveFilesOutOfDocuments/removeStaleExportStagingDir/index.ts').default; const OLD_ATTACHMENT_DIR = '/mock/documents/attachments'; const NEW_ATTACHMENT_DIR = '/mock/caches/attachments'; @@ -56,7 +52,6 @@ describe('MoveFilesOutOfDocuments migration (native)', () => { mockRNFS.unlink.mockImplementation(() => Promise.resolve()); mockRNFS.moveFile.mockImplementation(() => Promise.resolve()); mockRNFS.readDir.mockImplementation(() => Promise.resolve([])); - mockGetPlatform.mockImplementation(() => CONST.PLATFORM.IOS); await Onyx.clear(); await waitForBatchedUpdates(); }); @@ -167,11 +162,10 @@ describe('MoveFilesOutOfDocuments migration (native)', () => { it('keeps the Android share staging directory, which uses the same path as the old iOS export staging directory', async () => { // Given an Android device with a file staged by the share intent handler, which lives at the same path the iOS export staging folder used - mockGetPlatform.mockImplementation(() => CONST.PLATFORM.ANDROID); mockRNFS.exists.mockImplementation((existsPath: string) => Promise.resolve(existsPath === OLD_EXPORT_STAGING_DIR)); - // When the migration runs on app launch, possibly before the share screen has read the staged file - await MoveFilesOutOfDocuments(); + // When the export staging cleanup runs on app launch with the non-iOS implementation, possibly before the share screen has read the staged file + await removeStaleExportStagingDirShim(); // Then the directory is kept, because deleting it would lose the file the user is sharing into the app expect(mockRNFS.unlink).not.toHaveBeenCalledWith(OLD_EXPORT_STAGING_DIR); diff --git a/tests/unit/NativeBottomTabViewTest.tsx b/tests/unit/NativeBottomTabViewTest.tsx new file mode 100644 index 000000000000..d8e985001132 --- /dev/null +++ b/tests/unit/NativeBottomTabViewTest.tsx @@ -0,0 +1,144 @@ +import {act, render} from '@testing-library/react-native'; + +import type {ParamListBase, TabNavigationState} from '@react-navigation/native'; + +import {NativeBottomTabView} from '@react-navigation/bottom-tabs/unstable'; +import {DefaultTheme, ThemeProvider} from '@react-navigation/native'; +import React, {useEffect} from 'react'; + +type NativeBottomTabViewProps = Parameters[0]; + +type MockTabsHostProps = { + navStateRequest: {selectedScreenKey: string}; + onTabSelected: (event: {nativeEvent: {selectedScreenKey: string; provenance: number; actionOrigin: string}}) => void; + children: React.ReactNode; +}; + +type MockTabsScreenProps = { + screenKey: string; + children: React.ReactNode; +}; + +// The published types describe the web stub, which renders nothing. Jest resolves the native view. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const NativeTabView = NativeBottomTabView as unknown as React.ComponentType; + +// Each mounted native tab and each mounted tab content, in mount order +const mockNativeTabMounts: string[] = []; +const contentMounts: string[] = []; +let mockLatestHostProps: MockTabsHostProps | undefined; + +jest.mock('react-native-screens', () => { + const {useEffect: mockUseEffect} = jest.requireActual('react'); + + function Host(props: MockTabsHostProps) { + mockLatestHostProps = props; + return props.children; + } + + function Screen({screenKey, children}: MockTabsScreenProps) { + mockUseEffect(() => { + mockNativeTabMounts.push(screenKey); + }, []); + return children; + } + + return {Tabs: {Host, Screen}}; +}); + +function TabContent({routeKey}: {routeKey: string}) { + useEffect(() => { + contentMounts.push(routeKey); + }, [routeKey]); + return null; +} + +function buildTabState(inboxKey: string): TabNavigationState { + return { + type: 'tab', + key: 'tab', + index: 0, + stale: false, + routeNames: ['Inbox', 'Spend'], + routes: [ + {name: 'Inbox', key: inboxKey}, + {name: 'Spend', key: 'Spend-key'}, + ], + history: [{type: 'route', key: inboxKey}], + preloadedRouteKeys: [], + }; +} + +function buildNavigation() { + return {dispatch: jest.fn(), emit: jest.fn(), getState: jest.fn()}; +} + +function buildDescriptors(state: TabNavigationState): NativeBottomTabViewProps['descriptors'] { + const descriptors = Object.fromEntries( + state.routes.map((route) => [ + route.key, + { + route, + options: {}, + navigation: {}, + render: () => , + }, + ]), + ); + // The view reads only the fields built above. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return descriptors as unknown as NativeBottomTabViewProps['descriptors']; +} + +function renderTabView(state: TabNavigationState, navigation: ReturnType) { + return ( + + + + ); +} + +describe('NativeBottomTabView', () => { + beforeEach(() => { + mockNativeTabMounts.length = 0; + contentMounts.length = 0; + mockLatestHostProps = undefined; + }); + + it('keeps the native tab when the focused route gets a new key, and remounts only its content', () => { + // Given the Inbox tab focused under a native tab bar + const navigation = buildNavigation(); + const {rerender} = render(renderTabView(buildTabState('Inbox-key-1'), navigation)); + + // When the Inbox route gets a new key, as the pre-insert under the RHP does to remount it without a push flash (#90985) + rerender(renderTabView(buildTabState('Inbox-key-2'), navigation)); + + // Then no native tab is mounted again, since Android never removes the replaced one and would show Inbox twice + expect(mockNativeTabMounts).toEqual(['Inbox', 'Spend']); + // And the Inbox content still remounts under its new key, so the remount the new key asks for still happens + expect(contentMounts.filter((routeKey) => routeKey.startsWith('Inbox'))).toEqual(['Inbox-key-1', 'Inbox-key-2']); + // And native is asked to select the tab by its route name, which did not change + expect(mockLatestHostProps?.navStateRequest.selectedScreenKey).toBe('Inbox'); + }); + + it('switches to the route whose name native reports as selected', () => { + // Given the Inbox tab focused under a native tab bar + const navigation = buildNavigation(); + render(renderTabView(buildTabState('Inbox-key-1'), navigation)); + + // When the user selects the Spend tab natively, which reports the tab by its route name + act(() => { + mockLatestHostProps?.onTabSelected({nativeEvent: {selectedScreenKey: 'Spend', provenance: 1, actionOrigin: 'user'}}); + }); + + // Then JS presses and navigates to that route, so JS follows native instead of dropping the selection + expect(navigation.emit).toHaveBeenCalledWith({type: 'tabPress', target: 'Spend-key'}); + expect(navigation.dispatch).toHaveBeenCalledWith(expect.objectContaining({type: 'NAVIGATE', payload: expect.objectContaining({name: 'Spend'}), target: 'tab'})); + }); +}); diff --git a/tests/unit/Navigation/SplitRouterTest.ts b/tests/unit/Navigation/SplitRouterTest.ts new file mode 100644 index 000000000000..652f018ee737 --- /dev/null +++ b/tests/unit/Navigation/SplitRouterTest.ts @@ -0,0 +1,68 @@ +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import SplitRouter from '@libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import type {ParamListBase, RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; + +import {CommonActions} from '@react-navigation/native'; + +jest.mock('@libs/getIsNarrowLayout', () => jest.fn(() => true)); + +const mockGetIsNarrowLayout = jest.mocked(getIsNarrowLayout); + +const CONFIG_OPTIONS: RouterConfigOptions = { + routeNames: [SCREENS.INBOX, SCREENS.REPORT], + routeParamList: {}, + routeGetIdList: {}, +}; + +const router = SplitRouter({ + sidebarScreen: SCREENS.INBOX, + defaultCentralScreen: SCREENS.REPORT, + parentRoute: {key: 'reports-split-key', name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, +}); + +function makeState(routeNames: string[]): StackNavigationState { + return { + key: 'split-test', + index: routeNames.length - 1, + routeNames: [SCREENS.INBOX, SCREENS.REPORT], + routes: routeNames.map((name, index) => ({key: `${name}-${index}`, name})), + type: 'stack', + stale: false, + preloadedRoutes: [], + }; +} + +describe('SplitRouter', () => { + describe('navigating to the sidebar screen on narrow layout', () => { + beforeEach(() => { + mockGetIsNarrowLayout.mockReturnValue(true); + }); + + it('returns the current state when the sidebar is the only screen', () => { + // Given a split navigator that only shows the sidebar, as it does after the Reports tab is preloaded + const state = makeState([SCREENS.INBOX]); + + // When something navigates to the sidebar screen again, for example tapping the Inbox tab + const result = router.getStateForAction(state, CommonActions.navigate(SCREENS.INBOX), CONFIG_OPTIONS); + + // Then the router reports the action as handled with no change, instead of returning null, which React Navigation reports as params it couldn't apply + expect(result).toBe(state); + }); + + it('pops back to the sidebar when a central screen is on top', () => { + // Given a split navigator with a report opened on top of the sidebar + const state = makeState([SCREENS.INBOX, SCREENS.REPORT]); + + // When something navigates to the sidebar screen + const result = router.getStateForAction(state, CommonActions.navigate(SCREENS.INBOX), CONFIG_OPTIONS); + + // Then the router still pops to the sidebar, so the fallback only applies when there is nothing to pop + expect(result?.index).toBe(0); + expect(result?.routes.map((route) => route.name)).toEqual([SCREENS.INBOX]); + }); + }); +}); diff --git a/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts b/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts index 154020e06abe..ea326074fc21 100644 --- a/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts +++ b/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts @@ -1,10 +1,12 @@ import getPlatform from '@libs/getPlatform'; import { clearPreInsertedOriginalTabRoute, + removeStalePreMountsFromResetAction, handleRemoveFullscreenUnderRHP, handleReplaceFullscreenUnderRHP, } from '@libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers'; import type {RemoveFullscreenUnderRHPActionType, ReplaceFullscreenUnderRHPActionType} from '@libs/Navigation/AppNavigator/createRootStackNavigator/types'; +import {markWideTabPreMountRouteKeyRevealed, setLiveWideTabPreMountRouteKey} from '@libs/Navigation/helpers/wideTabPreMountRouteKey'; import type {NavigationPartialRoute, NavigationStateRoute, ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; @@ -12,9 +14,9 @@ import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {CommonActions, NavigationState, ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native'; +import type {NavigationState, ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native'; -import {StackRouter} from '@react-navigation/native'; +import {CommonActions, StackRouter} from '@react-navigation/native'; import createMock from '../../../utils/createMock'; @@ -26,6 +28,8 @@ jest.mock('@libs/Navigation/helpers/getStateFromPath', () => ({ default: jest.fn(() => mockStubbedParsedState), })); jest.mock('@libs/getPlatform', () => jest.fn()); +const mockGetIsNarrowLayout = jest.fn(() => false); +jest.mock('@libs/getIsNarrowLayout', () => () => mockGetIsNarrowLayout()); const mockGetPlatform = jest.mocked(getPlatform); @@ -168,6 +172,11 @@ function getReportsSplitState(result: StackNavigationState | null return reportsSplitRoute?.state; } +function getReportsSplitRouteKey(result: StackNavigationState | null) { + const tabRoute = result?.routes.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR); + return tabRoute?.state?.routes.find((route) => route.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR)?.key; +} + function hasReportParams(params: unknown): params is ReportsSplitNavigatorParamList[typeof SCREENS.REPORT] { return typeof params === 'object' && params !== null && 'reportID' in params && typeof params.reportID === 'string'; } @@ -214,6 +223,7 @@ const staleLongFormDeepLinkParams = { beforeEach(() => { clearPreInsertedOriginalTabRoute(); mockGetPlatform.mockReturnValue(CONST.PLATFORM.IOS); + mockGetIsNarrowLayout.mockReturnValue(false); }); describe('handleReplaceFullscreenUnderRHP — focused Reports stack preservation', () => { @@ -328,6 +338,272 @@ describe('handleReplaceFullscreenUnderRHP — focused Reports stack preservation expect(getReportIDs(result)).toEqual([undefined, 'B']); }); + it('drops the focused tab route key on wide layout when no pre-mount is revealed', () => { + // Given a wide layout with the Reports tab focused on report A and no wide pre-mount + mockGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockGetIsNarrowLayout.mockReturnValue(false); + mockStubbedParsedState = makeReportsParsedState('B'); + + // When report B is put under the RHP + const result = handleReplaceFullscreenUnderRHP( + makeExistingReportsState([makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), makeRoute(SCREENS.REPORT, {reportID: 'A'}, undefined, 'report-a-key')], 1), + makeReportsAction('B'), + CONFIG_OPTIONS, + stackRouter, + ); + + // Then the Reports split route is keyless and remounts, so reveals that do not use a pre-mount behave as before + expect(getReportsSplitRouteKey(result)).toBeUndefined(); + expect(getReportIDs(result)).toEqual([undefined, 'B']); + }); + + it('drops the focused tab route key on narrow layout so the destination mounts fresh behind the RHP (#90985)', () => { + // Given a narrow layout with the Reports tab focused on report A + mockGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockGetIsNarrowLayout.mockReturnValue(true); + mockStubbedParsedState = makeReportsParsedState('B'); + + // When report B is pre-inserted under the RHP + const result = handleReplaceFullscreenUnderRHP( + makeExistingReportsState([makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), makeRoute(SCREENS.REPORT, {reportID: 'A'}, undefined, 'report-a-key')], 1), + makeReportsAction('B'), + CONFIG_OPTIONS, + stackRouter, + ); + + // Then the Reports split route is keyless, which forces a remount and avoids the previous page flashing + expect(getReportsSplitRouteKey(result)).toBeUndefined(); + }); + + it('reveals the wide pre-mounted report by giving the built destination its key, with the rest built as without it', () => { + // Given a wide layout with report B pre-mounted under the focused report A + mockGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockStubbedParsedState = makeReportsParsedState('B'); + const state = makeExistingReportsState( + [ + makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), + makeRoute(SCREENS.REPORT, {reportID: 'B'}, undefined, 'Report-wide-pre-mount-1'), + makeRoute(SCREENS.REPORT, {reportID: 'A'}, undefined, 'report-a-key'), + ], + 2, + ); + const action: ReplaceFullscreenUnderRHPActionType = {...makeReportsAction('B'), payload: {...makeReportsAction('B').payload, preMountedRouteKey: 'Report-wide-pre-mount-1'}}; + + // When report B is revealed under the RHP + const result = handleReplaceFullscreenUnderRHP(state, action, CONFIG_OPTIONS, stackRouter); + + // Then the stack is the one the replace builds without a pre-mount, and only the destination reuses the mounted screen + expect(getReportIDs(result)).toEqual([undefined, 'B']); + expect(getReportsSplitState(result)?.routes.map((route) => route.key)).toEqual(['inbox-key', 'Report-wide-pre-mount-1']); + expect(getReportsSplitRouteKey(result)).toBe('reports-split-key'); + }); + + it('reveals the wide pre-mount in the mounted stack state with the params of the built destination', () => { + // Given a wide layout with a mounted Reports stack where report B was pre-mounted with params the reveal no longer asks for + mockGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockStubbedParsedState = makeReportsParsedState('B'); + const state = makeExistingReportsState( + [ + makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), + makeRoute(SCREENS.REPORT, {reportID: 'B', reportActionID: 'old-action'}, undefined, 'Report-wide-pre-mount-5'), + makeRoute(SCREENS.REPORT, {reportID: 'A'}, undefined, 'report-a-key'), + ], + 2, + ); + const reportsSplitState = getReportsSplitState(state); + if (reportsSplitState) { + Object.assign(reportsSplitState, {stale: false, key: 'reports-split-state-key'}); + } + const action: ReplaceFullscreenUnderRHPActionType = {...makeReportsAction('B'), payload: {...makeReportsAction('B').payload, preMountedRouteKey: 'Report-wide-pre-mount-5'}}; + + // When report B is revealed under the RHP + const result = handleReplaceFullscreenUnderRHP(state, action, CONFIG_OPTIONS, stackRouter); + + // Then the mounted stack state is kept so its screens are not re-rendered, but the destination shows what the action asked for + expect(getReportsSplitState(result)?.key).toBe('reports-split-state-key'); + expect(getReportsSplitState(result)?.routes.at(-1)?.key).toBe('Report-wide-pre-mount-5'); + expect(getReportsSplitState(result)?.routes.at(-1)?.params).toEqual({reportID: 'B'}); + }); + + it('rebuilds the route snapshot history of the mounted stack from the revealed routes', () => { + // Given a wide layout with a mounted stack whose route snapshot history still holds report A and an older params snapshot + mockGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockStubbedParsedState = makeReportsParsedState('B'); + const state = makeExistingReportsState( + [ + makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), + makeRoute(SCREENS.REPORT, {reportID: 'B', reportActionID: 'old-action'}, undefined, 'Report-wide-pre-mount-7'), + makeRoute(SCREENS.REPORT, {reportID: 'A'}, undefined, 'report-a-key'), + ], + 2, + ); + const reportsSplitState = getReportsSplitState(state); + if (reportsSplitState) { + Object.assign(reportsSplitState, { + stale: false, + key: 'reports-split-state-key', + history: [ + {key: 'inbox-key', name: SCREENS.INBOX}, + {key: 'Report-wide-pre-mount-7', name: SCREENS.REPORT, params: {reportID: 'B', reportActionID: 'old-action'}}, + {key: 'report-a-key', name: SCREENS.REPORT, params: {reportID: 'A'}}, + ], + }); + } + const action: ReplaceFullscreenUnderRHPActionType = {...makeReportsAction('B'), payload: {...makeReportsAction('B').payload, preMountedRouteKey: 'Report-wide-pre-mount-7'}}; + + // When report B is revealed under the RHP + const result = handleReplaceFullscreenUnderRHP(state, action, CONFIG_OPTIONS, stackRouter); + + // Then the history matches the revealed routes one to one, like a rehydrated stack, so back cannot land on the dropped report A + const resultHistory: unknown[] = getReportsSplitState(result)?.history ?? []; + expect(resultHistory).toEqual([expect.objectContaining({key: 'inbox-key'}), expect.objectContaining({key: 'Report-wide-pre-mount-7', params: {reportID: 'B'}})]); + }); + + it('drops the pre-mount kept in the preserved native history, so the revealed destination is its only copy', () => { + // Given a wide native layout with the Reports tab focused on report A and report B pre-mounted under it + mockGetPlatform.mockReturnValue(CONST.PLATFORM.IOS); + mockGetIsNarrowLayout.mockReturnValue(false); + mockStubbedParsedState = makeReportsParsedState('B'); + const state = makeExistingReportsState( + [ + makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), + makeRoute(SCREENS.REPORT, {reportID: 'B'}, undefined, 'Report-wide-pre-mount-6'), + makeRoute(SCREENS.REPORT, {reportID: 'A'}, undefined, 'report-a-key'), + ], + 2, + ); + const action: ReplaceFullscreenUnderRHPActionType = {...makeReportsAction('B'), payload: {...makeReportsAction('B').payload, preMountedRouteKey: 'Report-wide-pre-mount-6'}}; + + // When report B is revealed under the RHP, which on native keeps the history through the focused report A + const result = handleReplaceFullscreenUnderRHP(state, action, CONFIG_OPTIONS, stackRouter); + + // Then the pre-mount appears only as the destination, since duplicate keys would let the navigator show the wrong screen + expect(getReportIDs(result)).toEqual([undefined, 'A', 'B']); + expect(getReportsSplitState(result)?.routes.map((route) => route.key)).toEqual(['inbox-key', undefined, 'Report-wide-pre-mount-6']); + }); + + it('reveals a report pre-mounted in a covered tab and stops keeping that tab preloaded', () => { + // Given the Search tab focused and report B pre-mounted on top of the covered Reports tab, which is marked preloaded + mockGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockStubbedParsedState = makeReportsParsedState('B'); + const existing = makeExistingReportsState( + [makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), makeRoute(SCREENS.REPORT, {reportID: 'B'}, undefined, 'Report-wide-pre-mount-2')], + 1, + false, + ); + const tabNavigatorState = existing.routes.at(0)?.state; + if (tabNavigatorState) { + Object.assign(tabNavigatorState, {preloadedRouteKeys: ['reports-split-key']}); + } + const action: ReplaceFullscreenUnderRHPActionType = {...makeReportsAction('B'), payload: {...makeReportsAction('B').payload, preMountedRouteKey: 'Report-wide-pre-mount-2'}}; + + // When report B is revealed under the RHP + const result = handleReplaceFullscreenUnderRHP(existing, action, CONFIG_OPTIONS, stackRouter); + const resultTabState: {index?: number; preloadedRouteKeys?: string[]} | undefined = result?.routes.at(0)?.state; + + // Then the Reports tab is focused on the mounted report, and it may freeze again once the user leaves it + expect(resultTabState?.index).toBe(0); + expect(getReportsSplitState(result)?.routes.at(-1)?.key).toBe('Report-wide-pre-mount-2'); + expect(resultTabState?.preloadedRouteKeys).toEqual([]); + }); + + it('adds the destination tab to the tab history on wide layout, like a tab switch', () => { + // Given a wide layout with the Search tab focused and in the tab history, and report B pre-mounted in the covered Reports tab + mockGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockGetIsNarrowLayout.mockReturnValue(false); + mockStubbedParsedState = makeReportsParsedState('B'); + const existing = makeExistingReportsState( + [makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), makeRoute(SCREENS.REPORT, {reportID: 'B'}, undefined, 'Report-wide-pre-mount-3')], + 1, + false, + ); + const tabNavigatorState = existing.routes.at(0)?.state; + if (tabNavigatorState) { + Object.assign(tabNavigatorState, {history: [{type: 'route', key: 'search-tab-key'}]}); + } + const action: ReplaceFullscreenUnderRHPActionType = {...makeReportsAction('B'), payload: {...makeReportsAction('B').payload, preMountedRouteKey: 'Report-wide-pre-mount-3'}}; + + // When report B in the Reports tab is revealed under the RHP + const result = handleReplaceFullscreenUnderRHP(existing, action, CONFIG_OPTIONS, stackRouter); + const resultHistory: unknown[] = result?.routes.at(0)?.state?.history ?? []; + + // Then the history ends on the Reports tab, so tab back goes to Search instead of treating Reports as never visited + expect(resultHistory).toEqual([expect.objectContaining({key: 'search-tab-key'}), expect.objectContaining({key: 'reports-split-key'})]); + }); + + it('keeps the tab history as it is on wide layout when the destination is in the focused tab', () => { + // Given a wide layout with the Reports tab focused on report A, last in the tab history, and report B pre-mounted under A + mockGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockGetIsNarrowLayout.mockReturnValue(false); + mockStubbedParsedState = makeReportsParsedState('B'); + const existing = makeExistingReportsState( + [ + makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), + makeRoute(SCREENS.REPORT, {reportID: 'B'}, undefined, 'Report-wide-pre-mount-4'), + makeRoute(SCREENS.REPORT, {reportID: 'A'}, undefined, 'report-a-key'), + ], + 2, + ); + const tabNavigatorState = existing.routes.at(0)?.state; + if (tabNavigatorState) { + Object.assign(tabNavigatorState, { + history: [ + {type: 'route', key: 'search-tab-key'}, + {type: 'route', key: 'reports-split-key'}, + ], + }); + } + + const action: ReplaceFullscreenUnderRHPActionType = {...makeReportsAction('B'), payload: {...makeReportsAction('B').payload, preMountedRouteKey: 'Report-wide-pre-mount-4'}}; + + // When report B in the same tab is revealed under the RHP + const result = handleReplaceFullscreenUnderRHP(existing, action, CONFIG_OPTIONS, stackRouter); + const resultHistory: unknown[] = result?.routes.at(0)?.state?.history ?? []; + + // Then no duplicate entry is added, since the user did not switch tabs + expect(resultHistory).toEqual([expect.objectContaining({key: 'search-tab-key'}), expect.objectContaining({key: 'reports-split-key'})]); + }); + + it('replaces without the pre-mount when its screen is no longer in the destination tab', () => { + // Given a wide layout where the pre-mounted report was dropped before the reveal + mockGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockStubbedParsedState = makeReportsParsedState('B'); + const existing = makeExistingReportsState([makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), makeRoute(SCREENS.REPORT, {reportID: 'A'}, undefined, 'report-a-key')], 1); + const action: ReplaceFullscreenUnderRHPActionType = {...makeReportsAction('B'), payload: {...makeReportsAction('B').payload, preMountedRouteKey: 'Report-wide-pre-mount-gone'}}; + + // When the destination is revealed under the RHP + const result = handleReplaceFullscreenUnderRHP(existing, action, CONFIG_OPTIONS, stackRouter); + + // Then the regular in-place tab update runs, with a fresh destination route + expect(getReportsSplitRouteKey(result)).toBe('reports-split-key'); + expect(getReportIDs(result)).toEqual([undefined, 'B']); + expect(getReportsSplitState(result)?.routes.at(-1)?.key).toBeUndefined(); + }); + + it('stops keeping the destination tab preloaded when the pre-mount is gone before a cross-tab reveal', () => { + // Given the Search tab focused and the covered Reports tab still marked preloaded, but its pre-mounted report already removed + mockGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockStubbedParsedState = makeReportsParsedState('B'); + const existing = makeExistingReportsState( + [makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), makeRoute(SCREENS.REPORT, {reportID: 'A'}, undefined, 'report-a-key')], + 1, + false, + ); + const tabNavigatorState = existing.routes.at(0)?.state; + if (tabNavigatorState) { + Object.assign(tabNavigatorState, {preloadedRouteKeys: ['reports-split-key']}); + } + const action: ReplaceFullscreenUnderRHPActionType = {...makeReportsAction('B'), payload: {...makeReportsAction('B').payload, preMountedRouteKey: 'Report-wide-pre-mount-gone'}}; + + // When report B is revealed under the RHP without the pre-mount + const result = handleReplaceFullscreenUnderRHP(existing, action, CONFIG_OPTIONS, stackRouter); + const resultTabState: {index?: number; preloadedRouteKeys?: string[]} | undefined = result?.routes.at(0)?.state; + + // Then the focused Reports tab leaves the preload list, otherwise its screens keep skipping their fetch and read effects + expect(resultTabState?.index).toBe(0); + expect(resultTabState?.preloadedRouteKeys).toEqual([]); + }); + it('restores the untouched original Reports stack when the user cancels', () => { mockStubbedParsedState = makeReportsParsedState('B'); const originalState = makeExistingReportsState( @@ -384,12 +660,16 @@ describe('handleReplaceFullscreenUnderRHP — WORKSPACE_NAVIGATOR seeding', () = }); it('remounts the WORKSPACE_NAVIGATOR by dropping its key so it mounts the [list, split] cleanly instead of an incremental update that flashes (#90985)', () => { + // Given a narrow layout, where the push transition would flash on an in-place update + mockGetIsNarrowLayout.mockReturnValue(true); mockStubbedParsedState = makeParsedState(INCOMING_SPLIT_ONLY); // makeExistingState gives the workspace navigator route the key 'workspace-nav-key'. const existing = makeExistingState([makeRoute(SCREENS.WORKSPACES_LIST, undefined, undefined, 'list-key')], 0); + + // When the workspace split is revealed under the RHP const result = handleReplaceFullscreenUnderRHP(existing, makeAction(), CONFIG_OPTIONS, stackRouter); - // The focused tab route is marked for remount by dropping its key; TabRouter.getRehydratedState() + // Then the focused tab route is marked for remount by dropping its key; TabRouter.getRehydratedState() // then assigns a fresh key, so react-native-screens remounts the navigator instead of doing an // incremental update that flashes. The stale key must not survive. const {navigatorKey} = getWorkspaceNavInnerRoutes(result); @@ -485,6 +765,104 @@ describe('handleReplaceFullscreenUnderRHP — WORKSPACE_NAVIGATOR seeding', () = }); }); +describe('removeStalePreMountsFromResetAction', () => { + const staleKey = 'Report-wide-pre-mount-1'; + const liveKey = 'Report-wide-pre-mount-2'; + + function makeResetWithReportsStack(reportRoutes: TestRoute[], reportIndex: number) { + const reportsTabRoute = makeRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, undefined, {index: reportIndex, routes: reportRoutes}, 'reports-split-key'); + return CommonActions.reset({index: 1, routes: [makeRoute(NAVIGATORS.TAB_NAVIGATOR, undefined, {index: 0, routes: [reportsTabRoute]}, 'tab-nav-key'), makeRHPRoute()]}); + } + + type ResetStackState = {routes: Array<{key?: string; state?: unknown}>; index?: number}; + + function isResetStackState(state: unknown): state is ResetStackState { + return typeof state === 'object' && state !== null && 'routes' in state && Array.isArray(state.routes); + } + + function getResetReportsStack(action: ReturnType) { + const payload = action.type === CONST.NAVIGATION.ACTION_TYPE.RESET ? action.payload : undefined; + const tabRoute = payload?.routes.at(0); + const tabState = tabRoute && 'state' in tabRoute ? tabRoute.state : undefined; + const reportsStack = isResetStackState(tabState) ? tabState.routes.at(0)?.state : undefined; + return isResetStackState(reportsStack) ? reportsStack : undefined; + } + + afterEach(() => { + setLiveWideTabPreMountRouteKey(undefined); + }); + + it('drops a pre-mount restored from a saved browser history entry and keeps focus on the same screen', () => { + // Given browser forward resetting to a saved state that still holds an old pre-mount under the focused report + const action = makeResetWithReportsStack( + [ + makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), + makeRoute(SCREENS.REPORT, {reportID: 'B'}, undefined, staleKey), + makeRoute(SCREENS.REPORT, {reportID: 'A'}, undefined, 'a-key'), + ], + 2, + ); + + // When the root router sanitizes the RESET + const reportsStack = getResetReportsStack(removeStalePreMountsFromResetAction(action)); + + // Then the stale pre-mount is gone, so it cannot stay mounted and hidden with nothing left to remove it + expect(reportsStack?.routes.map((route) => route.key)).toEqual(['inbox-key', 'a-key']); + expect(reportsStack?.index).toBe(1); + }); + + it('drops the state of a tab that held only a stale pre-mount, so the tab builds its initial route again', () => { + // Given a saved state where a never-visited tab was built only for an old pre-mount + const action = makeResetWithReportsStack([makeRoute(SCREENS.REPORT, {reportID: 'B'}, undefined, staleKey)], 0); + + // When the root router sanitizes the RESET + const result = removeStalePreMountsFromResetAction(action); + + // Then the tab has no state instead of an empty routes list, which no navigator can render + const payload = result.type === CONST.NAVIGATION.ACTION_TYPE.RESET ? result.payload : undefined; + const tabRoute = payload?.routes.at(0); + const tabState = tabRoute && 'state' in tabRoute ? tabRoute.state : undefined; + const reportsTabRoute = isResetStackState(tabState) ? tabState.routes.at(0) : undefined; + expect(reportsTabRoute?.key).toBe('reports-split-key'); + expect(reportsTabRoute?.state).toBeUndefined(); + }); + + it('drops a stale pre-mount from the top of a covered tab and focuses the screen under it', () => { + // Given a saved state where an old pre-mount sits on top of a tab that was covered when it was saved + const action = makeResetWithReportsStack([makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), makeRoute(SCREENS.REPORT, {reportID: 'B'}, undefined, staleKey)], 1); + + // When the root router sanitizes the RESET + const reportsStack = getResetReportsStack(removeStalePreMountsFromResetAction(action)); + + // Then the tab is back to the stack it had before the pre-mount + expect(reportsStack?.routes.map((route) => route.key)).toEqual(['inbox-key']); + expect(reportsStack?.index).toBe(0); + }); + + it('keeps the live pre-mount and leaves other actions untouched', () => { + // Given a RESET that holds the pre-mount the current flow still owns + setLiveWideTabPreMountRouteKey(liveKey); + const action = makeResetWithReportsStack([makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), makeRoute(SCREENS.REPORT, {reportID: 'B'}, undefined, liveKey)], 1); + const goBack = CommonActions.goBack(); + + // When both go through the sanitizer + // Then neither changes, because only pre-mounts nobody owns anymore are dropped + expect(removeStalePreMountsFromResetAction(action)).toBe(action); + expect(removeStalePreMountsFromResetAction(goBack)).toBe(goBack); + }); + + it('keeps a revealed pre-mount, which is a regular screen after submit', () => { + // Given a pre-mount that a submit revealed, so browser forward can restore it as the visible report + const revealedKey = 'Report-wide-pre-mount-3'; + markWideTabPreMountRouteKeyRevealed(revealedKey); + const action = makeResetWithReportsStack([makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), makeRoute(SCREENS.REPORT, {reportID: 'B'}, undefined, revealedKey)], 1); + + // When the root router sanitizes the RESET + // Then the route survives, or the restored page would lose the report it shows + expect(removeStalePreMountsFromResetAction(action)).toBe(action); + }); +}); + describe('handleRemoveFullscreenUnderRHP — Share modal host', () => { it('restores the original tab route when the Share modal, not the RHP, is on top', () => { // Given a tab-switch pre-insert performed under the Share modal (#100767) diff --git a/tests/unit/Navigation/getActiveTabRouteTest.ts b/tests/unit/Navigation/getActiveTabRouteTest.ts new file mode 100644 index 000000000000..afa13d36df84 --- /dev/null +++ b/tests/unit/Navigation/getActiveTabRouteTest.ts @@ -0,0 +1,70 @@ +import getActiveTabRoute from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getActiveTabRoute'; +import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationState, PartialState} from '@react-navigation/native'; + +/** The TAB_NAVIGATOR state buildTabNavigatorNestedState builds on a cold start: not realized yet, with Inbox focused and its stack without an index. */ +function createColdStartTabState(innerRouteNames: string[]): PartialState { + return { + index: 1, + routes: [ + {name: SCREENS.HOME}, + { + name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, + state: {routes: innerRouteNames.map((name) => ({name}))}, + }, + ], + }; +} + +describe('getActiveTabRoute', () => { + it('reads the focused tab of a chat restored on a cold start, before the tab navigator state is realized', () => { + // Given the partial tab state a relaunch builds for an open chat, where Inbox is the focused tab and the chat is the last route of its stack + const tabState = createColdStartTabState([SCREENS.INBOX, SCREENS.REPORT]); + + // When the native tab bar reads the focused tab route on its first render + const activeTabRoute = getActiveTabRoute(tabState); + + // Then the Inbox tab is read from the partial state and it isn't at its root, so the bar stays hidden instead of showing over the chat until the state is realized + expect(activeTabRoute?.name).toBe(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR); + expect(isTabRouteAtRoot(activeTabRoute)).toBe(false); + }); + + it('reads the focused tab of the Inbox list restored on a cold start', () => { + // Given the partial tab state a relaunch builds for the Inbox list + const tabState = createColdStartTabState([SCREENS.INBOX]); + + // When the native tab bar reads the focused tab route + const activeTabRoute = getActiveTabRoute(tabState); + + // Then the Inbox tab is at its root, so the bar shows right away instead of appearing late + expect(activeTabRoute?.name).toBe(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR); + expect(isTabRouteAtRoot(activeTabRoute)).toBe(true); + }); + + it('falls back to the first tab when the partial state has no index', () => { + // Given a partial tab state without an index, which TabRouter rehydrates with the first tab focused + const tabState: PartialState = { + routes: [{name: SCREENS.HOME}, {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, state: {routes: [{name: SCREENS.INBOX}, {name: SCREENS.REPORT}]}}], + }; + + // When the native tab bar reads the focused tab route + const activeTabRoute = getActiveTabRoute(tabState); + + // Then it picks the first tab like TabRouter does, rather than the last route like a stack would + expect(activeTabRoute?.name).toBe(SCREENS.HOME); + }); + + it('returns undefined when there is no tab state yet', () => { + // Given a tab navigator that has no state yet + // When the native tab bar reads the focused tab route + const activeTabRoute = getActiveTabRoute(undefined); + + // Then there is no route, which counts as a tab root, so the bar shows on the default tab + expect(activeTabRoute).toBeUndefined(); + expect(isTabRouteAtRoot(activeTabRoute)).toBe(true); + }); +}); diff --git a/tests/unit/Navigation/getTabStateWithFocusedTargetTest.ts b/tests/unit/Navigation/getTabStateWithFocusedTargetTest.ts index 24cc6c336edc..67a70e8d2bb2 100644 --- a/tests/unit/Navigation/getTabStateWithFocusedTargetTest.ts +++ b/tests/unit/Navigation/getTabStateWithFocusedTargetTest.ts @@ -1,4 +1,4 @@ -import {getTabStateWithFocusedTarget, markFocusedTabRouteForRemount} from '@libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers'; +import {getTabStateWithFocusedTarget, getTabStateWithFreshTarget, markFocusedTabRouteForRemount} from '@libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers'; import TAB_SCREENS from '@libs/Navigation/AppNavigator/Navigators/TAB_SCREENS'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -126,6 +126,40 @@ describe('getTabStateWithFocusedTarget', () => { }); }); +describe('getTabStateWithFreshTarget', () => { + it('carries over the other tabs and the tab history from the existing state', () => { + // Given a tab state where Settings sits on a sub-page and was visited before Spend + const settingsState = {routes: [{name: SCREENS.SETTINGS.PREFERENCES.ROOT}], index: 0}; + const existingState = { + ...makeTabState([{name: SCREENS.HOME}, {name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, state: settingsState}, {name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}], 1), + history: [{type: 'route', key: `${NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR}-key-1`}], + }; + + // When the Spend tab is built fresh on top of it, e.g. for a target tab that is not mounted yet + const result = getTabStateWithFreshTarget(existingState, {name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}); + + // Then Settings keeps its key and sub-page, and the history still points at it, so the reveal does not reset the tab + const settingsRoute = result?.routes.find((r) => r.name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR); + expect(settingsRoute?.key).toBe(`${NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR}-key-1`); + expect(settingsRoute?.state).toBe(settingsState); + expect(result?.history).toEqual(existingState.history); + }); + + it('builds the target tab without reusing its existing key', () => { + // Given a tab state where the Spend tab is already mounted under a key + const existingState = makeTabState([{name: SCREENS.HOME}, {name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}], 1); + + // When the Spend tab is built fresh + const result = getTabStateWithFreshTarget(existingState, {name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}); + + // Then it has no key yet, so rehydration gives it a fresh one instead of reusing the old tab's key + const searchRoute = result?.routes.find((r) => r.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + expect(searchRoute).toBeDefined(); + expect(searchRoute?.key).toBeUndefined(); + expect(result?.stale).toBe(true); + }); +}); + describe('markFocusedTabRouteForRemount', () => { it('preserves existing tab history while forcing the focused route to remount', () => { const existingState = { diff --git a/tests/unit/Navigation/swapBackgroundTabForRHPTarget.test.ts b/tests/unit/Navigation/swapBackgroundTabForRHPTarget.test.ts index 29efcc74ceec..4fe2b20be48f 100644 --- a/tests/unit/Navigation/swapBackgroundTabForRHPTarget.test.ts +++ b/tests/unit/Navigation/swapBackgroundTabForRHPTarget.test.ts @@ -1,4 +1,4 @@ -import {getMatchingFullScreenRoute} from '@libs/Navigation/helpers/getAdaptedStateFromPath'; +import getMatchingFullScreenRoute from '@libs/Navigation/helpers/getMatchingFullScreenRoute'; import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; import swapBackgroundTabForRHPTarget from '@libs/Navigation/helpers/swapBackgroundTabForRHPTarget'; import navigationRef from '@libs/Navigation/navigationRef'; @@ -13,7 +13,7 @@ import type {NavigationState} from '@react-navigation/native'; import createMock from '../../utils/createMock'; -jest.mock('@libs/Navigation/helpers/getAdaptedStateFromPath', () => ({getMatchingFullScreenRoute: jest.fn()})); +jest.mock('@libs/Navigation/helpers/getMatchingFullScreenRoute', () => jest.fn()); jest.mock('@libs/Navigation/helpers/getStateFromPath', () => jest.fn()); jest.mock('@libs/Navigation/navigationRef', () => ({dispatch: jest.fn()})); diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index df46623cd012..bc0c2ee529d3 100644 --- a/tests/unit/NavigationFocusReturnTest.ts +++ b/tests/unit/NavigationFocusReturnTest.ts @@ -40,6 +40,7 @@ const {resetForTests: resetHadTabNavigation, setupHadTabNavigation} = require<{ }>('../../src/libs/hadTabNavigation.ts'); const { captureTriggerForRoute, + nominateTriggerFallback, restoreTriggerForRoute, handleStateChange, resetForTests, @@ -55,6 +56,7 @@ const { teardownNavigationFocusReturn, } = require<{ captureTriggerForRoute: (routeKey: string) => void; + nominateTriggerFallback: (element: HTMLElement | null) => void; restoreTriggerForRoute: (routeKey: string) => boolean; handleStateChange: (state: unknown) => void; resetForTests: () => void; @@ -3083,3 +3085,71 @@ describe('teardown / setup lifecycle', () => { } }); }); + +describe('nominateTriggerFallback', () => { + beforeEach(() => { + simulateTab(); + }); + + it('should restore the nominated element when the captured trigger has been removed', () => { + // Given a row the user came from, and a bulk action bar button that opened the route. The bar goes away with + // the selection the route cleared, so by the time the route pops its button is detached. + const row = appendButton(); + const barButton = appendButton(); + row.focus(); + row.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + nominateTriggerFallback(row); + barButton.focus(); + setLastInteractiveElementForTests(barButton); + + captureTriggerForRoute('route-a'); + barButton.remove(); + + // When the route pops + const rowSpy = jest.spyOn(row, 'focus'); + + // Then focus returns to the row rather than being lost for want of a candidate + expect(restoreTriggerForRoute('route-a')).toBe(true); + expect(rowSpy).toHaveBeenCalled(); + }); + + it('should stop offering the element once the nomination is withdrawn', () => { + // Given a row nominated as the fallback and then withdrawn, which is what the bulk action bar does as it + // unmounts, before a bar button opens the route + const row = appendButton(); + const barButton = appendButton(); + nominateTriggerFallback(row); + nominateTriggerFallback(null); + barButton.focus(); + setLastInteractiveElementForTests(barButton); + + captureTriggerForRoute('route-a'); + barButton.remove(); + + // When the route pops + // Then there is nothing left to restore to, as before the nomination existed + expect(restoreTriggerForRoute('route-a')).toBe(false); + }); + + it('should leave an active launcher as the fallback', () => { + // Given a popover launcher, which is the caller the existing fallback slot belongs to + const row = appendButton(); + const launcher = appendButton(); + const menuItem = appendButton(); + nominateTriggerFallback(row); + setActivePopoverLauncher(launcher); + menuItem.focus(); + menuItem.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + + captureTriggerForRoute('route-a'); + menuItem.remove(); + + // When the route pops + // Then the launcher is restored, and the nomination does not displace it + const launcherSpy = jest.spyOn(launcher, 'focus'); + const rowSpy = jest.spyOn(row, 'focus'); + expect(restoreTriggerForRoute('route-a')).toBe(true); + expect(launcherSpy).toHaveBeenCalled(); + expect(rowSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/NavigationRootInitialStateTest.tsx b/tests/unit/NavigationRootInitialStateTest.tsx new file mode 100644 index 000000000000..58136a74f434 --- /dev/null +++ b/tests/unit/NavigationRootInitialStateTest.tsx @@ -0,0 +1,93 @@ +import {render} from '@testing-library/react-native'; + +import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; +import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; +import NavigationRoot from '@libs/Navigation/NavigationRoot'; +import type {State} from '@libs/Navigation/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +// The `mock` prefix lets the hoisted jest.mock factory capture the initial state NavigationRoot computes. +let mockCapturedInitialState: State | undefined; + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + NavigationContainer: ({initialState}: {initialState?: State}) => { + mockCapturedInitialState = initialState; + return null; + }, + }; +}); +jest.mock('@libs/Navigation/AppNavigator', () => () => null); +jest.mock('@libs/NavigationFocusReturn', () => ({setupNavigationFocusReturn: jest.fn(), teardownNavigationFocusReturn: jest.fn()})); +jest.mock('@src/libs/telemetry/useStartSpansOnRender', () => jest.fn()); +jest.mock('@hooks/useThemePreference', () => () => 'dark'); +jest.mock('@hooks/useTheme', () => () => ({appBG: '#000000'})); +jest.mock('@hooks/useResponsiveLayout', () => () => ({shouldUseNarrowLayout: true})); + +const DISTANCE_EDIT_ROUTE = ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.SUBMIT, '1', '2'); + +describe('NavigationRoot initial state', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockCapturedInitialState = undefined; + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('restores the last visited path on a cold start for a public domain account', async () => { + // Given a signed-in user with a public domain email (e.g. gmail.com) who had the distance editor open + await Onyx.set(ONYXKEYS.ACCOUNT, {isFromPublicDomain: true}); + await waitForBatchedUpdates(); + + // When the native app cold starts without a deep link, so there is no initial URL + render( + {}} + />, + ); + + // Then the saved page is restored, because the email domain must not change which screen the user returns to + if (!mockCapturedInitialState) { + throw new Error('NavigationRoot did not restore an initial state'); + } + expect(getPathFromState(mockCapturedInitialState)).toBe(getPathFromState(getAdaptedStateFromPath(DISTANCE_EDIT_ROUTE as Route))); + }); + + it('does not restore the last visited path when the user is signed out', async () => { + // Given a public domain account whose session is no longer authenticated + await Onyx.set(ONYXKEYS.ACCOUNT, {isFromPublicDomain: true}); + await waitForBatchedUpdates(); + + // When the native app cold starts without a deep link + render( + {}} + />, + ); + + // Then React Navigation picks the initial state, so a signed-out user can't land on a protected page + expect(mockCapturedInitialState).toBeUndefined(); + }); +}); diff --git a/tests/unit/NumberFormatUtilsTest.ts b/tests/unit/NumberFormatUtilsTest.ts index bb115b91287b..e293e22a0f48 100644 --- a/tests/unit/NumberFormatUtilsTest.ts +++ b/tests/unit/NumberFormatUtilsTest.ts @@ -84,6 +84,48 @@ describe('NumberFormatUtils', () => { }); }); + describe('formatDistanceToParts', () => { + const joinParts = (parts: Intl.NumberFormatPart[]) => parts.map((part) => part.value).join(''); + + test('formats miles in English with the number first and a short unit part', () => { + // Given a distance in miles and the English locale + // When the distance is formatted into parts + const parts = NumberFormatUtils.formatDistanceToParts(CONST.LOCALES.EN, 1234.5, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + + // Then the locale grouping and two decimals are used, and the unit comes back as its own part so it can be styled separately + expect(joinParts(parts)).toBe('1,234.50 mi'); + expect(parts.find((part) => part.type === 'unit')?.value).toBe('mi'); + }); + + test('uses the locale decimal separator for kilometers', () => { + // Given a distance in kilometers and the Spanish locale, which uses a decimal comma + // When the distance is formatted into parts + const parts = NumberFormatUtils.formatDistanceToParts(CONST.LOCALES.ES, 12.5, CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + + // Then the number uses a comma and the unit is still a separate part + expect(joinParts(parts)).toBe('12,50 km'); + expect(parts.find((part) => part.type === 'unit')?.value).toBe('km'); + }); + + test('lets the locale decide spacing and unit spelling', () => { + // Given a locale that writes the unit without a space after the number + // When the distance is formatted into parts + const parts = NumberFormatUtils.formatDistanceToParts(CONST.LOCALES.ZH_HANS, 12.5, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + + // Then no space is inserted before the translated unit + expect(joinParts(parts)).toBe('12.50英里'); + expect(parts.find((part) => part.type === 'unit')?.value).toBe('英里'); + }); + + test('always shows two decimal places', () => { + // Given distances that would otherwise drop trailing zeros or show more than two decimals + // When they are formatted into parts + // Then they are rounded and padded to two decimals, matching how distances are stored + expect(joinParts(NumberFormatUtils.formatDistanceToParts(CONST.LOCALES.EN, 0, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES))).toBe('0.00 mi'); + expect(joinParts(NumberFormatUtils.formatDistanceToParts(CONST.LOCALES.EN, 3.14159, CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS))).toBe('3.14 km'); + }); + }); + describe('malformed-currency warn deduplication', () => { test('warns at most once per unique malformed currency across repeated format calls', () => { NumberFormatUtils.format(CONST.LOCALES.EN, 25, {style: 'currency', currency: 'XX'}); diff --git a/tests/unit/OfficeLocationUtilsTest.ts b/tests/unit/OfficeLocationUtilsTest.ts new file mode 100644 index 000000000000..026974a27776 --- /dev/null +++ b/tests/unit/OfficeLocationUtilsTest.ts @@ -0,0 +1,107 @@ +import {getAssignableOfficeLocations, getMemberOfficeLocation, hasOfficeLocations} from '@libs/OfficeLocationUtils'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import type {Policy} from '@src/types/onyx'; +import type {CompanyAddress, OfficeLocation} from '@src/types/onyx/Policy'; + +import createRandomPolicy from '../utils/collections/policies'; +import {translateLocal} from '../utils/TestHelper'; + +const companyAddress: CompanyAddress = {addressStreet: '1 Market St', city: 'San Francisco', state: 'CA', zipCode: '94105', country: 'US'}; +const headquarters: OfficeLocation = {name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}}; +const newJerseyOffice: OfficeLocation = {name: 'New Jersey Office', address: {addressStreet: '900 Asbury Ave', city: 'Ocean City', state: 'NJ', zipCode: '08226', country: 'US'}}; +const austinOffice: OfficeLocation = {name: 'Austin Office', address: {addressStreet: '500 Congress Ave', city: 'Austin', state: 'TX', zipCode: '78701', country: 'US'}}; + +const localeCompare = (a: string, b: string) => a.localeCompare(b); + +function buildPolicy(policy: Partial): Policy { + return {...createRandomPolicy(1), address: undefined, officeLocations: undefined, ...policy}; +} + +describe('OfficeLocationUtils', () => { + beforeAll(() => IntlStore.load(CONST.LOCALES.DEFAULT)); + + describe('hasOfficeLocations', () => { + it('counts the company address and the offices that are not being deleted', () => { + // Given workspaces with no office at all, with only an office pending deletion, with only a company address, and with an office + const withoutOffices = buildPolicy({}); + const withDeletedOffice = buildPolicy({officeLocations: {OFFICE1: {...headquarters, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}}}); + const withCompanyAddress = buildPolicy({address: companyAddress}); + const withOffice = buildPolicy({officeLocations: {OFFICE1: headquarters}}); + + // When checking whether members can be assigned to an office + // Then the company address counts as an office, the same as on the workspace overview + expect(hasOfficeLocations(withoutOffices)).toBe(false); + expect(hasOfficeLocations(withDeletedOffice)).toBe(false); + expect(hasOfficeLocations(withCompanyAddress)).toBe(true); + expect(hasOfficeLocations(withOffice)).toBe(true); + }); + }); + + describe('getAssignableOfficeLocations', () => { + it('lists the company address first, then the primary office and the rest by name', () => { + // Given a workspace with a company address and three offices, one of them primary and one being deleted + const policy = buildPolicy({ + address: companyAddress, + officeLocations: { + OFFICE1: newJerseyOffice, + OFFICE2: {...headquarters, isDefault: true}, + OFFICE3: austinOffice, + OFFICE4: {...austinOffice, name: 'Closed Office', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }, + }); + + // When the offices members can be assigned to are listed + const officeLocations = getAssignableOfficeLocations(policy, translateLocal, localeCompare); + + // Then the company address leads under its own officeID, and only the flagged office is primary + expect(officeLocations).toEqual([ + {officeID: CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, name: 'Company address', address: '1 Market St, San Francisco, CA 94105', isPrimary: false}, + {officeID: 'OFFICE2', name: 'Headquarters', address: '88 Kearny St, San Francisco, CA 94108', isPrimary: true}, + {officeID: 'OFFICE3', name: 'Austin Office', address: '500 Congress Ave, Austin, TX 78701', isPrimary: false}, + {officeID: 'OFFICE1', name: 'New Jersey Office', address: '900 Asbury Ave, Ocean City, NJ 08226', isPrimary: false}, + ]); + }); + + it('makes the company address primary while no office is', () => { + // Given a workspace with a company address and no primary office + const policy = buildPolicy({address: companyAddress, officeLocations: {OFFICE1: headquarters}}); + + // When the offices members can be assigned to are listed + const officeLocations = getAssignableOfficeLocations(policy, translateLocal, localeCompare); + + // Then the company address is the primary office + expect(officeLocations.map(({officeID, isPrimary}) => ({officeID, isPrimary}))).toEqual([ + {officeID: CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, isPrimary: true}, + {officeID: 'OFFICE1', isPrimary: false}, + ]); + }); + + it('leaves out the company address when the workspace has none', () => { + // Given a workspace without a company address + const policy = buildPolicy({officeLocations: {OFFICE1: {...headquarters, isDefault: true}}}); + + // When the offices members can be assigned to are listed + // Then only the offices are listed + expect(getAssignableOfficeLocations(policy, translateLocal, localeCompare).map(({officeID}) => officeID)).toEqual(['OFFICE1']); + }); + }); + + describe('getMemberOfficeLocation', () => { + const policy = buildPolicy({address: companyAddress, officeLocations: {OFFICE1: {...headquarters, isDefault: true}, OFFICE2: newJerseyOffice}}); + const officeLocations = getAssignableOfficeLocations(policy, translateLocal, localeCompare); + + it.each([ + ['the office assigned to the member', 'OFFICE2', 'OFFICE2'], + ['the company address when the member is assigned to it', CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID], + ['the primary office when the member has no office', undefined, 'OFFICE1'], + ['the primary office when the assigned office was removed', 'OFFICE9', 'OFFICE1'], + ])('returns %s', (_description, assignedOfficeID, expectedOfficeID) => { + // Given a member with the assigned officeID + // When their office is resolved + // Then it is the expected office + expect(getMemberOfficeLocation(officeLocations, assignedOfficeID)?.officeID).toBe(expectedOfficeID); + }); + }); +}); diff --git a/tests/unit/OnboardingFlowTest.ts b/tests/unit/OnboardingFlowTest.ts index 68988e7f0425..8e71267e8d38 100644 --- a/tests/unit/OnboardingFlowTest.ts +++ b/tests/unit/OnboardingFlowTest.ts @@ -6,6 +6,7 @@ import navigationRef from '@libs/Navigation/navigationRef'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import NAVIGATORS from '@src/NAVIGATORS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type * as NativeNavigation from '@react-navigation/native'; @@ -21,6 +22,8 @@ describe('OnboardingFlow', () => { beforeAll(() => IntlStore.load(CONST.LOCALES.EN)); describe('getOnboardingInitialPath', () => { + const savedPrivateDomainPath = `/${ROUTES.ONBOARDING_PRIVATE_DOMAIN.getRoute(ROUTES.ONBOARDING_WORK_EMAIL.getRoute())}`; + it('should return the onboarding fallback path when the last visited path is null', () => { const params: GetOnboardingInitialPathParamsType = { isUserFromPublicDomain: false, @@ -190,8 +193,7 @@ describe('OnboardingFlow', () => { }); it('should not redirect away from a private-domain URL for a public-domain unvalidated user', () => { - // Mirrors the BaseOnboardingPrivateDomain screen-level guard: an unvalidated public-domain user who just - // submitted a work email may land here while isFromPublicDomain is stale. They must keep the private-domain step. + // Given an unvalidated user submitted a work email while isFromPublicDomain is still stale const params: GetOnboardingInitialPathParamsType = { isUserFromPublicDomain: true, hasAccessiblePolicies: true, @@ -209,8 +211,73 @@ describe('OnboardingFlow', () => { onboardingValues: undefined, isAccountValidated: false, }; + + // When onboarding resumes after a refresh const path = getOnboardingInitialPath(params); - expect(path).not.toBe('/onboarding/purpose'); + + // Then it preserves the private-domain validation step + expect(path).toBe('/onboarding/private-domain'); + }); + + it.each( + [savedPrivateDomainPath, `/${ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()}`, `/${ROUTES.ONBOARDING_PURPOSE.getRoute()}`].flatMap((savedPath) => [ + {savedPath, shouldValidate: false, isAccountValidated: false, expectedPath: savedPath}, + {savedPath, shouldValidate: undefined, isAccountValidated: false, expectedPath: '/onboarding/work-email'}, + {savedPath, shouldValidate: true, isAccountValidated: false, expectedPath: '/onboarding/work-email'}, + {savedPath, shouldValidate: false, isAccountValidated: true, expectedPath: savedPath === savedPrivateDomainPath ? '/onboarding/purpose' : savedPath}, + ]), + )( + 'resumes $savedPath at $expectedPath after resetting merge flags with shouldValidate=$shouldValidate and validated=$isAccountValidated', + ({savedPath, shouldValidate, isAccountValidated, expectedPath}) => { + // Given a previously skipped work email was resubmitted, clearing its merge flags, while the domain flag is stale + const onboardingValues = { + hasCompletedGuidedSetupFlow: false, + signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.INDIVIDUAL, + isMergeAccountStepCompleted: undefined, + isMergeAccountStepSkipped: undefined, + shouldValidate, + }; + const params: GetOnboardingInitialPathParamsType = { + isUserFromPublicDomain: true, + isAccountValidated, + hasAccessiblePolicies: false, + onboardingValuesParam: onboardingValues, + onboardingValues, + currentOnboardingPurposeSelected: CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE, + currentOnboardingCompanySize: undefined, + onboardingInitialPath: savedPath, + }; + + // When refreshing restores the saved onboarding route + const path = getOnboardingInitialPath(params); + + // Then a successful direct add preserves validation and downstream progress, while pending retries still require work email + expect(path).toBe(expectedPath); + }, + ); + + it.each(['/', '/r/123'])('does not bypass work email for a non-onboarding saved path %s after a direct add', (savedPath) => { + // Given a direct-add result with a stale public-domain flag, but no saved onboarding progress + const params: GetOnboardingInitialPathParamsType = { + isUserFromPublicDomain: true, + isAccountValidated: false, + hasAccessiblePolicies: false, + onboardingValuesParam: { + hasCompletedGuidedSetupFlow: false, + signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.INDIVIDUAL, + shouldValidate: false, + }, + onboardingValues: undefined, + currentOnboardingPurposeSelected: CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE, + currentOnboardingCompanySize: undefined, + onboardingInitialPath: savedPath, + }; + + // When choosing the initial onboarding step + const path = getOnboardingInitialPath(params); + + // Then unrelated routes do not count as saved onboarding progress + expect(path).toBe('/onboarding/work-email'); }); it('should not redirect away from a work-email-validation URL for a public-domain user', () => { diff --git a/tests/unit/OnyxDerived/visibleReportActionsTest.ts b/tests/unit/OnyxDerived/visibleReportActionsTest.ts index 2c4ddc916dc1..0539db5c40c4 100644 --- a/tests/unit/OnyxDerived/visibleReportActionsTest.ts +++ b/tests/unit/OnyxDerived/visibleReportActionsTest.ts @@ -1,8 +1,9 @@ import visibleReportActionsConfig from '@libs/actions/OnyxDerived/configs/visibleReportActions'; +import CONST from '@src/CONST'; import type {OnyxKey} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportActions} from '@src/types/onyx'; +import type {Report, ReportActions, ReportNameValuePairs} from '@src/types/onyx'; import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; import type {OnyxCollection} from 'react-native-onyx'; @@ -12,7 +13,7 @@ describe('visibleReportActions', () => { const reportActionsKeyA = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}A`; // Only report A has report actions now; report B exists only in the previous derived value (currentValue). const allReportActions: OnyxCollection = {[reportActionsKeyA]: {}}; - const args: Parameters[0] = [allReportActions, undefined, {} as OnyxCollection]; + const args: Parameters[0] = [allReportActions, undefined, {} as OnyxCollection, {} as OnyxCollection]; const currentValue: VisibleReportActionsDerivedValue = {A: {}, B: {}}; const sourceValues: Parameters[1]['sourceValues'] = {[ONYXKEYS.COLLECTION.REPORT_ACTIONS]: {[reportActionsKeyA]: {}}}; @@ -31,4 +32,52 @@ describe('visibleReportActions', () => { expect(result.A).toBeDefined(); }); }); + + it('hides a support survey when the ticket has been replaced', () => { + // Given a support survey that was visible before the replacement ticket arrived + const reportID = 'supportTicketReportID'; + const surveyActionID = 'surveyActionID'; + const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`; + const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportID}`; + const reportNameValuePairsKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`; + const unrelatedReportID = 'unrelatedReportID'; + const unrelatedActionID = 'unrelatedActionID'; + const unrelatedReportKey = `${ONYXKEYS.COLLECTION.REPORT}${unrelatedReportID}`; + const unrelatedReportNameValuePairsKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${unrelatedReportID}`; + const allReportActions: OnyxCollection = { + [reportActionsKey]: { + [surveyActionID]: { + reportActionID: surveyActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY, + created: '2026-10-13 12:00:00.000', + }, + }, + }; + const args: Parameters[0] = [ + allReportActions, + undefined, + {[reportKey]: {reportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, [unrelatedReportKey]: {reportID: unrelatedReportID, type: CONST.REPORT.TYPE.CHAT}}, + {[reportNameValuePairsKey]: {reopenedAsReportID: 'replacementReportID'}}, + ]; + const currentValue: VisibleReportActionsDerivedValue = { + [reportID]: {[surveyActionID]: true}, + [unrelatedReportID]: {[unrelatedActionID]: true}, + }; + + // When the replacement and an unrelated report NVP trigger a recompute + const result = visibleReportActionsConfig.compute(args, { + currentValue, + sourceValues: { + [ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]: { + [reportNameValuePairsKey]: {reopenedAsReportID: 'replacementReportID'}, + [unrelatedReportNameValuePairsKey]: {agentZeroProcessingRequestIndicator: 'processing'}, + }, + }, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]), + }); + + // Then the shared visibility state hides the stale survey without recomputing unrelated reports + expect(result[reportID]?.[surveyActionID]).toBe(false); + expect(result[unrelatedReportID]).toBe(currentValue[unrelatedReportID]); + }); }); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 16b597e4c619..6c2d99adba7e 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -819,6 +819,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -828,7 +829,7 @@ describe('OptionsListUtils', () => { MOCK_REPORT_ATTRIBUTES_DERIVED_WITH_CONCIERGE, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true}, + {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true, formatPhoneNumber}, undefined, ); OPTIONS_WITH_CHRONOS = createFilteredOptionList( @@ -837,7 +838,7 @@ describe('OptionsListUtils', () => { MOCK_REPORT_ATTRIBUTES_DERIVED_WITH_CHRONOS, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true}, + {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true, formatPhoneNumber}, undefined, ); OPTIONS_WITH_RECEIPTS = createFilteredOptionList( @@ -846,7 +847,7 @@ describe('OptionsListUtils', () => { MOCK_REPORT_ATTRIBUTES_DERIVED_WITH_RECEIPTS, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true}, + {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true, formatPhoneNumber}, undefined, ); OPTIONS_WITH_WORKSPACE_ROOM = createFilteredOptionList( @@ -855,7 +856,7 @@ describe('OptionsListUtils', () => { MOCK_REPORT_ATTRIBUTES_DERIVED_WITH_WORKSPACE_ROOM, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true}, + {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true, formatPhoneNumber}, undefined, ); }); @@ -901,6 +902,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // Then all personal details (including those that have reports) should be returned @@ -939,7 +941,7 @@ describe('OptionsListUtils', () => { createMockReportAttributesDerived(memberWorkspaceChat, PERSONAL_DETAILS, CURRENT_USER_ACCOUNT_ID), EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true}, + {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true, formatPhoneNumber}, undefined, ); @@ -961,6 +963,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // Then the member's workspace chat should still be included in the search results @@ -993,6 +996,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // Then the current user should be included in personalDetails @@ -1030,6 +1034,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // Then the current user should not be included in personalDetails @@ -1065,6 +1070,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // Then recent reports should include the workspace room @@ -1100,6 +1106,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // Then it should still return personal details @@ -1134,6 +1141,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // Then it should still return personal details @@ -1164,6 +1172,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS_WITH_CONCIERGE, conciergeReportID, sortedActions: undefined, + formatPhoneNumber, }); // Then the Concierge report should be included in recent reports @@ -1193,6 +1202,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS_WITH_CONCIERGE, conciergeReportID: undefined, sortedActions: undefined, + formatPhoneNumber, }); // Then the function should complete without errors @@ -1225,6 +1235,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS_WITH_CONCIERGE, conciergeReportID, sortedActions: undefined, + formatPhoneNumber, }); // Then the Concierge report should be in recent reports @@ -1254,7 +1265,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); let results: Pick = validOptions; @@ -1301,7 +1312,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); let results: Pick = validOptions; @@ -1344,7 +1355,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -1376,7 +1387,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -1427,6 +1438,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -1447,7 +1459,7 @@ describe('OptionsListUtils', () => { personalDetails, searchString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -1468,6 +1480,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -1496,7 +1509,7 @@ describe('OptionsListUtils', () => { excludeLogins: {[CONST.EMAIL.CONCIERGE]: true}, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -1526,7 +1539,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -1558,7 +1571,7 @@ describe('OptionsListUtils', () => { excludeLogins: {[CONST.EMAIL.CHRONOS]: true}, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -1589,7 +1602,7 @@ describe('OptionsListUtils', () => { excludeLogins: {[CONST.EMAIL.RECEIPTS]: true}, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -1649,7 +1662,7 @@ describe('OptionsListUtils', () => { includeMultipleParticipantReports: true, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); const adminRoomOption = results.recentReports.find((report) => report.reportID === '1455140530846319'); @@ -1710,7 +1723,7 @@ describe('OptionsListUtils', () => { showRBR: true, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); expect(results.recentReports.at(0)?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); @@ -1768,7 +1781,7 @@ describe('OptionsListUtils', () => { showRBR: false, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); expect(results.recentReports.at(0)?.brickRoadIndicator).toBe(null); @@ -1831,7 +1844,7 @@ describe('OptionsListUtils', () => { includeMultipleParticipantReports: true, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -1870,7 +1883,7 @@ describe('OptionsListUtils', () => { personalDetails: customPersonalDetails, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -1901,7 +1914,7 @@ describe('OptionsListUtils', () => { includeSelectedOptions: true, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -1930,7 +1943,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, maxElements: 1, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -1952,7 +1965,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, maxElements: 100, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -1973,6 +1986,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -1994,7 +2008,7 @@ describe('OptionsListUtils', () => { includeSelfDM: true, searchString: 'me', }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -2028,6 +2042,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -2057,8 +2072,30 @@ describe('OptionsListUtils', () => { maxElements: 3, personalDetails: PERSONAL_DETAILS, }; - const {options: eagerResults} = getValidOptions(eagerList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal, undefined); - const {options: lazyResults} = getValidOptions(lazyList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal, undefined); + const {options: eagerResults} = getValidOptions( + eagerList, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + config, + {translate: translateLocal, formatPhoneNumber}, + undefined, + ); + const {options: lazyResults} = getValidOptions( + lazyList, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + config, + {translate: translateLocal, formatPhoneNumber}, + undefined, + ); // Then the surviving contacts are hydrated (createOption ran for them) and match the eager results exactly expect(lazyResults.personalDetails.length).toBeGreaterThan(0); @@ -2079,8 +2116,30 @@ describe('OptionsListUtils', () => { searchString: 'spider', personalDetails: PERSONAL_DETAILS, }; - const {options: eagerResults} = getValidOptions(eagerList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal, undefined); - const {options: lazyResults} = getValidOptions(lazyList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal, undefined); + const {options: eagerResults} = getValidOptions( + eagerList, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + config, + {translate: translateLocal, formatPhoneNumber}, + undefined, + ); + const {options: lazyResults} = getValidOptions( + lazyList, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + config, + {translate: translateLocal, formatPhoneNumber}, + undefined, + ); // Then filtering and ordering are unchanged and the hydrated results match expect(lazyResults.personalDetails).toEqual(eagerResults.personalDetails); @@ -2152,7 +2211,7 @@ describe('OptionsListUtils', () => { searchString, includeP2P: true, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); const filteredOptions = filterAndOrderOptions({ @@ -2169,6 +2228,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -2240,8 +2300,30 @@ describe('OptionsListUtils', () => { excludeLogins: {'peterparker@expensify.com': true}, personalDetails: PERSONAL_DETAILS, }; - const {options: eagerResults} = getValidOptions(eagerList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal, undefined); - const {options: lazyResults} = getValidOptions(lazyList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal, undefined); + const {options: eagerResults} = getValidOptions( + eagerList, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + config, + {translate: translateLocal, formatPhoneNumber}, + undefined, + ); + const {options: lazyResults} = getValidOptions( + lazyList, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + config, + {translate: translateLocal, formatPhoneNumber}, + undefined, + ); // Then the excluded contact is dropped from both and the remaining results match expect(lazyResults.personalDetails.some((option) => option.login === 'peterparker@expensify.com')).toBe(false); @@ -2262,6 +2344,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -2280,7 +2363,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -2308,7 +2391,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, personalDetails: PERSONAL_DETAILS, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -2340,8 +2423,30 @@ describe('OptionsListUtils', () => { maxElements: 3, personalDetails: PERSONAL_DETAILS, }; - const {options: eagerResults} = getValidOptions(eagerList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal, undefined); - const {options: lazyResults} = getValidOptions(lazyList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal, undefined); + const {options: eagerResults} = getValidOptions( + eagerList, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + config, + {translate: translateLocal, formatPhoneNumber}, + undefined, + ); + const {options: lazyResults} = getValidOptions( + lazyList, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + config, + {translate: translateLocal, formatPhoneNumber}, + undefined, + ); // Then filtering, ranking, and hydration match the eager path expect(lazyResults.personalDetails.length).toBeGreaterThan(0); @@ -2364,6 +2469,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -2378,6 +2484,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -2415,7 +2522,7 @@ describe('OptionsListUtils', () => { CURRENT_USER_EMAIL, undefined, config, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); const {options: cachedResults} = getValidOptions( @@ -2427,10 +2534,21 @@ describe('OptionsListUtils', () => { CURRENT_USER_EMAIL, undefined, config, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, + undefined, + ); + const {options: eagerResults} = getValidOptions( + eagerList, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + config, + {translate: translateLocal, formatPhoneNumber}, undefined, ); - const {options: eagerResults} = getValidOptions(eagerList, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal, undefined); // Then hydration from the cached clone matches the fresh lazy build and the eager path expect(cachedResults.personalDetails).toEqual(firstResults.personalDetails); @@ -2453,8 +2571,24 @@ describe('OptionsListUtils', () => { clearFilteredOptionListCache(); // When the warm-up builds the list and the first open builds it again from the same Onyx snapshots - const warmed = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, MOCK_REPORT_ATTRIBUTES_DERIVED, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, options, undefined); - const opened = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, MOCK_REPORT_ATTRIBUTES_DERIVED, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, options, undefined); + const warmed = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + MOCK_REPORT_ATTRIBUTES_DERIVED, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + {...options, formatPhoneNumber}, + undefined, + ); + const opened = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + MOCK_REPORT_ATTRIBUTES_DERIVED, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + {...options, formatPhoneNumber}, + undefined, + ); // Then the open gets a clone of the warm build instead of rebuilding it, contacts stay deferred, // and typing is a separate entry that still builds them @@ -2462,8 +2596,15 @@ describe('OptionsListUtils', () => { expect(opened.reports.at(0)?.icons).toBe(warmed.reports.at(0)?.icons); expect(warmed.personalDetails).toHaveLength(0); expect( - createFilteredOptionList(PERSONAL_DETAILS, REPORTS, MOCK_REPORT_ATTRIBUTES_DERIVED, EMPTY_PRIVATE_IS_ARCHIVED_MAP, allPolicies, {...options, isSearching: true}, undefined) - .personalDetails.length, + createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + MOCK_REPORT_ATTRIBUTES_DERIVED, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + allPolicies, + {...options, isSearching: true, formatPhoneNumber}, + undefined, + ).personalDetails.length, ).toBeGreaterThan(0); }); @@ -2491,6 +2632,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -2597,7 +2739,7 @@ describe('OptionsListUtils', () => { CURRENT_USER_EMAIL, undefined, config, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); const {options: lazyResults} = getValidOptions( @@ -2609,7 +2751,7 @@ describe('OptionsListUtils', () => { CURRENT_USER_EMAIL, undefined, config, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -2681,6 +2823,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -2709,6 +2852,7 @@ describe('OptionsListUtils', () => { ...createOption({ dateFnsLocale: undefined, convertToDisplayString, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, accountIDs: [PARITY_ACCOUNT_ID], personalDetails: PARITY_PERSONAL_DETAILS, report: PARITY_REPORT, @@ -2720,6 +2864,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: PARITY_ATTRIBUTES, policyTags: undefined, visibleReportActionsData: {}, + formatPhoneNumber, }), isHydrated: true, }; @@ -2764,6 +2909,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: PARITY_REPORT_ID, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -2779,6 +2925,7 @@ describe('OptionsListUtils', () => { const withConcierge = createOption({ dateFnsLocale: undefined, convertToDisplayString, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, accountIDs: [PARITY_ACCOUNT_ID], personalDetails: PARITY_PERSONAL_DETAILS, report: PARITY_REPORT, @@ -2790,6 +2937,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: PARITY_ATTRIBUTES, policyTags: undefined, visibleReportActionsData: {}, + formatPhoneNumber, }); expect(hydrated).toEqual({item: PARITY_PERSONAL_DETAIL, ...withConcierge, isHydrated: true}); @@ -2816,6 +2964,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -2830,6 +2979,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -2844,8 +2994,30 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, }; const firstBuilds = first.personalDetails.map(buildIdentity); - const {options: firstResults} = getValidOptions(first, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal, undefined); - const {options: secondResults} = getValidOptions(second, allPolicies, {}, loginList, CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, config, translateLocal, undefined); + const {options: firstResults} = getValidOptions( + first, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + config, + {translate: translateLocal, formatPhoneNumber}, + undefined, + ); + const {options: secondResults} = getValidOptions( + second, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + config, + {translate: translateLocal, formatPhoneNumber}, + undefined, + ); const secondBuilds = second.personalDetails.map(buildIdentity); // Then every contact on the second pass reused the first pass's build. @@ -2876,6 +3048,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -2924,6 +3097,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -2969,7 +3143,7 @@ describe('OptionsListUtils', () => { includeOwnedWorkspaceChats: true, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3018,7 +3192,7 @@ describe('OptionsListUtils', () => { personalDetails: customPersonalDetails, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3051,7 +3225,7 @@ describe('OptionsListUtils', () => { personalDetails: undefined, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3083,7 +3257,7 @@ describe('OptionsListUtils', () => { personalDetails: {}, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3115,7 +3289,7 @@ describe('OptionsListUtils', () => { personalDetails: {}, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3142,7 +3316,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); const reportLogins = new Set(results.recentReports.map((reportOption) => reportOption.login)); @@ -3172,7 +3346,7 @@ describe('OptionsListUtils', () => { excludeLogins: {'peterparker@expensify.com': true}, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3200,7 +3374,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3231,7 +3405,7 @@ describe('OptionsListUtils', () => { excludeLogins: {[CONST.EMAIL.CONCIERGE]: true}, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3263,7 +3437,7 @@ describe('OptionsListUtils', () => { excludeLogins: {[CONST.EMAIL.CHRONOS]: true}, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3295,7 +3469,7 @@ describe('OptionsListUtils', () => { excludeLogins: {[CONST.EMAIL.RECEIPTS]: true}, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3325,7 +3499,7 @@ describe('OptionsListUtils', () => { maxRecentReportElements: maxRecentReports, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3349,7 +3523,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); const {options: resultsWithLimit} = getValidOptions( @@ -3367,7 +3541,7 @@ describe('OptionsListUtils', () => { maxRecentReportElements: 2, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3391,7 +3565,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); const {options: resultsWithLimit} = getValidOptions( @@ -3409,7 +3583,7 @@ describe('OptionsListUtils', () => { maxRecentReportElements: 2, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3438,7 +3612,7 @@ describe('OptionsListUtils', () => { maxRecentReportElements: maxRecentReports, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3493,7 +3667,7 @@ describe('OptionsListUtils', () => { includeUserToInvite: false, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3543,7 +3717,7 @@ describe('OptionsListUtils', () => { includeUserToInvite: false, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -3593,6 +3767,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we pass the returned options to filterAndOrderOptions with an empty search value const filteredOptions = filterAndOrderOptions({ @@ -3609,6 +3784,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -3638,6 +3814,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we pass the returned options to filterAndOrderOptions with a search value and sortByReportTypeInSearch param const filteredOptions = filterAndOrderOptions({ @@ -3655,6 +3832,7 @@ describe('OptionsListUtils', () => { sortByReportTypeInSearch: true, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -3691,6 +3869,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we pass the returned options to filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions({ @@ -3707,6 +3886,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -3735,6 +3915,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -3756,6 +3937,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we pass the returned options to filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions({ @@ -3772,6 +3954,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -3798,6 +3981,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -3818,6 +4002,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS_WITH_PERIODS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we pass the returned options to filterAndOrderOptions with a search value and sortByReportTypeInSearch param const filteredOptions = filterAndOrderOptions({ @@ -3835,6 +4020,7 @@ describe('OptionsListUtils', () => { sortByReportTypeInSearch: true, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -3865,6 +4051,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we pass the returned options to filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions({ @@ -3881,6 +4068,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -3911,6 +4099,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we pass the returned options to filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions({ @@ -3927,6 +4116,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -3947,7 +4137,7 @@ describe('OptionsListUtils', () => { MOCK_REPORT_ATTRIBUTES_DERIVED_WITH_CHAT_ROOM, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true}, + {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true, formatPhoneNumber}, undefined, ); // When we call getSearchOptions with the default rooms beta enabled @@ -3967,6 +4157,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we pass the returned options to filterAndOrderOptions with a search value const filterOptions = filterAndOrderOptions({ @@ -3983,6 +4174,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -4013,6 +4205,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we call filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions({ @@ -4029,6 +4222,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -4061,6 +4255,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we call filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions({ @@ -4077,6 +4272,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -4103,7 +4299,7 @@ describe('OptionsListUtils', () => { excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we call filterAndOrderOptions with a search value and excluded logins list @@ -4122,6 +4318,7 @@ describe('OptionsListUtils', () => { excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -4150,6 +4347,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we call filterAndOrderOptions with a search value and excludeLogins const filteredOptions = filterAndOrderOptions({ @@ -4167,6 +4365,7 @@ describe('OptionsListUtils', () => { excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -4195,6 +4394,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we call filterAndOrderOptions with a search value and maxRecentReportsToShow set to 2 const filteredOptions = filterAndOrderOptions({ @@ -4212,6 +4412,7 @@ describe('OptionsListUtils', () => { maxRecentReportsToShow: 2, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -4236,6 +4437,7 @@ describe('OptionsListUtils', () => { maxRecentReportsToShow: 0, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -4264,6 +4466,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we call filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions({ @@ -4280,6 +4483,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -4326,7 +4530,7 @@ describe('OptionsListUtils', () => { includeUserToInvite: false, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we pass the returned options to filterAndOrderOptions with a search value that does not match the group chat name @@ -4344,6 +4548,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -4393,7 +4598,7 @@ describe('OptionsListUtils', () => { includeUserToInvite: false, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we pass the returned options to filterAndOrderOptions with a search value that matches the group chat name @@ -4411,6 +4616,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -4460,7 +4666,7 @@ describe('OptionsListUtils', () => { includeUserToInvite: false, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we pass the returned options to filterAndOrderOptions with a search value that does not match the group chat name @@ -4478,6 +4684,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -4501,7 +4708,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we call filterAndOrderOptions with a search value that matches a personal detail with no existing report @@ -4519,6 +4726,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -4568,6 +4776,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -4590,6 +4799,7 @@ describe('OptionsListUtils', () => { policyCollection: allPolicies, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // Then one report should be returned @@ -4635,6 +4845,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -4657,6 +4868,7 @@ describe('OptionsListUtils', () => { policyCollection: allPolicies, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // Then one report should be returned @@ -4702,6 +4914,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -4724,6 +4937,7 @@ describe('OptionsListUtils', () => { policyCollection: allPolicies, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // Then one report should be returned @@ -4769,6 +4983,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -4791,6 +5006,7 @@ describe('OptionsListUtils', () => { policyCollection: allPolicies, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // Then no reports should be returned @@ -4825,7 +5041,7 @@ describe('OptionsListUtils', () => { undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true}, + {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true, formatPhoneNumber}, undefined, ); @@ -4846,6 +5062,7 @@ describe('OptionsListUtils', () => { policyCollection: allPolicies, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we pass the returned options to filterAndOrderOptions with any search value @@ -4863,6 +5080,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -4887,7 +5105,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details or reports @@ -4905,6 +5123,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -4931,7 +5150,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details or reports @@ -4949,6 +5168,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -4975,7 +5195,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details or reports but matches user to invite @@ -4993,6 +5213,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5018,7 +5239,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we call filterAndOrderOptions with a search value without accent mark @@ -5036,6 +5257,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5059,7 +5281,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details or reports but matches user to invite @@ -5077,6 +5299,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5105,7 +5328,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details or reports but matches user to invite @@ -5123,6 +5346,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5151,7 +5375,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details or reports but matches user to invite @@ -5169,6 +5393,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5197,7 +5422,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details or reports @@ -5215,6 +5440,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5243,7 +5469,7 @@ describe('OptionsListUtils', () => { includeUserToInvite: true, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -5263,6 +5489,7 @@ describe('OptionsListUtils', () => { shouldAcceptName: false, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5288,7 +5515,7 @@ describe('OptionsListUtils', () => { includeUserToInvite: true, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -5308,6 +5535,7 @@ describe('OptionsListUtils', () => { shouldAcceptName: true, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5331,7 +5559,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we call filterAndOrderOptions with a search value that does not match any personal details @@ -5349,6 +5577,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5372,7 +5601,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we call filterAndOrderOptions with a search value that matches an email @@ -5391,6 +5620,7 @@ describe('OptionsListUtils', () => { sortByReportTypeInSearch: true, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5418,7 +5648,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); // When we call filterAndOrderOptions with a search value that matches both reports and personal details and maxRecentReportsToShow param @@ -5437,6 +5667,7 @@ describe('OptionsListUtils', () => { maxRecentReportsToShow: 5, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5470,6 +5701,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we call filterAndOrderOptions with a search value that matches a personal detail const filteredOptions = filterAndOrderOptions({ @@ -5486,6 +5718,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5514,6 +5747,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we call filterAndOrderOptions with a search value that matches multiple items const filteredOptions = filterAndOrderOptions({ @@ -5530,6 +5764,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5557,6 +5792,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -5576,6 +5812,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS_WITH_PERIODS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we pass the returned options to filterAndOrderOptions with a search value const filteredResults = filterAndOrderOptions({ @@ -5588,6 +5825,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS_WITH_PERIODS, config: {dateFnsLocale: undefined, convertToDisplayString, sortByReportTypeInSearch: true, currentUserAccountID: CURRENT_USER_ACCOUNT_ID}, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5623,6 +5861,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we call filterAndOrderOptions with a an empty search value const filteredOptions = filterAndOrderOptions({ @@ -5639,6 +5878,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5664,6 +5904,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -5685,6 +5926,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we call filterAndOrderOptions with a search value const filteredOptions = filterAndOrderOptions({ @@ -5701,6 +5943,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5723,6 +5966,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -5742,6 +5986,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // When we call filterAndOrderOptions with the localized word the current user uses to refer to themselves @@ -5759,6 +6004,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5785,6 +6031,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); const multiSpaceQueryResults = filterAndOrderOptions({ @@ -5801,6 +6048,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5818,6 +6066,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -5884,6 +6133,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ).reports; @@ -5908,6 +6158,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ).reports; @@ -6006,6 +6257,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ).reports; @@ -6112,6 +6364,7 @@ describe('OptionsListUtils', () => { currentUserAccountID: CURRENT_USER_ACCOUNT_ID, rules: undefined, ...overrides, + formatPhoneNumber, }); const seedActions = async (reportID: string, ...actions: ReportAction[]) => { @@ -6245,6 +6498,52 @@ describe('OptionsListUtils', () => { expect(result).toBe('Spider-Man: hello'); }); + /** + * Writes an expense report whose last visible action reimburses `ownerAccountID`, and returns the + * alternate text getAlternateText builds for `currentUserAccountID`. Each case needs its own + * reportID because the module-level report-action caches survive Onyx.clear(). + */ + const getReimbursedAlternateText = async (reportID: string, ownerAccountID: number, currentUserAccountID: number) => { + const report: Report = { + reportID, + reportName: 'Expense Report', + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID, + // Empty so getAlternateText falls through to getLastMessageTextForReport instead of reusing lastMessageText. + lastMessageText: '', + lastActionType: CONST.REPORT.ACTIONS.TYPE.REIMBURSED, + lastVisibleActionCreated: '2024-01-01 10:00:00.000', + }; + const reimbursed = buildAction(CONST.REPORT.ACTIONS.TYPE.REIMBURSED, 3, { + paymentMethod: 'Fast_ACH', + creditBankAccountLast4: '1111', + expectedDate: '2025-03-15', + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reimbursed.reportActionID]: reimbursed}); + await waitForBatchedUpdates(); + + const option: OptionData = {reportID, keyForList: '', isMoneyRequestReport: true}; + return getAlternateText(option, {showChatPreviewLine: true}, buildConfig({currentUserAccountID})); + }; + + it('should address the current user directly when they own the reimbursed report', async () => { + // Given a reimbursement paid to account 42, previewed by account 42 + const alternateText = await getReimbursedAlternateText('alternate-text-reimbursed-own', 42, 42); + + // Then the preview names their own account rather than spelling out whose it is + expect(alternateText).toContain('your bank account ending in 1111'); + }); + + it('should name the report owner in the reimbursed preview when someone else owns the report', async () => { + // Given the same reimbursement paid to account 3, previewed by account 999 + const alternateText = await getReimbursedAlternateText('alternate-text-reimbursed-other', 3, 999); + + // Then the preview names the owner instead of addressing the viewer + expect(alternateText).toContain("peterparker@expensify.com's bank account ending in 1111"); + expect(alternateText).not.toContain('your bank account'); + }); + it('should omit the actor prefix when the last action is a report preview', async () => { const reportID = '9355'; await setReport( @@ -6515,6 +6814,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -6535,7 +6835,7 @@ describe('OptionsListUtils', () => { includeMultipleParticipantReports: true, personalDetails: PERSONAL_DETAILS, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -6769,6 +7069,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -6797,6 +7098,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -6827,6 +7129,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -6861,6 +7164,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -6893,6 +7197,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -6927,6 +7232,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -6968,6 +7274,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, maxRecentReports: 20, + formatPhoneNumber, }, undefined, ); @@ -7833,6 +8140,7 @@ describe('OptionsListUtils', () => { const result = createOption({ dateFnsLocale: undefined, convertToDisplayString, + currentUserAccountID: 1, conciergeReportID: undefined, accountIDs: [1, 2], personalDetails: PERSONAL_DETAILS, @@ -7840,6 +8148,7 @@ describe('OptionsListUtils', () => { privateIsArchived: undefined, rules: undefined, config: {showChatPreviewLine: true}, + formatPhoneNumber, }); expect(result.alternateText).toBe('Iron Man owes ₫34'); @@ -7882,6 +8191,7 @@ describe('OptionsListUtils', () => { report, privateIsArchived: undefined, rules: undefined, + formatPhoneNumber, }); expect(result.reportID).toBe(reportID); @@ -7908,12 +8218,14 @@ describe('OptionsListUtils', () => { const result = createOption({ dateFnsLocale: undefined, convertToDisplayString, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, conciergeReportID: undefined, accountIDs: [1, 2], personalDetails: PERSONAL_DETAILS, report, privateIsArchived: undefined, rules: undefined, + formatPhoneNumber, }); expect(result.reportID).toBe(report.reportID); @@ -8076,6 +8388,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, rules: undefined, + formatPhoneNumber, pendingDeleteMemberAccountIDs: undefined, }); @@ -8109,6 +8422,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, rules: undefined, + formatPhoneNumber, pendingDeleteMemberAccountIDs: undefined, }); @@ -8144,6 +8458,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, rules: undefined, + formatPhoneNumber, pendingDeleteMemberAccountIDs: undefined, }); @@ -8178,6 +8493,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, rules: undefined, + formatPhoneNumber, pendingDeleteMemberAccountIDs: undefined, }); @@ -8220,6 +8536,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, rules: undefined, + formatPhoneNumber, pendingDeleteMemberAccountIDs: undefined, }); @@ -8250,6 +8567,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, rules: undefined, + formatPhoneNumber, pendingDeleteMemberAccountIDs: undefined, }); @@ -8275,6 +8593,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, rules: undefined, + formatPhoneNumber, pendingDeleteMemberAccountIDs: undefined, }); @@ -8315,7 +8634,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -8339,7 +8658,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -8363,7 +8682,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -8406,7 +8725,7 @@ describe('OptionsListUtils', () => { includeRecentReports: true, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -8462,7 +8781,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -8505,7 +8824,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: ownerAccountID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -8571,7 +8890,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -8606,7 +8925,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: report, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -8659,7 +8978,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -8706,7 +9025,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -8760,7 +9079,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -8815,7 +9134,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: {}, reportDraft: draftReport, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -8846,7 +9165,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -8874,7 +9193,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: draftReport, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -8906,7 +9225,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -8943,6 +9262,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, rules: undefined, + formatPhoneNumber, pendingDeleteMemberAccountIDs: undefined, }); @@ -8979,6 +9299,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, rules: undefined, + formatPhoneNumber, pendingDeleteMemberAccountIDs: undefined, }); @@ -9012,6 +9333,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, rules: undefined, + formatPhoneNumber, pendingDeleteMemberAccountIDs: undefined, }); @@ -9049,6 +9371,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, rules: undefined, + formatPhoneNumber, pendingDeleteMemberAccountIDs: undefined, }); @@ -9086,6 +9409,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, rules: undefined, + formatPhoneNumber, pendingDeleteMemberAccountIDs: undefined, }); @@ -9119,7 +9443,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -9152,7 +9476,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -9186,7 +9510,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -9234,7 +9558,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -9308,7 +9632,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -9345,7 +9669,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -9379,7 +9703,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -9441,7 +9765,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -9475,7 +9799,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -9506,7 +9830,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -9555,7 +9879,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -9587,7 +9911,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -9600,7 +9924,7 @@ describe('OptionsListUtils', () => { reportAttributesDerived: undefined, reportDraft: undefined, currentUserAccountID: CONST.DEFAULT_NUMBER_ID, - localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + localize: {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, rules: undefined, pendingDeleteMemberAccountIDs: undefined, }); @@ -9666,7 +9990,7 @@ describe('OptionsListUtils', () => { testPersonalDetails, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, CURRENT_USER_ACCOUNT_ID, undefined, ); @@ -9702,7 +10026,7 @@ describe('OptionsListUtils', () => { {}, report, undefined, - {translate: translateWithMarker, dateFnsLocale: undefined, convertToDisplayString}, + {translate: translateWithMarker, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, CURRENT_USER_ACCOUNT_ID, undefined, ); @@ -9773,7 +10097,7 @@ describe('OptionsListUtils', () => { testPersonalDetails, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, CURRENT_USER_ACCOUNT_ID, undefined, ); @@ -9827,7 +10151,7 @@ describe('OptionsListUtils', () => { {}, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, CURRENT_USER_ACCOUNT_ID, undefined, ); @@ -9881,7 +10205,7 @@ describe('OptionsListUtils', () => { undefined, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, CURRENT_USER_ACCOUNT_ID, undefined, ); @@ -9943,7 +10267,7 @@ describe('OptionsListUtils', () => { {}, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, CURRENT_USER_ACCOUNT_ID, undefined, ); @@ -9955,7 +10279,7 @@ describe('OptionsListUtils', () => { {}, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, CURRENT_USER_ACCOUNT_ID, undefined, ); @@ -10002,7 +10326,7 @@ describe('OptionsListUtils', () => { testPersonalDetails, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, CURRENT_USER_ACCOUNT_ID, undefined, ); @@ -10012,7 +10336,7 @@ describe('OptionsListUtils', () => { testPersonalDetails, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, 9999, undefined, ); @@ -10078,7 +10402,7 @@ describe('OptionsListUtils', () => { testPersonalDetails, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, CURRENT_USER_ACCOUNT_ID, undefined, ); @@ -10132,7 +10456,7 @@ describe('OptionsListUtils', () => { {}, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, CURRENT_USER_ACCOUNT_ID, undefined, ); @@ -10242,6 +10566,7 @@ describe('OptionsListUtils', () => { translateLocal, convertToDisplayString, undefined, + formatPhoneNumber, getFormatReportByID, undefined, formatPersonalDetails, @@ -10280,6 +10605,7 @@ describe('OptionsListUtils', () => { translateLocal, convertToDisplayString, undefined, + formatPhoneNumber, getReportByIDFromOnyx, undefined, formatPersonalDetails, @@ -10333,6 +10659,7 @@ describe('OptionsListUtils', () => { translateLocal, convertToDisplayString, undefined, + formatPhoneNumber, getFormatReportByID, undefined, formatPersonalDetails, @@ -10379,6 +10706,7 @@ describe('OptionsListUtils', () => { translateLocal, convertToDisplayString, undefined, + formatPhoneNumber, getReportByIDFromOnyx, undefined, formatPersonalDetails, @@ -10422,6 +10750,7 @@ describe('OptionsListUtils', () => { translateLocal, convertToDisplayString, undefined, + formatPhoneNumber, getFormatReportByID, undefined, formatPersonalDetails, @@ -10465,6 +10794,7 @@ describe('OptionsListUtils', () => { translateLocal, convertToDisplayString, undefined, + formatPhoneNumber, getReportByIDFromOnyx, undefined, formatPersonalDetails, @@ -10492,6 +10822,7 @@ describe('OptionsListUtils', () => { translateLocal, convertToDisplayString, undefined, + formatPhoneNumber, getReportByIDFromOnyx, undefined, formatPersonalDetails, @@ -10546,6 +10877,7 @@ describe('OptionsListUtils', () => { translateLocal, convertToDisplayString, undefined, + formatPhoneNumber, getReportByID, undefined, formatPersonalDetails, @@ -10570,6 +10902,7 @@ describe('OptionsListUtils', () => { loginList: {}, currentUserEmail: CURRENT_USER_EMAIL, rules: undefined, + formatPhoneNumber, }); expect(result).toBeNull(); }); @@ -10585,6 +10918,7 @@ describe('OptionsListUtils', () => { loginList: {}, currentUserEmail: CURRENT_USER_EMAIL, rules: undefined, + formatPhoneNumber, }); expect(result).not.toBeNull(); expect(result?.login).toBe('Jeff Amazon'); @@ -10600,6 +10934,7 @@ describe('OptionsListUtils', () => { loginList: {}, currentUserEmail: CURRENT_USER_EMAIL, rules: undefined, + formatPhoneNumber, }); expect(result).not.toBeNull(); @@ -10617,6 +10952,7 @@ describe('OptionsListUtils', () => { loginList: {}, currentUserEmail: CURRENT_USER_EMAIL, rules: undefined, + formatPhoneNumber, }); expect(result).toBeNull(); @@ -10632,6 +10968,7 @@ describe('OptionsListUtils', () => { loginList: {}, currentUserEmail: CURRENT_USER_EMAIL, rules: undefined, + formatPhoneNumber, }); expect(result).toBeNull(); @@ -10649,7 +10986,7 @@ describe('OptionsListUtils', () => { CURRENT_USER_EMAIL, undefined, {dateFnsLocale: undefined, convertToDisplayString, getReportByID: getReportByIDFromOnyx}, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -10667,6 +11004,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -10686,7 +11024,7 @@ describe('OptionsListUtils', () => { CURRENT_USER_EMAIL, undefined, {dateFnsLocale: undefined, convertToDisplayString, getReportByID: getReportByIDFromOnyx}, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -10704,6 +11042,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -10722,7 +11061,7 @@ describe('OptionsListUtils', () => { CURRENT_USER_EMAIL, undefined, {dateFnsLocale: undefined, convertToDisplayString, getReportByID: getReportByIDFromOnyx}, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -10740,6 +11079,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -10846,7 +11186,7 @@ describe('OptionsListUtils', () => { isOffline: false, getReportByID, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ).options; @@ -10894,7 +11234,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -10919,7 +11259,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -10938,6 +11278,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, }, translate: translateLocal, + formatPhoneNumber, rules: undefined, activePolicyID, }); @@ -10965,6 +11306,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); // Then the function should complete without errors and return valid results @@ -10990,6 +11332,7 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, sortedActions, conciergeReportID: undefined, + formatPhoneNumber, }); expect(options).toBeDefined(); @@ -11008,6 +11351,7 @@ describe('OptionsListUtils', () => { currentUserEmail: CURRENT_USER_EMAIL, personalDetails: PERSONAL_DETAILS, rules: undefined, + formatPhoneNumber, }); // Then the function should return a user to invite @@ -11030,7 +11374,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -11055,7 +11399,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -11100,12 +11444,14 @@ describe('OptionsListUtils', () => { const result = createOption({ dateFnsLocale: undefined, convertToDisplayString, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, conciergeReportID: undefined, accountIDs: [1, 2], personalDetails: PERSONAL_DETAILS, report: expenseReport, privateIsArchived: undefined, rules: undefined, + formatPhoneNumber, }); // Then the option should be created successfully @@ -11150,6 +11496,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, rules: undefined, + formatPhoneNumber, pendingDeleteMemberAccountIDs: undefined, }); @@ -11197,7 +11544,7 @@ describe('OptionsListUtils', () => { PERSONAL_DETAILS, report, undefined, - {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, CURRENT_USER_ACCOUNT_ID, undefined, ); @@ -11237,6 +11584,7 @@ describe('OptionsListUtils', () => { sortedActions: undefined, conciergeReportID: report.reportID, pendingDeleteMemberAccountIDs: undefined, + formatPhoneNumber, }); expect(conciergeOption.subtitle).toBe(translateLocal('reportActionsView.conciergeSupport')); @@ -11253,6 +11601,7 @@ describe('OptionsListUtils', () => { sortedActions: undefined, conciergeReportID: 'a-different-report-id', pendingDeleteMemberAccountIDs: undefined, + formatPhoneNumber, }); expect(regularOption.subtitle).not.toBe(translateLocal('reportActionsView.conciergeSupport')); }); @@ -11286,6 +11635,7 @@ describe('OptionsListUtils', () => { sortedActions, conciergeReportID: undefined, pendingDeleteMemberAccountIDs: undefined, + formatPhoneNumber, }); expect(result).toBeDefined(); @@ -11322,6 +11672,7 @@ describe('OptionsListUtils', () => { sortedActions, conciergeReportID: undefined, pendingDeleteMemberAccountIDs: undefined, + formatPhoneNumber, }); expect(result).toBeDefined(); @@ -11357,6 +11708,7 @@ describe('OptionsListUtils', () => { sortedActions, conciergeReportID: undefined, pendingDeleteMemberAccountIDs: undefined, + formatPhoneNumber, }); expect(result).toBeDefined(); @@ -11392,6 +11744,7 @@ describe('OptionsListUtils', () => { sortedActions, conciergeReportID: undefined, pendingDeleteMemberAccountIDs: undefined, + formatPhoneNumber, }); expect(result).toBeDefined(); @@ -11429,6 +11782,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, config, pendingDeleteMemberAccountIDs: undefined, + formatPhoneNumber, }); expect(result).toBeDefined(); @@ -11469,6 +11823,7 @@ describe('OptionsListUtils', () => { sortedActions, conciergeReportID: undefined, pendingDeleteMemberAccountIDs: undefined, + formatPhoneNumber, }); const personalDetailsOption = createOptionFromReport({ dateFnsLocale: undefined, @@ -11483,6 +11838,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, config: {showPersonalDetails: true}, pendingDeleteMemberAccountIDs: undefined, + formatPhoneNumber, }); expect(roomOption.text).toBe('#admins'); @@ -11547,8 +11903,8 @@ describe('OptionsListUtils', () => { }); // When the option is built for the report owner, and for somebody else - const ownOption = createOptionFromReport({...params, currentUserAccountID: ownerAccountID}); - const otherOption = createOptionFromReport({...params, currentUserAccountID: 999}); + const ownOption = createOptionFromReport({...params, currentUserAccountID: ownerAccountID, formatPhoneNumber}); + const otherOption = createOptionFromReport({...params, currentUserAccountID: 999, formatPhoneNumber}); // Then the wording follows the passed currentUserAccountID expect(ownOption.alternateText).toContain('your bank account ending in 1111'); @@ -11565,8 +11921,8 @@ describe('OptionsListUtils', () => { }); // When the option is built for the report owner, and for somebody else - const ownOption = createOptionFromReport({...params, currentUserAccountID: ownerAccountID}); - const otherOption = createOptionFromReport({...params, currentUserAccountID: 999}); + const ownOption = createOptionFromReport({...params, currentUserAccountID: ownerAccountID, formatPhoneNumber}); + const otherOption = createOptionFromReport({...params, currentUserAccountID: 999, formatPhoneNumber}); // Then the wording follows the passed currentUserAccountID expect(ownOption.alternateText).toContain('your bank account ending in 2222'); @@ -11582,7 +11938,7 @@ describe('OptionsListUtils', () => { }); // When the option is built for a different account than the one in the session - const option = createOptionFromReport({...params, currentUserAccountID: 999}); + const option = createOptionFromReport({...params, currentUserAccountID: 999, formatPhoneNumber}); // Then the passed account wins over the module-level session value, so the owner is named explicitly expect(option.alternateText).toContain(`${SUBMITTER_LOGIN}'s bank account ending in 3333`); @@ -11598,8 +11954,8 @@ describe('OptionsListUtils', () => { }); // When the option is built for the report owner, and for somebody else - const ownOption = createOptionFromReport({...params, currentUserAccountID: 42}); - const otherOption = createOptionFromReport({...params, currentUserAccountID: 999}); + const ownOption = createOptionFromReport({...params, currentUserAccountID: 42, formatPhoneNumber}); + const otherOption = createOptionFromReport({...params, currentUserAccountID: 999, formatPhoneNumber}); // Then both previews are identical expect(ownOption.alternateText).toBe(otherOption.alternateText); @@ -11643,6 +11999,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, maxRecentReports: 2, includeP2P: false, + formatPhoneNumber, }, undefined, ); @@ -11666,13 +12023,139 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, maxRecentReports: 1, includeP2P: false, + formatPhoneNumber, + }, + undefined, + ); + + expect(result.reports.map((option) => option.item?.reportID)).toEqual(['102']); + }); + + it('prioritizes the default workspace chat over newer reports', () => { + // Given a recent chat and an older expense chat that the current user owns in their default workspace + const defaultWorkspaceChat: Report = { + ...createChatReport('102', '2020-01-01 00:00:00', CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), + policyID: 'defaultPolicy', + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + }; + const reports = [createChatReport('101', '2024-01-01 00:00:00'), defaultWorkspaceChat]; + + // When the option list is capped to one report and the default workspace is passed + const result = createFilteredOptionList( + {}, + reportsToCollection(reports), + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + maxRecentReports: 1, + includeP2P: false, + formatPhoneNumber, + activePolicyID: 'defaultPolicy', }, undefined, ); + // Then the default workspace chat is kept, so a quiet default workspace still reaches the participant picker expect(result.reports.map((option) => option.item?.reportID)).toEqual(['102']); }); + it('does not prioritize an expense chat in the default workspace owned by another member', () => { + // Given a recent chat and an older expense chat in the default workspace that another member owns + const otherMemberWorkspaceChat: Report = { + ...createChatReport('102', '2020-01-01 00:00:00', CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), + policyID: 'defaultPolicy', + ownerAccountID: 1, + }; + const reports = [createChatReport('101', '2024-01-01 00:00:00'), otherMemberWorkspaceChat]; + + // When the option list is capped to one report and the default workspace is passed + const result = createFilteredOptionList( + {}, + reportsToCollection(reports), + undefined, + {}, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + maxRecentReports: 1, + includeP2P: false, + formatPhoneNumber, + activePolicyID: 'defaultPolicy', + }, + undefined, + ); + + // Then only the user's own default workspace chat is boosted, so recency decides here + expect(result.reports.map((option) => option.item?.reportID)).toEqual(['101']); + }); + + it('returns the default workspace chat first in the workspace chats of getValidOptions', () => { + // Given three workspace chats the current user owns, where the default workspace's chat is the least recent + const policies: Record = {}; + const reports = ['1', '2', '3'].map((index) => { + policies[`${ONYXKEYS.COLLECTION.POLICY}policy${index}`] = {...POLICY, id: `policy${index}`, name: `Workspace ${index}`}; + return { + ...createChatReport(`20${index}`, `202${index}-01-01 00:00:00`, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), + policyID: `policy${index}`, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + isOwnPolicyExpenseChat: true, + }; + }); + const optionList = createFilteredOptionList( + {}, + reportsToCollection(reports), + undefined, + {}, + policies, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + includeP2P: false, + formatPhoneNumber, + }, + undefined, + ); + + // When the workspace chats are separated and capped to two results with policy1 as the default workspace + const getWorkspaceChatIDs = (defaultPolicyID: string | undefined) => + getValidOptions( + optionList, + policies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + { + getReportByID: getReportByIDFromOnyx, + dateFnsLocale: undefined, + convertToDisplayString, + includeOwnedWorkspaceChats: true, + shouldSeparateWorkspaceChat: true, + includeP2P: false, + maxElements: 2, + activePolicyID: defaultPolicyID, + }, + {translate: translateLocal, formatPhoneNumber}, + undefined, + ).options.workspaceChats?.map((option) => option.reportID); + + // Then without a default workspace the two most recent chats are returned + expect(getWorkspaceChatIDs(undefined)).toEqual(['203', '202']); + // And with a default workspace its chat is kept and listed first, ahead of more recent ones + expect(getWorkspaceChatIDs('policy1')).toEqual(['201', '203']); + }); + it('prioritizes non-archived reports over newer archived reports', () => { const reports = [createChatReport('101', '2022-01-01 00:00:00'), createChatReport('102', '2024-01-01 00:00:00')]; const privateIsArchivedMap: PrivateIsArchivedMap = { @@ -11692,6 +12175,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, maxRecentReports: 1, includeP2P: false, + formatPhoneNumber, }, undefined, ); @@ -11716,6 +12200,7 @@ describe('OptionsListUtils', () => { maxRecentReports: 1, isSearching: true, includeP2P: false, + formatPhoneNumber, }, undefined, ); @@ -11739,6 +12224,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, maxRecentReports: 0, includeP2P: false, + formatPhoneNumber, }, undefined, ); @@ -11758,6 +12244,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, maxRecentReports: 5, + formatPhoneNumber, }, undefined, ); @@ -11825,6 +12312,7 @@ describe('OptionsListUtils', () => { maxRecentReports: 3, includeP2P: false, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -11845,6 +12333,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, includeP2P: true, + formatPhoneNumber, }, undefined, ); @@ -11867,6 +12356,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, includeP2P: false, + formatPhoneNumber, }, undefined, ); @@ -11887,6 +12377,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -11907,6 +12398,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -11951,6 +12443,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, maxRecentReports: 10, + formatPhoneNumber, }, undefined, ); @@ -11971,6 +12464,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -11986,7 +12480,15 @@ describe('OptionsListUtils', () => { undefined, {}, {}, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true, maxRecentReports: 2}, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + maxRecentReports: 2, + formatPhoneNumber, + }, undefined, ); @@ -12006,6 +12508,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -12030,6 +12533,7 @@ describe('OptionsListUtils', () => { convertToDisplayString, conciergeReportID: undefined, deferContactsUntilSearch: true, + formatPhoneNumber, }, undefined, ); @@ -12052,6 +12556,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, deferContactsUntilSearch: true, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -12071,6 +12576,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -12091,6 +12597,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -12118,6 +12625,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -12132,6 +12640,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: '1', + formatPhoneNumber, }, undefined, ); @@ -12157,6 +12666,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -12181,6 +12691,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -12226,7 +12737,7 @@ describe('OptionsListUtils', () => { maxRecentReportElements: 5, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -12254,7 +12765,7 @@ describe('OptionsListUtils', () => { searchString: 'john', sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -12276,6 +12787,7 @@ describe('OptionsListUtils', () => { dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, + formatPhoneNumber, }, undefined, ); @@ -12314,6 +12826,7 @@ describe('OptionsListUtils', () => { sortedActions, conciergeReportID: undefined, pendingDeleteMemberAccountIDs: undefined, + formatPhoneNumber, }); expect(result).toBeDefined(); expect(result.policyID).toBe(policyID); @@ -12347,6 +12860,7 @@ describe('OptionsListUtils', () => { translate: translateLocal, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, rules: undefined, + formatPhoneNumber, pendingDeleteMemberAccountIDs: undefined, }); expect(result).toBeDefined(); @@ -12383,7 +12897,7 @@ describe('OptionsListUtils', () => { PERSONAL_DETAILS, report, POLICY, - {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString}, + {translate: translateLocal, dateFnsLocale: undefined, convertToDisplayString, formatPhoneNumber}, CURRENT_USER_ACCOUNT_ID, undefined, ); @@ -12403,6 +12917,7 @@ describe('OptionsListUtils', () => { translateLocal, convertToDisplayString, undefined, + formatPhoneNumber, getReportByIDFromOnyx, undefined, PERSONAL_DETAILS, @@ -12490,7 +13005,7 @@ describe('OptionsListUtils', () => { action: CONST.IOU.ACTION.CREATE, sortedActions, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -12557,7 +13072,7 @@ describe('OptionsListUtils', () => { action: CONST.IOU.ACTION.CREATE, sortedActions, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -12621,7 +13136,7 @@ describe('OptionsListUtils', () => { action: CONST.IOU.ACTION.CREATE, sortedActions: undefined, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -12712,7 +13227,7 @@ describe('OptionsListUtils', () => { action: CONST.IOU.ACTION.CREATE, sortedActions, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -12794,7 +13309,7 @@ describe('OptionsListUtils', () => { includeRecentReports: true, sortedActions, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -12867,7 +13382,7 @@ describe('OptionsListUtils', () => { action: CONST.IOU.ACTION.CREATE, sortedActions, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -12993,7 +13508,7 @@ describe('OptionsListUtils', () => { action: CONST.IOU.ACTION.CREATE, sortedActions, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -13062,7 +13577,7 @@ describe('OptionsListUtils', () => { action: CONST.IOU.ACTION.CREATE, sortedActions: {[reportID]: [commentAction]}, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); @@ -13126,6 +13641,7 @@ describe('OptionsListUtils', () => { currentUserEmail: CURRENT_USER_EMAIL, sortedActions: undefined, conciergeReportID: undefined, + formatPhoneNumber, }); expect(results.recentReports.length).toBe(1); @@ -13198,6 +13714,7 @@ describe('OptionsListUtils', () => { shouldUnreadBeBold: true, sortedActions, conciergeReportID: undefined, + formatPhoneNumber, }); expect(results.recentReports.length).toBe(1); @@ -13239,6 +13756,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, isSearching, pendingDeleteMemberAccountIDsByReportID, + formatPhoneNumber, }, undefined, ); @@ -13293,6 +13811,7 @@ describe('OptionsListUtils', () => { conciergeReportID: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, pendingDeleteMemberAccountIDs: ['4'], + formatPhoneNumber, }); // Then the avatar label names only the members that are staying diff --git a/tests/unit/PaymentHistoryUtilsTest.ts b/tests/unit/PaymentHistoryUtilsTest.ts index 99eeb759e3b9..e47a90da3777 100644 --- a/tests/unit/PaymentHistoryUtilsTest.ts +++ b/tests/unit/PaymentHistoryUtilsTest.ts @@ -1,7 +1,16 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; -import {formatDate, getBadgeText, getPaymentDetailsBadge, getPaymentHistoryRow, getPaymentHistoryRows} from '@libs/PaymentHistoryUtils'; +import { + formatDate, + getBadgeText, + getPaymentDetailsBadge, + getPaymentHistoryRow, + getPaymentHistoryRows, + getReceiptFileName, + getReceiptSource, + isInvoiceBilling, +} from '@libs/PaymentHistoryUtils'; import CONST from '@src/CONST'; import type {Purchase} from '@src/types/onyx/PurchaseList'; @@ -278,3 +287,146 @@ describe('getBadgeText', () => { ); }); }); + +describe('getReceiptSource', () => { + it('returns the source when the purchase has a receipt', () => { + // Given a purchase with a resolved receipt + const purchase = createPurchase(1, '2026-02-01'); + purchase.receipt = {receiptID: 100, source: 'https://expensify.com/receipts/receipt.pdf'}; + + // When the receipt source is resolved + const source = getReceiptSource(toPurchases([purchase]), 1); + + // Then the source URL is returned + expect(source).toBe('https://expensify.com/receipts/receipt.pdf'); + }); + + it('returns null when the purchase has no receipt', () => { + // Given a purchase without a receipt + const purchase = createPurchase(1, '2026-02-01'); + + // When the receipt source is resolved + const source = getReceiptSource(toPurchases([purchase]), 1); + + // Then null is returned + expect(source).toBeNull(); + }); + + it('returns null when the receipt source is null', () => { + // Given a purchase whose receipt has no source + const purchase = createPurchase(1, '2026-02-01'); + purchase.receipt = {receiptID: 100, source: null}; + + // When the receipt source is resolved + const source = getReceiptSource(toPurchases([purchase]), 1); + + // Then null is returned + expect(source).toBeNull(); + }); + + it('follows failedPurchaseID chain for cleared bills', () => { + // Given a failed purchase with a receipt, cleared by a later purchase + const failed = createPurchase(10, '2026-02-01', {billingType: CONST.BILLING.TYPE_FAILED}); + failed.receipt = {receiptID: 200, source: 'https://expensify.com/receipts/failed-receipt.pdf'}; + const clear = createPurchase(20, '2026-03-01', {billingType: CONST.BILLING.TYPE_CLEAR, failedPurchaseID: 10}); + + // When the clear purchase's receipt source is resolved + const source = getReceiptSource(toPurchases([failed, clear]), 20); + + // Then the failed purchase's receipt source is returned + expect(source).toBe('https://expensify.com/receipts/failed-receipt.pdf'); + }); + + it('follows a multi-hop failedPurchaseID chain', () => { + // Given a chain of failed purchases where the original has the receipt + const original = createPurchase(10, '2026-01-01', {billingType: CONST.BILLING.TYPE_FAILED}); + original.receipt = {receiptID: 300, source: 'https://expensify.com/receipts/original.pdf'}; + const retry = createPurchase(20, '2026-02-01', {billingType: CONST.BILLING.TYPE_FAILED, failedPurchaseID: 10}); + const clear = createPurchase(30, '2026-03-01', {billingType: CONST.BILLING.TYPE_CLEAR, failedPurchaseID: 20}); + + // When the clear purchase's receipt source is resolved + const source = getReceiptSource(toPurchases([original, retry, clear]), 30); + + // Then the original purchase's receipt source is found through the chain + expect(source).toBe('https://expensify.com/receipts/original.pdf'); + }); + + it('returns null when the chain leads to a purchase without a receipt', () => { + // Given a clear purchase pointing to a failed purchase with no receipt + const failed = createPurchase(10, '2026-02-01', {billingType: CONST.BILLING.TYPE_FAILED}); + const clear = createPurchase(20, '2026-03-01', {billingType: CONST.BILLING.TYPE_CLEAR, failedPurchaseID: 10}); + + // When the clear purchase's receipt source is resolved + const source = getReceiptSource(toPurchases([failed, clear]), 20); + + // Then null is returned + expect(source).toBeNull(); + }); + + it('returns null for an unknown purchaseID', () => { + // Given a purchases map that does not contain the requested ID + const purchases = toPurchases([createPurchase(1, '2026-02-01')]); + + // When resolving a receipt source for a non-existent purchase + const source = getReceiptSource(purchases, 999); + + // Then null is returned + expect(source).toBeNull(); + }); +}); + +describe('isInvoiceBilling', () => { + it('returns true for an invoice bill with a receipt', () => { + // Given an invoice purchase with a receipt source + const purchase = createPurchase(1, '2026-02-01', {billingType: CONST.BILLING.TYPE_INVOICING}); + purchase.receipt = {receiptID: 100, source: 'https://example.com/invoice.pdf'}; + + // When checking if the purchase is invoice billing + // Then it returns true + expect(isInvoiceBilling(toPurchases([purchase]), 1)).toBe(true); + }); + + it('returns true for a cleared invoice by following the failedPurchaseID chain', () => { + // Given a cleared invoice bill whose original failed invoice holds the receipt + const failedInvoice = createPurchase(10, '2026-02-01', {billingType: CONST.BILLING.TYPE_INVOICING}); + failedInvoice.receipt = {receiptID: 200, source: 'https://example.com/invoice.pdf'}; + const clear = createPurchase(20, '2026-02-05', {billingType: CONST.BILLING.TYPE_CLEAR, failedPurchaseID: 10}); + + // When checking if the clear purchase is invoice billing + // Then it follows the chain to the failed invoice and returns true + expect(isInvoiceBilling(toPurchases([failedInvoice, clear]), 20)).toBe(true); + }); + + it('returns false for non-invoicing billing type', () => { + // Given a non-invoicing purchase with a receipt + const purchase = createPurchase(1, '2026-02-01', {billingType: CONST.BILLING.TYPE_CLEAR}); + purchase.receipt = {receiptID: 100, source: 'https://example.com/receipt.pdf'}; + + // When checking if the purchase is invoice billing + // Then it returns false + expect(isInvoiceBilling(toPurchases([purchase]), 1)).toBe(false); + }); +}); + +describe('getReceiptFileName', () => { + it('extracts filename from valid URL', () => { + // Given a receipt URL with a filename + // When extracting the filename + // Then it returns the last path segment + expect(getReceiptFileName('https://expensify.com/receipts/w_abc123.pdf', 42)).toBe('w_abc123.pdf'); + }); + + it('falls back to receipt-purchaseID for invalid URL', () => { + // Given an invalid URL string + // When extracting the filename + // Then it falls back to receipt-{purchaseID} + expect(getReceiptFileName('not-a-url', 42)).toBe('receipt-42'); + }); + + it('falls back to receipt-purchaseID for empty path', () => { + // Given a URL with no path segments + // When extracting the filename + // Then it falls back to receipt-{purchaseID} + expect(getReceiptFileName('https://expensify.com/', 7)).toBe('receipt-7'); + }); +}); diff --git a/tests/unit/PhoneNumberTest.ts b/tests/unit/PhoneNumberTest.ts index ef4fa599492f..195ea486b143 100644 --- a/tests/unit/PhoneNumberTest.ts +++ b/tests/unit/PhoneNumberTest.ts @@ -1,4 +1,4 @@ -import {parsePhoneNumber} from '@libs/PhoneNumber'; +import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber'; describe('PhoneNumber', () => { describe('parsePhoneNumber', () => { @@ -40,4 +40,50 @@ describe('PhoneNumber', () => { } }); }); + + describe('addSMSDomainIfPhoneNumber', () => { + it('returns an email containing a literal % without throwing', () => { + // Given a contact method route param the router already decoded, for an email whose `%al` is not a valid escape sequence + const contactMethodParam = 'test%alias@gmail.com'; + + // When the contact method pages read it + const contactMethod = addSMSDomainIfPhoneNumber(contactMethodParam); + + // Then the email is kept as-is, because decoding it a second time would throw a URIError and crash the page + expect(contactMethod).toBe('test%alias@gmail.com'); + }); + + it('does not decode valid escape sequences', () => { + // Given an already-decoded email that happens to contain a valid escape sequence + const contactMethodParam = 'a%41b@x.com'; + + // When the contact method pages read it + const contactMethod = addSMSDomainIfPhoneNumber(contactMethodParam); + + // Then the email is kept as-is, because turning `%41` into `A` would make the login lookup miss + expect(contactMethod).toBe('a%41b@x.com'); + }); + + it('returns a regular email unchanged', () => { + // Given a regular email + const contactMethodParam = 'test@gmail.com'; + + // When the contact method pages read it + const contactMethod = addSMSDomainIfPhoneNumber(contactMethodParam); + + // Then the email matches the login key stored in the login list + expect(contactMethod).toBe('test@gmail.com'); + }); + + it('adds the SMS domain to a phone number', () => { + // Given a phone number, which is stored in the login list with the SMS domain + const contactMethodParam = '+15005550006'; + + // When the contact method pages read it + const contactMethod = addSMSDomainIfPhoneNumber(contactMethodParam); + + // Then the SMS domain is added so the login lookup finds the phone number + expect(contactMethod).toBe('+15005550006@expensify.sms'); + }); + }); }); diff --git a/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx b/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx index f1b9acae0807..08fcf313e23b 100644 --- a/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx +++ b/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx @@ -1,8 +1,8 @@ -import {fireEvent, render, screen, within} from '@testing-library/react-native'; +import {act, fireEvent, render, screen, within} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import type {MenuItemProps} from '@components/MenuItem'; +import type * as MenuItemModule from '@components/MenuItem'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import Navigation from '@libs/Navigation/Navigation'; @@ -11,6 +11,7 @@ import PolicyAccountingPage from '@pages/workspace/accounting/PolicyAccountingPa import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; @@ -18,6 +19,7 @@ import type * as ReactNavigation from '@react-navigation/native'; import type {View} from 'react-native'; import React from 'react'; +import Onyx from 'react-native-onyx'; import createRandomPolicy from '../utils/collections/policies'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -117,9 +119,10 @@ jest.mock('@components/HeaderWithBackButton', () => ({__esModule: true, default: jest.mock('@components/MenuItemList', () => ({__esModule: true, default: () => null})); jest.mock('@components/MenuItem', () => { const {View: MockView} = jest.requireActual<{View: typeof View}>('react-native'); + const {default: ActualMenuItem} = jest.requireActual('@components/MenuItem'); return { __esModule: true, - default: ({title, rightComponent}: MenuItemProps) => {rightComponent}, + default: Object.assign(({title, rightComponent}: MenuItemModule.MenuItemProps) => {rightComponent}, ActualMenuItem), }; }); jest.mock('@components/Section', () => ({__esModule: true, default: ({children}: {children: React.ReactNode}) => children})); @@ -258,4 +261,26 @@ describe('PolicyAccountingPage auto-started connect flow', () => { expect(screen.getByTestId(CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.businessCentral)).toBeOnTheScreen(); expect(screen.getByTestId('Campfire')).toBeOnTheScreen(); }); + + it.each([{enabledBetas: []}, {enabledBetas: [CONST.BETAS.EXACT_ONLINE]}])('offers the Exact Online waitlist with beta enrollment $enabledBetas', async ({enabledBetas}) => { + // Given an eligible workspace and a user who may have the future Exact connector beta + mockEnabledBetas = enabledBetas; + mockRouteParams = {}; + + // When the Accounting page lists its integrations + render(, {wrapper: AccountingTestWrapper}); + await waitForBatchedUpdates(); + + // Then Exact Online offers the waitlist without starting an unimplemented connector flow + expect(screen.getByText('Exact Online')).toBeOnTheScreen(); + expect(screen.getByRole('button', {name: 'Join waitlist'})).toBeOnTheScreen(); + expect(mockStartIntegrationFlow).not.toHaveBeenCalled(); + expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen(); + + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-15 10:00:00')); + expect(screen.getByLabelText('Exact Online, Coming soon, You’re on the waitlist')).toBeOnTheScreen(); + + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, null)); + expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen(); + }); }); diff --git a/tests/unit/PolicyDistanceRatesUtilsTest.ts b/tests/unit/PolicyDistanceRatesUtilsTest.ts index e74d95af37ed..5469954525ed 100644 --- a/tests/unit/PolicyDistanceRatesUtilsTest.ts +++ b/tests/unit/PolicyDistanceRatesUtilsTest.ts @@ -2,6 +2,7 @@ import { getDistanceExpenseTypeForPolicy, getDistanceRateNameError, getDistanceRateValueError, + getDistanceRateValueErrorMessage, getExpectedUnitForCurrency, getGovernmentRateCountryForCurrency, getGovernmentRateCountryPhraseTranslationKey, @@ -318,4 +319,14 @@ describe('PolicyDistanceRatesUtils', () => { expect(getDistanceRateValueError('0.6700', toLocaleDigit)).toBeUndefined(); }); }); + + describe('getDistanceRateValueErrorMessage', () => { + it('uses the same copy as the distance rate edit form', () => { + // Given the two reasons a distance rate amount can be rejected + // When the table toast and the edit form ask for the message + // Then each reason maps to the form's copy, so the two surfaces cannot drift + expect(getDistanceRateValueErrorMessage(translateLocal, CONST.INPUT_VALIDATION_ERRORS.INVALID)).toBe(translateLocal('common.error.invalidRateError')); + expect(getDistanceRateValueErrorMessage(translateLocal, CONST.INPUT_VALIDATION_ERRORS.TOO_LOW)).toBe(translateLocal('common.error.lowRateError')); + }); + }); }); diff --git a/tests/unit/PolicyPaymentUtilsTest.ts b/tests/unit/PolicyPaymentUtilsTest.ts new file mode 100644 index 000000000000..d7e0634e7fab --- /dev/null +++ b/tests/unit/PolicyPaymentUtilsTest.ts @@ -0,0 +1,247 @@ +import {getPolicyReimburserAccountID, canAccessPolicyBankAccount, getAccessiblePolicyBankAccount, wasPaidWithPolicyBankAccount} from '@libs/PolicyPaymentUtils'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import createRandomPolicy from '../utils/collections/policies'; + +describe('canAccessPolicyBankAccount', () => { + const PAYER_EMAIL = 'payer@test.com'; + const NON_PAYER_ADMIN_EMAIL = 'admin@test.com'; + const POLICY_BANK_ACCOUNT_ID = 1111; + + const policyWithBankAccount: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + reimburser: PAYER_EMAIL, + achAccount: { + bankAccountID: POLICY_BANK_ACCOUNT_ID, + accountNumber: 'XXXXXX1111', + routingNumber: '123456789', + addressName: 'Test bank account', + bankName: 'Test bank', + reimburser: PAYER_EMAIL, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + }, + employeeList: { + [PAYER_EMAIL]: {email: PAYER_EMAIL, role: CONST.POLICY.ROLE.ADMIN}, + [NON_PAYER_ADMIN_EMAIL]: {email: NON_PAYER_ADMIN_EMAIL, role: CONST.POLICY.ROLE.ADMIN}, + }, + }; + + const bankAccountListWithPolicyAccount = { + [POLICY_BANK_ACCOUNT_ID]: {methodID: POLICY_BANK_ACCOUNT_ID, bankCurrency: CONST.CURRENCY.USD, bankCountry: CONST.COUNTRY.US}, + }; + + // The designated payer is the case that produced the original bug: the workspace account was advertised on their Pay + // button but never shared with them, so the backend debited a different account. + it('returns false for the designated payer when the workspace account is missing from their bank account list', () => { + // Given a designated payer whose bank account list is empty. + // When access to the workspace account is checked. + // Then payer designation alone does not grant access. + expect(canAccessPolicyBankAccount(policyWithBankAccount, {})).toBe(false); + }); + + it('returns true for the designated payer when the workspace account is in their bank account list', () => { + // Given the workspace account shared in the payer’s bank account list. + // When access to that account is checked. + // Then the shared account is available for payment. + expect(canAccessPolicyBankAccount(policyWithBankAccount, bankAccountListWithPolicyAccount)).toBe(true); + }); + + it('returns false when the bank account list only holds other accounts', () => { + // Given a bank account list containing only a different account. + // When workspace funding access is checked. + // Then an unrelated account does not grant access to the workspace account. + const otherAccountID = POLICY_BANK_ACCOUNT_ID + 1; + expect( + canAccessPolicyBankAccount(policyWithBankAccount, { + [otherAccountID]: {methodID: otherAccountID, bankCurrency: CONST.CURRENCY.USD, bankCountry: CONST.COUNTRY.US}, + }), + ).toBe(false); + }); + + it('returns false when the workspace has no connected bank account', () => { + // Given a workspace without a connected account. + // When bank account access is checked. + // Then an accessible account cannot be assumed. + expect(canAccessPolicyBankAccount({...policyWithBankAccount, achAccount: undefined}, bankAccountListWithPolicyAccount)).toBe(false); + }); + + it('returns false when there is no policy', () => { + // Given no policy and an otherwise accessible bank account. + // When workspace funding access is checked. + // Then there is no workspace account to pay from. + expect(canAccessPolicyBankAccount(undefined, bankAccountListWithPolicyAccount)).toBe(false); + }); +}); + +describe('getAccessiblePolicyBankAccount', () => { + const POLICY_BANK_ACCOUNT_ID = 1111; + + // `achAccount.accountNumber` is deliberately a different account's number than the one `bankAccountID` resolves to. + // The two really do fall out of sync, and reading the number off `achAccount` is what makes a Pay button name an + // account other than the one the payment debits. + const policyWithStaleAccountNumber: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + achAccount: { + bankAccountID: POLICY_BANK_ACCOUNT_ID, + accountNumber: 'XXXXXX9999', + routingNumber: '123456789', + addressName: 'Test bank account', + bankName: 'Test bank', + reimburser: 'payer@test.com', + state: CONST.BANK_ACCOUNT.STATE.OPEN, + }, + }; + + const bankAccountList = { + [POLICY_BANK_ACCOUNT_ID]: { + methodID: POLICY_BANK_ACCOUNT_ID, + bankCurrency: CONST.CURRENCY.USD, + bankCountry: CONST.COUNTRY.US, + accountData: {accountNumber: 'XXXXXX1234'}, + }, + }; + + it('resolves the account number through the bank account list rather than the stale one on achAccount', () => { + // Given an outdated account number on the policy and current details in the bank account list. + // When the connected account is resolved by ID. + // Then its current account number replaces the stale policy number. + expect(getAccessiblePolicyBankAccount(policyWithStaleAccountNumber, bankAccountList)?.accountData?.accountNumber).toBe('XXXXXX1234'); + }); + + it('returns undefined when the workspace account is not shared with the user', () => { + // Given a workspace account missing from the viewer’s bank account list. + // When the connected account is resolved. + // Then no inaccessible account is returned. + expect(getAccessiblePolicyBankAccount(policyWithStaleAccountNumber, {})).toBeUndefined(); + }); + + it('returns undefined when the workspace has no connected bank account', () => { + // Given a workspace with no connected bank account. + // When the connected account is resolved. + // Then no account is returned even though the viewer owns one. + expect(getAccessiblePolicyBankAccount({...policyWithStaleAccountNumber, achAccount: undefined}, bankAccountList)).toBeUndefined(); + }); +}); + +describe('wasPaidWithPolicyBankAccount', () => { + const PAYER_EMAIL = 'payer@test.com'; + const PAYER_ACCOUNT_ID = 101; + const NON_PAYER_ADMIN_ACCOUNT_ID = 102; + + const policyWithDesignatedPayer: Policy = { + ...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE), + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + reimburser: PAYER_EMAIL, + }; + + const personalDetails = { + [PAYER_ACCOUNT_ID]: {accountID: PAYER_ACCOUNT_ID, login: PAYER_EMAIL}, + [NON_PAYER_ADMIN_ACCOUNT_ID]: {accountID: NON_PAYER_ADMIN_ACCOUNT_ID, login: 'wsadmin@test.com'}, + }; + + it('returns true when the designated payer made the payment', () => { + // Given a workspace with a designated payer. + // When the payment is attributed to that payer. + // Then the workspace account remains a valid fallback. + expect(wasPaidWithPolicyBankAccount(policyWithDesignatedPayer, PAYER_ACCOUNT_ID, getPolicyReimburserAccountID(policyWithDesignatedPayer, personalDetails))).toBe(true); + }); + + it('returns false when an admin who is not the designated payer made the payment', () => { + // Given an admin payment made by someone other than the designated payer. + // When the funding account is inferred without an account ID on the action. + // Then the workspace account is rejected to avoid attributing another admin’s payment to it. + expect(wasPaidWithPolicyBankAccount(policyWithDesignatedPayer, NON_PAYER_ADMIN_ACCOUNT_ID, getPolicyReimburserAccountID(policyWithDesignatedPayer, personalDetails))).toBe(false); + }); + + it('falls back to the reimburser stored on the bank account when the workspace has no reimburser', () => { + // Given a workspace whose payer is recorded only on its ACH account. + // When both the designated payer and another admin are checked. + // Then the ACH payer identifies which payment may use the workspace account fallback. + const policy: Policy = { + ...policyWithDesignatedPayer, + reimburser: undefined, + achAccount: { + bankAccountID: 1111, + accountNumber: 'XXXXXX1111', + routingNumber: '123456789', + addressName: 'Test bank account', + bankName: 'Test bank', + reimburser: PAYER_EMAIL, + }, + }; + + expect(wasPaidWithPolicyBankAccount(policy, PAYER_ACCOUNT_ID, getPolicyReimburserAccountID(policy, personalDetails))).toBe(true); + expect(wasPaidWithPolicyBankAccount(policy, NON_PAYER_ADMIN_ACCOUNT_ID, getPolicyReimburserAccountID(policy, personalDetails))).toBe(false); + }); + + it('returns true for any payer when the workspace has no designated payer', () => { + // Given a workspace without a designated payer. + // When another admin’s payment is attributed. + // Then the workspace account remains the default fallback. + expect( + wasPaidWithPolicyBankAccount( + {...policyWithDesignatedPayer, reimburser: undefined}, + NON_PAYER_ADMIN_ACCOUNT_ID, + getPolicyReimburserAccountID(policyWithDesignatedPayer, personalDetails), + ), + ).toBe(true); + }); + it('updates attribution when the caller supplies newly loaded payer details', () => { + // Given the payer’s details have not loaded and a different admin made the payment. + const policy = policyWithDesignatedPayer; + expect(wasPaidWithPolicyBankAccount(policy, NON_PAYER_ADMIN_ACCOUNT_ID, undefined)).toBe(true); + + // When the subscribed details arrive, the same payment can be compared with the designated payer. + const result = wasPaidWithPolicyBankAccount(policy, NON_PAYER_ADMIN_ACCOUNT_ID, getPolicyReimburserAccountID(policy, personalDetails)); + + // Then the workspace account is no longer used as a fallback for this admin’s payment. + expect(result).toBe(false); + }); +}); + +describe('getPolicyReimburserAccountID', () => { + const PAYER_ACCOUNT_ID = 101; + const ADMIN_ACCOUNT_ID = 102; + + it('keeps the selected payer unchanged when unrelated personal details change', () => { + // Given payment rows only need the designated payer’s account ID. + const policy = {reimburser: 'payer@test.com'}; + const personalDetails = { + [PAYER_ACCOUNT_ID]: {accountID: 101, login: 'payer@test.com'}, + [ADMIN_ACCOUNT_ID]: {accountID: 102, login: 'admin@test.com'}, + }; + const before = getPolicyReimburserAccountID(policy, personalDetails); + + // When another person’s display name changes in the subscribed snapshot. + const after = getPolicyReimburserAccountID(policy, { + ...personalDetails, + [ADMIN_ACCOUNT_ID]: {...personalDetails[ADMIN_ACCOUNT_ID], displayName: 'Updated admin'}, + }); + + // Then the selector result stays equal, so payment rows do not re-render for this update. + expect(before).toBe(101); + expect(after).toBe(before); + }); + + it('resolves the payer when their details arrive in a new snapshot', () => { + // Given the viewer has not loaded the designated payer’s details. + const policy = {reimburser: 'payer@test.com'}; + const personalDetails = { + [ADMIN_ACCOUNT_ID]: {accountID: 102, login: 'admin@test.com'}, + }; + expect(getPolicyReimburserAccountID(policy, personalDetails)).toBeUndefined(); + + // When Onyx provides a new snapshot containing the payer. + const result = getPolicyReimburserAccountID(policy, { + ...personalDetails, + [PAYER_ACCOUNT_ID]: {accountID: 101, login: 'payer@test.com'}, + }); + + // Then the selected ID changes so payment attribution updates. + expect(result).toBe(101); + }); +}); diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 081dce3dacf4..04f226577f51 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -9,27 +9,38 @@ import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import { arePolicyRulesEnabled, + areSettingsInErrorFields, + canAdminPayReport, + canCreateCategoryInSitu, canEditWorkspaceSettings, canMemberAssignRole, canMemberManageMemberWithRole, canMemberRead, canMemberWrite, + canSendInvoice, canSendInvoiceFromWorkspace, + canShowReimbursableToggleForPolicy, + canSubmitPerDiemExpenseFromWorkspace, canUnarchivePolicy, evaluateApprovalWorkflowRule, findVendorByID, getVendorDisplayName, + hasCompanyAddress, + hasReadOnlyAllReportsAccess, hasVendorFeatureOnAnyPolicy, + getActiveAdminWorkspaces, getActivePolicies, getActivePoliciesWithExpenseChat, getActivePoliciesWithExpenseChatAndPerDiemEnabled, getAllTaxRates, getAllTaxRatesNamesAndValues, + getApprovalWorkflow, getConnectedIntegration, getConnectionExporters, getCurrentTaxID, getCustomUnitsForDuplication, getDefaultChatEnabledPolicy, + getDefaultApprover, getDefaultChatEnabledPolicySelection, getDefaultTimeTrackingRate, getDefaultWorkspacePlanType, @@ -40,8 +51,10 @@ import { getExpensifyTeamExclusions, getForwardsToAccount, getForwardsToFromRules, + getGroupPoliciesWhereReportCanBeCreated, getIntegrationLastSuccessfulDate, getManagerAccountID, + getMemberAccountIDsForWorkspace, getActiveVendorMatchingIntegration, getMatchingVendorByID, getMatchingVendors, @@ -55,11 +68,13 @@ import { getPolicyIDFromDomainName, getRateDisplayValue, getOwnerChangePayerSuccessData, + getOwnedPaidPolicies, getSageIntacctVendors, arePaymentsEnabled, getReimbursementChoice, isPolicyPayer, getReimburserEmail, + getRuleApprovers, getSubmitReportManagerAccountID, getSubmitToAccountID, getSubmitToEmail, @@ -67,6 +82,7 @@ import { findPolicyTagAtLevel, findPolicyTagEntryByParentFilter, getTagGLCode, + hasAnyTagGLCode, getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, getTagLists, @@ -77,6 +93,7 @@ import { getGLCodeFromPolicyTag, getTagList, getTagListByOrderWeight, + getTravelStep, getUberConnectionErrorDirectlyFromPolicy, getUnitRateValue, getXeroExpenseAccounts, @@ -97,6 +114,7 @@ import { isArchivedPolicy, isDualEntryVendorMatchingActive, isCertiniaVendorMatchingActive, + isExpensifyTeam, isInvoiceFieldsEnabled, isMatchingVendorListLoaded, isMaxExpenseAmountSet, @@ -106,6 +124,7 @@ import { isQBORefreshTokenExpiringSoonSelector, isPerDiemEligiblePolicy, isPerDiemEnabled, + isPolicyAccessible, isPolicyAdmin, isPolicyFeatureEnabled, isRoomMemberProtectedByPolicyRole, @@ -113,11 +132,12 @@ import { isSubmitterApproveBlockedOnSubmitWorkspace, isRilletVendorMatchingActive, isTaxCodeCustomized, - isXeroActiveMatchingSource, isXeroVendorMatchingActive, + settingsPendingAction, shouldHideDynamicExternalWorkflowPeople, shouldShowPolicy, sortPoliciesByName, + getDefaultVendorID, sortVendors, sortWorkspacesBySelected, tryNavigateToSubmitWorkspaceUpgrade, @@ -127,7 +147,7 @@ import {getApprovalChain, isWorkspaceEligibleForReportChange} from '@libs/Report import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetailsList, Policy, PolicyEmployeeList, PolicyTags, PolicyTagLists, Report, Transaction} from '@src/types/onyx'; +import type {PersonalDetailsList, Policy, PolicyEmployeeList, PolicyTags, PolicyTagLists, Report, Transaction, TravelSettings} from '@src/types/onyx'; import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; import type { Connections, @@ -393,1119 +413,2085 @@ describe('PolicyUtils', () => { }); }); - describe('canUnarchivePolicy', () => { - it('returns true for the owner of an archived workspace when the beta is enabled', () => { - expect(canUnarchivePolicy(true, ownerAccountID, ownerAccountID, true)).toBe(true); - }); + describe('policy', () => { + describe('canUnarchivePolicy', () => { + it('returns true for the owner of an archived workspace when the beta is enabled', () => { + expect(canUnarchivePolicy(true, ownerAccountID, ownerAccountID, true)).toBe(true); + }); - it('returns false when the beta is disabled', () => { - expect(canUnarchivePolicy(true, ownerAccountID, ownerAccountID, false)).toBe(false); - }); + it('returns false when the beta is disabled', () => { + expect(canUnarchivePolicy(true, ownerAccountID, ownerAccountID, false)).toBe(false); + }); + + it('returns false when the workspace is not archived', () => { + expect(canUnarchivePolicy(false, ownerAccountID, ownerAccountID, true)).toBe(false); + }); + + it('returns false when the current user is not the owner', () => { + expect(canUnarchivePolicy(true, ownerAccountID, ownerAccountID + 1, true)).toBe(false); + }); - it('returns false when the workspace is not archived', () => { - expect(canUnarchivePolicy(false, ownerAccountID, ownerAccountID, true)).toBe(false); + it('returns false when the owner is unknown', () => { + expect(canUnarchivePolicy(true, undefined, ownerAccountID, true)).toBe(false); + }); }); - it('returns false when the current user is not the owner', () => { - expect(canUnarchivePolicy(true, ownerAccountID, ownerAccountID + 1, true)).toBe(false); + describe('getActivePolicies', () => { + it("getActivePolicies should filter out policies that the current user doesn't belong to", () => { + const policies = createCollection( + (item) => `${ONYXKEYS.COLLECTION.POLICY}${item.id}`, + (index) => createMock({...createRandomPolicy(index + 1), name: 'workspace', pendingAction: null, ...(!index && {role: undefined})}), + 2, + ); + expect(getActivePolicies(policies, undefined)).toHaveLength(1); + }); + + it('excludes archived policies while keeping the active sibling', () => { + const activePolicy = createMock({...createRandomPolicy(1), name: 'active', pendingAction: null}); + const archivedPolicy = createMock({...createRandomPolicy(2), name: 'archived', pendingAction: null, archivedDate: '2024-01-01'}); + const policies = createCollection( + (item) => `${ONYXKEYS.COLLECTION.POLICY}${item.id}`, + (index) => (index === 0 ? activePolicy : archivedPolicy), + 2, + ); + + const result = getActivePolicies(policies, undefined); + expect(result).toHaveLength(1); + expect(result.at(0)?.id).toBe(activePolicy.id); + }); }); - it('returns false when the owner is unknown', () => { - expect(canUnarchivePolicy(true, undefined, ownerAccountID, true)).toBe(false); + describe('getActivePoliciesWithExpenseChat', () => { + it('excludes archived policies while keeping the active sibling', () => { + const activePolicy = createMock({...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), name: 'active', pendingAction: null}); + const archivedPolicy = createMock({ + ...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE), + name: 'archived', + pendingAction: null, + archivedDate: '2024-01-01', + }); + const policies = createCollection( + (item) => `${ONYXKEYS.COLLECTION.POLICY}${item.id}`, + (index) => (index === 0 ? activePolicy : archivedPolicy), + 2, + ); + + const result = getActivePoliciesWithExpenseChat(policies, undefined); + expect(result).toHaveLength(1); + expect(result.at(0)?.id).toBe(activePolicy.id); + }); }); - }); - describe('permissions', () => { - describe('canMemberRead and canMemberWrite', () => { - const memberLogin = 'member@test.com'; - const buildPolicy = (role: Policy['role']): Policy => - createMock({ - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - role, - employeeList: { - [memberLogin]: { - role, + describe('per diem policy filters', () => { + const perDiemCustomUnit = { + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + customUnitID: 'ABCDEF', + enabled: true, + rates: { + London: { + customUnitRateID: 'London', + name: 'London', + }, + }, + }; + + it('returns only control policies from getActivePoliciesWithExpenseChatAndPerDiemEnabled', () => { + const policies: OnyxCollection = { + corporate: { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + role: CONST.POLICY.ROLE.USER, + pendingAction: null, + arePerDiemRatesEnabled: true, + customUnits: { + ABCDEF: perDiemCustomUnit, }, }, - }); + collect: { + ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), + role: CONST.POLICY.ROLE.USER, + pendingAction: null, + arePerDiemRatesEnabled: true, + customUnits: { + ABCDEF: perDiemCustomUnit, + }, + }, + }; - it('allows write access to satisfy read access', () => { - expect(canMemberRead(buildPolicy(CONST.POLICY.ROLE.CARD_ADMIN), memberLogin, CONST.POLICY.POLICY_FEATURE.EXPENSIFY_CARD)).toBe(true); + const result = getActivePoliciesWithExpenseChatAndPerDiemEnabled(policies, undefined); + expect(result).toHaveLength(1); + expect(result.at(0)?.type).toBe(CONST.POLICY.TYPE.CORPORATE); }); - it('denies access when the role does not have the feature', () => { - expect(canMemberRead(buildPolicy(CONST.POLICY.ROLE.USER), memberLogin, CONST.POLICY.POLICY_FEATURE.EXPENSIFY_CARD)).toBe(false); + it('includes a control policy whose per diem is enabled but has no rates yet (rates load lazily after cache clear)', () => { + const policies: OnyxCollection = { + corporateWithRates: { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + role: CONST.POLICY.ROLE.USER, + pendingAction: null, + arePerDiemRatesEnabled: true, + customUnits: { + ABCDEF: perDiemCustomUnit, + }, + }, + corporateWithoutRates: { + ...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE), + role: CONST.POLICY.ROLE.USER, + pendingAction: null, + arePerDiemRatesEnabled: true, + customUnits: { + ABCDEF: { + ...perDiemCustomUnit, + rates: {}, + }, + }, + }, + collectWithRates: { + ...createRandomPolicy(3, CONST.POLICY.TYPE.TEAM), + role: CONST.POLICY.ROLE.USER, + pendingAction: null, + arePerDiemRatesEnabled: true, + customUnits: { + ABCDEF: perDiemCustomUnit, + }, + }, + }; + + const result = getActivePoliciesWithExpenseChatAndPerDiemEnabled(policies, undefined); + expect(result.map((currentPolicy) => currentPolicy.id).sort()).toEqual(['1', '2']); }); - it('uses the requested member role when login is provided', () => { - const policy = { - ...buildPolicy(CONST.POLICY.ROLE.ADMIN), - employeeList: { - 'member@test.com': { - role: CONST.POLICY.ROLE.USER, + it('includes a control policy whose arePerDiemRatesEnabled flag is missing but has an enabled per diem custom unit', () => { + const policies: OnyxCollection = { + corporate: { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + role: CONST.POLICY.ROLE.USER, + pendingAction: null, + // Migrated member: flag never arrived, but the configured custom unit is present + arePerDiemRatesEnabled: undefined, + customUnits: { + ABCDEF: perDiemCustomUnit, }, }, }; - expect(canMemberWrite(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.EXPENSIFY_CARD)).toBe(false); + expect(getActivePoliciesWithExpenseChatAndPerDiemEnabled(policies, undefined)).toHaveLength(1); }); - it('allows admins to write every policy feature', () => { - const policy = buildPolicy(CONST.POLICY.ROLE.ADMIN); + it('excludes a control policy where per diem was explicitly disabled even if a custom unit lingers', () => { + const policies: OnyxCollection = { + corporate: { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + role: CONST.POLICY.ROLE.USER, + pendingAction: null, + // Explicit off must be respected, so the lingering custom unit cannot re-enable it + arePerDiemRatesEnabled: false, + customUnits: { + ABCDEF: perDiemCustomUnit, + }, + }, + }; - for (const feature of Object.values(CONST.POLICY.POLICY_FEATURE)) { - expect(canMemberWrite(policy, memberLogin, feature)).toBe(true); - } + expect(getActivePoliciesWithExpenseChatAndPerDiemEnabled(policies, undefined)).toHaveLength(0); }); + }); - it('does not allow editors to assign elevated roles', () => { - const policy = buildPolicy(CONST.POLICY.ROLE.EDITOR); + describe('hasCompanyAddress', () => { + it('only counts a company address that has a street, the same way Auth does', () => { + // Given workspaces without an address, with a blank street, and with a street + const policy = createRandomPolicy(0); + const address = {city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US' as const}; + const withoutAddress: Policy = {...policy, address: undefined}; + const withBlankStreet: Policy = {...policy, address: {...address, addressStreet: ' '}}; + const withStreet: Policy = {...policy, address: {...address, addressStreet: '88 Kearny St'}}; - expect(canMemberWrite(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.OVERVIEW)).toBe(true); - expect(canMemberWrite(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.ASSIGN_ELEVATED_ROLES)).toBe(false); + // When checking whether each has a company address + // Then only the one with a street has one, since that's when Auth counts the company address as an office + expect(hasCompanyAddress(withoutAddress)).toBe(false); + expect(hasCompanyAddress(withBlankStreet)).toBe(false); + expect(hasCompanyAddress(withStreet)).toBe(true); }); + }); - it('allows auditors to read every policy feature but write only rooms', () => { - const policy = buildPolicy(CONST.POLICY.ROLE.AUDITOR); + describe('shouldShowPolicy', () => { + beforeEach(() => { + global.fetch = TestHelper.getGlobalFetchMock(); + return Onyx.clear().then(waitForBatchedUpdates); + }); + it('should return false', () => { + // Given an archived paid policy. + const policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + role: '', + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The empty role deliberately models malformed persisted policy data. + const result = shouldShowPolicy(policy as unknown as Parameters[0], false, CARLOS_EMAIL); + // The result should be false since it is an archived paid policy. + expect(result).toBe(false); + }); + it('should return true', () => { + // Given a paid policy. + const policy = createMock>({...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: null}); + const result = shouldShowPolicy(policy, false, CARLOS_EMAIL); + // The result should be true, since it is an active paid policy. + expect(result).toBe(true); + }); + it('should return false', () => { + // Given a control workspace which is pending delete. + const policy = createMock>({ + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }); + const result = shouldShowPolicy(policy, false, CARLOS_EMAIL); + // The result should be false since it is a policy which is pending deletion. + expect(result).toEqual(false); + }); + it('hides an archived policy by default', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: null, archivedDate: '2024-01-01'}; + const result = shouldShowPolicy(policy as OnyxEntry, false, CARLOS_EMAIL); + expect(result).toBe(false); + }); + it('shows an archived policy when includeArchivedPolicy is true', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: null, archivedDate: '2024-01-01'}; + const result = shouldShowPolicy(policy as OnyxEntry, false, CARLOS_EMAIL, true); + expect(result).toBe(true); + }); + }); - for (const feature of Object.values(CONST.POLICY.POLICY_FEATURE)) { - expect(canMemberRead(policy, memberLogin, feature)).toBe(true); - expect(canMemberWrite(policy, memberLogin, feature)).toBe(feature === CONST.POLICY.POLICY_FEATURE.ROOMS); - } + describe('getOwnedPaidPolicies', () => { + const ownerID = 100; + + it('returns only the paid workspaces the account owns', () => { + // Given owned Collect, Control, Submit and Personal workspaces, plus a Control workspace owned by someone else + const ownedTeam: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), ownerAccountID: ownerID}; + const ownedCorporate: Policy = {...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE), ownerAccountID: ownerID}; + const ownedSubmit: Policy = {...createRandomPolicy(3, CONST.POLICY.TYPE.SUBMIT), ownerAccountID: ownerID}; + const ownedPersonal: Policy = {...createRandomPolicy(4, CONST.POLICY.TYPE.PERSONAL), ownerAccountID: ownerID}; + const otherOwnersCorporate: Policy = {...createRandomPolicy(5, CONST.POLICY.TYPE.CORPORATE), ownerAccountID: ownerID + 1}; + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: ownedTeam, + [`${ONYXKEYS.COLLECTION.POLICY}2`]: ownedCorporate, + [`${ONYXKEYS.COLLECTION.POLICY}3`]: ownedSubmit, + [`${ONYXKEYS.COLLECTION.POLICY}4`]: ownedPersonal, + [`${ONYXKEYS.COLLECTION.POLICY}5`]: otherOwnersCorporate, + [`${ONYXKEYS.COLLECTION.POLICY}6`]: undefined, + }; + + // When we get the owned paid policies + const result = getOwnedPaidPolicies(policies, ownerID); + + // Then only the owned Collect and Control workspaces are returned, since Submit and Personal are not paid plans + expect(result.map((policy) => policy.id).sort()).toEqual(['1', '2']); }); - it('allows guests to read only the workspace overview', () => { - const policy = buildPolicy(CONST.POLICY.ROLE.GUEST); + it('returns an empty array when the account ID is unknown', () => { + // Given a paid workspace whose owner is unknown to the caller + const policies: OnyxCollection = {[`${ONYXKEYS.COLLECTION.POLICY}1`]: {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), ownerAccountID: ownerID}}; - for (const feature of Object.values(CONST.POLICY.POLICY_FEATURE)) { - expect(canMemberRead(policy, memberLogin, feature)).toBe(feature === CONST.POLICY.POLICY_FEATURE.OVERVIEW); - expect(canMemberWrite(policy, memberLogin, feature)).toBe(false); - } + // When we get the owned paid policies without an account ID + // Then nothing is returned, because no account can be matched as the owner + expect(getOwnedPaidPolicies(policies, undefined)).toEqual([]); }); - it('limits scoped admins to their assigned write features', () => { - expect(canMemberWrite(buildPolicy(CONST.POLICY.ROLE.CARD_ADMIN), memberLogin, CONST.POLICY.POLICY_FEATURE.COMPANY_CARDS)).toBe(true); - expect(canMemberWrite(buildPolicy(CONST.POLICY.ROLE.CARD_ADMIN), memberLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)).toBe(false); - expect(canMemberWrite(buildPolicy(CONST.POLICY.ROLE.PEOPLE_ADMIN), memberLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_APPROVALS)).toBe(true); - expect(canMemberWrite(buildPolicy(CONST.POLICY.ROLE.PAYMENTS_ADMIN), memberLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)).toBe(true); + it('returns an empty array when there are no policies', () => { + // Given no policies collection + // When we get the owned paid policies + // Then nothing is returned + expect(getOwnedPaidPolicies(null, ownerID)).toEqual([]); }); + }); - it('limits People Admin member role management to guests, members, and auditors', () => { - const policy = buildPolicy(CONST.POLICY.ROLE.PEOPLE_ADMIN); + describe('canCreateCategoryInSitu', () => { + const categoriesPolicy = (role: ValueOf, areCategoriesEnabled = true): Policy => ({ + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + role, + areCategoriesEnabled, + connections: undefined, + }); - expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.USER)).toBe(true); - expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.GUEST)).toBe(false); - expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.AUDITOR)).toBe(true); - expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.ADMIN)).toBe(false); - expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.CARD_ADMIN)).toBe(false); - expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.PEOPLE_ADMIN)).toBe(false); - expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.PAYMENTS_ADMIN)).toBe(false); + it('lets an admin add a category from the picker when the workspace uses categories', () => { + // Given an admin of a workspace with categories on and no accounting integration + // When we check whether the category picker can offer to add one + // Then it can, as the full-page selector's "+" does + expect(canCreateCategoryInSitu(categoriesPolicy(CONST.POLICY.ROLE.ADMIN))).toBe(true); }); - it('blocks Guest assignment even on Control workspaces', () => { - const controlPolicy = buildPolicy(CONST.POLICY.ROLE.ADMIN); + it('does not let a member add a category', () => { + // Given a member, who cannot change workspace settings + // When we check whether the category picker can offer to add one + // Then it cannot, so the pop-over hides its add button + expect(canCreateCategoryInSitu(categoriesPolicy(CONST.POLICY.ROLE.USER))).toBe(false); + }); - expect(canMemberAssignRole(controlPolicy, memberLogin, CONST.POLICY.ROLE.GUEST)).toBe(false); + it('does not offer to add a category when the workspace has categories turned off', () => { + // Given an admin of a workspace that has turned categories off + // When we check whether the category picker can offer to add one + // Then it cannot, because a new category would have nowhere to show + expect(canCreateCategoryInSitu(categoriesPolicy(CONST.POLICY.ROLE.ADMIN, false))).toBe(false); }); - it('allows Submit workspace editors to manage editor memberships without assigning roles', () => { - const policy = { - ...buildPolicy(CONST.POLICY.ROLE.EDITOR), - type: CONST.POLICY.TYPE.SUBMIT, - }; + it('does not offer to add a category when the workspace syncs with an accounting integration', () => { + // Given an admin of a workspace whose categories come from an accounting integration + const policy = {...categoriesPolicy(CONST.POLICY.ROLE.ADMIN), connections: createMock({[CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: {}})}; - expect(canMemberManageMemberWithRole(policy, memberLogin, CONST.POLICY.ROLE.EDITOR)).toBe(true); - expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.EDITOR)).toBe(false); + // When we check whether the category picker can offer to add one + // Then it cannot, because new categories have to be created in the integration + expect(canCreateCategoryInSitu(policy)).toBe(false); }); + }); - it('denies write access to an archived policy even for admins', () => { - const policy = {...buildPolicy(CONST.POLICY.ROLE.ADMIN), archivedDate: '2024-01-01'}; + describe('canShowReimbursableToggleForPolicy', () => { + const teamBase = createMock({id: 'policy1', type: CONST.POLICY.TYPE.TEAM}); - expect(canMemberWrite(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.OVERVIEW)).toBe(false); + it('returns true when the reimbursable field is not locked', () => { + // Given a workspace that lets members change the reimbursable field + const policy = {...teamBase, defaultReimbursable: false, disabledFields: {reimbursable: false}}; + + // When checking an expense that already matches the default + // Then the toggle is shown, because nothing stops the member from editing it + expect(canShowReimbursableToggleForPolicy(policy, false)).toBe(true); }); - it('denies workspace settings edit access to an archived policy even for admins', () => { - const policy = {...buildPolicy(CONST.POLICY.ROLE.ADMIN), archivedDate: '2024-01-01'}; + it('returns false when the field is locked and the expense matches the policy default', () => { + // Given a workspace locked to "cash expenses are always non-reimbursable" + const policy = {...teamBase, defaultReimbursable: false, disabledFields: {reimbursable: true}}; - expect(canEditWorkspaceSettings(policy, memberLogin)).toBe(false); + // When checking an expense that is already non-reimbursable + // Then the toggle is hidden, because the lock says members cannot change it + expect(canShowReimbursableToggleForPolicy(policy, false)).toBe(false); }); - }); - describe('isPolicyAdmin', () => { - const adminLogin = 'admin@test.com'; - const memberLogin = 'member@test.com'; - // `role` is the role of the user currently viewing the policy, `employeeList` holds every member's own role - const buildPolicy = (): Policy => - createMock({ - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - role: CONST.POLICY.ROLE.ADMIN, - employeeList: { - [adminLogin]: {role: CONST.POLICY.ROLE.ADMIN}, - [memberLogin]: {role: CONST.POLICY.ROLE.USER}, - }, - }); + it('returns true when the field is locked but the expense disagrees with the policy default', () => { + // Given a workspace locked to "cash expenses are always non-reimbursable" + const policy = {...teamBase, defaultReimbursable: false, disabledFields: {reimbursable: true}}; - it('resolves the role of the passed login when shouldCheckGlobalPolicyRole is false', () => { - // Given a policy viewed by an admin, holding one admin and one regular member in its employee list - // When the role of each login is resolved without the global policy role - // Then each login resolves to its own role, not the viewer's - expect(isPolicyAdmin(buildPolicy(), memberLogin, false)).toBe(false); - expect(isPolicyAdmin(buildPolicy(), adminLogin, false)).toBe(true); + // When checking an expense that drifted to reimbursable + // Then the toggle is shown so the expense can be corrected instead of staying stuck + expect(canShowReimbursableToggleForPolicy(policy, true)).toBe(true); }); - it('ignores the passed login and answers for the viewing user by default', () => { - // Given a policy viewed by an admin, holding a regular member in its employee list - // When the member's role is resolved with the default shouldCheckGlobalPolicyRole - // Then the check short-circuits on the viewer's role and reports the member as an admin. This documents the - // trap that made every member of a workspace chat look like an admin to a viewing admin, which disabled the - // "Remove from chat" button for all of them - expect(isPolicyAdmin(buildPolicy(), memberLogin)).toBe(true); + it('treats an absent defaultReimbursable as reimbursable when the field is locked', () => { + // Given a locked workspace with no stored defaultReimbursable + const policy = {...teamBase, defaultReimbursable: undefined, disabledFields: {reimbursable: true}}; + + // When checking a reimbursable expense and a non-reimbursable expense + // Then only the non-reimbursable one disagrees with the implied "reimbursable" default + expect(canShowReimbursableToggleForPolicy(policy, true)).toBe(false); + expect(canShowReimbursableToggleForPolicy(policy, false)).toBe(true); }); - it('returns false for a login that is not in the employee list when shouldCheckGlobalPolicyRole is false', () => { - // Given a policy that does not list the passed login as an employee - // When that login's role is resolved without the global policy role - // Then it does not resolve to an admin - expect(isPolicyAdmin(buildPolicy(), 'stranger@test.com', false)).toBe(false); + it('treats an absent expense reimbursable value as reimbursable when the field is locked', () => { + // Given a locked workspace whose default is reimbursable, stored or implied + const policyWithDefault = {...teamBase, defaultReimbursable: true, disabledFields: {reimbursable: true}}; + const policyWithoutDefault = {...teamBase, defaultReimbursable: undefined, disabledFields: {reimbursable: true}}; + + // When checking an expense with no stored reimbursable value + // Then the toggle is hidden, because the expense defaults to reimbursable and already matches the policy + expect(canShowReimbursableToggleForPolicy(policyWithDefault, undefined)).toBe(false); + expect(canShowReimbursableToggleForPolicy(policyWithoutDefault, undefined)).toBe(false); }); - it('returns false for an undefined login when shouldCheckGlobalPolicyRole is false', () => { - // Given a member whose login could not be resolved - // When their role is resolved without the global policy role - // Then it does not resolve to an admin - expect(isPolicyAdmin(buildPolicy(), undefined, false)).toBe(false); + it('shows the toggle for an expense with no reimbursable value on a locked non-reimbursable workspace', () => { + // Given a workspace locked to "cash expenses are always non-reimbursable" + const policy = {...teamBase, defaultReimbursable: false, disabledFields: {reimbursable: true}}; + + // When checking an expense with no stored reimbursable value, which defaults to reimbursable + // Then the toggle is shown, because the expense disagrees with the policy default + expect(canShowReimbursableToggleForPolicy(policy, undefined)).toBe(true); }); + }); - it('matches an employee whose login is not lowercase when shouldCheckGlobalPolicyRole is false', () => { - // Given an employee list keyed by canonical lowercase logins - // When a mixed-case login read off personal details is resolved without the global policy role - // Then it still matches the employee entry through the normalized fallback - expect(isPolicyAdmin(buildPolicy(), 'Admin@Test.com', false)).toBe(true); - expect(isPolicyAdmin(buildPolicy(), 'Member@Test.com', false)).toBe(false); + describe('getActiveAdminWorkspaces', () => { + const currentUserLogin = 'admin@test.com'; + // createRandomPolicy randomizes these, so set the fields that decide whether a workspace is active + const buildActivePolicy = (id: number, type: ValueOf, role: ValueOf): Policy => ({ + ...createRandomPolicy(id, type), + name: `Workspace ${id}`, + role, + pendingAction: null, + archivedDate: undefined, }); - it('prefers an exact employee list key over the normalized one', () => { - // Given an employee list holding both a mixed-case and a lowercase key with different roles - const policy = createMock({ - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - employeeList: { - 'Mixed@Test.com': {role: CONST.POLICY.ROLE.ADMIN}, - 'mixed@test.com': {role: CONST.POLICY.ROLE.USER}, - }, - }); + it('returns only the active workspaces where the user is an admin', () => { + // Given an admin workspace and a workspace where the user is only a member + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: buildActivePolicy(1, CONST.POLICY.TYPE.CORPORATE, CONST.POLICY.ROLE.ADMIN), + [`${ONYXKEYS.COLLECTION.POLICY}2`]: buildActivePolicy(2, CONST.POLICY.TYPE.CORPORATE, CONST.POLICY.ROLE.USER), + }; - // When each key is resolved without the global policy role - // Then the exact key wins, so the normalized fallback can never regress an existing hit - expect(isPolicyAdmin(policy, 'Mixed@Test.com', false)).toBe(true); - expect(isPolicyAdmin(policy, 'mixed@test.com', false)).toBe(false); + // When we get the active admin workspaces + const result = getActiveAdminWorkspaces(policies, currentUserLogin); + + // Then only the admin workspace is returned + expect(result.map((policy) => policy.id)).toEqual(['1']); }); - it('stops at the exact employee list key even when that entry carries no role', () => { - // Given an employee list where the exact mixed-case key exists without a role, next to an admin entry - // under the normalized key. `role` is optional on PolicyEmployee, so this is representable + it('excludes admin workspaces that are pending deletion, archived, or personal', () => { + // Given admin workspaces that are pending deletion, archived, or personal, next to a regular one + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: buildActivePolicy(1, CONST.POLICY.TYPE.TEAM, CONST.POLICY.ROLE.ADMIN), + [`${ONYXKEYS.COLLECTION.POLICY}2`]: {...buildActivePolicy(2, CONST.POLICY.TYPE.TEAM, CONST.POLICY.ROLE.ADMIN), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + [`${ONYXKEYS.COLLECTION.POLICY}3`]: {...buildActivePolicy(3, CONST.POLICY.TYPE.TEAM, CONST.POLICY.ROLE.ADMIN), archivedDate: '2024-01-01'}, + [`${ONYXKEYS.COLLECTION.POLICY}4`]: buildActivePolicy(4, CONST.POLICY.TYPE.PERSONAL, CONST.POLICY.ROLE.ADMIN), + }; + + // When we get the active admin workspaces + const result = getActiveAdminWorkspaces(policies, currentUserLogin); + + // Then only the regular workspace is returned, since the others can't be shown or used + expect(result.map((policy) => policy.id)).toEqual(['1']); + }); + + it('resolves the admin role from the employee list when the policy has no role', () => { + // Given a workspace with no role of its own that lists the user as an admin const policy = createMock({ - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - employeeList: { - 'Mixed@Test.com': {}, - 'mixed@test.com': {role: CONST.POLICY.ROLE.ADMIN}, - }, + ...buildActivePolicy(1, CONST.POLICY.TYPE.CORPORATE, CONST.POLICY.ROLE.ADMIN), + role: undefined, + employeeList: {[currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN}}, }); - // When the mixed-case login is resolved without the global policy role - // Then the exact entry still wins and resolves to no role, rather than borrowing the other entry's role. - // Reading one account's role off a different account's entry is the failure this check exists to prevent - expect(isPolicyAdmin(policy, 'Mixed@Test.com', false)).toBe(false); + // When we get the active admin workspaces + const result = getActiveAdminWorkspaces({[`${ONYXKEYS.COLLECTION.POLICY}1`]: policy}, currentUserLogin); + + // Then the workspace is returned, because the employee list says the user is an admin + expect(result.map((currentPolicy) => currentPolicy.id)).toEqual(['1']); + }); + + it('returns an empty array when there are no policies', () => { + // Given no policies collection + // When we get the active admin workspaces + // Then nothing is returned + expect(getActiveAdminWorkspaces(null, currentUserLogin)).toEqual([]); }); }); - describe('isRoomMemberProtectedByPolicyRole', () => { - const adminLogin = 'admin@test.com'; - const memberLogin = 'member@test.com'; - const policyOwnerAccountID = 3001; - const regularMemberAccountID = 3002; - // `role` is the role of the user currently viewing the policy, `employeeList` holds every member's own role - const buildPolicy = (): Policy => - createMock({ - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - role: CONST.POLICY.ROLE.ADMIN, - ownerAccountID: policyOwnerAccountID, - employeeList: { - [adminLogin]: {role: CONST.POLICY.ROLE.ADMIN}, - [memberLogin]: {role: CONST.POLICY.ROLE.USER}, - }, - }); - - it('protects a member who is an admin of the policy in their own right', () => { - // Given a policy viewed by an admin, holding another admin in its employee list - // When that member's protection is resolved - // Then they are protected, because removing a workspace admin from the chat is not allowed - expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), adminLogin, undefined)).toBe(true); - }); - - it('does not protect a regular member even when the viewing user is an admin', () => { - // Given a policy whose global `role` marks the viewer as an admin, holding a regular member - // When that member's protection is resolved - // Then they are not protected, because the listed member's own role is what counts. This is the bug that - // made every member of a workspace chat un-removable to a viewing admin - expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), memberLogin, undefined)).toBe(false); + describe('canSendInvoiceFromWorkspace', () => { + it('returns true when areInvoicesEnabled is true', () => { + const policy = createMock({areInvoicesEnabled: true}); + expect(canSendInvoiceFromWorkspace(policy)).toBe(true); }); - it('fails closed for a member whose login is missing', () => { - // Given a room member with personal details but no login, which `login?: string` allows - // When their protection is resolved - // Then they are protected, because a role we cannot resolve must not be treated as "not an admin". - // Both the members list and the member details page depend on this branch to avoid offering removal - // for a member who may well be a workspace admin - expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), undefined, undefined)).toBe(true); - expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), '', undefined)).toBe(true); + it('returns false when areInvoicesEnabled is false', () => { + const policy = createMock({areInvoicesEnabled: false}); + expect(canSendInvoiceFromWorkspace(policy)).toBe(false); }); - it('does not protect a member who is absent from the employee list', () => { - // Given a login that the policy does not list as an employee - // When their protection is resolved - // Then they are not protected, since a resolvable login that holds no policy role is not an admin - expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), 'stranger@test.com', undefined)).toBe(false); + it('returns false when policy is undefined', () => { + expect(canSendInvoiceFromWorkspace(undefined)).toBe(false); }); + }); - it('protects an admin whose login is not lowercase', () => { - // Given an employee list keyed by canonical lowercase logins - // When a mixed-case login read off personal details is resolved - // Then the normalized fallback still matches the admin entry and protects them - expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), 'Admin@Test.com', undefined)).toBe(true); - expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), 'Member@Test.com', undefined)).toBe(false); - }); + describe('canSubmitPerDiemExpenseFromWorkspace', () => { + const perDiemCustomUnit = { + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + customUnitID: 'ABCDEF', + enabled: true, + rates: {}, + }; + const buildPolicy = (type: ValueOf, customUnits: Policy['customUnits']): Policy => ({...createRandomPolicy(1, type), customUnits}); - it('protects a member who is an auditor of the policy in their own right', () => { - // Given a policy viewed by an admin, holding an auditor in its employee list - const auditorLogin = 'auditor@test.com'; - const policy = buildPolicy(); - const policyWithAuditor = {...policy, employeeList: {...policy.employeeList, [auditorLogin]: {role: CONST.POLICY.ROLE.AUDITOR}}}; + it('returns true for a paid workspace with an enabled per diem custom unit', () => { + // Given a Control workspace with an enabled per diem custom unit + const policy = buildPolicy(CONST.POLICY.TYPE.CORPORATE, {ABCDEF: perDiemCustomUnit}); - // When that member's protection is resolved, including through a mixed-case login - // Then they are protected, because auditors are default members of the workspace chat and the server adds - // them back after removal. The viewer's global `role` must not hide the auditor's own role - expect(isRoomMemberProtectedByPolicyRole(policyWithAuditor, auditorLogin, undefined)).toBe(true); - expect(isRoomMemberProtectedByPolicyRole(policyWithAuditor, 'Auditor@Test.com', undefined)).toBe(true); + // When we check whether a per diem expense can be submitted from it + // Then it can + expect(canSubmitPerDiemExpenseFromWorkspace(policy)).toBe(true); }); - it('protects the policy owner by accountID even when the employee list does not list them', () => { - // Given the policy owner participating in another employee's workspace chat, absent from `employeeList` - // When their protection is resolved - // Then they are protected by `ownerAccountID`, not incidentally by carrying `role: admin` in the roster. - // The callers' own owner check compares against `report.ownerAccountID`, which is the employee whose - // expense chat it is, so without this the policy owner has no identity-based protection at all - expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), 'owner@test.com', policyOwnerAccountID)).toBe(true); + it('returns false when the per diem custom unit is disabled', () => { + // Given a Control workspace whose per diem custom unit is turned off + const policy = buildPolicy(CONST.POLICY.TYPE.CORPORATE, {ABCDEF: {...perDiemCustomUnit, enabled: false}}); + + // When we check whether a per diem expense can be submitted from it + // Then it cannot + expect(canSubmitPerDiemExpenseFromWorkspace(policy)).toBe(false); }); - it('protects the policy owner even when the employee list is blank', () => { - // Given a policy whose `employeeList` has not loaded, which happens when it is not the viewer's active - // policy, so no login can resolve to a role - const policyWithoutRoster = createMock({ - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - role: undefined, - ownerAccountID: policyOwnerAccountID, - employeeList: {}, + it('returns false when the workspace has no per diem custom unit', () => { + // Given a Control workspace that only has a distance custom unit + const policy = buildPolicy(CONST.POLICY.TYPE.CORPORATE, { + DISTANCE: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, customUnitID: 'DISTANCE', enabled: true, rates: {}}, }); - // When the owner's protection is resolved - // Then they are still protected, because `ownerAccountID` is a required top-level field that resolves - // without the roster - expect(isRoomMemberProtectedByPolicyRole(policyWithoutRoster, 'owner@test.com', policyOwnerAccountID)).toBe(true); + // When we check whether a per diem expense can be submitted from it + // Then it cannot, because there is no per diem unit to submit against + expect(canSubmitPerDiemExpenseFromWorkspace(policy)).toBe(false); }); - it('does not protect a non-owner just because an accountID is passed', () => { - // Given a regular member's accountID alongside their login - // When their protection is resolved - // Then they stay removable, since the owner check must not widen protection to every member with an - // accountID. This is the regression that would reintroduce the original bug - expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), memberLogin, regularMemberAccountID)).toBe(false); + it.each([CONST.POLICY.TYPE.SUBMIT, CONST.POLICY.TYPE.PERSONAL])('returns false for a %s workspace even with an enabled per diem custom unit', (type) => { + // Given a workspace on a plan that isn't paid, with an enabled per diem custom unit + const policy = buildPolicy(type, {ABCDEF: perDiemCustomUnit}); + + // When we check whether a per diem expense can be submitted from it + // Then it cannot, because per diem is only available on paid workspaces + expect(canSubmitPerDiemExpenseFromWorkspace(policy)).toBe(false); }); - describe('approvers auto-added to the expense chat by the approval chain', () => { - const approverLogin = 'approver@test.com'; - const forwardsToLogin = 'forwardsto@test.com'; - const chainApproverAccountID = 3003; - // An approval chain that pulls two non-admin approvers into the member's expense chat: one they submit to, - // one their reports forward to. `policy.approver` is the workspace's default approver on top of that - const buildPolicyWithApprovalChain = (approverRole: ValueOf = CONST.POLICY.ROLE.USER): Policy => - createMock({ - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - role: CONST.POLICY.ROLE.ADMIN, - ownerAccountID: policyOwnerAccountID, - approver: approverLogin, - employeeList: { - [approverLogin]: {role: approverRole}, - [forwardsToLogin]: {role: approverRole}, - [memberLogin]: {role: CONST.POLICY.ROLE.USER, submitsTo: approverLogin, forwardsTo: forwardsToLogin}, - }, - }); + it('returns false when the policy is undefined', () => { + // Given no policy + // When we check whether a per diem expense can be submitted from it + // Then it cannot + expect(canSubmitPerDiemExpenseFromWorkspace(undefined)).toBe(false); + }); + }); - it('protects an approver who holds no admin role of their own', () => { - // Given a workspace chat whose participants include two approvers from the submitter's approval - // chain, both plain members of the workspace - const policy = buildPolicyWithApprovalChain(); + describe('canSendInvoice', () => { + const currentUserLogin = 'admin@test.com'; + // createRandomPolicy randomizes these, so set the fields that decide whether a workspace is active + const buildActivePolicy = (id: number, role: ValueOf, areInvoicesEnabled: boolean): Policy => ({ + ...createRandomPolicy(id, CONST.POLICY.TYPE.CORPORATE), + name: `Workspace ${id}`, + role, + pendingAction: null, + archivedDate: undefined, + areInvoicesEnabled, + }); - // When each approver's protection is resolved - // Then they are protected on the strength of being approvers alone. Approvers are auto-added to the - // chat of everyone who submits to them, so their membership is governed by the workspace's approval - // workflow rather than by this screen: only a member who was invited to the chat can be removed from - // it, per the expense chat rules in contributingGuides/philosophies/SECURITY.md - expect(isRoomMemberProtectedByPolicyRole(policy, approverLogin, chainApproverAccountID)).toBe(true); - expect(isRoomMemberProtectedByPolicyRole(policy, forwardsToLogin, chainApproverAccountID)).toBe(true); - }); + it('returns true when an active admin workspace has invoices enabled', () => { + // Given an admin workspace with invoices enabled and one with invoices disabled + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: buildActivePolicy(1, CONST.POLICY.ROLE.ADMIN, false), + [`${ONYXKEYS.COLLECTION.POLICY}2`]: buildActivePolicy(2, CONST.POLICY.ROLE.ADMIN, true), + }; - it('still allows removing an invited member of a workspace that has an approval chain', () => { - // Given the same approval chain, and the submitter who is neither an admin, the owner, nor an approver - const policy = buildPolicyWithApprovalChain(); + // When we check whether the user can send an invoice + // Then they can, from the workspace with invoices enabled + expect(canSendInvoice(policies, currentUserLogin)).toBe(true); + }); - // When their protection is resolved - // Then they stay removable. Protecting approvers must not widen back out to every member and - // reintroduce the bug this PR fixes - expect(isRoomMemberProtectedByPolicyRole(policy, memberLogin, regularMemberAccountID)).toBe(false); - }); + it('returns false when invoices are only enabled on a workspace where the user is a member', () => { + // Given invoices enabled only on a workspace where the user is not an admin + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: buildActivePolicy(1, CONST.POLICY.ROLE.ADMIN, false), + [`${ONYXKEYS.COLLECTION.POLICY}2`]: buildActivePolicy(2, CONST.POLICY.ROLE.USER, true), + }; - it('protects an approver who is also an admin of the policy', () => { - // Given the same approval chain, with both approvers holding the admin role - const policy = buildPolicyWithApprovalChain(CONST.POLICY.ROLE.ADMIN); + // When we check whether the user can send an invoice + // Then they cannot, because only admins can send invoices + expect(canSendInvoice(policies, currentUserLogin)).toBe(false); + }); - // When each approver's protection is resolved - // Then they are protected, because they are admins. Most approvers are, so this is the common case - expect(isRoomMemberProtectedByPolicyRole(policy, approverLogin, chainApproverAccountID)).toBe(true); - expect(isRoomMemberProtectedByPolicyRole(policy, forwardsToLogin, chainApproverAccountID)).toBe(true); - }); + it('returns false when the only admin workspace with invoices enabled is pending deletion', () => { + // Given an admin workspace with invoices enabled that is being deleted + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: {...buildActivePolicy(1, CONST.POLICY.ROLE.ADMIN, true), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }; - it('protects an approver who is the policy owner', () => { - // Given an approval chain whose approvers are plain members, one of whom owns the workspace - const policy = buildPolicyWithApprovalChain(); + // When we check whether the user can send an invoice + // Then they cannot, because the workspace is no longer active + expect(canSendInvoice(policies, currentUserLogin)).toBe(false); + }); - // When the owning approver's protection is resolved by accountID - // Then they are protected as the policy owner, regardless of their role in the employee list - expect(isRoomMemberProtectedByPolicyRole(policy, approverLogin, policyOwnerAccountID)).toBe(true); - }); + it('returns false when there are no policies', () => { + // Given no policies collection + // When we check whether the user can send an invoice + // Then they cannot + expect(canSendInvoice(null, currentUserLogin)).toBe(false); }); }); - describe('getPolicyApproverLogins', () => { - it('returns an empty set when policy is undefined', () => { - expect(getPolicyApproverLogins(undefined).size).toBe(0); + describe('sortWorkspacesBySelected', () => { + it('should order workspaces with selected workspace first', () => { + const workspace1 = {policyID: '1', name: 'Workspace 1'}; + const workspace2 = {policyID: '2', name: 'Workspace 2'}; + const selectedWorkspace1 = {policyID: '3', name: 'Workspace 3'}; + const selectedWorkspace2 = {policyID: '4', name: 'Workspace 4'}; + expect(sortWorkspacesBySelected(workspace1, workspace2, ['3', '4'], TestHelper.localeCompare)).toBe(-1); + expect(sortWorkspacesBySelected(workspace1, selectedWorkspace1, ['3', '4'], TestHelper.localeCompare)).toBe(1); + expect(sortWorkspacesBySelected(selectedWorkspace1, selectedWorkspace2, ['3', '4'], TestHelper.localeCompare)).toBe(-1); }); - it('returns an empty set when there is no approver and no employees route anywhere', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: undefined, - employeeList: { - 'employee@test.com': {email: 'employee@test.com', submitsTo: '', forwardsTo: '', overLimitForwardsTo: ''}, - }, - }; - expect(getPolicyApproverLogins(policy).size).toBe(0); + it('should order workspaces using name if no workspace is selected', () => { + const workspace1 = {policyID: '1', name: 'Workspace 1'}; + const workspace2 = {policyID: '2', name: 'Workspace 2'}; + const workspace3 = {policyID: '3', name: 'Workspace 3'}; + const workspace4 = {policyID: '4', name: 'Workspace 4'}; + expect(sortWorkspacesBySelected(workspace1, workspace2, undefined, TestHelper.localeCompare)).toBe(-1); + expect(sortWorkspacesBySelected(workspace1, workspace3, undefined, TestHelper.localeCompare)).toBe(-1); + expect(sortWorkspacesBySelected(workspace3, workspace4, undefined, TestHelper.localeCompare)).toBe(-1); }); - it('includes the named policy approver', () => { - const policy: Policy = {...createRandomPolicy(0), approver: 'boss@test.com', employeeList: {}}; - expect([...getPolicyApproverLogins(policy)]).toEqual(['boss@test.com']); + it('should sort workspaces when using this method correctly', () => { + const unsortedWorkspaces = [ + {policyID: '2', name: 'Workspace 2'}, + {policyID: '1', name: 'Workspace 1'}, + {policyID: '4', name: 'Workspace 4'}, + {policyID: '3', name: 'Workspace 3'}, + ]; + const selectedWorkspaceIDs = ['3', '4']; + const sortedWorkspaces = unsortedWorkspaces.sort((a, b) => sortWorkspacesBySelected(a, b, selectedWorkspaceIDs, TestHelper.localeCompare)); + expect(sortedWorkspaces).toEqual([ + {policyID: '3', name: 'Workspace 3'}, + {policyID: '4', name: 'Workspace 4'}, + {policyID: '1', name: 'Workspace 1'}, + {policyID: '2', name: 'Workspace 2'}, + ]); }); + }); - it('collects submitsTo, forwardsTo and overLimitForwardsTo targets', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: undefined, - employeeList: { - 'a@test.com': {email: 'a@test.com', submitsTo: 'manager@test.com'}, - 'b@test.com': {email: 'b@test.com', forwardsTo: 'director@test.com'}, - 'c@test.com': {email: 'c@test.com', overLimitForwardsTo: 'vp@test.com'}, - }, - }; - expect([...getPolicyApproverLogins(policy)].sort()).toEqual(['director@test.com', 'manager@test.com', 'vp@test.com']); + describe('hasOnlyPersonalPolicies', () => { + it('should return true when policies is empty', () => { + const result = hasOnlyPersonalPolicies({}); + expect(result).toBe(true); }); - it('deduplicates approvers referenced by multiple employees or fields', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'manager@test.com', - employeeList: { - 'a@test.com': {email: 'a@test.com', submitsTo: 'manager@test.com'}, - 'b@test.com': {email: 'b@test.com', submitsTo: 'manager@test.com', forwardsTo: 'manager@test.com'}, - }, + it('should return false when there are policies other than personal policies', () => { + const policies = { + '1': {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, + '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.PERSONAL), pendingAction: undefined}, }; - expect([...getPolicyApproverLogins(policy)]).toEqual(['manager@test.com']); + const result = hasOnlyPersonalPolicies(policies); + expect(result).toBe(false); }); - it('ignores empty-string routing fields', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: '', - employeeList: { - 'a@test.com': {email: 'a@test.com', submitsTo: '', forwardsTo: 'director@test.com', overLimitForwardsTo: ''}, - }, + it('should return true when there are no policies other than personal policies', () => { + const policies = { + '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.PERSONAL), pendingAction: undefined}, }; - expect([...getPolicyApproverLogins(policy)]).toEqual(['director@test.com']); + const result = hasOnlyPersonalPolicies(policies); + expect(result).toBe(true); }); }); - }); - describe('useDefaultFundID', () => { - beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); + describe('getDefaultWorkspacePlanType', () => { + const submitPolicy = {...createRandomPolicy(1, CONST.POLICY.TYPE.SUBMIT), id: 'submit1'}; + const teamPolicy = {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), id: 'team1'}; + const corporatePolicy = {...createRandomPolicy(3, CONST.POLICY.TYPE.CORPORATE), id: 'corporate1'}; + const personalPolicy = {...createRandomPolicy(4, CONST.POLICY.TYPE.PERSONAL), id: 'personal1'}; + + it.each([ + {description: 'the collection is null', policies: null, expected: CONST.POLICY.TYPE.TEAM}, + {description: 'the collection is undefined', policies: undefined, expected: CONST.POLICY.TYPE.TEAM}, + {description: 'the collection is empty', policies: {}, expected: CONST.POLICY.TYPE.TEAM}, + {description: 'the collection only holds empty entries', policies: {policy_1: undefined, policy_2: undefined}, expected: CONST.POLICY.TYPE.TEAM}, + { + description: 'the collection holds Submit, Collect and Personal workspaces', + policies: {policy_submit1: submitPolicy, policy_team1: teamPolicy, policy_personal1: personalPolicy}, + expected: CONST.POLICY.TYPE.TEAM, + }, + {description: 'the collection holds a single Control workspace', policies: {policy_corporate1: corporatePolicy}, expected: CONST.POLICY.TYPE.CORPORATE}, + { + description: 'the collection holds a Control workspace mixed in with other plan types', + policies: {policy_submit1: submitPolicy, policy_team1: teamPolicy, policy_corporate1: corporatePolicy, policy_personal1: personalPolicy}, + expected: CONST.POLICY.TYPE.CORPORATE, + }, + { + description: 'the collection holds a Control workspace between empty entries', + policies: {policy_1: undefined, policy_corporate1: corporatePolicy, policy_2: undefined}, + expected: CONST.POLICY.TYPE.CORPORATE, + }, + ])('returns $expected when $description', ({policies, expected}) => { + expect(getDefaultWorkspacePlanType(policies)).toBe(expected); + }); }); - it('should return domainID for given policyID when workspaceID is not set', async () => { - const policy: Policy = { - ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), - policyAccountID: 0, - }; - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}2`, policy); - await Onyx.set(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}18441278`, { - currentBalance: 0, - domainName: 'expensify-policy8fe6324c4897.exfy', - earnedCashback: 0, - isLoading: false, - isMonthlySettlementAllowed: false, - marqetaBusinessToken: 18441278, - paymentBankAccountAddressName: 'Alberta Bobbeth Charleson', - paymentBankAccountID: 3288123, - paymentBankAccountNumber: 'XXXXXXXXXXXX1111', - preferredPolicy: '2', - remainingLimit: 0, + + describe('isPolicyAccessible', () => { + const currentUserLogin = 'member@test.com'; + const accessiblePolicy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), role: CONST.POLICY.ROLE.USER, pendingAction: null}; + + it('returns true for a workspace the user belongs to', () => { + // Given a workspace where the user has a role and that isn't being deleted + // When we check whether the user can access it + // Then they can + expect(isPolicyAccessible(accessiblePolicy, currentUserLogin)).toBe(true); }); - const {result} = renderHook(() => useDefaultFundID(policy.id)); - expect(result?.current).toBe(18441278); - }); + it('returns false when the policy is undefined or empty', () => { + // Given no policy data + // When we check whether the user can access it + // Then they cannot + expect(isPolicyAccessible(undefined, currentUserLogin)).toBe(false); + expect(isPolicyAccessible(createMock({}), currentUserLogin)).toBe(false); + }); - it('should return lastSelectedExpensifyCardFeed for given policyID when lastSelectedExpensifyCardFeed is set', async () => { - const policy: Policy = { - ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), - policyAccountID: 0, - }; - const lastSelectedExpensifyCardFeed = 11111; - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}2`, policy); - await Onyx.set(`${ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_FEED}2`, lastSelectedExpensifyCardFeed); - await Onyx.set(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${lastSelectedExpensifyCardFeed}`, { - [CONST.COUNTRY.US]: { - paymentBankAccountID: 1234, - }, + it('returns false when the policy only holds errors', () => { + // Given a policy entry that only holds the error from a failed request, such as an invalid policy ID + const policy = createMock({errors: {error: 'Policy not found'}}); + + // When we check whether the user can access it + // Then they cannot + expect(isPolicyAccessible(policy, currentUserLogin)).toBe(false); }); - const {result} = renderHook(() => useDefaultFundID(policy.id)); - expect(result?.current).toBe(lastSelectedExpensifyCardFeed); - }); + it('returns false when the policy has no ID', () => { + // Given a workspace whose ID is missing + const policy = createMock({...accessiblePolicy, id: undefined}); - it('should return workspaceAccountID for given policyID', async () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - policyAccountID: 123234, - }; - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); - const {result} = renderHook(() => useDefaultFundID(policy.id)); + // When we check whether the user can access it + // Then they cannot + expect(isPolicyAccessible(policy, currentUserLogin)).toBe(false); + }); - expect(result?.current).toBe(123234); - }); - }); + it('returns false when the policy is pending deletion', () => { + // Given a workspace that is being deleted + const policy: Policy = {...accessiblePolicy, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; - describe('getActivePolicies', () => { - it("getActivePolicies should filter out policies that the current user doesn't belong to", () => { - const policies = createCollection( - (item) => `${ONYXKEYS.COLLECTION.POLICY}${item.id}`, - (index) => createMock({...createRandomPolicy(index + 1), name: 'workspace', pendingAction: null, ...(!index && {role: undefined})}), - 2, - ); - expect(getActivePolicies(policies, undefined)).toHaveLength(1); - }); + // When we check whether the user can access it + // Then they cannot + expect(isPolicyAccessible(policy, currentUserLogin)).toBe(false); + }); - it('excludes archived policies while keeping the active sibling', () => { - const activePolicy = createMock({...createRandomPolicy(1), name: 'active', pendingAction: null}); - const archivedPolicy = createMock({...createRandomPolicy(2), name: 'archived', pendingAction: null, archivedDate: '2024-01-01'}); - const policies = createCollection( - (item) => `${ONYXKEYS.COLLECTION.POLICY}${item.id}`, - (index) => (index === 0 ? activePolicy : archivedPolicy), - 2, - ); + it('returns false when the user has no role in the policy', () => { + // Given a workspace with no role for the user and no employee list entry for them + const policy = createMock({...accessiblePolicy, role: undefined, employeeList: {}}); - const result = getActivePolicies(policies, undefined); - expect(result).toHaveLength(1); - expect(result.at(0)?.id).toBe(activePolicy.id); - }); - }); - describe('getActivePoliciesWithExpenseChat', () => { - it('excludes archived policies while keeping the active sibling', () => { - const activePolicy = createMock({...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), name: 'active', pendingAction: null}); - const archivedPolicy = createMock({ - ...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE), - name: 'archived', - pendingAction: null, - archivedDate: '2024-01-01', + // When we check whether the user can access it + // Then they cannot, because they are not a member + expect(isPolicyAccessible(policy, currentUserLogin)).toBe(false); }); - const policies = createCollection( - (item) => `${ONYXKEYS.COLLECTION.POLICY}${item.id}`, - (index) => (index === 0 ? activePolicy : archivedPolicy), - 2, - ); - const result = getActivePoliciesWithExpenseChat(policies, undefined); - expect(result).toHaveLength(1); - expect(result.at(0)?.id).toBe(activePolicy.id); + it('returns true when the user role comes from the employee list', () => { + // Given a workspace with no role of its own that lists the user as a member + const policy = createMock({...accessiblePolicy, role: undefined, employeeList: {[currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.USER}}}); + + // When we check whether the user can access it + // Then they can + expect(isPolicyAccessible(policy, currentUserLogin)).toBe(true); + }); }); - }); - describe('customUnits', () => { - const perDiemCustomUnit = { - name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, - customUnitID: 'ABCDEF', - enabled: true, - rates: { - London: { - customUnitRateID: 'London', - name: 'London', - }, - }, - }; + describe('getGroupPoliciesWhereReportCanBeCreated', () => { + const currentUserLogin = 'member@test.com'; + // createRandomPolicy randomizes these, so set the fields that decide whether a workspace is shown + const buildPolicy = (id: string, type: ValueOf): Policy => ({ + ...createRandomPolicy(0, type), + id, + role: CONST.POLICY.ROLE.USER, + isJoinRequestPending: false, + pendingAction: null, + archivedDate: undefined, + }); - describe('getCustomUnitsForDuplication', () => { - const perDiemUnit = { - customUnitID: '123', - name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, - enabled: true, - rates: {}, - }; - const otherUnit = { - customUnitID: '456', - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - enabled: true, - rates: {}, - }; - const policy: Policy = { - ...createRandomPolicy(0), - customUnits: { - [perDiemUnit.customUnitID]: perDiemUnit, - [otherUnit.customUnitID]: otherUnit, - }, - }; + it('returns Collect, Control and Submit workspaces but not Personal ones', () => { + // Given workspaces on every plan type + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}team`]: buildPolicy('team', CONST.POLICY.TYPE.TEAM), + [`${ONYXKEYS.COLLECTION.POLICY}corporate`]: buildPolicy('corporate', CONST.POLICY.TYPE.CORPORATE), + [`${ONYXKEYS.COLLECTION.POLICY}submit`]: buildPolicy('submit', CONST.POLICY.TYPE.SUBMIT), + [`${ONYXKEYS.COLLECTION.POLICY}personal`]: buildPolicy('personal', CONST.POLICY.TYPE.PERSONAL), + }; - const policyWithoutCustomUnits: Policy = { - ...createRandomPolicy(0), - }; + // When we get the workspaces where a report can be created + const result = getGroupPoliciesWhereReportCanBeCreated(policies, currentUserLogin); - it('returns undefined if neither option is selected', () => { - expect(getCustomUnitsForDuplication(policy, false, false, {distanceCustomUnitID: otherUnit.customUnitID, perDiemCustomUnitID: perDiemUnit.customUnitID})).toBeUndefined(); + // Then the paid and Submit workspaces are returned, since Submit workspaces support reports even though they're free + expect(result.map((policy) => policy.id).sort()).toEqual(['corporate', 'submit', 'team']); }); - it('returns all custom units if both options are selected', () => { - const result = getCustomUnitsForDuplication(policy, true, true, {distanceCustomUnitID: otherUnit.customUnitID, perDiemCustomUnitID: perDiemUnit.customUnitID}); - expect(result).toEqual(policy.customUnits); - }); - it('returns only non-per-diem units if only custom units option is selected', () => { - const result = getCustomUnitsForDuplication(policy, true, false, {distanceCustomUnitID: otherUnit.customUnitID, perDiemCustomUnitID: perDiemUnit.customUnitID}); - expect(result).toEqual({[otherUnit.customUnitID]: otherUnit}); - }); + it('excludes workspaces with a pending join request, pending deletion, or an archive date', () => { + // Given a regular workspace next to ones the user can't create reports in yet or anymore + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}active`]: buildPolicy('active', CONST.POLICY.TYPE.TEAM), + [`${ONYXKEYS.COLLECTION.POLICY}joinPending`]: {...buildPolicy('joinPending', CONST.POLICY.TYPE.TEAM), isJoinRequestPending: true}, + [`${ONYXKEYS.COLLECTION.POLICY}deleting`]: {...buildPolicy('deleting', CONST.POLICY.TYPE.TEAM), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + [`${ONYXKEYS.COLLECTION.POLICY}archived`]: {...buildPolicy('archived', CONST.POLICY.TYPE.TEAM), archivedDate: '2024-01-01'}, + }; - it('returns only per diem unit if only per diem option is selected', () => { - const result = getCustomUnitsForDuplication(policy, false, true, {distanceCustomUnitID: otherUnit.customUnitID, perDiemCustomUnitID: perDiemUnit.customUnitID}); - expect(result).toEqual({[perDiemUnit.customUnitID]: perDiemUnit}); - }); + // When we get the workspaces where a report can be created + const result = getGroupPoliciesWhereReportCanBeCreated(policies, currentUserLogin); - it('returns undefined if customUnits is empty', () => { - expect( - getCustomUnitsForDuplication(policyWithoutCustomUnits, true, true, {distanceCustomUnitID: otherUnit.customUnitID, perDiemCustomUnitID: perDiemUnit.customUnitID}), - ).toBeUndefined(); + // Then only the regular workspace is returned + expect(result.map((policy) => policy.id)).toEqual(['active']); }); - it('rebinds the default rate to the API-known customUnitRateID and keeps non-default rates with their source IDs', () => { - const distanceUnitWithMultipleRates = { - customUnitID: 'srcDist', - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - enabled: true, - attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, taxEnabled: true}, - rates: { - rateB: {customUnitRateID: 'rateB', name: 'New Rate 1', rate: 100, currency: 'USD', enabled: true, index: 1, attributes: {taxRateExternalID: 'tax_other'}}, - rateA: {customUnitRateID: 'rateA', name: 'Default Rate', rate: 70, currency: 'USD', enabled: true, index: 0, attributes: {taxRateExternalID: 'tax_default'}}, - }, - }; - const policyWithMultipleRates: Policy = { - ...createRandomPolicy(0), - customUnits: {[distanceUnitWithMultipleRates.customUnitID]: distanceUnitWithMultipleRates}, + it('excludes the TeachersUnite workspace', () => { + // Given the TeachersUnite workspace next to a regular one + const teachersUnitePolicyID = CONST.TEACHERS_UNITE.TEST_POLICY_ID; + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}active`]: buildPolicy('active', CONST.POLICY.TYPE.TEAM), + [`${ONYXKEYS.COLLECTION.POLICY}${teachersUnitePolicyID}`]: buildPolicy(teachersUnitePolicyID, CONST.POLICY.TYPE.TEAM), }; - const result = getCustomUnitsForDuplication(policyWithMultipleRates, true, false, { - distanceCustomUnitID: 'newDist', - perDiemCustomUnitID: 'newPerDiem', - customUnitRateID: 'newRate', - }); - expect(result).toEqual({ - newDist: { - ...distanceUnitWithMultipleRates, - customUnitID: 'newDist', - rates: { - rateB: {customUnitRateID: 'rateB', name: 'New Rate 1', rate: 100, currency: 'USD', enabled: true, index: 1, attributes: {taxRateExternalID: 'tax_other'}}, - newRate: {customUnitRateID: 'newRate', name: 'Default Rate', rate: 70, currency: 'USD', enabled: true, index: 0, attributes: {taxRateExternalID: 'tax_default'}}, - }, - }, - }); - }); - it('keeps source rates with their source IDs when no enabled rate exists', () => { - const distanceUnitAllDisabled = { - customUnitID: 'srcDist', - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - enabled: true, - attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, - rates: { - rateA: {customUnitRateID: 'rateA', name: 'Disabled', rate: 50, currency: 'USD', enabled: false, index: 0}, - }, - }; - const policyAllDisabled: Policy = { - ...createRandomPolicy(0), - customUnits: {[distanceUnitAllDisabled.customUnitID]: distanceUnitAllDisabled}, - }; - const result = getCustomUnitsForDuplication(policyAllDisabled, true, false, { - distanceCustomUnitID: 'newDist', - perDiemCustomUnitID: 'newPerDiem', - customUnitRateID: 'newRate', - }); - expect(result?.newDist.rates).toEqual({ - rateA: {customUnitRateID: 'rateA', name: 'Disabled', rate: 50, currency: 'USD', enabled: false, index: 0}, - }); + // When we get the workspaces where a report can be created + const result = getGroupPoliciesWhereReportCanBeCreated(policies, currentUserLogin); + + // Then the TeachersUnite workspace is left out + expect(result.map((policy) => policy.id)).toEqual(['active']); }); - it('treats missing index as 0 when picking the default rate', () => { - const distanceUnitWithMissingIndex = { - customUnitID: 'srcDist', - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - enabled: true, - attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, - rates: { - rateB: {customUnitRateID: 'rateB', name: 'Indexed Rate', rate: 100, currency: 'USD', enabled: true, index: 1}, - rateA: {customUnitRateID: 'rateA', name: 'No-Index Rate', rate: 70, currency: 'USD', enabled: true}, - }, - }; - const policyWithMissingIndex: Policy = { - ...createRandomPolicy(0), - customUnits: {[distanceUnitWithMissingIndex.customUnitID]: distanceUnitWithMissingIndex}, - }; - const result = getCustomUnitsForDuplication(policyWithMissingIndex, true, false, { - distanceCustomUnitID: 'newDist', - perDiemCustomUnitID: 'newPerDiem', - customUnitRateID: 'newRate', - }); - expect(result?.newDist.rates).toEqual({ - rateB: {customUnitRateID: 'rateB', name: 'Indexed Rate', rate: 100, currency: 'USD', enabled: true, index: 1}, - newRate: {customUnitRateID: 'newRate', name: 'No-Index Rate', rate: 70, currency: 'USD', enabled: true}, - }); + it('returns an empty array when there are no policies', () => { + // Given an empty or missing policies collection + // When we get the workspaces where a report can be created + // Then nothing is returned + expect(getGroupPoliciesWhereReportCanBeCreated({}, currentUserLogin)).toEqual([]); + expect(getGroupPoliciesWhereReportCanBeCreated(null, currentUserLogin)).toEqual([]); }); + }); - it('preserves source iteration order so getDefaultMileageRate still picks the original default when a non-default rate has a missing index', () => { - // Source has 3 rates: Default (index 0), Indexed (index 1), and No-Index (no index field). - // No-Index ties with Default at the index-0 sort key after `?? CONST.DEFAULT_NUMBER_ID`. - // The optimistic clone must keep the source's iteration order so JavaScript's stable - // sort still places Default before No-Index — otherwise getDefaultMileageRate would - // pick whichever tied rate appeared first in iteration order, swapping the default. - const sourceUnit = { - customUnitID: 'srcDist', - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - enabled: true, - attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, - rates: { - rateA: {customUnitRateID: 'rateA', name: 'Default Rate', rate: 72.5, currency: 'USD', enabled: true, index: 0}, - rateB: {customUnitRateID: 'rateB', name: 'Indexed Rate', rate: 100, currency: 'USD', enabled: true, index: 1}, - rateC: {customUnitRateID: 'rateC', name: 'No-Index Rate', rate: 200, currency: 'USD', enabled: true}, - }, - }; - const policyWithTiedIndex: Policy = { - ...createRandomPolicy(0), - customUnits: {[sourceUnit.customUnitID]: sourceUnit}, - }; - const result = getCustomUnitsForDuplication(policyWithTiedIndex, true, false, { - distanceCustomUnitID: 'newDist', - perDiemCustomUnitID: 'newPerDiem', - customUnitRateID: 'newRate', - }); + describe('getDefaultChatEnabledPolicy', () => { + const submitPolicy = {...createRandomPolicy(1, CONST.POLICY.TYPE.SUBMIT), id: 'submit1'}; + const teamPolicy = {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), id: 'team1'}; + const corporatePolicy = {...createRandomPolicy(3, CONST.POLICY.TYPE.CORPORATE), id: 'corporate1'}; + const personalPolicy = {...createRandomPolicy(4, CONST.POLICY.TYPE.PERSONAL), id: 'personal1'}; - const cloned = result?.newDist; - if (!cloned) { - throw new Error('Expected cloned distance unit'); - } - // Iteration order: the rebound default should still be first, before the No-Index rate. - const orderedKeys = Object.keys(cloned.rates); - expect(orderedKeys).toEqual(['newRate', 'rateB', 'rateC']); + it('returns the active policy when it is a Submit workspace, even with multiple eligible workspaces', () => { + // Regression: a Submit active policy must be recognized as the report-creation default. Previously + // isPaidGroupPolicy excluded it, returning undefined here and wrongly forcing the workspace selector. + expect(getDefaultChatEnabledPolicy([submitPolicy, teamPolicy], submitPolicy)).toBe(submitPolicy); + }); - // Sanity-check that getDefaultMileageRate's selection (enabled, sorted by index ?? 0, - // first) lands on the rebound default and not on No-Index Rate. - const pickedDefault = Object.values(cloned.rates) - .filter((rate) => rate.enabled !== false) - .sort((a, b) => (a.index ?? CONST.DEFAULT_NUMBER_ID) - (b.index ?? CONST.DEFAULT_NUMBER_ID)) - .at(0); - expect(pickedDefault?.customUnitRateID).toBe('newRate'); - expect(pickedDefault?.name).toBe('Default Rate'); + it('returns the active policy when it is a paid (Collect/Control) workspace', () => { + expect(getDefaultChatEnabledPolicy([teamPolicy, corporatePolicy], teamPolicy)).toBe(teamPolicy); }); - }); - describe('getRateDisplayValue', () => { - it('should return an empty string for NaN', () => { - // This deliberately malformed string exercises the runtime NaN guard while preserving the production return type. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The invalid string is the scenario under test. - const rate = getRateDisplayValue('invalid' as unknown as Parameters[0], toLocaleDigitMock); - expect(rate).toEqual(''); + + it('returns the only eligible workspace when the active policy is personal', () => { + expect(getDefaultChatEnabledPolicy([submitPolicy], personalPolicy)).toBe(submitPolicy); }); - describe('withDecimals = false', () => { - it('should return integer value as is', () => { - const rate = getRateDisplayValue(100, toLocaleDigitMock); - expect(rate).toEqual('100'); - }); + it('returns undefined when the active policy is not a group policy and there are multiple eligible workspaces', () => { + expect(getDefaultChatEnabledPolicy([submitPolicy, teamPolicy], personalPolicy)).toBeUndefined(); + }); - it('should return non-integer value as is', () => { - const rate = getRateDisplayValue(10.5, toLocaleDigitMock); - expect(rate).toEqual('10.5'); - }); + it('returns undefined when there are no eligible workspaces', () => { + expect(getDefaultChatEnabledPolicy([], undefined)).toBeUndefined(); }); - describe('withDecimals = true', () => { - it('should return integer value with 2 trailing zeros', () => { - const rate = getRateDisplayValue(10, toLocaleDigitMock, true); - expect(rate).toEqual('10.00'); - }); + it('does not return the active policy when it is not in the eligible list (e.g. Submit beta off), falling back to the eligible workspace', () => { + expect(getDefaultChatEnabledPolicy([teamPolicy], submitPolicy)).toBe(teamPolicy); + }); - it('should return non-integer value with up to 2 trailing zeros', () => { - const rate = getRateDisplayValue(10.5, toLocaleDigitMock, true); - expect(rate).toEqual('10.50'); - }); + it('returns undefined when the active policy is ineligible and there are multiple eligible workspaces', () => { + expect(getDefaultChatEnabledPolicy([teamPolicy, corporatePolicy], submitPolicy)).toBeUndefined(); + }); + }); - it('should return non-integer value with 4 decimals as is', () => { - const rate = getRateDisplayValue(10.5312, toLocaleDigitMock, true); - expect(rate).toEqual('10.5312'); - }); + describe('getDefaultChatEnabledPolicySelection', () => { + // createRandomPolicy randomizes these, so eligibility comes out flaky + const eligibleFields = {role: CONST.POLICY.ROLE.ADMIN, isJoinRequestPending: false, pendingAction: undefined, archivedDate: undefined}; + const teamPolicy = {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), ...eligibleFields, id: 'team1'}; + const corporatePolicy = {...createRandomPolicy(3, CONST.POLICY.TYPE.CORPORATE), ...eligibleFields, id: 'corporate1'}; + const collection = { + [`${ONYXKEYS.COLLECTION.POLICY}${teamPolicy.id}`]: teamPolicy, + [`${ONYXKEYS.COLLECTION.POLICY}${corporatePolicy.id}`]: corporatePolicy, + }; - it('should return non-integer value with 3 decimals as is', () => { - const rate = getRateDisplayValue(10.531, toLocaleDigitMock, true); - expect(rate).toEqual('10.531'); - }); + it.each([ + [ + 'resolves the active policy from the collection by ID', + collection, + corporatePolicy.id, + {defaultChatEnabledPolicyID: corporatePolicy.id, hasChatEnabledPolicies: true, hasMultipleChatEnabledPolicies: true}, + ], + [ + 'returns no default when the active policy is unknown and multiple workspaces are eligible', + collection, + undefined, + {defaultChatEnabledPolicyID: undefined, hasChatEnabledPolicies: true, hasMultipleChatEnabledPolicies: true}, + ], + [ + 'returns the only eligible workspace and reports a single workspace', + {[`${ONYXKEYS.COLLECTION.POLICY}${teamPolicy.id}`]: teamPolicy}, + undefined, + {defaultChatEnabledPolicyID: teamPolicy.id, hasChatEnabledPolicies: true, hasMultipleChatEnabledPolicies: false}, + ], + [ + 'reports no eligible workspace for an empty collection', + {}, + undefined, + {defaultChatEnabledPolicyID: undefined, hasChatEnabledPolicies: false, hasMultipleChatEnabledPolicies: false}, + ], + ])('%s', (_description, policies, activePolicyID, expected) => { + // Given the user's policy collection and active policy ID + // When the scalar selection used by the create-report entry points is computed + const selection = getDefaultChatEnabledPolicySelection(policies, undefined, activePolicyID); - it('should return non-integer value with 4+ decimals cut to 4', () => { - const rate = getRateDisplayValue(10.53135, toLocaleDigitMock, true); - expect(rate).toEqual('10.5313'); - }); + // Then it carries the default workspace and the eligibility flags the hook branches on + expect(selection).toEqual(expected); }); }); - describe('getUnitRateValue', () => { - it('should return an empty string for NaN', () => { - // This deliberately malformed string exercises the runtime NaN guard while preserving the production return type. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The invalid rate is the scenario under test. - const rate = getUnitRateValue(toLocaleDigitMock, {rate: 'invalid' as unknown as NonNullable[1]>['rate']}); - expect(rate).toEqual(''); + describe('hasOtherControlWorkspaces', () => { + it('should return false when policies is empty', () => { + const result = hasOtherControlWorkspaces([], '1'); + expect(result).toBe(false); }); - describe('withDecimals = false', () => { - it('should return value divisible by 100 with no decimal places', () => { - const rate = getUnitRateValue(toLocaleDigitMock, {rate: 100}); - expect(rate).toEqual('1'); - }); + it('should return false when there are no control workspaces other than the current one', () => { + const policies = [ + {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: undefined}, + {...createRandomPolicy(2, CONST.POLICY.TYPE.PERSONAL), pendingAction: undefined}, + {...createRandomPolicy(3, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, + ]; + const result = hasOtherControlWorkspaces(policies, '1'); + expect(result).toBe(false); + }); - it('should return non-integer value as is divided by 100', () => { - const rate = getUnitRateValue(toLocaleDigitMock, {rate: 11.11}); - expect(rate).toEqual('0.1111'); - }); + it('should return true when there are other control workspaces', () => { + const policies = [ + {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: undefined}, + {...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE), pendingAction: undefined}, + {...createRandomPolicy(3, CONST.POLICY.TYPE.PERSONAL), pendingAction: undefined}, + ]; + const result = hasOtherControlWorkspaces(policies, '1'); + expect(result).toBe(true); }); + }); - describe('withDecimals = true', () => { - it('should return value divisible by 100 with 2 decimal places', () => { - const rate = getUnitRateValue(toLocaleDigitMock, {rate: 100}, true); - expect(rate).toEqual('1.00'); - }); + describe('sortPoliciesByName', () => { + const localeCompare = (a: string, b: string) => a.localeCompare(b); - it('should return non-integer value as is divided by 100', () => { - const rate = getUnitRateValue(toLocaleDigitMock, {rate: 11.11}, true); - expect(rate).toEqual('0.1111'); - }); + it('sorts policies alphabetically by name', () => { + const policies: Policy[] = [ + {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), name: 'Charlie'}, + {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), name: 'Alpha'}, + {...createRandomPolicy(3, CONST.POLICY.TYPE.TEAM), name: 'Bravo'}, + ]; + + const result = sortPoliciesByName(policies, localeCompare); + expect(result.map((p) => p.name)).toEqual(['Alpha', 'Bravo', 'Charlie']); }); - }); - describe('isPerDiemEnabled', () => { - it('returns true when arePerDiemRatesEnabled is explicitly true', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: true, customUnits: {}}; - expect(isPerDiemEnabled(policy)).toBe(true); + it('returns empty array for empty input', () => { + expect(sortPoliciesByName([], localeCompare)).toEqual([]); }); - it('returns false when arePerDiemRatesEnabled is explicitly false, ignoring the configured custom unit', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: false, customUnits: {ABCDEF: perDiemCustomUnit}}; - expect(isPerDiemEnabled(policy)).toBe(false); + it('treats undefined or empty names as empty string', () => { + const policies: Policy[] = [ + {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), name: 'Bravo'}, + {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), name: ''}, + {...createRandomPolicy(3, CONST.POLICY.TYPE.TEAM), name: 'Alpha'}, + ]; + + const result = sortPoliciesByName(policies, localeCompare); + expect(result.map((p) => p.name)).toEqual(['', 'Alpha', 'Bravo']); }); - it('infers true from an enabled per diem custom unit when the flag is missing', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: undefined, customUnits: {ABCDEF: perDiemCustomUnit}}; - expect(isPerDiemEnabled(policy)).toBe(true); + it('returns single-element array as-is', () => { + const policies: Policy[] = [{...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), name: 'Only'}]; + + const result = sortPoliciesByName(policies, localeCompare); + expect(result).toHaveLength(1); + expect(result.at(0)?.name).toBe('Only'); }); - it('returns false when the flag is missing and the per diem custom unit is disabled', () => { - const policy = { + it('does not sort the input array in place', () => { + const policies: Policy[] = [ + {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), name: 'Charlie'}, + {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), name: 'Alpha'}, + ]; + + const result = sortPoliciesByName(policies, localeCompare); + + expect(result).not.toBe(policies); + }); + }); + }); + + describe('permissions', () => { + describe('canMemberRead and canMemberWrite', () => { + const memberLogin = 'member@test.com'; + const buildPolicy = (role: Policy['role']): Policy => + createMock({ ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - arePerDiemRatesEnabled: undefined, - customUnits: {ABCDEF: {...perDiemCustomUnit, enabled: false}}, + role, + employeeList: { + [memberLogin]: { + role, + }, + }, + }); + + it('allows write access to satisfy read access', () => { + expect(canMemberRead(buildPolicy(CONST.POLICY.ROLE.CARD_ADMIN), memberLogin, CONST.POLICY.POLICY_FEATURE.EXPENSIFY_CARD)).toBe(true); + }); + + it('denies access when the role does not have the feature', () => { + expect(canMemberRead(buildPolicy(CONST.POLICY.ROLE.USER), memberLogin, CONST.POLICY.POLICY_FEATURE.EXPENSIFY_CARD)).toBe(false); + }); + + it('uses the requested member role when login is provided', () => { + const policy = { + ...buildPolicy(CONST.POLICY.ROLE.ADMIN), + employeeList: { + 'member@test.com': { + role: CONST.POLICY.ROLE.USER, + }, + }, }; - expect(isPerDiemEnabled(policy)).toBe(false); + + expect(canMemberWrite(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.EXPENSIFY_CARD)).toBe(false); }); - it('returns false when the flag is missing and there is no per diem custom unit', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: undefined, customUnits: {}}; - expect(isPerDiemEnabled(policy)).toBe(false); + it('allows admins to write every policy feature', () => { + const policy = buildPolicy(CONST.POLICY.ROLE.ADMIN); + + for (const feature of Object.values(CONST.POLICY.POLICY_FEATURE)) { + expect(canMemberWrite(policy, memberLogin, feature)).toBe(true); + } }); - }); - describe('isPerDiemEligiblePolicy', () => { - it('returns true for a control policy with arePerDiemRatesEnabled explicitly true', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: true, customUnits: {}}; - expect(isPerDiemEligiblePolicy(policy)).toBe(true); + it('does not allow editors to assign elevated roles', () => { + const policy = buildPolicy(CONST.POLICY.ROLE.EDITOR); + + expect(canMemberWrite(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.OVERVIEW)).toBe(true); + expect(canMemberWrite(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.ASSIGN_ELEVATED_ROLES)).toBe(false); }); - it('returns true for a control policy whose flag is missing but has an enabled per diem custom unit', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: undefined, customUnits: {ABCDEF: perDiemCustomUnit}}; - expect(isPerDiemEligiblePolicy(policy)).toBe(true); + it.each([ + [CONST.POLICY.ROLE.ADMIN, true, true], + [CONST.POLICY.ROLE.AUDITOR, true, false], + [CONST.POLICY.ROLE.PAYMENTS_ADMIN, true, false], + [CONST.POLICY.ROLE.EDITOR, false, false], + [CONST.POLICY.ROLE.CARD_ADMIN, false, false], + [CONST.POLICY.ROLE.PEOPLE_ADMIN, false, false], + [CONST.POLICY.ROLE.USER, false, false], + [CONST.POLICY.ROLE.GUEST, false, false], + ])('gives %s read=%s and write=%s on the all reports feature', (role, canRead, canWrite) => { + // Given a member with the role + const policy = buildPolicy(role); + + // When we check their all reports access + // Then it matches the role bundle in Auth, so the client and server can't diverge + expect(canMemberRead(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.ALL_REPORTS)).toBe(canRead); + expect(canMemberWrite(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.ALL_REPORTS)).toBe(canWrite); }); - it('returns false for a collect policy even when per diem is enabled', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), arePerDiemRatesEnabled: true, customUnits: {ABCDEF: perDiemCustomUnit}}; - expect(isPerDiemEligiblePolicy(policy)).toBe(false); + it.each([ + [CONST.POLICY.ROLE.AUDITOR, true], + [CONST.POLICY.ROLE.PAYMENTS_ADMIN, true], + [CONST.POLICY.ROLE.ADMIN, false], + [CONST.POLICY.ROLE.EDITOR, false], + [CONST.POLICY.ROLE.USER, false], + ])('hasReadOnlyAllReportsAccess for %s is %s', (role, expected) => { + // Given a member with the role + const policy = buildPolicy(role); + + // When we check whether they can only read all reports + // Then only roles that can see every report without administering them match + expect(hasReadOnlyAllReportsAccess(policy, memberLogin)).toBe(expected); }); - it('returns false for a submit policy even when per diem is enabled', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.SUBMIT), arePerDiemRatesEnabled: true, customUnits: {ABCDEF: perDiemCustomUnit}}; - expect(isPerDiemEligiblePolicy(policy)).toBe(false); + it.each([CONST.POLICY.ROLE.AUDITOR, CONST.POLICY.ROLE.PAYMENTS_ADMIN])('hasReadOnlyAllReportsAccess is false for a %s after the workspace is downgraded to Team', (role) => { + // Given a member whose control-only role is still stored after the policy type changed to Team + const policy = {...buildPolicy(role), type: CONST.POLICY.TYPE.TEAM}; + + // When we check whether they can only read all reports + // Then the role no longer applies, matching the other permission helpers + expect(hasReadOnlyAllReportsAccess(policy, memberLogin)).toBe(false); }); - it('returns false for a control policy with per diem explicitly disabled', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: false, customUnits: {ABCDEF: perDiemCustomUnit}}; - expect(isPerDiemEligiblePolicy(policy)).toBe(false); + it('allows auditors to read every policy feature but write only rooms', () => { + const policy = buildPolicy(CONST.POLICY.ROLE.AUDITOR); + + for (const feature of Object.values(CONST.POLICY.POLICY_FEATURE)) { + expect(canMemberRead(policy, memberLogin, feature)).toBe(true); + expect(canMemberWrite(policy, memberLogin, feature)).toBe(feature === CONST.POLICY.POLICY_FEATURE.ROOMS); + } }); - it('returns false for an undefined policy', () => { - expect(isPerDiemEligiblePolicy(undefined)).toBe(false); + it('allows guests to read only the workspace overview', () => { + const policy = buildPolicy(CONST.POLICY.ROLE.GUEST); + + for (const feature of Object.values(CONST.POLICY.POLICY_FEATURE)) { + expect(canMemberRead(policy, memberLogin, feature)).toBe(feature === CONST.POLICY.POLICY_FEATURE.OVERVIEW); + expect(canMemberWrite(policy, memberLogin, feature)).toBe(false); + } }); - }); - describe('getPolicyByCustomUnitID', () => { - const transactionWithPerDiemUnit: Transaction = { - ...createRandomTransaction(0), - comment: {customUnit: {customUnitID: 'ABCDEF'}}, - }; + it('limits scoped admins to their assigned write features', () => { + expect(canMemberWrite(buildPolicy(CONST.POLICY.ROLE.CARD_ADMIN), memberLogin, CONST.POLICY.POLICY_FEATURE.COMPANY_CARDS)).toBe(true); + expect(canMemberWrite(buildPolicy(CONST.POLICY.ROLE.CARD_ADMIN), memberLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)).toBe(false); + expect(canMemberWrite(buildPolicy(CONST.POLICY.ROLE.PEOPLE_ADMIN), memberLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_APPROVALS)).toBe(true); + expect(canMemberWrite(buildPolicy(CONST.POLICY.ROLE.PAYMENTS_ADMIN), memberLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)).toBe(true); + }); - it('resolves the policy with the matching custom unit when arePerDiemRatesEnabled is missing', () => { - const policies: OnyxCollection = { - corporate: {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: undefined, customUnits: {ABCDEF: perDiemCustomUnit}}, - }; - expect(getPolicyByCustomUnitID(transactionWithPerDiemUnit, policies)?.id).toBe('1'); + it('limits People Admin member role management to guests, members, and auditors', () => { + const policy = buildPolicy(CONST.POLICY.ROLE.PEOPLE_ADMIN); + + expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.USER)).toBe(true); + expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.GUEST)).toBe(true); + expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.AUDITOR)).toBe(true); + expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.ADMIN)).toBe(false); + expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.CARD_ADMIN)).toBe(false); + expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.PEOPLE_ADMIN)).toBe(false); + expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.PAYMENTS_ADMIN)).toBe(false); }); - it('does not resolve a policy where per diem was explicitly disabled', () => { - const policies: OnyxCollection = { - corporate: {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: false, customUnits: {ABCDEF: perDiemCustomUnit}}, - }; - expect(getPolicyByCustomUnitID(transactionWithPerDiemUnit, policies)).toBeUndefined(); + it('allows Guest assignment only on Control workspaces', () => { + const controlPolicy = buildPolicy(CONST.POLICY.ROLE.ADMIN); + const collectPolicy = {...controlPolicy, type: CONST.POLICY.TYPE.TEAM}; + + expect(canMemberAssignRole(controlPolicy, memberLogin, CONST.POLICY.ROLE.GUEST)).toBe(true); + expect(canMemberAssignRole(collectPolicy, memberLogin, CONST.POLICY.ROLE.GUEST)).toBe(false); }); - }); - }); - describe('getSubmitToAccountID', () => { - beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); - }); - describe('Has no rule approver', () => { - it('should return the policy approver/owner if the policy use the basic workflow', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - type: CONST.POLICY.TYPE.TEAM, - approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - type: CONST.REPORT.TYPE.EXPENSE, + it('allows Submit workspace editors to manage editor memberships without assigning roles', () => { + const policy = { + ...buildPolicy(CONST.POLICY.ROLE.EDITOR), + type: CONST.POLICY.TYPE.SUBMIT, }; - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(ownerAccountID); + + expect(canMemberManageMemberWithRole(policy, memberLogin, CONST.POLICY.ROLE.EDITOR)).toBe(true); + expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.EDITOR)).toBe(false); }); - it('should return the policy approver/owner if the policy use the optional workflow', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - type: CONST.POLICY.TYPE.TEAM, - approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(ownerAccountID); + + it('denies write access to an archived policy even for admins', () => { + const policy = {...buildPolicy(CONST.POLICY.ROLE.ADMIN), archivedDate: '2024-01-01'}; + + expect(canMemberWrite(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.OVERVIEW)).toBe(false); }); - it('should return the employee submitsTo if the policy use the advance workflow', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - employeeList, - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(adminAccountID); + + it('denies workspace settings edit access to an archived policy even for admins', () => { + const policy = {...buildPolicy(CONST.POLICY.ROLE.ADMIN), archivedDate: '2024-01-01'}; + + expect(canEditWorkspaceSettings(policy, memberLogin)).toBe(false); }); }); - describe('Has category/tag approver', () => { - it('should return the first category approver if has any transaction category match with category approver rule', async () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - type: CONST.POLICY.TYPE.CORPORATE, - employeeList, - rules, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - const transaction1: Transaction = { - ...createRandomTransaction(0), - category: 'cat1', - reportID: expenseReport.reportID, - }; - const transaction2: Transaction = { - ...createRandomTransaction(1), - category: '', - reportID: expenseReport.reportID, - }; - const transactionData: TransactionCollectionDataSet = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, - }; - await Onyx.multiSet({...transactionData}); - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(categoryApprover1AccountID); - }); - it('should return default approver if rule approver is submitter and prevent self approval is enabled', async () => { + + describe('isPolicyAdmin', () => { + const adminLogin = 'admin@test.com'; + const memberLogin = 'member@test.com'; + // `role` is the role of the user currently viewing the policy, `employeeList` holds every member's own role + const buildPolicy = (): Policy => + createMock({ + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + role: CONST.POLICY.ROLE.ADMIN, + employeeList: { + [adminLogin]: {role: CONST.POLICY.ROLE.ADMIN}, + [memberLogin]: {role: CONST.POLICY.ROLE.USER}, + }, + }); + + it('resolves the role of the passed login when shouldCheckGlobalPolicyRole is false', () => { + // Given a policy viewed by an admin, holding one admin and one regular member in its employee list + // When the role of each login is resolved without the global policy role + // Then each login resolves to its own role, not the viewer's + expect(isPolicyAdmin(buildPolicy(), memberLogin, false)).toBe(false); + expect(isPolicyAdmin(buildPolicy(), adminLogin, false)).toBe(true); + }); + + it('ignores the passed login and answers for the viewing user by default', () => { + // Given a policy viewed by an admin, holding a regular member in its employee list + // When the member's role is resolved with the default shouldCheckGlobalPolicyRole + // Then the check short-circuits on the viewer's role and reports the member as an admin. This documents the + // trap that made every member of a workspace chat look like an admin to a viewing admin, which disabled the + // "Remove from chat" button for all of them + expect(isPolicyAdmin(buildPolicy(), memberLogin)).toBe(true); + }); + + it('returns false for a login that is not in the employee list when shouldCheckGlobalPolicyRole is false', () => { + // Given a policy that does not list the passed login as an employee + // When that login's role is resolved without the global policy role + // Then it does not resolve to an admin + expect(isPolicyAdmin(buildPolicy(), 'stranger@test.com', false)).toBe(false); + }); + + it('returns false for an undefined login when shouldCheckGlobalPolicyRole is false', () => { + // Given a member whose login could not be resolved + // When their role is resolved without the global policy role + // Then it does not resolve to an admin + expect(isPolicyAdmin(buildPolicy(), undefined, false)).toBe(false); + }); + + it('matches an employee whose login is not lowercase when shouldCheckGlobalPolicyRole is false', () => { + // Given an employee list keyed by canonical lowercase logins + // When a mixed-case login read off personal details is resolved without the global policy role + // Then it still matches the employee entry through the normalized fallback + expect(isPolicyAdmin(buildPolicy(), 'Admin@Test.com', false)).toBe(true); + expect(isPolicyAdmin(buildPolicy(), 'Member@Test.com', false)).toBe(false); + }); + + it('prefers an exact employee list key over the normalized one', () => { + // Given an employee list holding both a mixed-case and a lowercase key with different roles + const policy = createMock({ + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + employeeList: { + 'Mixed@Test.com': {role: CONST.POLICY.ROLE.ADMIN}, + 'mixed@test.com': {role: CONST.POLICY.ROLE.USER}, + }, + }); + + // When each key is resolved without the global policy role + // Then the exact key wins, so the normalized fallback can never regress an existing hit + expect(isPolicyAdmin(policy, 'Mixed@Test.com', false)).toBe(true); + expect(isPolicyAdmin(policy, 'mixed@test.com', false)).toBe(false); + }); + + it('stops at the exact employee list key even when that entry carries no role', () => { + // Given an employee list where the exact mixed-case key exists without a role, next to an admin entry + // under the normalized key. `role` is optional on PolicyEmployee, so this is representable + const policy = createMock({ + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + employeeList: { + 'Mixed@Test.com': {}, + 'mixed@test.com': {role: CONST.POLICY.ROLE.ADMIN}, + }, + }); + + // When the mixed-case login is resolved without the global policy role + // Then the exact entry still wins and resolves to no role, rather than borrowing the other entry's role. + // Reading one account's role off a different account's entry is the failure this check exists to prevent + expect(isPolicyAdmin(policy, 'Mixed@Test.com', false)).toBe(false); + }); + }); + + describe('isRoomMemberProtectedByPolicyRole', () => { + const adminLogin = 'admin@test.com'; + const memberLogin = 'member@test.com'; + const policyOwnerAccountID = 3001; + const regularMemberAccountID = 3002; + // `role` is the role of the user currently viewing the policy, `employeeList` holds every member's own role + const buildPolicy = (): Policy => + createMock({ + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + role: CONST.POLICY.ROLE.ADMIN, + ownerAccountID: policyOwnerAccountID, + employeeList: { + [adminLogin]: {role: CONST.POLICY.ROLE.ADMIN}, + [memberLogin]: {role: CONST.POLICY.ROLE.USER}, + }, + }); + + it('protects a member who is an admin of the policy in their own right', () => { + // Given a policy viewed by an admin, holding another admin in its employee list + // When that member's protection is resolved + // Then they are protected, because removing a workspace admin from the chat is not allowed + expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), adminLogin, undefined)).toBe(true); + }); + + it('does not protect a regular member even when the viewing user is an admin', () => { + // Given a policy whose global `role` marks the viewer as an admin, holding a regular member + // When that member's protection is resolved + // Then they are not protected, because the listed member's own role is what counts. This is the bug that + // made every member of a workspace chat un-removable to a viewing admin + expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), memberLogin, undefined)).toBe(false); + }); + + it('fails closed for a member whose login is missing', () => { + // Given a room member with personal details but no login, which `login?: string` allows + // When their protection is resolved + // Then they are protected, because a role we cannot resolve must not be treated as "not an admin". + // Both the members list and the member details page depend on this branch to avoid offering removal + // for a member who may well be a workspace admin + expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), undefined, undefined)).toBe(true); + expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), '', undefined)).toBe(true); + }); + + it('does not protect a member who is absent from the employee list', () => { + // Given a login that the policy does not list as an employee + // When their protection is resolved + // Then they are not protected, since a resolvable login that holds no policy role is not an admin + expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), 'stranger@test.com', undefined)).toBe(false); + }); + + it('protects an admin whose login is not lowercase', () => { + // Given an employee list keyed by canonical lowercase logins + // When a mixed-case login read off personal details is resolved + // Then the normalized fallback still matches the admin entry and protects them + expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), 'Admin@Test.com', undefined)).toBe(true); + expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), 'Member@Test.com', undefined)).toBe(false); + }); + + it('protects a member who is an auditor of the policy in their own right', () => { + // Given a policy viewed by an admin, holding an auditor in its employee list + const auditorLogin = 'auditor@test.com'; + const policy = buildPolicy(); + const policyWithAuditor = {...policy, employeeList: {...policy.employeeList, [auditorLogin]: {role: CONST.POLICY.ROLE.AUDITOR}}}; + + // When that member's protection is resolved, including through a mixed-case login + // Then they are protected, because auditors are default members of the workspace chat and the server adds + // them back after removal. The viewer's global `role` must not hide the auditor's own role + expect(isRoomMemberProtectedByPolicyRole(policyWithAuditor, auditorLogin, undefined)).toBe(true); + expect(isRoomMemberProtectedByPolicyRole(policyWithAuditor, 'Auditor@Test.com', undefined)).toBe(true); + }); + + it('protects a member who is a payments admin of the policy', () => { + // Given a policy viewed by an admin, holding a payments admin in its employee list + const paymentsAdminLogin = 'paymentsadmin@test.com'; + const policy = buildPolicy(); + const policyWithPaymentsAdmin = {...policy, employeeList: {...policy.employeeList, [paymentsAdminLogin]: {role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}}}; + + // When that member's protection is resolved + // Then they are protected, because Auth rejects removing any member who can read every workspace report + expect(isRoomMemberProtectedByPolicyRole(policyWithPaymentsAdmin, paymentsAdminLogin, undefined)).toBe(true); + }); + + it('protects the policy owner by accountID even when the employee list does not list them', () => { + // Given the policy owner participating in another employee's workspace chat, absent from `employeeList` + // When their protection is resolved + // Then they are protected by `ownerAccountID`, not incidentally by carrying `role: admin` in the roster. + // The callers' own owner check compares against `report.ownerAccountID`, which is the employee whose + // expense chat it is, so without this the policy owner has no identity-based protection at all + expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), 'owner@test.com', policyOwnerAccountID)).toBe(true); + }); + + it('protects the policy owner even when the employee list is blank', () => { + // Given a policy whose `employeeList` has not loaded, which happens when it is not the viewer's active + // policy, so no login can resolve to a role + const policyWithoutRoster = createMock({ + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + role: undefined, + ownerAccountID: policyOwnerAccountID, + employeeList: {}, + }); + + // When the owner's protection is resolved + // Then they are still protected, because `ownerAccountID` is a required top-level field that resolves + // without the roster + expect(isRoomMemberProtectedByPolicyRole(policyWithoutRoster, 'owner@test.com', policyOwnerAccountID)).toBe(true); + }); + + it('does not protect a non-owner just because an accountID is passed', () => { + // Given a regular member's accountID alongside their login + // When their protection is resolved + // Then they stay removable, since the owner check must not widen protection to every member with an + // accountID. This is the regression that would reintroduce the original bug + expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), memberLogin, regularMemberAccountID)).toBe(false); + }); + + describe('approvers auto-added to the expense chat by the approval chain', () => { + const approverLogin = 'approver@test.com'; + const forwardsToLogin = 'forwardsto@test.com'; + const chainApproverAccountID = 3003; + // An approval chain that pulls two non-admin approvers into the member's expense chat: one they submit to, + // one their reports forward to. `policy.approver` is the workspace's default approver on top of that + const buildPolicyWithApprovalChain = (approverRole: ValueOf = CONST.POLICY.ROLE.USER): Policy => + createMock({ + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + role: CONST.POLICY.ROLE.ADMIN, + ownerAccountID: policyOwnerAccountID, + approver: approverLogin, + employeeList: { + [approverLogin]: {role: approverRole}, + [forwardsToLogin]: {role: approverRole}, + [memberLogin]: {role: CONST.POLICY.ROLE.USER, submitsTo: approverLogin, forwardsTo: forwardsToLogin}, + }, + }); + + it('protects an approver who holds no admin role of their own', () => { + // Given a workspace chat whose participants include two approvers from the submitter's approval + // chain, both plain members of the workspace + const policy = buildPolicyWithApprovalChain(); + + // When each approver's protection is resolved + // Then they are protected on the strength of being approvers alone. Approvers are auto-added to the + // chat of everyone who submits to them, so their membership is governed by the workspace's approval + // workflow rather than by this screen: only a member who was invited to the chat can be removed from + // it, per the expense chat rules in contributingGuides/philosophies/SECURITY.md + expect(isRoomMemberProtectedByPolicyRole(policy, approverLogin, chainApproverAccountID)).toBe(true); + expect(isRoomMemberProtectedByPolicyRole(policy, forwardsToLogin, chainApproverAccountID)).toBe(true); + }); + + it('still allows removing an invited member of a workspace that has an approval chain', () => { + // Given the same approval chain, and the submitter who is neither an admin, the owner, nor an approver + const policy = buildPolicyWithApprovalChain(); + + // When their protection is resolved + // Then they stay removable. Protecting approvers must not widen back out to every member and + // reintroduce the bug this PR fixes + expect(isRoomMemberProtectedByPolicyRole(policy, memberLogin, regularMemberAccountID)).toBe(false); + }); + + it('protects an approver who is also an admin of the policy', () => { + // Given the same approval chain, with both approvers holding the admin role + const policy = buildPolicyWithApprovalChain(CONST.POLICY.ROLE.ADMIN); + + // When each approver's protection is resolved + // Then they are protected, because they are admins. Most approvers are, so this is the common case + expect(isRoomMemberProtectedByPolicyRole(policy, approverLogin, chainApproverAccountID)).toBe(true); + expect(isRoomMemberProtectedByPolicyRole(policy, forwardsToLogin, chainApproverAccountID)).toBe(true); + }); + + it('protects an approver who is the policy owner', () => { + // Given an approval chain whose approvers are plain members, one of whom owns the workspace + const policy = buildPolicyWithApprovalChain(); + + // When the owning approver's protection is resolved by accountID + // Then they are protected as the policy owner, regardless of their role in the employee list + expect(isRoomMemberProtectedByPolicyRole(policy, approverLogin, policyOwnerAccountID)).toBe(true); + }); + }); + }); + + describe('getPolicyApproverLogins', () => { + it('returns an empty set when policy is undefined', () => { + expect(getPolicyApproverLogins(undefined).size).toBe(0); + }); + + it('returns an empty set when there is no approver and no employees route anywhere', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: undefined, + employeeList: { + 'employee@test.com': {email: 'employee@test.com', submitsTo: '', forwardsTo: '', overLimitForwardsTo: ''}, + }, + }; + expect(getPolicyApproverLogins(policy).size).toBe(0); + }); + + it('includes the named policy approver', () => { + const policy: Policy = {...createRandomPolicy(0), approver: 'boss@test.com', employeeList: {}}; + expect([...getPolicyApproverLogins(policy)]).toEqual(['boss@test.com']); + }); + + it('collects submitsTo, forwardsTo and overLimitForwardsTo targets', () => { const policy: Policy = { ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - type: CONST.POLICY.TYPE.CORPORATE, - employeeList, - rules, - preventSelfApproval: true, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + approver: undefined, + employeeList: { + 'a@test.com': {email: 'a@test.com', submitsTo: 'manager@test.com'}, + 'b@test.com': {email: 'b@test.com', forwardsTo: 'director@test.com'}, + 'c@test.com': {email: 'c@test.com', overLimitForwardsTo: 'vp@test.com'}, + }, + }; + expect([...getPolicyApproverLogins(policy)].sort()).toEqual(['director@test.com', 'manager@test.com', 'vp@test.com']); + }); + + it('deduplicates approvers referenced by multiple employees or fields', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'manager@test.com', + employeeList: { + 'a@test.com': {email: 'a@test.com', submitsTo: 'manager@test.com'}, + 'b@test.com': {email: 'b@test.com', submitsTo: 'manager@test.com', forwardsTo: 'manager@test.com'}, + }, + }; + expect([...getPolicyApproverLogins(policy)]).toEqual(['manager@test.com']); + }); + + it('ignores empty-string routing fields', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: '', + employeeList: { + 'a@test.com': {email: 'a@test.com', submitsTo: '', forwardsTo: 'director@test.com', overLimitForwardsTo: ''}, + }, + }; + expect([...getPolicyApproverLogins(policy)]).toEqual(['director@test.com']); + }); + }); + }); + + describe('useDefaultFundID', () => { + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + }); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + it('should return domainID for given policyID when workspaceID is not set', async () => { + const policy: Policy = { + ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), + policyAccountID: 0, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}2`, policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}18441278`, { + currentBalance: 0, + domainName: 'expensify-policy8fe6324c4897.exfy', + earnedCashback: 0, + isLoading: false, + isMonthlySettlementAllowed: false, + marqetaBusinessToken: 18441278, + paymentBankAccountAddressName: 'Alberta Bobbeth Charleson', + paymentBankAccountID: 3288123, + paymentBankAccountNumber: 'XXXXXXXXXXXX1111', + preferredPolicy: '2', + remainingLimit: 0, + }); + const {result} = renderHook(() => useDefaultFundID(policy.id)); + + expect(result?.current).toBe(18441278); + }); + + it('should return lastSelectedExpensifyCardFeed for given policyID when lastSelectedExpensifyCardFeed is set', async () => { + const policy: Policy = { + ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), + policyAccountID: 0, + }; + const lastSelectedExpensifyCardFeed = 11111; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}2`, policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_FEED}2`, lastSelectedExpensifyCardFeed); + await Onyx.set(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${lastSelectedExpensifyCardFeed}`, { + [CONST.COUNTRY.US]: { + paymentBankAccountID: 1234, + }, + }); + const {result} = renderHook(() => useDefaultFundID(policy.id)); + + expect(result?.current).toBe(lastSelectedExpensifyCardFeed); + }); + + it('should return workspaceAccountID for given policyID', async () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + policyAccountID: 123234, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}1`, policy); + const {result} = renderHook(() => useDefaultFundID(policy.id)); + + expect(result?.current).toBe(123234); + }); + }); + + describe('customUnits', () => { + const perDiemCustomUnit = { + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + customUnitID: 'ABCDEF', + enabled: true, + rates: { + London: { + customUnitRateID: 'London', + name: 'London', + }, + }, + }; + + describe('getCustomUnitsForDuplication', () => { + const perDiemUnit = { + customUnitID: '123', + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + enabled: true, + rates: {}, + }; + const otherUnit = { + customUnitID: '456', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + rates: {}, + }; + const policy: Policy = { + ...createRandomPolicy(0), + customUnits: { + [perDiemUnit.customUnitID]: perDiemUnit, + [otherUnit.customUnitID]: otherUnit, + }, + }; + + const policyWithoutCustomUnits: Policy = { + ...createRandomPolicy(0), + }; + + it('returns undefined if neither option is selected', () => { + expect(getCustomUnitsForDuplication(policy, false, false, {distanceCustomUnitID: otherUnit.customUnitID, perDiemCustomUnitID: perDiemUnit.customUnitID})).toBeUndefined(); + }); + + it('returns all custom units if both options are selected', () => { + const result = getCustomUnitsForDuplication(policy, true, true, {distanceCustomUnitID: otherUnit.customUnitID, perDiemCustomUnitID: perDiemUnit.customUnitID}); + expect(result).toEqual(policy.customUnits); + }); + it('returns only non-per-diem units if only custom units option is selected', () => { + const result = getCustomUnitsForDuplication(policy, true, false, {distanceCustomUnitID: otherUnit.customUnitID, perDiemCustomUnitID: perDiemUnit.customUnitID}); + expect(result).toEqual({[otherUnit.customUnitID]: otherUnit}); + }); + + it('returns only per diem unit if only per diem option is selected', () => { + const result = getCustomUnitsForDuplication(policy, false, true, {distanceCustomUnitID: otherUnit.customUnitID, perDiemCustomUnitID: perDiemUnit.customUnitID}); + expect(result).toEqual({[perDiemUnit.customUnitID]: perDiemUnit}); + }); + + it('returns undefined if customUnits is empty', () => { + expect( + getCustomUnitsForDuplication(policyWithoutCustomUnits, true, true, {distanceCustomUnitID: otherUnit.customUnitID, perDiemCustomUnitID: perDiemUnit.customUnitID}), + ).toBeUndefined(); + }); + + it('rebinds the default rate to the API-known customUnitRateID and keeps non-default rates with their source IDs', () => { + const distanceUnitWithMultipleRates = { + customUnitID: 'srcDist', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, taxEnabled: true}, + rates: { + rateB: {customUnitRateID: 'rateB', name: 'New Rate 1', rate: 100, currency: 'USD', enabled: true, index: 1, attributes: {taxRateExternalID: 'tax_other'}}, + rateA: {customUnitRateID: 'rateA', name: 'Default Rate', rate: 70, currency: 'USD', enabled: true, index: 0, attributes: {taxRateExternalID: 'tax_default'}}, + }, + }; + const policyWithMultipleRates: Policy = { + ...createRandomPolicy(0), + customUnits: {[distanceUnitWithMultipleRates.customUnitID]: distanceUnitWithMultipleRates}, + }; + const result = getCustomUnitsForDuplication(policyWithMultipleRates, true, false, { + distanceCustomUnitID: 'newDist', + perDiemCustomUnitID: 'newPerDiem', + customUnitRateID: 'newRate', + }); + expect(result).toEqual({ + newDist: { + ...distanceUnitWithMultipleRates, + customUnitID: 'newDist', + rates: { + rateB: {customUnitRateID: 'rateB', name: 'New Rate 1', rate: 100, currency: 'USD', enabled: true, index: 1, attributes: {taxRateExternalID: 'tax_other'}}, + newRate: {customUnitRateID: 'newRate', name: 'Default Rate', rate: 70, currency: 'USD', enabled: true, index: 0, attributes: {taxRateExternalID: 'tax_default'}}, + }, + }, + }); + }); + + it('keeps source rates with their source IDs when no enabled rate exists', () => { + const distanceUnitAllDisabled = { + customUnitID: 'srcDist', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: { + rateA: {customUnitRateID: 'rateA', name: 'Disabled', rate: 50, currency: 'USD', enabled: false, index: 0}, + }, + }; + const policyAllDisabled: Policy = { + ...createRandomPolicy(0), + customUnits: {[distanceUnitAllDisabled.customUnitID]: distanceUnitAllDisabled}, }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: categoryApprover1AccountID, - type: CONST.REPORT.TYPE.EXPENSE, + const result = getCustomUnitsForDuplication(policyAllDisabled, true, false, { + distanceCustomUnitID: 'newDist', + perDiemCustomUnitID: 'newPerDiem', + customUnitRateID: 'newRate', + }); + expect(result?.newDist.rates).toEqual({ + rateA: {customUnitRateID: 'rateA', name: 'Disabled', rate: 50, currency: 'USD', enabled: false, index: 0}, + }); + }); + + it('treats missing index as 0 when picking the default rate', () => { + const distanceUnitWithMissingIndex = { + customUnitID: 'srcDist', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: { + rateB: {customUnitRateID: 'rateB', name: 'Indexed Rate', rate: 100, currency: 'USD', enabled: true, index: 1}, + rateA: {customUnitRateID: 'rateA', name: 'No-Index Rate', rate: 70, currency: 'USD', enabled: true}, + }, + }; + const policyWithMissingIndex: Policy = { + ...createRandomPolicy(0), + customUnits: {[distanceUnitWithMissingIndex.customUnitID]: distanceUnitWithMissingIndex}, + }; + const result = getCustomUnitsForDuplication(policyWithMissingIndex, true, false, { + distanceCustomUnitID: 'newDist', + perDiemCustomUnitID: 'newPerDiem', + customUnitRateID: 'newRate', + }); + expect(result?.newDist.rates).toEqual({ + rateB: {customUnitRateID: 'rateB', name: 'Indexed Rate', rate: 100, currency: 'USD', enabled: true, index: 1}, + newRate: {customUnitRateID: 'newRate', name: 'No-Index Rate', rate: 70, currency: 'USD', enabled: true}, + }); + }); + + it('preserves source iteration order so getDefaultMileageRate still picks the original default when a non-default rate has a missing index', () => { + // Source has 3 rates: Default (index 0), Indexed (index 1), and No-Index (no index field). + // No-Index ties with Default at the index-0 sort key after `?? CONST.DEFAULT_NUMBER_ID`. + // The optimistic clone must keep the source's iteration order so JavaScript's stable + // sort still places Default before No-Index — otherwise getDefaultMileageRate would + // pick whichever tied rate appeared first in iteration order, swapping the default. + const sourceUnit = { + customUnitID: 'srcDist', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: { + rateA: {customUnitRateID: 'rateA', name: 'Default Rate', rate: 72.5, currency: 'USD', enabled: true, index: 0}, + rateB: {customUnitRateID: 'rateB', name: 'Indexed Rate', rate: 100, currency: 'USD', enabled: true, index: 1}, + rateC: {customUnitRateID: 'rateC', name: 'No-Index Rate', rate: 200, currency: 'USD', enabled: true}, + }, + }; + const policyWithTiedIndex: Policy = { + ...createRandomPolicy(0), + customUnits: {[sourceUnit.customUnitID]: sourceUnit}, + }; + const result = getCustomUnitsForDuplication(policyWithTiedIndex, true, false, { + distanceCustomUnitID: 'newDist', + perDiemCustomUnitID: 'newPerDiem', + customUnitRateID: 'newRate', + }); + + const cloned = result?.newDist; + if (!cloned) { + throw new Error('Expected cloned distance unit'); + } + // Iteration order: the rebound default should still be first, before the No-Index rate. + const orderedKeys = Object.keys(cloned.rates); + expect(orderedKeys).toEqual(['newRate', 'rateB', 'rateC']); + + // Sanity-check that getDefaultMileageRate's selection (enabled, sorted by index ?? 0, + // first) lands on the rebound default and not on No-Index Rate. + const pickedDefault = Object.values(cloned.rates) + .filter((rate) => rate.enabled !== false) + .sort((a, b) => (a.index ?? CONST.DEFAULT_NUMBER_ID) - (b.index ?? CONST.DEFAULT_NUMBER_ID)) + .at(0); + expect(pickedDefault?.customUnitRateID).toBe('newRate'); + expect(pickedDefault?.name).toBe('Default Rate'); + }); + }); + describe('getRateDisplayValue', () => { + it('should return an empty string for NaN', () => { + // This deliberately malformed string exercises the runtime NaN guard while preserving the production return type. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The invalid string is the scenario under test. + const rate = getRateDisplayValue('invalid' as unknown as Parameters[0], toLocaleDigitMock); + expect(rate).toEqual(''); + }); + + describe('withDecimals = false', () => { + it('should return integer value as is', () => { + const rate = getRateDisplayValue(100, toLocaleDigitMock); + expect(rate).toEqual('100'); + }); + + it('should return non-integer value as is', () => { + const rate = getRateDisplayValue(10.5, toLocaleDigitMock); + expect(rate).toEqual('10.5'); + }); + }); + + describe('withDecimals = true', () => { + it('should return integer value with 2 trailing zeros', () => { + const rate = getRateDisplayValue(10, toLocaleDigitMock, true); + expect(rate).toEqual('10.00'); + }); + + it('should return non-integer value with up to 2 trailing zeros', () => { + const rate = getRateDisplayValue(10.5, toLocaleDigitMock, true); + expect(rate).toEqual('10.50'); + }); + + it('should return non-integer value with 4 decimals as is', () => { + const rate = getRateDisplayValue(10.5312, toLocaleDigitMock, true); + expect(rate).toEqual('10.5312'); + }); + + it('should return non-integer value with 3 decimals as is', () => { + const rate = getRateDisplayValue(10.531, toLocaleDigitMock, true); + expect(rate).toEqual('10.531'); + }); + + it('should return non-integer value with 4+ decimals cut to 4', () => { + const rate = getRateDisplayValue(10.53135, toLocaleDigitMock, true); + expect(rate).toEqual('10.5313'); + }); + }); + }); + + describe('getUnitRateValue', () => { + it('should return an empty string for NaN', () => { + // This deliberately malformed string exercises the runtime NaN guard while preserving the production return type. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The invalid rate is the scenario under test. + const rate = getUnitRateValue(toLocaleDigitMock, {rate: 'invalid' as unknown as NonNullable[1]>['rate']}); + expect(rate).toEqual(''); + }); + + describe('withDecimals = false', () => { + it('should return value divisible by 100 with no decimal places', () => { + const rate = getUnitRateValue(toLocaleDigitMock, {rate: 100}); + expect(rate).toEqual('1'); + }); + + it('should return non-integer value as is divided by 100', () => { + const rate = getUnitRateValue(toLocaleDigitMock, {rate: 11.11}); + expect(rate).toEqual('0.1111'); + }); + }); + + describe('withDecimals = true', () => { + it('should return value divisible by 100 with 2 decimal places', () => { + const rate = getUnitRateValue(toLocaleDigitMock, {rate: 100}, true); + expect(rate).toEqual('1.00'); + }); + + it('should return non-integer value as is divided by 100', () => { + const rate = getUnitRateValue(toLocaleDigitMock, {rate: 11.11}, true); + expect(rate).toEqual('0.1111'); + }); + }); + }); + + describe('isPerDiemEnabled', () => { + it('returns true when arePerDiemRatesEnabled is explicitly true', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: true, customUnits: {}}; + expect(isPerDiemEnabled(policy)).toBe(true); + }); + + it('returns false when arePerDiemRatesEnabled is explicitly false, ignoring the configured custom unit', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: false, customUnits: {ABCDEF: perDiemCustomUnit}}; + expect(isPerDiemEnabled(policy)).toBe(false); + }); + + it('infers true from an enabled per diem custom unit when the flag is missing', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: undefined, customUnits: {ABCDEF: perDiemCustomUnit}}; + expect(isPerDiemEnabled(policy)).toBe(true); + }); + + it('returns false when the flag is missing and the per diem custom unit is disabled', () => { + const policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + arePerDiemRatesEnabled: undefined, + customUnits: {ABCDEF: {...perDiemCustomUnit, enabled: false}}, + }; + expect(isPerDiemEnabled(policy)).toBe(false); + }); + + it('returns false when the flag is missing and there is no per diem custom unit', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: undefined, customUnits: {}}; + expect(isPerDiemEnabled(policy)).toBe(false); + }); + }); + + describe('isPerDiemEligiblePolicy', () => { + it('returns true for a control policy with arePerDiemRatesEnabled explicitly true', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: true, customUnits: {}}; + expect(isPerDiemEligiblePolicy(policy)).toBe(true); + }); + + it('returns true for a control policy whose flag is missing but has an enabled per diem custom unit', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: undefined, customUnits: {ABCDEF: perDiemCustomUnit}}; + expect(isPerDiemEligiblePolicy(policy)).toBe(true); + }); + + it('returns false for a collect policy even when per diem is enabled', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), arePerDiemRatesEnabled: true, customUnits: {ABCDEF: perDiemCustomUnit}}; + expect(isPerDiemEligiblePolicy(policy)).toBe(false); + }); + + it('returns false for a submit policy even when per diem is enabled', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.SUBMIT), arePerDiemRatesEnabled: true, customUnits: {ABCDEF: perDiemCustomUnit}}; + expect(isPerDiemEligiblePolicy(policy)).toBe(false); + }); + + it('returns false for a control policy with per diem explicitly disabled', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: false, customUnits: {ABCDEF: perDiemCustomUnit}}; + expect(isPerDiemEligiblePolicy(policy)).toBe(false); + }); + + it('returns false for an undefined policy', () => { + expect(isPerDiemEligiblePolicy(undefined)).toBe(false); + }); + }); + + describe('getPolicyByCustomUnitID', () => { + const transactionWithPerDiemUnit: Transaction = { + ...createRandomTransaction(0), + comment: {customUnit: {customUnitID: 'ABCDEF'}}, + }; + + it('resolves the policy with the matching custom unit when arePerDiemRatesEnabled is missing', () => { + const policies: OnyxCollection = { + corporate: {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: undefined, customUnits: {ABCDEF: perDiemCustomUnit}}, }; - const transaction: Transaction = { - ...createRandomTransaction(0), - category: 'cat1', - reportID: expenseReport.reportID, - tag: '', + expect(getPolicyByCustomUnitID(transactionWithPerDiemUnit, policies)?.id).toBe('1'); + }); + + it('does not resolve a policy where per diem was explicitly disabled', () => { + const policies: OnyxCollection = { + corporate: {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), arePerDiemRatesEnabled: false, customUnits: {ABCDEF: perDiemCustomUnit}}, }; + expect(getPolicyByCustomUnitID(transactionWithPerDiemUnit, policies)).toBeUndefined(); + }); + }); + }); + + describe('approvals', () => { + describe('getApprovalWorkflow', () => { + it('should return the optional approval mode for a personal policy', () => { + // Given a personal policy that has the advanced approval mode stored on it + const policy = createMock({type: CONST.POLICY.TYPE.PERSONAL, approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED}); + + // When the approval workflow is read + // Then it is optional, because personal policies never require approval + expect(getApprovalWorkflow(policy)).toBe(CONST.POLICY.APPROVAL_MODE.OPTIONAL); + }); + + it('should return the approval mode set on a group policy', () => { + // Given a group policy that uses the basic approval mode + const policy = createMock({type: CONST.POLICY.TYPE.TEAM, approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC}); + + // When the approval workflow is read + // Then the policy's own approval mode is returned + expect(getApprovalWorkflow(policy)).toBe(CONST.POLICY.APPROVAL_MODE.BASIC); + }); + + it('should default to the advanced approval mode when a group policy has no approval mode', () => { + // Given a group policy without an approval mode + const policy = createMock({type: CONST.POLICY.TYPE.CORPORATE}); + + // When the approval workflow is read + // Then it falls back to advanced approvals + expect(getApprovalWorkflow(policy)).toBe(CONST.POLICY.APPROVAL_MODE.ADVANCED); + }); + + it('should default to the advanced approval mode when the policy is undefined', () => { + // Given no policy + // When the approval workflow is read + // Then it falls back to advanced approvals + expect(getApprovalWorkflow(undefined)).toBe(CONST.POLICY.APPROVAL_MODE.ADVANCED); + }); + }); + + describe('getDefaultApprover', () => { + it('should return the policy approver when one is set', () => { + // Given a policy whose approver is someone other than the owner + const policy = createMock({approver: approverEmail, owner: 'owner@test.com'}); + + // When the default approver is read + // Then the approver wins over the owner + expect(getDefaultApprover(policy)).toBe(approverEmail); + }); + + it('should fall back to the policy owner when there is no approver', () => { + // Given a policy with an owner but no approver + const policy = createMock({owner: 'owner@test.com'}); + + // When the default approver is read + // Then the owner is used + expect(getDefaultApprover(policy)).toBe('owner@test.com'); + }); + + it('should fall back to the policy owner when the approver is an empty string', () => { + // Given a policy whose approver was cleared to an empty string + const policy = createMock({approver: '', owner: 'owner@test.com'}); + + // When the default approver is read + // Then the empty approver is skipped and the owner is used + expect(getDefaultApprover(policy)).toBe('owner@test.com'); + }); + + it('should return an empty string when the policy has no approver or owner', () => { + // Given a policy with neither an approver nor an owner, and no policy at all + const policy = createMock({}); + + // When the default approver is read + // Then there is no one to fall back to + expect(getDefaultApprover(policy)).toBe(''); + expect(getDefaultApprover(undefined)).toBe(''); + }); + }); + + describe('getRuleApprovers', () => { + const policy = createMock({type: CONST.POLICY.TYPE.CORPORATE, rules}); + const expenseReport: Report = { + ...createRandomReport(0, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + }; + + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + }); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should return no approvers when the report has no transactions', () => { + // Given an expense report with no transactions in Onyx + // When the rule approvers are read + // Then there is no transaction to match a rule against + expect(getRuleApprovers(policy, expenseReport)).toEqual([]); + }); + it('should return no approvers when the policy has no approval rules', async () => { + // Given a report whose transaction has a category and tag, on a policy without approval rules + const transaction: Transaction = {...createRandomTransaction(0), category: 'cat1', tag: 'tag1', reportID: expenseReport.reportID}; await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); - expect(getSubmitToAccountID(policy, expenseReport, categoryApprover1Email, undefined)).toBe(adminAccountID); + + // When the rule approvers are read + // Then nothing matches, because there are no rules + expect(getRuleApprovers(createMock({type: CONST.POLICY.TYPE.CORPORATE}), expenseReport)).toEqual([]); }); - it('should return the category approver of the first transaction sorted by created if we have many transaction categories match with the category approver rule', async () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - type: CONST.POLICY.TYPE.CORPORATE, - employeeList, - rules, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - const transaction1: Transaction = { - ...createRandomTransaction(0), - category: 'cat1', - created: testDate, - reportID: expenseReport.reportID, - }; - const transaction2: Transaction = { - ...createRandomTransaction(1), - category: 'cat2', - created: DateUtils.subtractMillisecondsFromDateTime(testDate, 1), - reportID: expenseReport.reportID, - }; + + it('should put category approvers before tag approvers', async () => { + // Given an earlier transaction that only matches a tag rule and a later one that only matches a category rule + const tagTransaction: Transaction = {...createRandomTransaction(0), category: '', tag: 'tag1', created: '2025-01-01', reportID: expenseReport.reportID}; + const categoryTransaction: Transaction = {...createRandomTransaction(1), category: 'cat1', tag: '', created: '2025-01-02', reportID: expenseReport.reportID}; const transactionData: TransactionCollectionDataSet = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${tagTransaction.transactionID}`]: tagTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${categoryTransaction.transactionID}`]: categoryTransaction, }; await Onyx.multiSet({...transactionData}); - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(categoryApprover2AccountID); + + // When the rule approvers are read + // Then the category approver comes first even though its transaction is newer, because category approvers are prioritized + expect(getRuleApprovers(policy, expenseReport)).toEqual(['categoryapprover1@test.com', 'tagapprover1@test.com']); }); - it('should return the first rule approver who is not the current submitter', async () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - type: CONST.POLICY.TYPE.CORPORATE, - employeeList, - rules, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: categoryApprover1AccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - const transaction1: Transaction = { - ...createRandomTransaction(0), - category: 'cat1', - reportID: expenseReport.reportID, - tag: '', - created: DateUtils.subtractMillisecondsFromDateTime(testDate, 2), - }; - const transaction2: Transaction = { - ...createRandomTransaction(1), - category: 'cat3', - reportID: expenseReport.reportID, - tag: '', - created: DateUtils.subtractMillisecondsFromDateTime(testDate, 1), + it('should order approvers of the same kind by transaction created date', async () => { + // Given two transactions matching different category rules, where the cat2 transaction is the newer one + const newerTransaction: Transaction = {...createRandomTransaction(0), category: 'cat2', tag: '', created: '2025-01-02', reportID: expenseReport.reportID}; + const olderTransaction: Transaction = {...createRandomTransaction(1), category: 'cat1', tag: '', created: '2025-01-01', reportID: expenseReport.reportID}; + const transactionData: TransactionCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${newerTransaction.transactionID}`]: newerTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${olderTransaction.transactionID}`]: olderTransaction, }; + await Onyx.multiSet({...transactionData}); - const transaction3: Transaction = { - ...createRandomTransaction(2), - category: '', - reportID: expenseReport.reportID, - tag: 'tag1', - created: testDate, - }; + // When the rule approvers are read + // Then the approver of the older transaction comes first + expect(getRuleApprovers(policy, expenseReport)).toEqual(['categoryapprover1@test.com', 'categoryapprover2@test.com']); + }); + it('should return each approver only once', async () => { + // Given two transactions whose categories (cat1 and cat3) both route to the same approver + const transaction1: Transaction = {...createRandomTransaction(0), category: 'cat1', tag: '', created: '2025-01-01', reportID: expenseReport.reportID}; + const transaction2: Transaction = {...createRandomTransaction(1), category: 'cat3', tag: '', created: '2025-01-02', reportID: expenseReport.reportID}; const transactionData: TransactionCollectionDataSet = { [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1, [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction3.transactionID}`]: transaction3, }; await Onyx.multiSet({...transactionData}); - expect(getSubmitToAccountID(policy, expenseReport, categoryApprover1Email, undefined)).toBe(tagApprover1AccountID); + // When the rule approvers are read + // Then the shared approver is listed once + expect(getRuleApprovers(policy, expenseReport)).toEqual(['categoryapprover1@test.com']); + }); + }); + + describe('getSubmitToAccountID', () => { + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + }); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + describe('Has no rule approver', () => { + it('should return the policy approver/owner if the policy use the basic workflow', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(ownerAccountID); + }); + it('should return the policy approver/owner if the policy use the optional workflow', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(ownerAccountID); + }); + it('should return the employee submitsTo if the policy use the advance workflow', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + employeeList, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(adminAccountID); + }); }); - describe('Has no transaction match with the category approver rule', () => { - it('should return the first tag approver if has any transaction tag match with with the tag approver rule ', async () => { + describe('Has category/tag approver', () => { + it('should return the first category approver if has any transaction category match with category approver rule', async () => { const policy: Policy = { ...createRandomPolicy(0), approver: 'owner@test.com', @@ -1522,16 +2508,12 @@ describe('PolicyUtils', () => { }; const transaction1: Transaction = { ...createRandomTransaction(0), - category: '', - tag: 'tag1', - created: testDate, + category: 'cat1', reportID: expenseReport.reportID, }; const transaction2: Transaction = { ...createRandomTransaction(1), category: '', - tag: '', - created: DateUtils.subtractMillisecondsFromDateTime(testDate, 1), reportID: expenseReport.reportID, }; const transactionData: TransactionCollectionDataSet = { @@ -1539,9 +2521,35 @@ describe('PolicyUtils', () => { [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, }; await Onyx.multiSet({...transactionData}); - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(tagApprover1AccountID); + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(categoryApprover1AccountID); + }); + it('should return default approver if rule approver is submitter and prevent self approval is enabled', async () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + type: CONST.POLICY.TYPE.CORPORATE, + employeeList, + rules, + preventSelfApproval: true, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: categoryApprover1AccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + const transaction: Transaction = { + ...createRandomTransaction(0), + category: 'cat1', + reportID: expenseReport.reportID, + tag: '', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + expect(getSubmitToAccountID(policy, expenseReport, categoryApprover1Email, undefined)).toBe(adminAccountID); }); - it('should return the tag approver of the first transaction sorted by created if we have many transaction tags match with the tag approver rule', async () => { + it('should return the category approver of the first transaction sorted by created if we have many transaction categories match with the category approver rule', async () => { const policy: Policy = { ...createRandomPolicy(0), approver: 'owner@test.com', @@ -1558,516 +2566,618 @@ describe('PolicyUtils', () => { }; const transaction1: Transaction = { ...createRandomTransaction(0), - category: '', - tag: 'tag1', + category: 'cat1', created: testDate, reportID: expenseReport.reportID, }; const transaction2: Transaction = { ...createRandomTransaction(1), - category: '', - tag: 'tag2', + category: 'cat2', + created: DateUtils.subtractMillisecondsFromDateTime(testDate, 1), + reportID: expenseReport.reportID, + }; + const transactionData: TransactionCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, + }; + await Onyx.multiSet({...transactionData}); + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(categoryApprover2AccountID); + }); + it('should return the first rule approver who is not the current submitter', async () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + type: CONST.POLICY.TYPE.CORPORATE, + employeeList, + rules, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: categoryApprover1AccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + const transaction1: Transaction = { + ...createRandomTransaction(0), + category: 'cat1', + reportID: expenseReport.reportID, + tag: '', + created: DateUtils.subtractMillisecondsFromDateTime(testDate, 2), + }; + + const transaction2: Transaction = { + ...createRandomTransaction(1), + category: 'cat3', + reportID: expenseReport.reportID, + tag: '', created: DateUtils.subtractMillisecondsFromDateTime(testDate, 1), + }; + + const transaction3: Transaction = { + ...createRandomTransaction(2), + category: '', reportID: expenseReport.reportID, + tag: 'tag1', + created: testDate, }; + const transactionData: TransactionCollectionDataSet = { [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1, [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction3.transactionID}`]: transaction3, }; await Onyx.multiSet({...transactionData}); - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(tagApprover2AccountID); + + expect(getSubmitToAccountID(policy, expenseReport, categoryApprover1Email, undefined)).toBe(tagApprover1AccountID); + }); + describe('Has no transaction match with the category approver rule', () => { + it('should return the first tag approver if has any transaction tag match with with the tag approver rule ', async () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + type: CONST.POLICY.TYPE.CORPORATE, + employeeList, + rules, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + const transaction1: Transaction = { + ...createRandomTransaction(0), + category: '', + tag: 'tag1', + created: testDate, + reportID: expenseReport.reportID, + }; + const transaction2: Transaction = { + ...createRandomTransaction(1), + category: '', + tag: '', + created: DateUtils.subtractMillisecondsFromDateTime(testDate, 1), + reportID: expenseReport.reportID, + }; + const transactionData: TransactionCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, + }; + await Onyx.multiSet({...transactionData}); + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(tagApprover1AccountID); + }); + it('should return the tag approver of the first transaction sorted by created if we have many transaction tags match with the tag approver rule', async () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + type: CONST.POLICY.TYPE.CORPORATE, + employeeList, + rules, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + const transaction1: Transaction = { + ...createRandomTransaction(0), + category: '', + tag: 'tag1', + created: testDate, + reportID: expenseReport.reportID, + }; + const transaction2: Transaction = { + ...createRandomTransaction(1), + category: '', + tag: 'tag2', + created: DateUtils.subtractMillisecondsFromDateTime(testDate, 1), + reportID: expenseReport.reportID, + }; + const transactionData: TransactionCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, + }; + await Onyx.multiSet({...transactionData}); + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(tagApprover2AccountID); + }); }); }); }); - }); - describe('getSubmitReportManagerAccountID', () => { - beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); - }); - it('should return undefined for an advanced policy where the submitter is missing from the employee list, even if the report has a valid managerID', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - // The submitter isn't in the employee list, which is the case for reports migrated from Expensify Classic. - employeeList: {}, - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - // The stale Classic-era manager stamped on the migrated report. - managerID: categoryApprover1AccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBeUndefined(); - }); - it('should return the known approver accountID when the policy route is reliable', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - employeeList, - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - managerID: categoryApprover1AccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBe(adminAccountID); - }); - it('should return undefined instead of a generated accountID when the approver is missing from personal details', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - employeeList: { - [employeeEmail]: { - email: employeeEmail, - role: 'user', - submitsTo: 'unknown@test.com', - }, - // The approver is still a policy member, so the submit-to route stays put instead of falling back to the default approver. - 'unknown@test.com': { - email: 'unknown@test.com', - role: 'user', + describe('getSubmitReportManagerAccountID', () => { + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + }); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + it('should return undefined for an advanced policy where the submitter is missing from the employee list, even if the report has a valid managerID', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + // The submitter isn't in the employee list, which is the case for reports migrated from Expensify Classic. + employeeList: {}, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + // The stale Classic-era manager stamped on the migrated report. + managerID: categoryApprover1AccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBeUndefined(); + }); + it('should return the known approver accountID when the policy route is reliable', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + employeeList, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + managerID: categoryApprover1AccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBe(adminAccountID); + }); + it('should return undefined instead of a generated accountID when the approver is missing from personal details', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + employeeList: { + [employeeEmail]: { + email: employeeEmail, + role: 'user', + submitsTo: 'unknown@test.com', + }, + // The approver is still a policy member, so the submit-to route stays put instead of falling back to the default approver. + 'unknown@test.com': { + email: 'unknown@test.com', + role: 'user', + }, }, - }, - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - managerID: categoryApprover1AccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBeUndefined(); - }); - }); - describe('shouldShowPolicy', () => { - beforeEach(() => { - global.fetch = TestHelper.getGlobalFetchMock(); - return Onyx.clear().then(waitForBatchedUpdates); - }); - it('should return false', () => { - // Given an archived paid policy. - const policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - role: '', - }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The empty role deliberately models malformed persisted policy data. - const result = shouldShowPolicy(policy as unknown as Parameters[0], false, CARLOS_EMAIL); - // The result should be false since it is an archived paid policy. - expect(result).toBe(false); - }); - it('should return true', () => { - // Given a paid policy. - const policy = createMock>({...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: null}); - const result = shouldShowPolicy(policy, false, CARLOS_EMAIL); - // The result should be true, since it is an active paid policy. - expect(result).toBe(true); - }); - it('should return false', () => { - // Given a control workspace which is pending delete. - const policy = createMock>({ - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + managerID: categoryApprover1AccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBeUndefined(); }); - const result = shouldShowPolicy(policy, false, CARLOS_EMAIL); - // The result should be false since it is a policy which is pending deletion. - expect(result).toEqual(false); - }); - it('hides an archived policy by default', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: null, archivedDate: '2024-01-01'}; - const result = shouldShowPolicy(policy as OnyxEntry, false, CARLOS_EMAIL); - expect(result).toBe(false); - }); - it('shows an archived policy when includeArchivedPolicy is true', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: null, archivedDate: '2024-01-01'}; - const result = shouldShowPolicy(policy as OnyxEntry, false, CARLOS_EMAIL, true); - expect(result).toBe(true); }); - }); - describe('getManagerAccountID', () => { - beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); - }); + describe('getManagerAccountID', () => { + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + }); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); - it('should return default approver for personal workspaces', () => { - const policy: Policy = { - ...createRandomPolicy(0), - type: CONST.POLICY.TYPE.PERSONAL, - approver: categoryApprover1Email, - }; - const result = getManagerAccountID(policy, '', undefined); + it('should return default approver for personal workspaces', () => { + const policy: Policy = { + ...createRandomPolicy(0), + type: CONST.POLICY.TYPE.PERSONAL, + approver: categoryApprover1Email, + }; + const result = getManagerAccountID(policy, '', undefined); - expect(result).toBe(categoryApprover1AccountID); - }); + expect(result).toBe(categoryApprover1AccountID); + }); - it('should return -1 if there is no employee or default approver', () => { - const policy: Policy = { - ...createRandomPolicy(0), - type: CONST.POLICY.TYPE.TEAM, - approvalMode: undefined, - approver: undefined, - owner: '', - }; + it('should return -1 if there is no employee or default approver', () => { + const policy: Policy = { + ...createRandomPolicy(0), + type: CONST.POLICY.TYPE.TEAM, + approvalMode: undefined, + approver: undefined, + owner: '', + }; - const result = getManagerAccountID(policy, '', undefined); + const result = getManagerAccountID(policy, '', undefined); - expect(result).toBe(-1); - }); + expect(result).toBe(-1); + }); - it('should return submitsTo account ID', () => { - const policy: Policy = { - ...createRandomPolicy(0), - type: CONST.POLICY.TYPE.TEAM, - approvalMode: undefined, - employeeList: { - [employeeEmail]: { - email: employeeEmail, - submitsTo: adminEmail, + it('should return submitsTo account ID', () => { + const policy: Policy = { + ...createRandomPolicy(0), + type: CONST.POLICY.TYPE.TEAM, + approvalMode: undefined, + employeeList: { + [employeeEmail]: { + email: employeeEmail, + submitsTo: adminEmail, + }, }, - }, - }; + }; - const result = getManagerAccountID(policy, employeeEmail, undefined); + const result = getManagerAccountID(policy, employeeEmail, undefined); - expect(result).toBe(adminAccountID); - }); + expect(result).toBe(adminAccountID); + }); - it('should return submitsTo email from workspace approval config', () => { - const policy: Policy = { - ...createRandomPolicy(0), - type: CONST.POLICY.TYPE.TEAM, - approvalMode: undefined, - employeeList: { - [adminEmail]: { - email: adminEmail, - }, - [employeeEmail]: { - email: employeeEmail, - submitsTo: adminEmail, + it('should return submitsTo email from workspace approval config', () => { + const policy: Policy = { + ...createRandomPolicy(0), + type: CONST.POLICY.TYPE.TEAM, + approvalMode: undefined, + employeeList: { + [adminEmail]: { + email: adminEmail, + }, + [employeeEmail]: { + email: employeeEmail, + submitsTo: adminEmail, + }, }, - }, - }; - const report: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - }; + }; + const report: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + }; - expect(getSubmitToEmail(policy, report, employeeEmail, undefined)).toBe(adminEmail); - }); + expect(getSubmitToEmail(policy, report, employeeEmail, undefined)).toBe(adminEmail); + }); - it('should return the default approver', () => { - const policy: Policy = { - ...createRandomPolicy(0), - type: CONST.POLICY.TYPE.TEAM, - approvalMode: undefined, - approver: categoryApprover1Email, - }; + it('should return the default approver', () => { + const policy: Policy = { + ...createRandomPolicy(0), + type: CONST.POLICY.TYPE.TEAM, + approvalMode: undefined, + approver: categoryApprover1Email, + }; - const result = getManagerAccountID(policy, '', undefined); + const result = getManagerAccountID(policy, '', undefined); - expect(result).toBe(categoryApprover1AccountID); + expect(result).toBe(categoryApprover1AccountID); + }); }); - }); - describe('approval workflow rules', () => { - const policyID = 'RULES_POLICY_1'; - const submitFilter = {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employeeEmail]}; + describe('approval workflow rules', () => { + const policyID = 'RULES_POLICY_1'; + const submitFilter = {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employeeEmail]}; - const buildRule = (rule: ApprovalWorkflowRule): Rule => ({...rule, scope: CONST.RULES.SCOPE.POLICY, scopeID: policyID}); + const buildRule = (rule: ApprovalWorkflowRule): Rule => ({...rule, scope: CONST.RULES.SCOPE.POLICY, scopeID: policyID}); - const submitRule: ApprovalWorkflowRule = { - triggers: {'1': CONST.RULES.TRIGGERS.REPORT_SUBMIT}, - filters: submitFilter, - actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: adminEmail}}, - }; + const submitRule: ApprovalWorkflowRule = { + triggers: {'1': CONST.RULES.TRIGGERS.REPORT_SUBMIT}, + filters: submitFilter, + actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: adminEmail}}, + }; - // After the admin approves, an under-limit report continues to the approver and an over-limit one is - // escalated to the category approver instead. - const underLimitRule: ApprovalWorkflowRule = { - triggers: {'1': CONST.RULES.TRIGGERS.REPORT_APPROVE}, - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: submitFilter, - right: { + // After the admin approves, an under-limit report continues to the approver and an over-limit one is + // escalated to the category approver instead. + const underLimitRule: ApprovalWorkflowRule = { + triggers: {'1': CONST.RULES.TRIGGERS.REPORT_APPROVE}, + filters: { operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: adminEmail}, - right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 10000}, + left: submitFilter, + right: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: adminEmail}, + right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 10000}, + }, }, - }, - actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: approverEmail}}, - }; - const overLimitRule: ApprovalWorkflowRule = { - triggers: {'1': CONST.RULES.TRIGGERS.REPORT_APPROVE}, - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: submitFilter, - right: { + actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: approverEmail}}, + }; + const overLimitRule: ApprovalWorkflowRule = { + triggers: {'1': CONST.RULES.TRIGGERS.REPORT_APPROVE}, + filters: { operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: adminEmail}, - right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 10000}, + left: submitFilter, + right: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: adminEmail}, + right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 10000}, + }, }, - }, - actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: categoryApprover1Email}}, - }; - const terminalRule: ApprovalWorkflowRule = { - triggers: {'1': CONST.RULES.TRIGGERS.REPORT_APPROVE}, - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: submitFilter, - right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: approverEmail}, - }, - actions: {'1': {name: CONST.RULES.ACTIONS.APPROVE_REPORT}}, - }; - - // A workspace whose employeeList still points somewhere else, so a rule-driven answer is distinguishable - // from the legacy fallback. - const policy: Policy = { - ...createRandomPolicy(0), - id: policyID, - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - approver: categoryApprover1Email, - employeeList: { - [employeeEmail]: {email: employeeEmail, submitsTo: categoryApprover1Email}, - }, - }; - - const rulesCollection: OnyxCollection = { - [`${ONYXKEYS.COLLECTION.RULE}1`]: buildRule(submitRule), - [`${ONYXKEYS.COLLECTION.RULE}2`]: buildRule(underLimitRule), - [`${ONYXKEYS.COLLECTION.RULE}3`]: buildRule(overLimitRule), - [`${ONYXKEYS.COLLECTION.RULE}4`]: buildRule(terminalRule), - }; - - beforeEach(async () => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}1` as const, buildRule(submitRule)); - await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}2` as const, buildRule(underLimitRule)); - await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}3` as const, buildRule(overLimitRule)); - await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}4` as const, buildRule(terminalRule)); - await waitForBatchedUpdatesWithAct(); - }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); - }); + actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: categoryApprover1Email}}, + }; + const terminalRule: ApprovalWorkflowRule = { + triggers: {'1': CONST.RULES.TRIGGERS.REPORT_APPROVE}, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: submitFilter, + right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: approverEmail}, + }, + actions: {'1': {name: CONST.RULES.ACTIONS.APPROVE_REPORT}}, + }; - describe('evaluateApprovalWorkflowRule', () => { - it('matches the submit rule for a submitter it covers', () => { - expect(evaluateApprovalWorkflowRule(submitRule, {submitterEmail: employeeEmail, reportTotal: 0})).toBe(true); - }); + // A workspace whose employeeList still points somewhere else, so a rule-driven answer is distinguishable + // from the legacy fallback. + const policy: Policy = { + ...createRandomPolicy(0), + id: policyID, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + approver: categoryApprover1Email, + employeeList: { + [employeeEmail]: {email: employeeEmail, submitsTo: categoryApprover1Email}, + }, + }; - it('does not match the submit rule for another submitter', () => { - expect(evaluateApprovalWorkflowRule(submitRule, {submitterEmail: adminEmail, reportTotal: 0})).toBe(false); - }); + const rulesCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.RULE}1`]: buildRule(submitRule), + [`${ONYXKEYS.COLLECTION.RULE}2`]: buildRule(underLimitRule), + [`${ONYXKEYS.COLLECTION.RULE}3`]: buildRule(overLimitRule), + [`${ONYXKEYS.COLLECTION.RULE}4`]: buildRule(terminalRule), + }; - it('does not match a rule gated on the current approver when the report has none', () => { - expect(evaluateApprovalWorkflowRule(underLimitRule, {submitterEmail: employeeEmail, reportTotal: -5000})).toBe(false); + beforeEach(async () => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}1` as const, buildRule(submitRule)); + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}2` as const, buildRule(underLimitRule)); + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}3` as const, buildRule(overLimitRule)); + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}4` as const, buildRule(terminalRule)); + await waitForBatchedUpdatesWithAct(); }); - - it('splits on the report total, comparing the absolute amount', () => { - const context = {submitterEmail: employeeEmail, currentApproverEmail: adminEmail}; - expect(evaluateApprovalWorkflowRule(underLimitRule, {...context, reportTotal: -5000})).toBe(true); - expect(evaluateApprovalWorkflowRule(overLimitRule, {...context, reportTotal: -5000})).toBe(false); - expect(evaluateApprovalWorkflowRule(underLimitRule, {...context, reportTotal: -20000})).toBe(false); - expect(evaluateApprovalWorkflowRule(overLimitRule, {...context, reportTotal: -20000})).toBe(true); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); }); - const buildAmountRule = (operator: ValueOf, right: number): ApprovalWorkflowRule => ({ - triggers: {'1': CONST.RULES.TRIGGERS.REPORT_SUBMIT}, - filters: {operator, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right}, - actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: adminEmail}}, - }); + describe('evaluateApprovalWorkflowRule', () => { + it('matches the submit rule for a submitter it covers', () => { + expect(evaluateApprovalWorkflowRule(submitRule, {submitterEmail: employeeEmail, reportTotal: 0})).toBe(true); + }); - it.each([ - ['eq matches an exact amount', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, 5000, -5000, true], - ['eq rejects a different amount', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, 5000, -6000, false], - ['neq rejects an exact amount', CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO, 5000, -5000, false], - ['neq matches a different amount', CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO, 5000, -6000, true], - ['gt rejects an equal amount', CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN, 5000, -5000, false], - ['gt matches a strictly greater amount', CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN, 5000, -5001, true], - ['lte matches an equal amount', CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, 5000, -5000, true], - ['lte rejects a greater amount', CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, 5000, -5001, false], - ])('%s', (_description, operator, right, reportTotal, expected) => { - expect(evaluateApprovalWorkflowRule(buildAmountRule(operator, right), {submitterEmail: employeeEmail, reportTotal})).toBe(expected); - }); + it('does not match the submit rule for another submitter', () => { + expect(evaluateApprovalWorkflowRule(submitRule, {submitterEmail: adminEmail, reportTotal: 0})).toBe(false); + }); - it('does not match an amount filter whose right side is not numeric', () => { - const rule = buildAmountRule(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, Number('not-a-number')); - expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: -5000})).toBe(false); - }); + it('does not match a rule gated on the current approver when the report has none', () => { + expect(evaluateApprovalWorkflowRule(underLimitRule, {submitterEmail: employeeEmail, reportTotal: -5000})).toBe(false); + }); - it('does not match an amount filter using an operator this client does not understand', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The unrecognized operator is the scenario under test (a future backend operator this client predates). - const rule = buildAmountRule('unsupportedOperator' as ValueOf, 5000); - expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: -5000})).toBe(false); - }); + it('splits on the report total, comparing the absolute amount', () => { + const context = {submitterEmail: employeeEmail, currentApproverEmail: adminEmail}; + expect(evaluateApprovalWorkflowRule(underLimitRule, {...context, reportTotal: -5000})).toBe(true); + expect(evaluateApprovalWorkflowRule(overLimitRule, {...context, reportTotal: -5000})).toBe(false); + expect(evaluateApprovalWorkflowRule(underLimitRule, {...context, reportTotal: -20000})).toBe(false); + expect(evaluateApprovalWorkflowRule(overLimitRule, {...context, reportTotal: -20000})).toBe(true); + }); - it('does not match a filter on a field this client does not understand', () => { - const rule: ApprovalWorkflowRule = { + const buildAmountRule = (operator: ValueOf, right: number): ApprovalWorkflowRule => ({ triggers: {'1': CONST.RULES.TRIGGERS.REPORT_SUBMIT}, - filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: 'unsupportedField', right: employeeEmail}, + filters: {operator, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right}, actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: adminEmail}}, - }; - expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: 0})).toBe(false); - }); + }); - it('matches an OR filter when either side matches', () => { - const rule: ApprovalWorkflowRule = { - triggers: {'1': CONST.RULES.TRIGGERS.REPORT_SUBMIT}, - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.OR, - left: submitFilter, - right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [adminEmail]}, - }, - actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: adminEmail}}, - }; + it.each([ + ['eq matches an exact amount', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, 5000, -5000, true], + ['eq rejects a different amount', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, 5000, -6000, false], + ['neq rejects an exact amount', CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO, 5000, -5000, false], + ['neq matches a different amount', CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO, 5000, -6000, true], + ['gt rejects an equal amount', CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN, 5000, -5000, false], + ['gt matches a strictly greater amount', CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN, 5000, -5001, true], + ['lte matches an equal amount', CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, 5000, -5000, true], + ['lte rejects a greater amount', CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, 5000, -5001, false], + ])('%s', (_description, operator, right, reportTotal, expected) => { + expect(evaluateApprovalWorkflowRule(buildAmountRule(operator, right), {submitterEmail: employeeEmail, reportTotal})).toBe(expected); + }); - // Only the left side matches this submitter, but OR only needs one side. - expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: 0})).toBe(true); - // Neither side names this submitter. - expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: guideEmail, reportTotal: 0})).toBe(false); - }); - }); + it('does not match an amount filter whose right side is not numeric', () => { + const rule = buildAmountRule(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, Number('not-a-number')); + expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: -5000})).toBe(false); + }); - describe('getForwardsToFromRules', () => { - it('returns the first approver from the submit rule', () => { - expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, reportTotal: -5000}, rulesCollection)).toEqual({forwardsTo: adminEmail}); - }); + it('does not match an amount filter using an operator this client does not understand', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The unrecognized operator is the scenario under test (a future backend operator this client predates). + const rule = buildAmountRule('unsupportedOperator' as ValueOf, 5000); + expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: -5000})).toBe(false); + }); - it('returns the next approver from the matching approve rule', () => { - expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, currentApproverEmail: adminEmail, reportTotal: -5000}, rulesCollection)).toEqual({ - forwardsTo: approverEmail, + it('does not match a filter on a field this client does not understand', () => { + const rule: ApprovalWorkflowRule = { + triggers: {'1': CONST.RULES.TRIGGERS.REPORT_SUBMIT}, + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: 'unsupportedField', right: employeeEmail}, + actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: adminEmail}}, + }; + expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: 0})).toBe(false); }); - expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, currentApproverEmail: adminEmail, reportTotal: -20000}, rulesCollection)).toEqual({ - forwardsTo: categoryApprover1Email, + + it('matches an OR filter when either side matches', () => { + const rule: ApprovalWorkflowRule = { + triggers: {'1': CONST.RULES.TRIGGERS.REPORT_SUBMIT}, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.OR, + left: submitFilter, + right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [adminEmail]}, + }, + actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: adminEmail}}, + }; + + // Only the left side matches this submitter, but OR only needs one side. + expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: 0})).toBe(true); + // Neither side names this submitter. + expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: guideEmail, reportTotal: 0})).toBe(false); }); }); - it('matches with no forwardsTo when the rule finalizes the report', () => { - expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, currentApproverEmail: approverEmail, reportTotal: -5000}, rulesCollection)).toEqual({ - forwardsTo: undefined, + describe('getForwardsToFromRules', () => { + it('returns the first approver from the submit rule', () => { + expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, reportTotal: -5000}, rulesCollection)).toEqual({forwardsTo: adminEmail}); }); - }); - it('returns undefined when no rule covers the submitter', () => { - expect(getForwardsToFromRules(policy, {submitterEmail: guideEmail, reportTotal: -5000}, rulesCollection)).toBeUndefined(); - }); + it('returns the next approver from the matching approve rule', () => { + expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, currentApproverEmail: adminEmail, reportTotal: -5000}, rulesCollection)).toEqual({ + forwardsTo: approverEmail, + }); + expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, currentApproverEmail: adminEmail, reportTotal: -20000}, rulesCollection)).toEqual({ + forwardsTo: categoryApprover1Email, + }); + }); + + it('matches with no forwardsTo when the rule finalizes the report', () => { + expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, currentApproverEmail: approverEmail, reportTotal: -5000}, rulesCollection)).toEqual({ + forwardsTo: undefined, + }); + }); + + it('returns undefined when no rule covers the submitter', () => { + expect(getForwardsToFromRules(policy, {submitterEmail: guideEmail, reportTotal: -5000}, rulesCollection)).toBeUndefined(); + }); - it('ignores rules belonging to another workspace', () => { - expect(getForwardsToFromRules({...policy, id: 'OTHER_POLICY'}, {submitterEmail: employeeEmail, reportTotal: -5000}, rulesCollection)).toBeUndefined(); + it('ignores rules belonging to another workspace', () => { + expect(getForwardsToFromRules({...policy, id: 'OTHER_POLICY'}, {submitterEmail: employeeEmail, reportTotal: -5000}, rulesCollection)).toBeUndefined(); + }); }); - }); - it('getManagerAccountID prefers the rules over the employee submitsTo', () => { - expect(getManagerAccountID(policy, employeeEmail, rulesCollection)).toBe(adminAccountID); - }); + it('getManagerAccountID prefers the rules over the employee submitsTo', () => { + expect(getManagerAccountID(policy, employeeEmail, rulesCollection)).toBe(adminAccountID); + }); - it('getForwardsToAccount follows the rules for the next hop', () => { - expect(getForwardsToAccount(policy, adminEmail, -5000, rulesCollection, employeeEmail)).toBe(approverEmail); - expect(getForwardsToAccount(policy, adminEmail, -20000, rulesCollection, employeeEmail)).toBe(categoryApprover1Email); - expect(getForwardsToAccount(policy, approverEmail, -5000, rulesCollection, employeeEmail)).toBe(''); - }); + it('getForwardsToAccount follows the rules for the next hop', () => { + expect(getForwardsToAccount(policy, adminEmail, -5000, rulesCollection, employeeEmail)).toBe(approverEmail); + expect(getForwardsToAccount(policy, adminEmail, -20000, rulesCollection, employeeEmail)).toBe(categoryApprover1Email); + expect(getForwardsToAccount(policy, approverEmail, -5000, rulesCollection, employeeEmail)).toBe(''); + }); - it('getApprovalChain walks the whole rule chain', () => { - const report: Report = {...createRandomReport(0, undefined), policyID, ownerAccountID: employeeAccountID, total: -5000}; - expect(getApprovalChain(policy, report, employeeEmail, rulesCollection)).toEqual([adminEmail, approverEmail]); - }); + it('getApprovalChain walks the whole rule chain', () => { + const report: Report = {...createRandomReport(0, undefined), policyID, ownerAccountID: employeeAccountID, total: -5000}; + expect(getApprovalChain(policy, report, employeeEmail, rulesCollection)).toEqual([adminEmail, approverEmail]); + }); - it('getSubmitToEmail keeps the rules approver when that approver has no employeeList entry', () => { - const report: Report = {...createRandomReport(0, undefined), policyID, ownerAccountID: employeeAccountID, total: -5000}; - expect(getSubmitToEmail(policy, report, employeeEmail, rulesCollection, true)).toBe(adminEmail); + it('getSubmitToEmail keeps the rules approver when that approver has no employeeList entry', () => { + const report: Report = {...createRandomReport(0, undefined), policyID, ownerAccountID: employeeAccountID, total: -5000}; + expect(getSubmitToEmail(policy, report, employeeEmail, rulesCollection, true)).toBe(adminEmail); + }); }); - }); - describe('getSubmitReportManagerAccountID', () => { - beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); - }); - it('should return the default approver if the employee submitsTo is no longer a policy member and the policy use the advance workflow', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - employeeList: { - 'owner@test.com': { - email: 'owner@test.com', - role: 'admin', - submitsTo: '', + describe('getSubmitReportManagerAccountID', () => { + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + }); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + it('should return the default approver if the employee submitsTo is no longer a policy member and the policy use the advance workflow', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + employeeList: { + 'owner@test.com': { + email: 'owner@test.com', + role: 'admin', + submitsTo: '', + }, + [employeeEmail]: { + email: employeeEmail, + role: 'user', + submitsTo: 'removed-approver@test.com', + }, }, - [employeeEmail]: { - email: employeeEmail, - role: 'user', - submitsTo: 'removed-approver@test.com', + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBe(ownerAccountID); + }); + it('should keep the submitsTo approver that is not a policy member when the policy uses HR advanced (manager) mode', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + employeeList: { + 'owner@test.com': { + email: 'owner@test.com', + role: 'admin', + submitsTo: '', + }, + [employeeEmail]: { + email: employeeEmail, + role: 'user', + submitsTo: adminEmail, + }, }, - }, - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBe(ownerAccountID); + connections: { + [CONST.POLICY.CONNECTIONS.NAME.GUSTO]: { + config: {approvalMode: CONST.GUSTO.APPROVAL_MODE.MANAGER, finalApprover: 'owner@test.com'}, + }, + } as Policy['connections'], + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBe(adminAccountID); + }); }); - it('should keep the submitsTo approver that is not a policy member when the policy uses HR advanced (manager) mode', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', + + describe('isNonMemberApprover', () => { + const ownerEmail = 'owner@example.com'; + const policy = createMock({ + id: '1', + owner: ownerEmail, employeeList: { - 'owner@test.com': { - email: 'owner@test.com', - role: 'admin', - submitsTo: '', - }, - [employeeEmail]: { - email: employeeEmail, - role: 'user', - submitsTo: adminEmail, - }, + [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, }, - connections: { - [CONST.POLICY.CONNECTIONS.NAME.GUSTO]: { - config: {approvalMode: CONST.GUSTO.APPROVAL_MODE.MANAGER, finalApprover: 'owner@test.com'}, - }, - } as Policy['connections'], - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBe(adminAccountID); + }); + + it('flags an approver who is no longer on the workspace, but not a member', () => { + // Given a workspace with one member + // When checking a removed approver and that member + // Then only the removed approver is flagged + expect(isNonMemberApprover(policy, 'removed@example.com')).toBe(true); + expect(isNonMemberApprover(policy, employeeEmail)).toBe(false); + }); + + it('does not flag the owner when their employee entry is missing', () => { + // Given a workspace whose employeeList has no entry for the owner + // When checking the owner as an approver + // Then they still count as a member, since the owner always belongs to the workspace + expect(isNonMemberApprover(policy, ownerEmail)).toBe(false); + }); }); }); @@ -2180,6 +3290,71 @@ describe('PolicyUtils', () => { expect(tagList.name).toEqual(expected); }); }); + describe('hasAnyTagGLCode', () => { + it('returns false when the workspace has no tags', () => { + // Given no tag lists, there is no GL code to pick, so the Tag GL code cell must stay read-only + // When / Then + expect(hasAnyTagGLCode(undefined)).toBe(false); + expect(hasAnyTagGLCode({})).toBe(false); + }); + + it('returns false when every GL code is missing or blank', () => { + // Given tags whose GL codes are missing, empty, whitespace or only quotes + const policyTagLists: PolicyTagLists = { + Department: { + name: 'Department', + orderWeight: 0, + required: false, + tags: { + Engineering: {name: 'Engineering', enabled: true}, + Marketing: {name: 'Marketing', enabled: true, 'GL Code': ''}, + Sales: {name: 'Sales', enabled: true, 'GL Code': ' '}, + Support: {name: 'Support', enabled: true, 'GL Code': '""'}, + }, + }, + }; + + // When / Then a blank code does not count as a GL code + expect(hasAnyTagGLCode(policyTagLists)).toBe(false); + }); + + it('returns true when at least one tag has a GL code', () => { + // Given a mix of tags with and without a GL code + const policyTagLists: PolicyTagLists = { + Department: { + name: 'Department', + orderWeight: 0, + required: false, + tags: { + Engineering: {name: 'Engineering', enabled: true, 'GL Code': '1234'}, + Marketing: {name: 'Marketing', enabled: true}, + }, + }, + }; + + // When / Then one code is enough to keep the cell editable + expect(hasAnyTagGLCode(policyTagLists)).toBe(true); + }); + + it('returns false when the only GL codes are on disabled or pending-deletion tags', () => { + // Given GL codes only on tags the tag picker won't let the user select + const policyTagLists: PolicyTagLists = { + Department: { + name: 'Department', + orderWeight: 0, + required: false, + tags: { + Engineering: {name: 'Engineering', enabled: false, 'GL Code': '1234'}, + Marketing: {name: 'Marketing', enabled: true, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, 'GL Code': '5678'}, + Sales: {name: 'Sales', enabled: true}, + }, + }, + }; + + // When / Then the cell stays read-only, because the picker would offer no GL code to pick + expect(hasAnyTagGLCode(policyTagLists)).toBe(false); + }); + }); describe('getTagGLCode', () => { // Tag lists are intentionally declared out of orderWeight order to verify levels resolve by orderWeight const glCodePolicyTagLists: PolicyTagLists = { @@ -2855,515 +4030,354 @@ describe('PolicyUtils', () => { }); }); - describe('sortWorkspacesBySelected', () => { - it('should order workspaces with selected workspace first', () => { - const workspace1 = {policyID: '1', name: 'Workspace 1'}; - const workspace2 = {policyID: '2', name: 'Workspace 2'}; - const selectedWorkspace1 = {policyID: '3', name: 'Workspace 3'}; - const selectedWorkspace2 = {policyID: '4', name: 'Workspace 4'}; - expect(sortWorkspacesBySelected(workspace1, workspace2, ['3', '4'], TestHelper.localeCompare)).toBe(-1); - expect(sortWorkspacesBySelected(workspace1, selectedWorkspace1, ['3', '4'], TestHelper.localeCompare)).toBe(1); - expect(sortWorkspacesBySelected(selectedWorkspace1, selectedWorkspace2, ['3', '4'], TestHelper.localeCompare)).toBe(-1); - }); - - it('should order workspaces using name if no workspace is selected', () => { - const workspace1 = {policyID: '1', name: 'Workspace 1'}; - const workspace2 = {policyID: '2', name: 'Workspace 2'}; - const workspace3 = {policyID: '3', name: 'Workspace 3'}; - const workspace4 = {policyID: '4', name: 'Workspace 4'}; - expect(sortWorkspacesBySelected(workspace1, workspace2, undefined, TestHelper.localeCompare)).toBe(-1); - expect(sortWorkspacesBySelected(workspace1, workspace3, undefined, TestHelper.localeCompare)).toBe(-1); - expect(sortWorkspacesBySelected(workspace3, workspace4, undefined, TestHelper.localeCompare)).toBe(-1); - }); - - it('should sort workspaces when using this method correctly', () => { - const unsortedWorkspaces = [ - {policyID: '2', name: 'Workspace 2'}, - {policyID: '1', name: 'Workspace 1'}, - {policyID: '4', name: 'Workspace 4'}, - {policyID: '3', name: 'Workspace 3'}, - ]; - const selectedWorkspaceIDs = ['3', '4']; - const sortedWorkspaces = unsortedWorkspaces.sort((a, b) => sortWorkspacesBySelected(a, b, selectedWorkspaceIDs, TestHelper.localeCompare)); - expect(sortedWorkspaces).toEqual([ - {policyID: '3', name: 'Workspace 3'}, - {policyID: '4', name: 'Workspace 4'}, - {policyID: '1', name: 'Workspace 1'}, - {policyID: '2', name: 'Workspace 2'}, - ]); - }); - }); - - describe('isNonMemberApprover', () => { - const ownerEmail = 'owner@example.com'; - const policy = createMock({ - id: '1', - owner: ownerEmail, - employeeList: { - [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, - }, - }); - - it('flags an approver who is no longer on the workspace, but not a member', () => { - // Given a workspace with one member - // When checking a removed approver and that member - // Then only the removed approver is flagged - expect(isNonMemberApprover(policy, 'removed@example.com')).toBe(true); - expect(isNonMemberApprover(policy, employeeEmail)).toBe(false); - }); - - it('does not flag the owner when their employee entry is missing', () => { - // Given a workspace whose employeeList has no entry for the owner - // When checking the owner as an approver - // Then they still count as a member, since the owner always belongs to the workspace - expect(isNonMemberApprover(policy, ownerEmail)).toBe(false); - }); - }); - - describe('isPolicyMemberWithoutPendingDelete', () => { - it('should return true if the policy member is not pending delete', () => { - const policy = createMock({ - id: '1', - employeeList: { - [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, - }, + describe('payments', () => { + describe('getReimburserEmail', () => { + it('should return undefined when reimbursement is disabled', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, + reimburser: 'reimburser@example.com', + achAccount: {reimburser: 'ach@example.com'}, + owner: 'owner@example.com', + }); + expect(getReimburserEmail(policy)).toBeUndefined(); }); - const result = isPolicyMemberWithoutPendingDelete(employeeEmail, policy); - expect(result).toBe(true); - }); - it('should return false if the policy member is pending delete', () => { - const policy = createMock({ - id: '1', - employeeList: { - [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, - }, + it('should resolve the payer for manual (indirect) reimbursement from achAccount.reimburser', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + achAccount: {reimburser: 'ach@example.com'}, + owner: 'owner@example.com', + }); + expect(getReimburserEmail(policy)).toBe('ach@example.com'); }); - const result = isPolicyMemberWithoutPendingDelete(employeeEmail, policy); - expect(result).toBe(false); - }); - it('should return false if the policy member is not found', () => { - const policy = createMock({ - id: '1', - employeeList: { - [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, - }, + it('should prefer policy.reimburser over achAccount.reimburser', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + reimburser: 'reimburser@example.com', + achAccount: {reimburser: 'ach@example.com'}, + }); + expect(getReimburserEmail(policy)).toBe('reimburser@example.com'); }); - const result = isPolicyMemberWithoutPendingDelete('fakeEmail', policy); - expect(result).toBe(false); - }); - }); - describe('getReimburserEmail', () => { - it('should return undefined when reimbursement is disabled', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, - reimburser: 'reimburser@example.com', - achAccount: {reimburser: 'ach@example.com'}, - owner: 'owner@example.com', + it('should fall back to the owner for manual reimbursement when no reimburser is set', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + owner: 'owner@example.com', + }); + expect(getReimburserEmail(policy)).toBe('owner@example.com'); }); - expect(getReimburserEmail(policy)).toBeUndefined(); - }); - it('should resolve the payer for manual (indirect) reimbursement from achAccount.reimburser', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - achAccount: {reimburser: 'ach@example.com'}, - owner: 'owner@example.com', + it('should not fall back to the owner for auto reimbursement when no reimburser is set', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + owner: 'owner@example.com', + }); + expect(getReimburserEmail(policy)).toBeUndefined(); }); - expect(getReimburserEmail(policy)).toBe('ach@example.com'); - }); - it('should prefer policy.reimburser over achAccount.reimburser', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, - reimburser: 'reimburser@example.com', - achAccount: {reimburser: 'ach@example.com'}, - }); - expect(getReimburserEmail(policy)).toBe('reimburser@example.com'); - }); + it('should resolve the payer for deprecated manual reimbursement', () => { + // Given a workspace on Indirect reimbursement that reports the deprecated value and names no payer + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + owner: 'owner@example.com', + }); - it('should fall back to the owner for manual reimbursement when no reimburser is set', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - owner: 'owner@example.com', - }); - expect(getReimburserEmail(policy)).toBe('owner@example.com'); - }); + // When the payer is resolved + const reimburserEmail = getReimburserEmail(policy); - it('should not fall back to the owner for auto reimbursement when no reimburser is set', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, - owner: 'owner@example.com', + // Then it falls back to the owner, because the deprecated value means Indirect just like the plain one + expect(reimburserEmail).toBe('owner@example.com'); }); - expect(getReimburserEmail(policy)).toBeUndefined(); - }); - it('should resolve the payer for deprecated manual reimbursement', () => { - // Given a workspace on Indirect reimbursement that reports the deprecated value and names no payer - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - owner: 'owner@example.com', - }); + it('should return undefined when deprecated reimbursement is disabled', () => { + // Given a workspace that reports the deprecated disabled value but still names a payer + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, + reimburser: 'reimburser@example.com', + owner: 'owner@example.com', + }); - // When the payer is resolved - const reimburserEmail = getReimburserEmail(policy); + // When the payer is resolved + const reimburserEmail = getReimburserEmail(policy); - // Then it falls back to the owner, because the deprecated value means Indirect just like the plain one - expect(reimburserEmail).toBe('owner@example.com'); + // Then nobody is returned, because a disabled workspace has no payer no matter who is named on it + expect(reimburserEmail).toBeUndefined(); + }); }); - it('should return undefined when deprecated reimbursement is disabled', () => { - // Given a workspace that reports the deprecated disabled value but still names a payer - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, - reimburser: 'reimburser@example.com', - owner: 'owner@example.com', - }); + describe('getReimbursementChoice', () => { + it('should return undefined when there is no policy', () => { + // Given no policy, which happens while a workspace is still loading - // When the payer is resolved - const reimburserEmail = getReimburserEmail(policy); + // When the choice is resolved + const choice = getReimbursementChoice(undefined); - // Then nobody is returned, because a disabled workspace has no payer no matter who is named on it - expect(reimburserEmail).toBeUndefined(); - }); - }); + // Then nothing is returned, so callers fall back to their own defaults instead of guessing a choice + expect(choice).toBeUndefined(); + }); - describe('getReimbursementChoice', () => { - it('should return undefined when there is no policy', () => { - // Given no policy, which happens while a workspace is still loading + it('should return undefined when the policy has no choice', () => { + // Given a workspace that reports no reimbursement choice at all + const policy = createMock({id: '1'}); - // When the choice is resolved - const choice = getReimbursementChoice(undefined); + // When the choice is resolved + const choice = getReimbursementChoice(policy); - // Then nothing is returned, so callers fall back to their own defaults instead of guessing a choice - expect(choice).toBeUndefined(); - }); + // Then nothing is returned, because an absent choice must stay absent rather than become a real value + expect(choice).toBeUndefined(); + }); - it('should return undefined when the policy has no choice', () => { - // Given a workspace that reports no reimbursement choice at all - const policy = createMock({id: '1'}); + it.each([CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL])( + 'should pass through the current value %s', + (choice) => { + // Given a workspace on one of the three values the app already understands + const policy = createMock({id: '1', reimbursementChoice: choice}); - // When the choice is resolved - const choice = getReimbursementChoice(policy); + // When the choice is resolved + const resolvedChoice = getReimbursementChoice(policy); - // Then nothing is returned, because an absent choice must stay absent rather than become a real value - expect(choice).toBeUndefined(); - }); + // Then it is returned untouched, so resolving cannot change the meaning of a workspace that was already correct + expect(resolvedChoice).toBe(choice); + }, + ); - it.each([CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL])( - 'should pass through the current value %s', - (choice) => { - // Given a workspace on one of the three values the app already understands - const policy = createMock({id: '1', reimbursementChoice: choice}); + it.each([ + [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO], + [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL], + ])('should resolve %s to %s', (deprecatedChoice, expectedChoice) => { + // Given a workspace that reports a deprecated value + const policy = createMock({id: '1', reimbursementChoice: deprecatedChoice}); // When the choice is resolved const resolvedChoice = getReimbursementChoice(policy); - // Then it is returned untouched, so resolving cannot change the meaning of a workspace that was already correct - expect(resolvedChoice).toBe(choice); - }, - ); + // Then it becomes the plain value it means, so every comparison downstream treats the workspace correctly + expect(resolvedChoice).toBe(expectedChoice); + }); - it.each([ - [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO], - [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL], - ])('should resolve %s to %s', (deprecatedChoice, expectedChoice) => { - // Given a workspace that reports a deprecated value - const policy = createMock({id: '1', reimbursementChoice: deprecatedChoice}); + it('should not resolve a value that merely starts with the deprecated prefix', () => { + // Given a workspace on an unknown value that only shares the deprecated prefix + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The value outside the type is the scenario under test. + const policy = createMock({id: '1', reimbursementChoice: 'deprecated_reimburseSomethingElse' as Policy['reimbursementChoice']}); - // When the choice is resolved - const resolvedChoice = getReimbursementChoice(policy); + // When the choice is resolved + const resolvedChoice = getReimbursementChoice(policy); - // Then it becomes the plain value it means, so every comparison downstream treats the workspace correctly - expect(resolvedChoice).toBe(expectedChoice); + // Then it is passed through, because matching on the prefix would silently invent a meaning for a value we do not know + expect(resolvedChoice).toBe('deprecated_reimburseSomethingElse'); + }); }); - it('should not resolve a value that merely starts with the deprecated prefix', () => { - // Given a workspace on an unknown value that only shares the deprecated prefix - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The value outside the type is the scenario under test. - const policy = createMock({id: '1', reimbursementChoice: 'deprecated_reimburseSomethingElse' as Policy['reimbursementChoice']}); + describe('arePaymentsEnabled', () => { + it.each([ + [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, true], + [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, true], + [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, false], + [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, true], + [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, false], + ])('should return %s -> %s', (choice, expected) => { + // Given a workspace on one of the current or deprecated values + const policy = createMock({id: '1', reimbursementChoice: choice}); - // When the choice is resolved - const resolvedChoice = getReimbursementChoice(policy); + // When payments availability is checked + const paymentsEnabled = arePaymentsEnabled(policy); - // Then it is passed through, because matching on the prefix would silently invent a meaning for a value we do not know - expect(resolvedChoice).toBe('deprecated_reimburseSomethingElse'); + // Then only a disabled workspace turns payments off, whichever of the two values it reports + expect(paymentsEnabled).toBe(expected); + }); }); - }); - - describe('arePaymentsEnabled', () => { - it.each([ - [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, true], - [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, true], - [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, false], - [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, true], - [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, false], - ])('should return %s -> %s', (choice, expected) => { - // Given a workspace on one of the current or deprecated values - const policy = createMock({id: '1', reimbursementChoice: choice}); - // When payments availability is checked - const paymentsEnabled = arePaymentsEnabled(policy); + describe('canAdminPayReport', () => { + describe.each([CONST.POLICY.ROLE.ADMIN, CONST.POLICY.ROLE.PAYMENTS_ADMIN])('for a non-payer %s', (role) => { + it.each([ + [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, true], + [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, true], + [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, false], + [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, true], + [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, false], + ])('should allow payment for %s: %s', (reimbursementChoice, expected) => { + // Given a non-payer admin on a workspace using a current or deprecated reimbursement choice + const policy = createMock({ + type: CONST.POLICY.TYPE.CORPORATE, + role, + reimbursementChoice, + reimburser: 'payer@example.com', + owner: 'payer@example.com', + }); - // Then only a disabled workspace turns payments off, whichever of the two values it reports - expect(paymentsEnabled).toBe(expected); - }); - }); + // When the admin's permission to pay a report is checked + const canPay = canAdminPayReport(policy, 'admin@example.com'); - describe('isPolicyPayer', () => { - // The owner is the payer under Indirect reimbursement, so both values have to resolve to the same payer. - it.each([CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL])( - 'should treat the owner as the payer for %s', - (choice) => { - // Given an owner who administers a workspace on Indirect reimbursement - const policy = createMock({ - id: '1', - role: CONST.POLICY.ROLE.ADMIN, - reimbursementChoice: choice, - owner: 'owner@example.com', + // Then deprecated choices preserve the same payment access as their current equivalents + expect(canPay).toBe(expected); }); + }); - // When they are checked against the workspace - const isPayer = isPolicyPayer(policy, 'owner@example.com'); + it.each([CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL])( + 'should not allow a regular member to pay for %s', + (reimbursementChoice) => { + // Given a regular member on a workspace with manual reimbursement enabled + const policy = createMock({ + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.USER, + reimbursementChoice, + }); - // Then they can pay, so the deprecated value does not strip an owner of the Pay button - expect(isPayer).toBe(true); - }, - ); + // When payment access is checked with either representation of manual reimbursement + const canPay = canAdminPayReport(policy, 'member@example.com'); - it.each([CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO])( - 'should treat nobody as the payer for %s', - (choice) => { - // Given an owner who administers a workspace with reimbursement disabled - const policy = createMock({ - id: '1', - role: CONST.POLICY.ROLE.ADMIN, - reimbursementChoice: choice, - owner: 'owner@example.com', - }); + // Then normalizing the reimbursement choice does not grant payment permissions to regular members + expect(canPay).toBe(false); + }, + ); + }); - // When they are checked against the workspace - const isPayer = isPolicyPayer(policy, 'owner@example.com'); + describe('isPolicyPayer', () => { + // The owner is the payer under Indirect reimbursement, so both values have to resolve to the same payer. + it.each([CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL])( + 'should treat the owner as the payer for %s', + (choice) => { + // Given an owner who administers a workspace on Indirect reimbursement + const policy = createMock({ + id: '1', + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: choice, + owner: 'owner@example.com', + }); - // Then not even the owner can pay, so resolving the deprecated value does not hand out Pay where it was switched off - expect(isPayer).toBe(false); - }, - ); - }); + // When they are checked against the workspace + const isPayer = isPolicyPayer(policy, 'owner@example.com'); - describe('getOwnerChangePayerSuccessData', () => { - it('should reassign policy.reimburser when the outgoing owner is the payer', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - reimburser: 'owner@example.com', - owner: 'owner@example.com', - }); + // Then they can pay, so the deprecated value does not strip an owner of the Pay button + expect(isPayer).toBe(true); + }, + ); - expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({reimburser: 'new@example.com'}); - }); + it.each([CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO])( + 'should treat nobody as the payer for %s', + (choice) => { + // Given an owner who administers a workspace with reimbursement disabled + const policy = createMock({ + id: '1', + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: choice, + owner: 'owner@example.com', + }); - it('should reassign achAccount.reimburser when that is where the payer is stored', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - achAccount: {reimburser: 'owner@example.com'}, - owner: 'owner@example.com', - }); + // When they are checked against the workspace + const isPayer = isPolicyPayer(policy, 'owner@example.com'); - expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({achAccount: {reimburser: 'new@example.com'}}); + // Then not even the owner can pay, so resolving the deprecated value does not hand out Pay where it was switched off + expect(isPayer).toBe(false); + }, + ); }); - it('should not reassign when the workspace has a bank account, because the backend keeps the former payer', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - reimburser: 'owner@example.com', - achAccount: {bankAccountID: 1234, reimburser: 'owner@example.com'}, - owner: 'owner@example.com', - }); - - expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({}); - }); + describe('getOwnerChangePayerSuccessData', () => { + it('should reassign policy.reimburser when the outgoing owner is the payer', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + reimburser: 'owner@example.com', + owner: 'owner@example.com', + }); - it('should not reassign when someone other than the outgoing owner is the payer', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - reimburser: 'payer@example.com', - owner: 'owner@example.com', + expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({reimburser: 'new@example.com'}); }); - expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({}); - }); + it('should reassign achAccount.reimburser when that is where the payer is stored', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + achAccount: {reimburser: 'owner@example.com'}, + owner: 'owner@example.com', + }); - it('should not reassign when reimbursement is disabled', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, - reimburser: 'owner@example.com', - owner: 'owner@example.com', + expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({achAccount: {reimburser: 'new@example.com'}}); }); - expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({}); - }); - - it('should return no payer fields for an undefined policy', () => { - expect(getOwnerChangePayerSuccessData(undefined, 'new@example.com')).toEqual({}); - }); - }); + it('should not reassign when the workspace has a bank account, because the backend keeps the former payer', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + reimburser: 'owner@example.com', + achAccount: {bankAccountID: 1234, reimburser: 'owner@example.com'}, + owner: 'owner@example.com', + }); - describe('getPolicyBrickRoadIndicatorStatus', () => { - const baseAdminPolicy: OnyxEntry = createMock>({ - id: 'ABC123', - name: 'Test Workspace', - type: CONST.POLICY.TYPE.TEAM, - role: CONST.POLICY.ROLE.ADMIN, - employeeList: {}, - connections: {}, - errors: {}, - errorFields: {}, - }); - - const baseUserPolicy: OnyxEntry = createMock>({ - id: 'DEF456', - name: 'User Workspace', - type: CONST.POLICY.TYPE.TEAM, - role: CONST.POLICY.ROLE.USER, - employeeList: {}, - connections: {}, - errors: {}, - errorFields: {}, - }); - - it('does return an ERROR RBR when a sync error exists for an admin', () => { - const policyWithConnectionFailures = createMock>({ - ...baseAdminPolicy, - // Failed sync - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { - verified: false, - lastSync: { - errorDate: new Date().toISOString(), - errorMessage: 'Error', - isAuthenticationError: true, - isConnected: false, - isSuccessful: false, - source: 'NEWEXPENSIFY', - successfulDate: '', - }, - }, - }), + expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({}); }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithConnectionFailures, false); - expect(result).toEqual(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); - }); + it('should not reassign when someone other than the outgoing owner is the payer', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + reimburser: 'payer@example.com', + owner: 'owner@example.com', + }); - it('does not return an ERROR RBR when a sync error exists for a user', () => { - const policyWithConnectionFailures = createMock>({ - ...baseUserPolicy, - // Failed sync - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { - verified: false, - lastSync: { - errorDate: new Date().toISOString(), - errorMessage: 'Error', - isAuthenticationError: true, - isConnected: false, - isSuccessful: false, - source: 'NEWEXPENSIFY', - successfulDate: '', - }, - }, - }), + expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({}); }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithConnectionFailures, false); - expect(result).toBeUndefined(); - }); + it('should not reassign when reimbursement is disabled', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, + reimburser: 'owner@example.com', + owner: 'owner@example.com', + }); - it('does not return an ERROR RBR when no sync errors exist for an admin', () => { - const policyWithoutConnections = createMock>({ - ...baseAdminPolicy, - connections: createMock({}), + expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({}); }); - const policyWithoutConnectionFailures = createMock>({ - ...baseAdminPolicy, - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { - verified: true, - lastSync: { - errorDate: '', - errorMessage: '', - isAuthenticationError: false, - isConnected: true, - isSuccessful: true, - source: 'NEWEXPENSIFY', - successfulDate: '', - }, - }, - }), + it('should return no payer fields for an undefined policy', () => { + expect(getOwnerChangePayerSuccessData(undefined, 'new@example.com')).toEqual({}); }); - - const result = getPolicyBrickRoadIndicatorStatus(policyWithoutConnectionFailures, false); - expect(result).toBeUndefined(); - - const result2 = getPolicyBrickRoadIndicatorStatus(policyWithoutConnections, false); - expect(result2).toBeUndefined(); }); + }); - it('does not return an ERROR RBR when no sync error exists for a user', () => { - const policyWithoutConnections = createMock>({ - ...baseUserPolicy, - connections: createMock({}), + describe('errors', () => { + describe('getPolicyBrickRoadIndicatorStatus', () => { + const baseAdminPolicy: OnyxEntry = createMock>({ + id: 'ABC123', + name: 'Test Workspace', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + employeeList: {}, + connections: {}, + errors: {}, + errorFields: {}, }); - const policyWithoutConnectionFailures = createMock>({ - ...baseUserPolicy, - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { - verified: true, - lastSync: { - errorDate: '', - errorMessage: '', - isAuthenticationError: false, - isConnected: true, - isSuccessful: true, - source: 'NEWEXPENSIFY', - successfulDate: '', - }, - }, - }), + const baseUserPolicy: OnyxEntry = createMock>({ + id: 'DEF456', + name: 'User Workspace', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + employeeList: {}, + connections: {}, + errors: {}, + errorFields: {}, }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithoutConnections, false); - expect(result).toBeUndefined(); - - const result2 = getPolicyBrickRoadIndicatorStatus(policyWithoutConnectionFailures, false); - expect(result2).toBeUndefined(); - }); - - describe('QBO Export Errors', () => { - it('does return an ERROR RBR when a QBO sync error exists for an admin', () => { - const policyWithQBOSyncError = createMock>({ + it('does return an ERROR RBR when a sync error exists for an admin', () => { + const policyWithConnectionFailures = createMock>({ ...baseAdminPolicy, + // Failed sync connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { + verified: false, lastSync: { errorDate: new Date().toISOString(), - errorMessage: 'QBO sync failed', + errorMessage: 'Error', isAuthenticationError: true, isConnected: false, isSuccessful: false, @@ -3374,18 +4388,20 @@ describe('PolicyUtils', () => { }), }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOSyncError, false); + const result = getPolicyBrickRoadIndicatorStatus(policyWithConnectionFailures, false); expect(result).toEqual(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); - it('does not return an ERROR RBR when a QBO sync error exists for a user', () => { - const policyWithQBOSyncError = createMock>({ + it('does not return an ERROR RBR when a sync error exists for a user', () => { + const policyWithConnectionFailures = createMock>({ ...baseUserPolicy, + // Failed sync connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { + verified: false, lastSync: { errorDate: new Date().toISOString(), - errorMessage: 'QBO sync failed', + errorMessage: 'Error', isAuthenticationError: true, isConnected: false, isSuccessful: false, @@ -3396,15 +4412,21 @@ describe('PolicyUtils', () => { }), }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOSyncError, false); + const result = getPolicyBrickRoadIndicatorStatus(policyWithConnectionFailures, false); expect(result).toBeUndefined(); }); - it('does not return an ERROR RBR when no QBO sync errors exist for an admin', () => { - const policyWithSuccessfulQBOSync = createMock>({ + it('does not return an ERROR RBR when no sync errors exist for an admin', () => { + const policyWithoutConnections = createMock>({ + ...baseAdminPolicy, + connections: createMock({}), + }); + + const policyWithoutConnectionFailures = createMock>({ ...baseAdminPolicy, connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { + verified: true, lastSync: { errorDate: '', errorMessage: '', @@ -3412,27 +4434,36 @@ describe('PolicyUtils', () => { isConnected: true, isSuccessful: true, source: 'NEWEXPENSIFY', - successfulDate: new Date().toISOString(), + successfulDate: '', }, }, }), }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithSuccessfulQBOSync, false); + const result = getPolicyBrickRoadIndicatorStatus(policyWithoutConnectionFailures, false); expect(result).toBeUndefined(); + + const result2 = getPolicyBrickRoadIndicatorStatus(policyWithoutConnections, false); + expect(result2).toBeUndefined(); }); - it('does not return an ERROR RBR when QBO sync is in progress for an admin', () => { - const policyWithQBOSyncError = createMock>({ - ...baseAdminPolicy, + it('does not return an ERROR RBR when no sync error exists for a user', () => { + const policyWithoutConnections = createMock>({ + ...baseUserPolicy, + connections: createMock({}), + }); + + const policyWithoutConnectionFailures = createMock>({ + ...baseUserPolicy, connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { + verified: true, lastSync: { - errorDate: new Date().toISOString(), - errorMessage: 'QBO sync failed', - isAuthenticationError: true, - isConnected: false, - isSuccessful: false, + errorDate: '', + errorMessage: '', + isAuthenticationError: false, + isConnected: true, + isSuccessful: true, source: 'NEWEXPENSIFY', successfulDate: '', }, @@ -3440,554 +4471,1104 @@ describe('PolicyUtils', () => { }), }); - // When sync is in progress (second parameter is true), should not show error - const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOSyncError, true); + const result = getPolicyBrickRoadIndicatorStatus(policyWithoutConnections, false); expect(result).toBeUndefined(); + + const result2 = getPolicyBrickRoadIndicatorStatus(policyWithoutConnectionFailures, false); + expect(result2).toBeUndefined(); }); - it('does return an ERROR RBR when QBO reimbursable export destination account is missing for an admin', () => { - const policyWithMissingQBOAccount = createMock>({ - ...baseAdminPolicy, - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: { - reimbursableExpensesExportDestination: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, - reimbursableExpensesAccount: undefined, + describe('QBO Export Errors', () => { + it('does return an ERROR RBR when a QBO sync error exists for an admin', () => { + const policyWithQBOSyncError = createMock>({ + ...baseAdminPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: { + errorDate: new Date().toISOString(), + errorMessage: 'QBO sync failed', + isAuthenticationError: true, + isConnected: false, + isSuccessful: false, + source: 'NEWEXPENSIFY', + successfulDate: '', + }, }, - }, - }), + }), + }); + + const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOSyncError, false); + expect(result).toEqual(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithMissingQBOAccount, false); - expect(result).toEqual(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); - }); + it('does not return an ERROR RBR when a QBO sync error exists for a user', () => { + const policyWithQBOSyncError = createMock>({ + ...baseUserPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: { + errorDate: new Date().toISOString(), + errorMessage: 'QBO sync failed', + isAuthenticationError: true, + isConnected: false, + isSuccessful: false, + source: 'NEWEXPENSIFY', + successfulDate: '', + }, + }, + }), + }); - it('does not return an ERROR RBR when QBO reimbursable export destination account is missing for a user', () => { - const policyWithMissingQBOAccount = createMock>({ - ...baseUserPolicy, - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: { - reimbursableExpensesExportDestination: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, - reimbursableExpensesAccount: undefined, + const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOSyncError, false); + expect(result).toBeUndefined(); + }); + + it('does not return an ERROR RBR when no QBO sync errors exist for an admin', () => { + const policyWithSuccessfulQBOSync = createMock>({ + ...baseAdminPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: { + errorDate: '', + errorMessage: '', + isAuthenticationError: false, + isConnected: true, + isSuccessful: true, + source: 'NEWEXPENSIFY', + successfulDate: new Date().toISOString(), + }, }, - }, - }), + }), + }); + + const result = getPolicyBrickRoadIndicatorStatus(policyWithSuccessfulQBOSync, false); + expect(result).toBeUndefined(); }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithMissingQBOAccount, false); - expect(result).toBeUndefined(); - }); + it('does not return an ERROR RBR when QBO sync is in progress for an admin', () => { + const policyWithQBOSyncError = createMock>({ + ...baseAdminPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: { + errorDate: new Date().toISOString(), + errorMessage: 'QBO sync failed', + isAuthenticationError: true, + isConnected: false, + isSuccessful: false, + source: 'NEWEXPENSIFY', + successfulDate: '', + }, + }, + }), + }); - it('does not return an ERROR RBR when QBO reimbursable export destination account is configured for an admin', () => { - const policyWithQBOConfigured = createMock>({ - ...baseAdminPolicy, - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: { - reimbursableExpensesExportDestination: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, - reimbursableExpensesAccount: {id: '123', name: 'Test Account'}, + // When sync is in progress (second parameter is true), should not show error + const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOSyncError, true); + expect(result).toBeUndefined(); + }); + + it('does return an ERROR RBR when QBO reimbursable export destination account is missing for an admin', () => { + const policyWithMissingQBOAccount = createMock>({ + ...baseAdminPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + reimbursableExpensesExportDestination: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, + reimbursableExpensesAccount: undefined, + }, }, - }, - }), + }), + }); + + const result = getPolicyBrickRoadIndicatorStatus(policyWithMissingQBOAccount, false); + expect(result).toEqual(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOConfigured, false); - expect(result).toBeUndefined(); - }); + it('does not return an ERROR RBR when QBO reimbursable export destination account is missing for a user', () => { + const policyWithMissingQBOAccount = createMock>({ + ...baseUserPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + reimbursableExpensesExportDestination: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, + reimbursableExpensesAccount: undefined, + }, + }, + }), + }); - it('does not return an ERROR RBR when QBO connection does not exist for an admin', () => { - const policyWithoutQBOConnection = createMock>({ - ...baseAdminPolicy, - connections: {}, + const result = getPolicyBrickRoadIndicatorStatus(policyWithMissingQBOAccount, false); + expect(result).toBeUndefined(); }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithoutQBOConnection, false); - expect(result).toBeUndefined(); - }); + it('does not return an ERROR RBR when QBO reimbursable export destination account is configured for an admin', () => { + const policyWithQBOConfigured = createMock>({ + ...baseAdminPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + reimbursableExpensesExportDestination: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, + reimbursableExpensesAccount: {id: '123', name: 'Test Account'}, + }, + }, + }), + }); - it('does not return an ERROR RBR when QBO reimbursable export destination is not set for an admin', () => { - const policyWithQBONoExportDestination = createMock>({ - ...baseAdminPolicy, - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: { - reimbursableExpensesExportDestination: undefined, - reimbursableExpensesAccount: undefined, + const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOConfigured, false); + expect(result).toBeUndefined(); + }); + + it('does not return an ERROR RBR when QBO connection does not exist for an admin', () => { + const policyWithoutQBOConnection = createMock>({ + ...baseAdminPolicy, + connections: {}, + }); + + const result = getPolicyBrickRoadIndicatorStatus(policyWithoutQBOConnection, false); + expect(result).toBeUndefined(); + }); + + it('does not return an ERROR RBR when QBO reimbursable export destination is not set for an admin', () => { + const policyWithQBONoExportDestination = createMock>({ + ...baseAdminPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + reimbursableExpensesExportDestination: undefined, + reimbursableExpensesAccount: undefined, + }, }, - }, - }), + }), + }); + + const result = getPolicyBrickRoadIndicatorStatus(policyWithQBONoExportDestination, false); + expect(result).toBeUndefined(); }); + }); + }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithQBONoExportDestination, false); - expect(result).toBeUndefined(); + describe('hasPolicyRulesError', () => { + // Whether a merchant rule failed is reduced by the caller's selector and covered in + // ExpenseDefaultRuleUtilsTest, so only the agent rules and the pass-through are checked here. + const POLICY_ID = 'policy-with-rules'; + + it('returns false for an undefined policy', () => { + expect(hasPolicyRulesError(undefined, false)).toBe(false); + }); + + it('returns false when no merchant or agent rules exist', () => { + const policy: Policy = {...createRandomPolicy(0), id: POLICY_ID, rules: {}}; + expect(hasPolicyRulesError(policy, false)).toBe(false); + }); + + it('returns false when agent rules exist but none have errors', () => { + const policy: Policy = { + ...createRandomPolicy(0), + id: POLICY_ID, + rules: {agentRules: {ai1: {ruleID: 'ai1', prompt: 'p', created: '2026-06-08'}}}, + }; + expect(hasPolicyRulesError(policy, false)).toBe(false); + }); + + it('returns true when a merchant rule has errors', () => { + const policy: Policy = {...createRandomPolicy(0), id: POLICY_ID, rules: {}}; + expect(hasPolicyRulesError(policy, true)).toBe(true); + }); + + it('returns true when an agent rule has errors', () => { + const policy: Policy = { + ...createRandomPolicy(0), + id: POLICY_ID, + rules: { + agentRules: {ai1: {ruleID: 'ai1', prompt: 'p', created: '2026-06-08', errors: {123: 'boom'}}}, + }, + }; + expect(hasPolicyRulesError(policy, false)).toBe(true); }); }); - }); - describe('hasDynamicExternalWorkflow', () => { - it('should return true when policy has DYNAMICEXTERNAL approval mode', () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, - }; - const result = hasDynamicExternalWorkflow(policy); - expect(result).toBe(true); + describe('isQBORefreshTokenExpiringSoonSelector', () => { + const buildQBOPolicy = (role: string, refreshTokenExpiresAt: number): Policy => + Object.assign(createRandomPolicy(1), { + role, + connections: {quickbooksOnline: {config: {credentials: {companyID: '12345', refreshTokenExpiresAt}}, lastSync: {isAuthenticationError: false}}}, + }); + const expiringSoon = Math.floor(Date.now() / 1000) + 3 * 86400; + + it('returns true for an admin whose QBO refresh token expires within the warning window', () => { + expect(isQBORefreshTokenExpiringSoonSelector(buildQBOPolicy(CONST.POLICY.ROLE.ADMIN, expiringSoon))).toBe(true); + }); + + it('returns false for a member, since only admins can reconnect', () => { + expect(isQBORefreshTokenExpiringSoonSelector(buildQBOPolicy(CONST.POLICY.ROLE.USER, expiringSoon))).toBe(false); + }); + + it('returns false when the token is still far from expiring or the policy is undefined', () => { + expect(isQBORefreshTokenExpiringSoonSelector(buildQBOPolicy(CONST.POLICY.ROLE.ADMIN, Math.floor(Date.now() / 1000) + 60 * 86400))).toBe(false); + expect(isQBORefreshTokenExpiringSoonSelector(undefined)).toBe(false); + }); }); - it('should return false when policy has BASIC approval mode', () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + describe('isMergeHRCompleteSetupNeededSelector', () => { + // Only the fields read by the selector are modeled here; `Object.assign` attaches the connection + // to a real policy without an unsafe cast. + type MergeHRTestConnection = { + lastSync?: {syncStatus?: string}; + data?: {groups?: unknown[]}; + config?: {groups?: unknown[] | null}; }; - const result = hasDynamicExternalWorkflow(policy); - expect(result).toBe(false); + const buildMergeHRPolicy = (seed: number, mergeHR: MergeHRTestConnection): Policy => Object.assign(createRandomPolicy(seed), {connections: {merge_hris: mergeHR}}); + const mergeHRBase = { + lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE}, + data: {groups: [{id: 'g1', name: 'Engineering'}]}, + }; + + it('returns false when policy is undefined', () => { + expect(isMergeHRCompleteSetupNeededSelector(undefined)).toBe(false); + }); + + it('returns false when policy has no merge_hris connection', () => { + const policy = createRandomPolicy(1); + expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); + }); + + it('returns false when sync is not done', () => { + const policy = buildMergeHRPolicy(2, {...mergeHRBase, lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.SYNCING}}); + expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); + }); + + it('returns false when sync is done but there are no groups', () => { + const policy = buildMergeHRPolicy(3, {...mergeHRBase, data: {groups: []}}); + expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); + }); + + it('returns false when setup is already complete (groups configured)', () => { + const policy = buildMergeHRPolicy(4, {...mergeHRBase, config: {groups: ['g1']}}); + expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); + }); + + it('returns true when sync is done, groups exist, and setup is not yet complete', () => { + const policy = buildMergeHRPolicy(5, mergeHRBase); + expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(true); + }); }); - it('should return false when policy has ADVANCED approval mode', () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const result = hasDynamicExternalWorkflow(policy); - expect(result).toBe(false); + describe('areSettingsInErrorFields', () => { + it('returns false when the settings or the error fields are undefined', () => { + // Given settings without error fields, and error fields without settings + const errorFields = {autoSync: {error: 'Sync failed'}}; + + // When checking either incomplete input + // Then there is nothing to match, so no error is reported + expect(areSettingsInErrorFields(undefined, errorFields)).toBe(false); + expect(areSettingsInErrorFields(['autoSync'], undefined)).toBe(false); + }); + + it('returns true when one of the settings has an error field', () => { + // Given error fields where only one of the requested settings has an error + const errorFields = {exporter: {error: 'Invalid exporter'}}; + + // When checking a list of settings that includes it + const result = areSettingsInErrorFields(['autoSync', 'exporter'], errorFields); + + // Then the error is reported because a single matching setting is enough + expect(result).toBe(true); + }); + + it('returns false when none of the settings has an error field', () => { + // Given error fields for a setting that was not requested + const errorFields = {exporter: {error: 'Invalid exporter'}}; + + // When checking unrelated settings + const result = areSettingsInErrorFields(['autoSync', 'syncReimbursedReports'], errorFields); + + // Then no error is reported for those settings + expect(result).toBe(false); + }); }); - it('should return false when policy has OPTIONAL approval mode', () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, - }; - const result = hasDynamicExternalWorkflow(policy); - expect(result).toBe(false); + describe('settingsPendingAction', () => { + it('returns null when the settings or the pending fields are undefined', () => { + // Given settings without pending fields, and pending fields without settings + const pendingFields = {autoSync: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; + + // When reading the pending action from either incomplete input + // Then null is returned to signal there is no pending action to show + expect(settingsPendingAction(undefined, pendingFields)).toBeNull(); + expect(settingsPendingAction(['autoSync'], undefined)).toBeNull(); + }); + + it('returns the pending action of the setting that has one', () => { + // Given pending fields where one of the requested settings is being updated + const pendingFields = {exporter: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; + + // When reading the pending action for a list of settings that includes it + const result = settingsPendingAction(['autoSync', 'exporter'], pendingFields); + + // Then that setting's pending action is returned so the UI can show it as pending + expect(result).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + }); + + it('returns undefined when none of the settings has a pending field', () => { + // Given pending fields for a setting that was not requested + const pendingFields = {exporter: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; + + // When reading the pending action for unrelated settings + const result = settingsPendingAction(['autoSync', 'syncReimbursedReports'], pendingFields); + + // Then no pending action is returned + expect(result).toBeUndefined(); + }); }); + }); + + describe('workflows', () => { + describe('hasDynamicExternalWorkflow', () => { + it('should return true when policy has DYNAMICEXTERNAL approval mode', () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + }; + const result = hasDynamicExternalWorkflow(policy); + expect(result).toBe(true); + }); + + it('should return false when policy has BASIC approval mode', () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + }; + const result = hasDynamicExternalWorkflow(policy); + expect(result).toBe(false); + }); + + it('should return false when policy has ADVANCED approval mode', () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const result = hasDynamicExternalWorkflow(policy); + expect(result).toBe(false); + }); + + it('should return false when policy has OPTIONAL approval mode', () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + }; + const result = hasDynamicExternalWorkflow(policy); + expect(result).toBe(false); + }); - it('should return false when policy is undefined', () => { - const result = hasDynamicExternalWorkflow(undefined); - expect(result).toBe(false); - }); + it('should return false when policy is undefined', () => { + const result = hasDynamicExternalWorkflow(undefined); + expect(result).toBe(false); + }); - it('should return false when policy has no approvalMode', () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - approvalMode: undefined, - }; - const result = hasDynamicExternalWorkflow(policy); - expect(result).toBe(false); + it('should return false when policy has no approvalMode', () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + approvalMode: undefined, + }; + const result = hasDynamicExternalWorkflow(policy); + expect(result).toBe(false); + }); }); - }); - describe('hasOnlyPersonalPolicies', () => { - it('should return true when policies is empty', () => { - const result = hasOnlyPersonalPolicies({}); - expect(result).toBe(true); - }); + describe('shouldHideDynamicExternalWorkflowPeople', () => { + it('returns false when the policy is undefined', () => { + expect(shouldHideDynamicExternalWorkflowPeople(undefined)).toBe(false); + }); - it('should return false when there are policies other than personal policies', () => { - const policies = { - '1': {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, - '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.PERSONAL), pendingAction: undefined}, - }; - const result = hasOnlyPersonalPolicies(policies); - expect(result).toBe(false); - }); + it('returns true for a Dynamic External Workflow policy with the flag set', () => { + const policy: Policy = {...createRandomPolicy(0), approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, dynamicExternalWorkflowHidePeople: true}; + expect(shouldHideDynamicExternalWorkflowPeople(policy)).toBe(true); + }); - it('should return true when there are no policies other than personal policies', () => { - const policies = { - '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.PERSONAL), pendingAction: undefined}, - }; - const result = hasOnlyPersonalPolicies(policies); - expect(result).toBe(true); + // The backend only returns the flag when it is `true`, so an absent flag is the normal DEW case. + it('returns false for a Dynamic External Workflow policy without the flag', () => { + const policy: Policy = {...createRandomPolicy(0), approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL}; + expect(shouldHideDynamicExternalWorkflowPeople(policy)).toBe(false); + }); + + it('returns false when a stale flag is left on a policy that no longer uses a Dynamic External Workflow', () => { + const policy: Policy = {...createRandomPolicy(0), approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, dynamicExternalWorkflowHidePeople: true}; + expect(shouldHideDynamicExternalWorkflowPeople(policy)).toBe(false); + }); }); }); - describe('getEligibleBankAccountShareRecipientEmails', () => { - beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); - }); - it('should return empty array if no admins in policies', async () => { - const bankAccountID = '1'; - await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { - 1: { - methodID: 12345, - accountData: { - additionalData: {policyID: '1'}, - }, - }, - }); - const policies = { - '1': {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, - '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, - }; - const result = getEligibleBankAccountShareRecipientEmails(policies, approverEmail, bankAccountID); - expect(result).toHaveLength(0); - }); - it('should return array with admins from the bank account workspace', async () => { - const bankAccountID = '1'; - const currentUserLogin = adminEmail; - await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { - 1: { - methodID: 12345, - accountData: { - additionalData: {policyID: '1'}, + describe('members', () => { + describe('isPolicyMemberWithoutPendingDelete', () => { + it('should return true if the policy member is not pending delete', () => { + const policy = createMock({ + id: '1', + employeeList: { + [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, }, - }, + }); + const result = isPolicyMemberWithoutPendingDelete(employeeEmail, policy); + expect(result).toBe(true); }); - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, + it('should return false if the policy member is pending delete', () => { + const policy = createMock({ + id: '1', employeeList: { - [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN}, - }, - }, - '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, - }; - const result = getEligibleBankAccountShareRecipientEmails(policies, approverEmail, bankAccountID); - expect(result).toHaveLength(1); - }); - it('should not return user with already shared bank account', async () => { - const bankAccountID = '1'; - const currentUserLogin = adminEmail; - await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { - 1: { - methodID: 12345, - accountData: { - additionalData: {policyID: '1'}, - sharees: [adminEmail], + [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, }, - }, + }); + const result = isPolicyMemberWithoutPendingDelete(employeeEmail, policy); + expect(result).toBe(false); }); - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, + it('should return false if the policy member is not found', () => { + const policy = createMock({ + id: '1', employeeList: { - [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN}, + [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, }, - }, - '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, - }; - const result = getEligibleBankAccountShareRecipientEmails(policies, approverEmail, bankAccountID); - expect(result).toHaveLength(0); + }); + const result = isPolicyMemberWithoutPendingDelete('fakeEmail', policy); + expect(result).toBe(false); + }); }); - it('should not return current user for sharing account', async () => { - const bankAccountID = '1'; - await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { - 1: { - methodID: 12345, - accountData: { - additionalData: {policyID: '1'}, + + describe('getEligibleBankAccountShareRecipientEmails', () => { + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + }); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + it('should return empty array if no admins in policies', async () => { + const bankAccountID = '1'; + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { + 1: { + methodID: 12345, + accountData: { + additionalData: {policyID: '1'}, + }, }, - }, + }); + const policies = { + '1': {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, + '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, + }; + const result = getEligibleBankAccountShareRecipientEmails(policies, approverEmail, bankAccountID); + expect(result).toHaveLength(0); + }); + it('should return array with admins from the bank account workspace', async () => { + const bankAccountID = '1'; + const currentUserLogin = adminEmail; + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { + 1: { + methodID: 12345, + accountData: { + additionalData: {policyID: '1'}, + }, + }, + }); + + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + employeeList: { + [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, + }; + const result = getEligibleBankAccountShareRecipientEmails(policies, approverEmail, bankAccountID); + expect(result).toHaveLength(1); }); + it('should not return user with already shared bank account', async () => { + const bankAccountID = '1'; + const currentUserLogin = adminEmail; + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { + 1: { + methodID: 12345, + accountData: { + additionalData: {policyID: '1'}, + sharees: [adminEmail], + }, + }, + }); - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - employeeList: { - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, - [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + employeeList: { + [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN}, + }, }, - }, - }; - const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, bankAccountID); - expect(result).toHaveLength(1); - }); - it('should allow Payments Admins to share with and receive from members who can manage payments', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - pendingAction: undefined, - role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, - employeeList: { - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, - [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, + '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, + }; + const result = getEligibleBankAccountShareRecipientEmails(policies, approverEmail, bankAccountID); + expect(result).toHaveLength(0); + }); + it('should not return current user for sharing account', async () => { + const bankAccountID = '1'; + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { + 1: { + methodID: 12345, + accountData: { + additionalData: {policyID: '1'}, + }, }, - }, - }; + }); + + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + employeeList: { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, + [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + }; + const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, bankAccountID); + expect(result).toHaveLength(1); + }); + it('should allow Payments Admins to share with and receive from members who can manage payments', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + pendingAction: undefined, + role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, + employeeList: { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, + [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, + }, + }, + }; - const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); + const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); - expect(result).toHaveLength(1); - expect(result.at(0)).toBe(approverEmail); - }); - it('should not return members who cannot manage payments', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - pendingAction: undefined, - role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, - employeeList: { - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, - [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.PEOPLE_ADMIN}, + expect(result).toHaveLength(1); + expect(result.at(0)).toBe(approverEmail); + }); + it('should not return members who cannot manage payments', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + pendingAction: undefined, + role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, + employeeList: { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, + [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.PEOPLE_ADMIN}, + }, }, - }, - }; + }; - const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); + const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); - expect(result).toHaveLength(0); - }); - it('should not fall back to the current user role when a recipient role is missing', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - pendingAction: undefined, - role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, - employeeList: { - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, - [approverEmail]: {email: approverEmail}, + expect(result).toHaveLength(0); + }); + it('should not fall back to the current user role when a recipient role is missing', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + pendingAction: undefined, + role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, + employeeList: { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, + [approverEmail]: {email: approverEmail}, + }, }, - }, - }; + }; - const recipients = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); - const hasEligibleRecipient = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); + const recipients = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); + const hasEligibleRecipient = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); - expect(recipients).toHaveLength(0); - expect(hasEligibleRecipient).toBe(false); - }); - it('should not return Expensify guide when policy owner is not Expensify team', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - owner: 'normalowner@test.com', - employeeList: { - [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + expect(recipients).toHaveLength(0); + expect(hasEligibleRecipient).toBe(false); + }); + it('should not return Expensify guide when policy owner is not Expensify team', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'normalowner@test.com', + employeeList: { + [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, }, - }, - }; - const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); - expect(result).toHaveLength(0); - }); - it('should return Expensify guide when policy owner is Expensify team', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - owner: 'owner@expensify.com', - employeeList: { - [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + }; + const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); + expect(result).toHaveLength(0); + }); + it('should return Expensify guide when policy owner is Expensify team', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'owner@expensify.com', + employeeList: { + [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, }, - }, - }; - const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); - expect(result).toHaveLength(1); - }); - it('should return Expensify guide when current user is Expensify team', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - owner: 'normalowner@test.com', - employeeList: { - [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + }; + const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); + expect(result).toHaveLength(1); + }); + it('should return Expensify guide when current user is Expensify team', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'normalowner@test.com', + employeeList: { + [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, }, - }, - }; - const result = getEligibleBankAccountShareRecipientEmails(policies, 'someone@expensify.com', '1'); - expect(result).toHaveLength(1); + }; + const result = getEligibleBankAccountShareRecipientEmails(policies, 'someone@expensify.com', '1'); + expect(result).toHaveLength(1); + }); }); - }); - describe('hasEligibleBankAccountShareRecipient', () => { - beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); - }); - it('should return false when the only admin is an Expensify guide on a non-Expensify policy', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - owner: 'normalowner@test.com', - employeeList: { - [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + describe('hasEligibleBankAccountShareRecipient', () => { + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + }); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + it('should return false when the only admin is an Expensify guide on a non-Expensify policy', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'normalowner@test.com', + employeeList: { + [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, }, - }, - }; - const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); - expect(result).toBe(false); - }); - it('should return true when there is a non-guide admin', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - owner: 'normalowner@test.com', - employeeList: { - [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, - [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, + }; + const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); + expect(result).toBe(false); + }); + it('should return true when there is a non-guide admin', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'normalowner@test.com', + employeeList: { + [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, }, - }, - }; - const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); - expect(result).toBe(true); - }); - it('should return true when the guide is on an Expensify-owned policy', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - owner: 'owner@expensify.com', - employeeList: { - [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + }; + const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); + expect(result).toBe(true); + }); + it('should return true when the guide is on an Expensify-owned policy', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'owner@expensify.com', + employeeList: { + [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + }; + const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); + expect(result).toBe(true); + }); + it('should return true when another admin is available in the bank account workspace', async () => { + const bankAccountID = '1'; + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { + 1: { + methodID: 12345, + accountData: { + additionalData: {policyID: '1'}, + }, + }, + }); + + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + employeeList: { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, + [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + }; + const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, bankAccountID); + expect(result).toBe(true); + }); + it('should return true when a Payments Admin can share with another payment-capable member', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + pendingAction: undefined, + role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, + employeeList: { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, + [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + }; + + const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); + + expect(result).toBe(true); + }); + it('should return false when the user only joined another workspace as a member', async () => { + const bankAccountID = '1'; + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { + 1: { + methodID: 12345, + accountData: { + additionalData: {policyID: '1'}, + }, + }, + }); + + const policies = { + // The bank account's own workspace - current user is the only admin, no one to share with + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + employeeList: { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + // Another user's workspace the current user only joined as a member - has its own admin + '2': { + ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.USER, + employeeList: { + [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.USER}, + }, }, - }, - }; - const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); - expect(result).toBe(true); + }; + const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, bankAccountID); + expect(result).toBe(false); + }); }); - }); - describe('hasEligibleBankAccountShareRecipient', () => { - beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); + describe('getExpensifyTeamExclusions', () => { + const buildPersonalDetails = (logins: string[]): PersonalDetailsList => { + const result: PersonalDetailsList = {}; + for (const [index, login] of logins.entries()) { + const accountID = index + 1; + result[accountID] = {accountID, login}; + } + return result; + }; + const buildPolicies = ( + specs: Array<{ + owner: string; + employeeLogins: string[]; + }>, + ): OnyxCollection => { + const result: OnyxCollection = {}; + for (const [index, {owner, employeeLogins}] of specs.entries()) { + const memberList: PolicyEmployeeList = {}; + for (const login of employeeLogins) { + memberList[login] = {email: login}; + } + result[`policy_${index + 1}`] = createMock({id: `${index + 1}`, owner, employeeList: memberList}); + } + return result; + }; + + it('returns empty when currentUserLogin is undefined', () => { + const details = buildPersonalDetails(['am@expensify.com', 'bob@acme.com']); + const policies = buildPolicies([{owner: 'customer@acme.com', employeeLogins: ['bob@acme.com']}]); + expect(getExpensifyTeamExclusions(details, policies, undefined)).toEqual({}); + }); + + it('returns empty when currentUserLogin is on the Expensify team (exception)', () => { + const details = buildPersonalDetails(['am@expensify.com', 'bob@acme.com']); + const policies = buildPolicies([{owner: 'customer@acme.com', employeeLogins: ['am@expensify.com', 'bob@acme.com']}]); + expect(getExpensifyTeamExclusions(details, policies, 'staff@expensify.com')).toEqual({}); + }); + + it('excludes all Expensify-team logins from personalDetails when current user is non-Expensify and has no Expensify-owned policies', () => { + const details = buildPersonalDetails(['am@expensify.com', 'guide@team.expensify.com', 'bob@acme.com']); + const policies = buildPolicies([{owner: 'customer@acme.com', employeeLogins: ['bob@acme.com']}]); + const result = getExpensifyTeamExclusions(details, policies, 'customer@acme.com'); + expect(result['am@expensify.com']).toBe(true); + expect(result['guide@team.expensify.com']).toBe(true); + expect(result['bob@acme.com']).toBeUndefined(); + }); + + it('preserves Expensify-team members of an Expensify-owned policy the current user belongs to', () => { + const details = buildPersonalDetails(['lead@expensify.com', 'unrelated_am@expensify.com', 'contractor@acme.com']); + const policies = buildPolicies([{owner: 'lead@expensify.com', employeeLogins: ['contractor@acme.com', 'lead@expensify.com']}]); + const result = getExpensifyTeamExclusions(details, policies, 'contractor@acme.com'); + expect(result['lead@expensify.com']).toBeUndefined(); + expect(result['unrelated_am@expensify.com']).toBe(true); + expect(result['contractor@acme.com']).toBeUndefined(); + }); + + it('does not preserve Expensify-team members of policies the current user is not in', () => { + const details = buildPersonalDetails(['lead@expensify.com', 'bob@acme.com']); + const policies = buildPolicies([{owner: 'lead@expensify.com', employeeLogins: ['lead@expensify.com', 'someone_else@acme.com']}]); + const result = getExpensifyTeamExclusions(details, policies, 'bob@acme.com'); + expect(result['lead@expensify.com']).toBe(true); + }); + + it('does not preserve when policy owner is non-Expensify', () => { + const details = buildPersonalDetails(['am@expensify.com', 'bob@acme.com']); + const policies = buildPolicies([{owner: 'bob@acme.com', employeeLogins: ['am@expensify.com', 'bob@acme.com']}]); + const result = getExpensifyTeamExclusions(details, policies, 'bob@acme.com'); + expect(result['am@expensify.com']).toBe(true); + }); + + it('handles undefined and empty inputs gracefully', () => { + expect(getExpensifyTeamExclusions(undefined, undefined, 'customer@acme.com')).toEqual({}); + // The null values deliberately exercise the runtime guard for malformed Onyx data. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Null is the malformed-input scenario under test. + const nullPersonalDetails = null as unknown as Parameters[0]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Null is the malformed-input scenario under test. + const nullPolicies = null as unknown as Parameters[1]; + expect(getExpensifyTeamExclusions(nullPersonalDetails, nullPolicies, 'customer@acme.com')).toEqual({}); + expect(getExpensifyTeamExclusions({}, {}, 'customer@acme.com')).toEqual({}); + }); + + it('lowercases comparison keys for current user and employeeList matching', () => { + const details = buildPersonalDetails(['AM@expensify.com', 'Bob@Acme.com']); + const policies = buildPolicies([{owner: 'lead@expensify.com', employeeLogins: ['AM@expensify.com', 'Contractor@Acme.com']}]); + const result = getExpensifyTeamExclusions(details, policies, 'Contractor@Acme.com'); + expect(result['am@expensify.com']).toBeUndefined(); + expect(result['bob@acme.com']).toBeUndefined(); + }); }); - it('should return true when another admin is available in the bank account workspace', async () => { - const bankAccountID = '1'; - await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { - 1: { - methodID: 12345, - accountData: { - additionalData: {policyID: '1'}, - }, - }, + + describe('getExcludedUsers', () => { + it('marks every active policy member as excluded', () => { + const result = getExcludedUsers(employeeList); + expect(result['owner@test.com']).toBe(true); + expect(result['admin@test.com']).toBe(true); + expect(result['employee@test.com']).toBe(true); }); - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - employeeList: { - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, - [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, - }, - }, - }; - const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, bankAccountID); - expect(result).toBe(true); + it('always excludes Expensify emails', () => { + const result = getExcludedUsers(employeeList); + for (const email of CONST.EXPENSIFY_EMAILS) { + expect(result[email]).toBe(true); + } + }); + + it('does not exclude a member that is pending delete', () => { + const list: PolicyEmployeeList = { + 'employee@test.com': {email: 'employee@test.com', role: 'user', submitsTo: '', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }; + const result = getExcludedUsers(list); + expect(result['employee@test.com']).toBeUndefined(); + }); + + it('does not exclude a member that has errors', () => { + const list: PolicyEmployeeList = { + 'employee@test.com': {email: 'employee@test.com', role: 'user', submitsTo: '', errors: {error1: 'Something went wrong'}}, + }; + const result = getExcludedUsers(list); + expect(result['employee@test.com']).toBeUndefined(); + }); + + it('returns only Expensify emails when the employee list is undefined', () => { + const result = getExcludedUsers(undefined); + expect(Object.keys(result)).toEqual([...CONST.EXPENSIFY_EMAILS]); + }); }); - it('should return true when a Payments Admin can share with another payment-capable member', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - pendingAction: undefined, - role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, + + describe('hasActiveExpensifyCard', () => { + it('returns false when policy is undefined', () => { + expect(hasActiveExpensifyCard(undefined, 'cardholder@test.com')).toBe(false); + }); + + it('returns true when the backend flags the member as holding an active Expensify Card', () => { + const policy: Policy = { + ...createRandomPolicy(0), employeeList: { - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, - [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, + 'cardholder@test.com': {email: 'cardholder@test.com', hasActiveExpensifyCard: true}, }, - }, - }; - - const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); + }; + expect(hasActiveExpensifyCard(policy, 'cardholder@test.com')).toBe(true); + }); - expect(result).toBe(true); - }); - it('should return false when the user only joined another workspace as a member', async () => { - const bankAccountID = '1'; - await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { - 1: { - methodID: 12345, - accountData: { - additionalData: {policyID: '1'}, + it('returns false when the flag is false or missing', () => { + const policy: Policy = { + ...createRandomPolicy(0), + employeeList: { + 'former-cardholder@test.com': {email: 'former-cardholder@test.com', hasActiveExpensifyCard: false}, + 'employee@test.com': {email: 'employee@test.com'}, }, - }, + }; + expect(hasActiveExpensifyCard(policy, 'former-cardholder@test.com')).toBe(false); + expect(hasActiveExpensifyCard(policy, 'employee@test.com')).toBe(false); }); - const policies = { - // The bank account's own workspace - current user is the only admin, no one to share with - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, + it('returns false when the member is not in the employeeList', () => { + const policy: Policy = { + ...createRandomPolicy(0), employeeList: { - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, + 'cardholder@test.com': {email: 'cardholder@test.com', hasActiveExpensifyCard: true}, }, - }, - // Another user's workspace the current user only joined as a member - has its own admin - '2': { - ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.USER, + }; + expect(hasActiveExpensifyCard(policy, 'someone-else@test.com')).toBe(false); + }); + + it('returns true when the secondary login is checked and the backend flags its paired primary login', () => { + const policy: Policy = { + ...createRandomPolicy(0), + primaryLoginsInvited: {'secondary@test.com': 'primary@test.com'}, employeeList: { - [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.USER}, + 'secondary@test.com': {email: 'secondary@test.com'}, + 'primary@test.com': {email: 'primary@test.com', hasActiveExpensifyCard: true}, }, - }, - }; - const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, bankAccountID); - expect(result).toBe(false); + }; + expect(hasActiveExpensifyCard(policy, 'secondary@test.com')).toBe(true); + }); }); - }); - describe('hasOtherControlWorkspaces', () => { - it('should return false when policies is empty', () => { - const result = hasOtherControlWorkspaces([], '1'); - expect(result).toBe(false); - }); + describe('isMemberInHomeAndOfficeWorkspace', () => { + it('only allows members of home and office workspaces', () => { + // Given a workspace whose employee list contains one member + const memberLogin = 'member@example.com'; + const policy = createMock({ + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE}, + employeeList: {[memberLogin]: {email: memberLogin}}, + }); + const otherWorkspace = {...policy, commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE}}; - it('should return false when there are no control workspaces other than the current one', () => { - const policies = [ - {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: undefined}, - {...createRandomPolicy(2, CONST.POLICY.TYPE.PERSONAL), pendingAction: undefined}, - {...createRandomPolicy(3, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, - ]; - const result = hasOtherControlWorkspaces(policies, '1'); - expect(result).toBe(false); + // When workspace mode and membership are checked + const isEligibleMember = isMemberInHomeAndOfficeWorkspace(policy, memberLogin); + const isEligibleForOtherWorkspaceMode = isMemberInHomeAndOfficeWorkspace(otherWorkspace, memberLogin); + const isEligibleForUnknownMember = isMemberInHomeAndOfficeWorkspace(policy, 'unknown@example.com'); + + // Then only the known member in a home and office workspace is eligible + expect(isEligibleMember).toBe(true); + expect(isEligibleForOtherWorkspaceMode).toBe(false); + expect(isEligibleForUnknownMember).toBe(false); + }); }); - it('should return true when there are other control workspaces', () => { - const policies = [ - {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: undefined}, - {...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE), pendingAction: undefined}, - {...createRandomPolicy(3, CONST.POLICY.TYPE.PERSONAL), pendingAction: undefined}, - ]; - const result = hasOtherControlWorkspaces(policies, '1'); - expect(result).toBe(true); + describe('isExpensifyTeam', () => { + it('returns true for emails on the Expensify and guides domains', () => { + // Given emails on the Expensify partner domain and the guides domain + // When checking whether they belong to the Expensify team + // Then both count as Expensify team members + expect(isExpensifyTeam(guideEmail)).toBe(true); + expect(isExpensifyTeam('guide@team.expensify.com')).toBe(true); + }); + + it('returns false for emails on other domains', () => { + // Given an email on a customer domain and one on another subdomain of expensify.com + // When checking whether they belong to the Expensify team + // Then neither counts, because only an exact domain match is treated as Expensify + expect(isExpensifyTeam(employeeEmail)).toBe(false); + expect(isExpensifyTeam('someone@sub.expensify.com')).toBe(false); + }); + + it('returns false for a missing or empty email', () => { + // Given no email at all + // When checking whether it belongs to the Expensify team + // Then it does not, so callers can pass optional logins safely + expect(isExpensifyTeam(undefined)).toBe(false); + expect(isExpensifyTeam('')).toBe(false); + }); }); - }); - describe('getTagApproverRule', () => { - it('should return undefined when no approval rules are present', () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - rules: { - approvalRules: [], - }, - }; - const result = getTagApproverRule(policy, ''); - expect(result).toBeUndefined(); + describe('getMemberAccountIDsForWorkspace', () => { + it('maps every member email to its accountID from personal details', () => { + // Given a workspace whose members all have personal details + // When building the email-to-accountID map + const result = getMemberAccountIDsForWorkspace(employeeList, personalDetailsByLogins); + + // Then every member resolves to its accountID + expect(result).toEqual({ + 'owner@test.com': ownerAccountID, + [adminEmail]: adminAccountID, + [employeeEmail]: employeeAccountID, + [categoryApprover1Email]: categoryApprover1AccountID, + 'categoryapprover2@test.com': categoryApprover2AccountID, + 'tagapprover1@test.com': tagApprover1AccountID, + 'tagapprover2@test.com': tagApprover2AccountID, + }); + }); + + it('skips members without personal details', () => { + // Given a member whose login is not in personal details yet + const list: PolicyEmployeeList = { + [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, + 'unknown@test.com': {email: 'unknown@test.com', role: CONST.POLICY.ROLE.USER}, + }; + + // When building the email-to-accountID map + const result = getMemberAccountIDsForWorkspace(list, personalDetailsByLogins); + + // Then that member is left out, since there is no accountID to map to + expect(result).toEqual({[employeeEmail]: employeeAccountID}); + }); + + it('excludes members with errors unless includeMemberWithErrors is true', () => { + // Given a member with an error, such as a failed invite + const list: PolicyEmployeeList = { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, + [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER, errors: {error1: 'Something went wrong'}}, + }; + + // When building the map with and without includeMemberWithErrors + const withoutErrors = getMemberAccountIDsForWorkspace(list, personalDetailsByLogins); + const withErrors = getMemberAccountIDsForWorkspace(list, personalDetailsByLogins, true); + + // Then the member is only kept when asked for, so the error stays visible until the user reads it + expect(withoutErrors).toEqual({[adminEmail]: adminAccountID}); + expect(withErrors).toEqual({[adminEmail]: adminAccountID, [employeeEmail]: employeeAccountID}); + }); + + it('excludes members pending delete only when includeMemberWithPendingDelete is false', () => { + // Given a member who is pending removal + const list: PolicyEmployeeList = { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, + [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }; + + // When building the map with the default and with includeMemberWithPendingDelete set to false + const withPendingDelete = getMemberAccountIDsForWorkspace(list, personalDetailsByLogins); + const withoutPendingDelete = getMemberAccountIDsForWorkspace(list, personalDetailsByLogins, false, false); + + // Then the member is kept by default and dropped when pending deletes are excluded + expect(withPendingDelete).toEqual({[adminEmail]: adminAccountID, [employeeEmail]: employeeAccountID}); + expect(withoutPendingDelete).toEqual({[adminEmail]: adminAccountID}); + }); + + it('returns an empty object when the employee list is undefined', () => { + // Given a workspace with no employee list loaded + // When building the email-to-accountID map + // Then there are no members to map + expect(getMemberAccountIDsForWorkspace(undefined, personalDetailsByLogins)).toEqual({}); + }); }); }); @@ -4244,267 +5825,83 @@ describe('PolicyUtils', () => { p1: { ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), taxRates: { - taxes: {id_vat: {name: 'VAT', value: '20'}}, - name: '', - defaultExternalID: '', - defaultValue: '', - foreignTaxDefault: '', - }, - }, - p2: undefined, - }; - const result = getAllTaxRates(policies); - expect(result).toEqual({VAT: ['id_vat']}); - }); - }); - - describe('isTaxCodeCustomized', () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - taxRates: { - taxes: { - id_vat: {name: 'VAT', value: '20'}, - id_gst: {name: 'GST', value: '10', previousTaxCode: 'id_gst_prev'}, - id_sales: {name: 'Sales Tax', value: '8', previousTaxCode: ''}, - }, - name: '', - defaultExternalID: '', - defaultValue: '', - foreignTaxDefault: '', - }, - }; - - it('returns false when there is no policy, tax code, or tax rate', () => { - expect(isTaxCodeCustomized(undefined, policy)).toEqual(false); - expect(isTaxCodeCustomized('', policy)).toEqual(false); - expect(isTaxCodeCustomized('id_vat', undefined)).toEqual(false); - expect(isTaxCodeCustomized('id_INVALID', policy)).toEqual(false); - }); - - it('returns true only when the tax rate has a non-empty `previousTaxCode` value', () => { - expect(isTaxCodeCustomized('id_vat', policy)).toEqual(false); - expect(isTaxCodeCustomized('id_gst', policy)).toEqual(true); - expect(isTaxCodeCustomized('id_sales', policy)).toEqual(false); - }); - }); - }); - - describe('canSendInvoiceFromWorkspace', () => { - it('returns true when areInvoicesEnabled is true', () => { - const policy = createMock({areInvoicesEnabled: true}); - expect(canSendInvoiceFromWorkspace(policy)).toBe(true); - }); - - it('returns false when areInvoicesEnabled is false', () => { - const policy = createMock({areInvoicesEnabled: false}); - expect(canSendInvoiceFromWorkspace(policy)).toBe(false); - }); - - it('returns false when policy is undefined', () => { - expect(canSendInvoiceFromWorkspace(undefined)).toBe(false); - }); - }); - - it('should return undefined when no tag approver rule is present', () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - rules: { - approvalRules: [ - { - id: 'rule-1', - applyWhen: [ - { - field: CONST.POLICY.FIELDS.TAG, - condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, - value: 'Fake tag', - }, - ], - approver: 'lol@hhh.com', - }, - ], - }, - }; - const result = getTagApproverRule(policy, 'NonExistentTag'); - expect(result).toBeUndefined(); - }); - - it('should return the tag approver rule when present', () => { - const tagName = 'ImportantTag'; - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - rules: { - approvalRules: [ - { - id: 'rule-1', - applyWhen: [ - { - field: CONST.POLICY.FIELDS.TAG, - condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, - value: tagName, - }, - ], - approver: 'approver@example.com', - }, - ], - }, - }; - const result = getTagApproverRule(policy, tagName); - expect(result).toEqual({ - id: 'rule-1', - applyWhen: [ - { - field: CONST.POLICY.FIELDS.TAG, - condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, - value: tagName, - }, - ], - approver: 'approver@example.com', - }); - }); - - describe('getDefaultTimeTrackingRate', () => { - it('should return rate in subunits', () => { - const policy: Policy = { - ...createRandomPolicy(1), - units: { - time: { - enabled: true, - rate: 20, - }, - }, - }; - expect(getDefaultTimeTrackingRate(policy)).toBe(2000); - }); - - it('should return 0 when the rate is 0, not undefined', () => { - const policy: Policy = { - ...createRandomPolicy(1), - units: { - time: { - enabled: true, - rate: 0, - }, - }, - }; - expect(getDefaultTimeTrackingRate(policy)).toBe(0); - }); - - it('should return undefined when the rate is not defined on the policy', () => { - const policy = createRandomPolicy(1); - expect(getDefaultTimeTrackingRate(policy)).toBeUndefined(); - }); - }); - - describe('per diem policy filters', () => { - const perDiemCustomUnit = { - name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, - customUnitID: 'ABCDEF', - enabled: true, - rates: { - London: { - customUnitRateID: 'London', - name: 'London', - }, - }, - }; - - it('returns only control policies from getActivePoliciesWithExpenseChatAndPerDiemEnabled', () => { - const policies: OnyxCollection = { - corporate: { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - role: CONST.POLICY.ROLE.USER, - pendingAction: null, - arePerDiemRatesEnabled: true, - customUnits: { - ABCDEF: perDiemCustomUnit, - }, - }, - collect: { - ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), - role: CONST.POLICY.ROLE.USER, - pendingAction: null, - arePerDiemRatesEnabled: true, - customUnits: { - ABCDEF: perDiemCustomUnit, - }, - }, - }; - - const result = getActivePoliciesWithExpenseChatAndPerDiemEnabled(policies, undefined); - expect(result).toHaveLength(1); - expect(result.at(0)?.type).toBe(CONST.POLICY.TYPE.CORPORATE); - }); - - it('includes a control policy whose per diem is enabled but has no rates yet (rates load lazily after cache clear)', () => { - const policies: OnyxCollection = { - corporateWithRates: { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - role: CONST.POLICY.ROLE.USER, - pendingAction: null, - arePerDiemRatesEnabled: true, - customUnits: { - ABCDEF: perDiemCustomUnit, - }, - }, - corporateWithoutRates: { - ...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE), - role: CONST.POLICY.ROLE.USER, - pendingAction: null, - arePerDiemRatesEnabled: true, - customUnits: { - ABCDEF: { - ...perDiemCustomUnit, - rates: {}, + taxes: {id_vat: {name: 'VAT', value: '20'}}, + name: '', + defaultExternalID: '', + defaultValue: '', + foreignTaxDefault: '', }, }, - }, - collectWithRates: { - ...createRandomPolicy(3, CONST.POLICY.TYPE.TEAM), - role: CONST.POLICY.ROLE.USER, - pendingAction: null, - arePerDiemRatesEnabled: true, - customUnits: { - ABCDEF: perDiemCustomUnit, - }, - }, - }; - - const result = getActivePoliciesWithExpenseChatAndPerDiemEnabled(policies, undefined); - expect(result.map((currentPolicy) => currentPolicy.id).sort()).toEqual(['1', '2']); + p2: undefined, + }; + const result = getAllTaxRates(policies); + expect(result).toEqual({VAT: ['id_vat']}); + }); }); - it('includes a control policy whose arePerDiemRatesEnabled flag is missing but has an enabled per diem custom unit', () => { - const policies: OnyxCollection = { - corporate: { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - role: CONST.POLICY.ROLE.USER, - pendingAction: null, - // Migrated member: flag never arrived, but the configured custom unit is present - arePerDiemRatesEnabled: undefined, - customUnits: { - ABCDEF: perDiemCustomUnit, + describe('isTaxCodeCustomized', () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + taxRates: { + taxes: { + id_vat: {name: 'VAT', value: '20'}, + id_gst: {name: 'GST', value: '10', previousTaxCode: 'id_gst_prev'}, + id_sales: {name: 'Sales Tax', value: '8', previousTaxCode: ''}, }, + name: '', + defaultExternalID: '', + defaultValue: '', + foreignTaxDefault: '', }, }; - expect(getActivePoliciesWithExpenseChatAndPerDiemEnabled(policies, undefined)).toHaveLength(1); + it('returns false when there is no policy, tax code, or tax rate', () => { + expect(isTaxCodeCustomized(undefined, policy)).toEqual(false); + expect(isTaxCodeCustomized('', policy)).toEqual(false); + expect(isTaxCodeCustomized('id_vat', undefined)).toEqual(false); + expect(isTaxCodeCustomized('id_INVALID', policy)).toEqual(false); + }); + + it('returns true only when the tax rate has a non-empty `previousTaxCode` value', () => { + expect(isTaxCodeCustomized('id_vat', policy)).toEqual(false); + expect(isTaxCodeCustomized('id_gst', policy)).toEqual(true); + expect(isTaxCodeCustomized('id_sales', policy)).toEqual(false); + }); }); + }); - it('excludes a control policy where per diem was explicitly disabled even if a custom unit lingers', () => { - const policies: OnyxCollection = { - corporate: { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - role: CONST.POLICY.ROLE.USER, - pendingAction: null, - // Explicit off must be respected, so the lingering custom unit cannot re-enable it - arePerDiemRatesEnabled: false, - customUnits: { - ABCDEF: perDiemCustomUnit, + describe('features', () => { + describe('getDefaultTimeTrackingRate', () => { + it('should return rate in subunits', () => { + const policy: Policy = { + ...createRandomPolicy(1), + units: { + time: { + enabled: true, + rate: 20, + }, }, - }, - }; + }; + expect(getDefaultTimeTrackingRate(policy)).toBe(2000); + }); + + it('should return 0 when the rate is 0, not undefined', () => { + const policy: Policy = { + ...createRandomPolicy(1), + units: { + time: { + enabled: true, + rate: 0, + }, + }, + }; + expect(getDefaultTimeTrackingRate(policy)).toBe(0); + }); - expect(getActivePoliciesWithExpenseChatAndPerDiemEnabled(policies, undefined)).toHaveLength(0); + it('should return undefined when the rate is not defined on the policy', () => { + const policy = createRandomPolicy(1); + expect(getDefaultTimeTrackingRate(policy)).toBeUndefined(); + }); }); describe('isInvoiceFieldsEnabled', () => { @@ -4544,369 +5941,376 @@ describe('PolicyUtils', () => { expect(isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED)).toBe(false); }); }); - }); - - describe('sortPoliciesByName', () => { - const localeCompare = (a: string, b: string) => a.localeCompare(b); - - it('sorts policies alphabetically by name', () => { - const policies: Policy[] = [ - {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), name: 'Charlie'}, - {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), name: 'Alpha'}, - {...createRandomPolicy(3, CONST.POLICY.TYPE.TEAM), name: 'Bravo'}, - ]; - - const result = sortPoliciesByName(policies, localeCompare); - expect(result.map((p) => p.name)).toEqual(['Alpha', 'Bravo', 'Charlie']); - }); - - it('returns empty array for empty input', () => { - expect(sortPoliciesByName([], localeCompare)).toEqual([]); - }); - it('treats undefined or empty names as empty string', () => { - const policies: Policy[] = [ - {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), name: 'Bravo'}, - {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), name: ''}, - {...createRandomPolicy(3, CONST.POLICY.TYPE.TEAM), name: 'Alpha'}, - ]; + describe('arePolicyRulesEnabled', () => { + const corporateBase = createMock({id: 'policy1', type: CONST.POLICY.TYPE.CORPORATE}); + const teamBase = createMock({id: 'policy2', type: CONST.POLICY.TYPE.TEAM}); - const result = sortPoliciesByName(policies, localeCompare); - expect(result.map((p) => p.name)).toEqual(['', 'Alpha', 'Bravo']); - }); + it('returns true for a corporate policy with areRulesEnabled explicitly true', () => { + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: true})).toBe(true); + }); - it('returns single-element array as-is', () => { - const policies: Policy[] = [{...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), name: 'Only'}]; + it('returns false for a corporate policy with areRulesEnabled explicitly false', () => { + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: false})).toBe(false); + }); - const result = sortPoliciesByName(policies, localeCompare); - expect(result).toHaveLength(1); - expect(result.at(0)?.name).toBe('Only'); - }); + it('returns false for a corporate policy with areRulesEnabled undefined and no categories', () => { + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined})).toBe(false); + }); - it('does not sort the input array in place', () => { - const policies: Policy[] = [ - {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), name: 'Charlie'}, - {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), name: 'Alpha'}, - ]; + it('returns false for a corporate policy with areRulesEnabled undefined and categories with no rule fields', () => { + const categories = {Advertising: {name: 'Advertising', enabled: true, 'GL Code': '1234'}}; + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(false); + }); - const result = sortPoliciesByName(policies, localeCompare); + it('returns true for a corporate policy with areRulesEnabled undefined and a category with maxExpenseAmount set', () => { + const categories = {Advertising: {name: 'Advertising', enabled: true, maxExpenseAmount: 50000}}; + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); + }); - expect(result).not.toBe(policies); - }); - }); + it('returns true for a corporate policy with areRulesEnabled undefined and a category with maxAmountNoReceipt = 0', () => { + const categories = {Travel: {name: 'Travel', enabled: true, maxAmountNoReceipt: 0}}; + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); + }); - describe('hasConfiguredRules', () => { - it('returns false when policy is undefined', () => { - expect(hasConfiguredRules(undefined, undefined, false)).toBe(false); - }); + it('returns true for a corporate policy with areRulesEnabled undefined and a category with areCommentsRequired = true', () => { + const categories = {Meals: {name: 'Meals', enabled: true, areCommentsRequired: true}}; + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); + }); - it('returns false when policy has no rules configured', () => { - expect(hasConfiguredRules(createMock({}), undefined, false)).toBe(false); - }); + it('returns true for a corporate policy with areRulesEnabled undefined and a category with commentHint set', () => { + const categories = {Car: {name: 'Car', enabled: true, commentHint: 'Enter purpose'}}; + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); + }); - describe('customRules', () => { - it('returns true when customRules is non-empty', () => { - expect(hasConfiguredRules(createMock({customRules: 'some rule'}), undefined, false)).toBe(true); + it('returns false for a team policy with areRulesEnabled undefined', () => { + expect(arePolicyRulesEnabled({...teamBase, areRulesEnabled: undefined})).toBe(false); }); - it('returns false when customRules is an empty string', () => { - expect(hasConfiguredRules(createMock({customRules: ''}), undefined, false)).toBe(false); + it('returns true for a team policy with areRulesEnabled explicitly true', () => { + expect(arePolicyRulesEnabled({...teamBase, areRulesEnabled: true}, undefined)).toBe(true); }); - it('returns false when customRules is only whitespace', () => { - expect(hasConfiguredRules(createMock({customRules: ' '}), undefined, false)).toBe(false); + it('returns false for a team policy with areRulesEnabled explicitly false', () => { + expect(arePolicyRulesEnabled({...teamBase, areRulesEnabled: false})).toBe(false); }); }); + }); - describe('rules.approvalRules', () => { - it('returns true when approvalRules has items', () => { - const policy = createMock({rules: {approvalRules: [{id: '1', applyWhen: [], approver: 'approver@test.com'}]}}); - expect(hasConfiguredRules(policy, undefined, false)).toBe(true); + describe('rules', () => { + describe('getTagApproverRule', () => { + it('should return undefined when no approval rules are present', () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + rules: { + approvalRules: [], + }, + }; + const result = getTagApproverRule(policy, ''); + expect(result).toBeUndefined(); }); - it('returns false when approvalRules is empty', () => { - expect(hasConfiguredRules(createMock({rules: {approvalRules: []}}), undefined, false)).toBe(false); + it('should return undefined when no tag approver rule is present', () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + rules: { + approvalRules: [ + { + id: 'rule-1', + applyWhen: [ + { + field: CONST.POLICY.FIELDS.TAG, + condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, + value: 'Fake tag', + }, + ], + approver: 'lol@hhh.com', + }, + ], + }, + }; + const result = getTagApproverRule(policy, 'NonExistentTag'); + expect(result).toBeUndefined(); }); - }); - describe('rules.expenseRules', () => { - it('returns true when expenseRules has items', () => { - const policy = createMock({ + it('should return the tag approver rule when present', () => { + const tagName = 'ImportantTag'; + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), rules: { - expenseRules: [ + approvalRules: [ { - id: '1', - applyWhen: [], - tax: {field_id_TAX: {externalID: 'TAX_US'}}, + id: 'rule-1', + applyWhen: [ + { + field: CONST.POLICY.FIELDS.TAG, + condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, + value: tagName, + }, + ], + approver: 'approver@example.com', }, ], }, + }; + const result = getTagApproverRule(policy, tagName); + expect(result).toEqual({ + id: 'rule-1', + applyWhen: [ + { + field: CONST.POLICY.FIELDS.TAG, + condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, + value: tagName, + }, + ], + approver: 'approver@example.com', }); - expect(hasConfiguredRules(policy, undefined, false)).toBe(true); - }); - - it('returns false when expenseRules is empty', () => { - expect(hasConfiguredRules(createMock({rules: {expenseRules: []}}), undefined, false)).toBe(false); }); }); - describe('merchant rules', () => { - // Which rules count towards this is decided by the caller's selector and covered in - // ExpenseDefaultRuleUtilsTest, so only the pass-through is checked here. - const policy = createMock({id: 'policy1', rules: {}}); - - it('returns true when the policy has a merchant rule', () => { - expect(hasConfiguredRules(policy, undefined, true)).toBe(true); + describe('hasConfiguredRules', () => { + it('returns false when policy is undefined', () => { + expect(hasConfiguredRules(undefined, undefined, false)).toBe(false); }); - it('returns false when it has none', () => { - expect(hasConfiguredRules(policy, undefined, false)).toBe(false); + it('returns false when policy has no rules configured', () => { + expect(hasConfiguredRules(createMock({}), undefined, false)).toBe(false); }); - }); - describe('maxExpenseAmount', () => { - it('returns true when maxExpenseAmount is set to a non-default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmount: 500000}), undefined, false)).toBe(true); - }); + describe('customRules', () => { + it('returns true when customRules is non-empty', () => { + expect(hasConfiguredRules(createMock({customRules: 'some rule'}), undefined, false)).toBe(true); + }); - it('returns false when maxExpenseAmount is the default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmount: CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT}), undefined, false)).toBe(false); - }); + it('returns false when customRules is an empty string', () => { + expect(hasConfiguredRules(createMock({customRules: ''}), undefined, false)).toBe(false); + }); - it('returns false when maxExpenseAmount is the disabled value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmount: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); + it('returns false when customRules is only whitespace', () => { + expect(hasConfiguredRules(createMock({customRules: ' '}), undefined, false)).toBe(false); + }); }); - }); - describe('maxExpenseAge', () => { - it('returns true when maxExpenseAge is set to a non-default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAge: 30}), undefined, false)).toBe(true); - }); + describe('rules.approvalRules', () => { + it('returns true when approvalRules has items', () => { + const policy = createMock({rules: {approvalRules: [{id: '1', applyWhen: [], approver: 'approver@test.com'}]}}); + expect(hasConfiguredRules(policy, undefined, false)).toBe(true); + }); - it('returns false when maxExpenseAge is the default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAge: CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE}), undefined, false)).toBe(false); + it('returns false when approvalRules is empty', () => { + expect(hasConfiguredRules(createMock({rules: {approvalRules: []}}), undefined, false)).toBe(false); + }); }); - it('returns false when maxExpenseAge is the disabled value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAge: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); - }); - }); + describe('rules.expenseRules', () => { + it('returns true when expenseRules has items', () => { + const policy = createMock({ + rules: { + expenseRules: [ + { + id: '1', + applyWhen: [], + tax: {field_id_TAX: {externalID: 'TAX_US'}}, + }, + ], + }, + }); + expect(hasConfiguredRules(policy, undefined, false)).toBe(true); + }); - describe('maxExpenseAmountNoReceipt', () => { - it('returns true when maxExpenseAmountNoReceipt is set to a non-default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmountNoReceipt: 5000}), undefined, false)).toBe(true); + it('returns false when expenseRules is empty', () => { + expect(hasConfiguredRules(createMock({rules: {expenseRules: []}}), undefined, false)).toBe(false); + }); }); - it('returns false when maxExpenseAmountNoReceipt is the default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmountNoReceipt: CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT}), undefined, false)).toBe(false); - }); + describe('merchant rules', () => { + // Which rules count towards this is decided by the caller's selector and covered in + // ExpenseDefaultRuleUtilsTest, so only the pass-through is checked here. + const policy = createMock({id: 'policy1', rules: {}}); - it('returns false when maxExpenseAmountNoReceipt is the disabled value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmountNoReceipt: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); - }); - }); + it('returns true when the policy has a merchant rule', () => { + expect(hasConfiguredRules(policy, undefined, true)).toBe(true); + }); - describe('maxExpenseAmountNoItemizedReceipt', () => { - it('returns true when maxExpenseAmountNoItemizedReceipt is set to a non-default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmountNoItemizedReceipt: 10000}), undefined, false)).toBe(true); + it('returns false when it has none', () => { + expect(hasConfiguredRules(policy, undefined, false)).toBe(false); + }); }); - it('returns false when maxExpenseAmountNoItemizedReceipt is the default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmountNoItemizedReceipt: CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT}), undefined, false)).toBe(false); - }); + describe('maxExpenseAmount', () => { + it('returns true when maxExpenseAmount is set to a non-default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmount: 500000}), undefined, false)).toBe(true); + }); - it('returns false when maxExpenseAmountNoItemizedReceipt is the disabled value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmountNoItemizedReceipt: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); - }); - }); + it('returns false when maxExpenseAmount is the default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmount: CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT}), undefined, false)).toBe(false); + }); - describe('defaultBillable', () => { - it('returns true when defaultBillable is true', () => { - expect(hasConfiguredRules(createMock({defaultBillable: true}), undefined, false)).toBe(true); + it('returns false when maxExpenseAmount is the disabled value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmount: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); + }); }); - it('returns false when defaultBillable is false', () => { - expect(hasConfiguredRules(createMock({defaultBillable: false}), undefined, false)).toBe(false); - }); - }); + describe('maxExpenseAge', () => { + it('returns true when maxExpenseAge is set to a non-default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAge: 30}), undefined, false)).toBe(true); + }); - describe('defaultReimbursable', () => { - it('returns true when defaultReimbursable is false', () => { - expect(hasConfiguredRules(createMock({defaultReimbursable: false}), undefined, false)).toBe(true); - }); + it('returns false when maxExpenseAge is the default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAge: CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE}), undefined, false)).toBe(false); + }); - it('returns false when defaultReimbursable is true', () => { - expect(hasConfiguredRules(createMock({defaultReimbursable: true}), undefined, false)).toBe(false); + it('returns false when maxExpenseAge is the disabled value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAge: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); + }); }); - }); - describe('eReceipts', () => { - it('returns true when eReceipts is true', () => { - expect(hasConfiguredRules(createMock({eReceipts: true}), undefined, false)).toBe(true); - }); + describe('maxExpenseAmountNoReceipt', () => { + it('returns true when maxExpenseAmountNoReceipt is set to a non-default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmountNoReceipt: 5000}), undefined, false)).toBe(true); + }); - it('returns false when eReceipts is false', () => { - expect(hasConfiguredRules(createMock({eReceipts: false}), undefined, false)).toBe(false); - }); - }); + it('returns false when maxExpenseAmountNoReceipt is the default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmountNoReceipt: CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT}), undefined, false)).toBe(false); + }); - describe('requireCompanyCardsEnabled', () => { - it('returns true when requireCompanyCardsEnabled is true', () => { - expect(hasConfiguredRules(createMock({requireCompanyCardsEnabled: true}), undefined, false)).toBe(true); + it('returns false when maxExpenseAmountNoReceipt is the disabled value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmountNoReceipt: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); + }); }); - it('returns false when requireCompanyCardsEnabled is false', () => { - expect(hasConfiguredRules(createMock({requireCompanyCardsEnabled: false}), undefined, false)).toBe(false); - }); - }); + describe('maxExpenseAmountNoItemizedReceipt', () => { + it('returns true when maxExpenseAmountNoItemizedReceipt is set to a non-default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmountNoItemizedReceipt: 10000}), undefined, false)).toBe(true); + }); + + it('returns false when maxExpenseAmountNoItemizedReceipt is the default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmountNoItemizedReceipt: CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT}), undefined, false)).toBe(false); + }); - describe('prohibitedExpenses', () => { - it('returns true when a prohibitedExpenses value differs from its default', () => { - // alcohol defaults to false — setting it to true triggers the rule - expect(hasConfiguredRules(createMock({prohibitedExpenses: {alcohol: true}}), undefined, false)).toBe(true); + it('returns false when maxExpenseAmountNoItemizedReceipt is the disabled value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmountNoItemizedReceipt: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); + }); }); - it('returns true when gambling is disabled (differs from default true)', () => { - expect(hasConfiguredRules(createMock({prohibitedExpenses: {gambling: false}}), undefined, false)).toBe(true); + describe('defaultBillable', () => { + it('returns true when defaultBillable is true', () => { + expect(hasConfiguredRules(createMock({defaultBillable: true}), undefined, false)).toBe(true); + }); + + it('returns false when defaultBillable is false', () => { + expect(hasConfiguredRules(createMock({defaultBillable: false}), undefined, false)).toBe(false); + }); }); - it('returns false when prohibitedExpenses matches all defaults', () => { - expect(hasConfiguredRules(createMock({prohibitedExpenses: {...CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES}}), undefined, false)).toBe(false); + describe('defaultReimbursable', () => { + it('returns true when defaultReimbursable is false', () => { + expect(hasConfiguredRules(createMock({defaultReimbursable: false}), undefined, false)).toBe(true); + }); + + it('returns false when defaultReimbursable is true', () => { + expect(hasConfiguredRules(createMock({defaultReimbursable: true}), undefined, false)).toBe(false); + }); }); - it('returns false when prohibitedExpenses is an empty object', () => { - expect(hasConfiguredRules(createMock({prohibitedExpenses: {}}), undefined, false)).toBe(false); + describe('eReceipts', () => { + it('returns true when eReceipts is true', () => { + expect(hasConfiguredRules(createMock({eReceipts: true}), undefined, false)).toBe(true); + }); + + it('returns false when eReceipts is false', () => { + expect(hasConfiguredRules(createMock({eReceipts: false}), undefined, false)).toBe(false); + }); }); - }); - it('returns true when only Classic category rules exist', () => { - const categories = {Travel: {name: 'Travel', enabled: true, maxAmountNoReceipt: 0}}; - expect(hasConfiguredRules(createMock({}), categories, false)).toBe(true); - }); + describe('requireCompanyCardsEnabled', () => { + it('returns true when requireCompanyCardsEnabled is true', () => { + expect(hasConfiguredRules(createMock({requireCompanyCardsEnabled: true}), undefined, false)).toBe(true); + }); - it('returns false when categories have no active rule fields', () => { - const categories = {Advertising: {name: 'Advertising', enabled: true, 'GL Code': '1234'}}; - expect(hasConfiguredRules(createMock({}), categories, false)).toBe(false); - }); - }); + it('returns false when requireCompanyCardsEnabled is false', () => { + expect(hasConfiguredRules(createMock({requireCompanyCardsEnabled: false}), undefined, false)).toBe(false); + }); + }); - describe('getExpensifyTeamExclusions', () => { - const buildPersonalDetails = (logins: string[]): PersonalDetailsList => { - const result: PersonalDetailsList = {}; - for (const [index, login] of logins.entries()) { - const accountID = index + 1; - result[accountID] = {accountID, login}; - } - return result; - }; - const buildPolicies = ( - specs: Array<{ - owner: string; - employeeLogins: string[]; - }>, - ): OnyxCollection => { - const result: OnyxCollection = {}; - for (const [index, {owner, employeeLogins}] of specs.entries()) { - const memberList: PolicyEmployeeList = {}; - for (const login of employeeLogins) { - memberList[login] = {email: login}; - } - result[`policy_${index + 1}`] = createMock({id: `${index + 1}`, owner, employeeList: memberList}); - } - return result; - }; + describe('prohibitedExpenses', () => { + it('returns true when a prohibitedExpenses value differs from its default', () => { + // alcohol defaults to false — setting it to true triggers the rule + expect(hasConfiguredRules(createMock({prohibitedExpenses: {alcohol: true}}), undefined, false)).toBe(true); + }); - it('returns empty when currentUserLogin is undefined', () => { - const details = buildPersonalDetails(['am@expensify.com', 'bob@acme.com']); - const policies = buildPolicies([{owner: 'customer@acme.com', employeeLogins: ['bob@acme.com']}]); - expect(getExpensifyTeamExclusions(details, policies, undefined)).toEqual({}); - }); + it('returns true when gambling is disabled (differs from default true)', () => { + expect(hasConfiguredRules(createMock({prohibitedExpenses: {gambling: false}}), undefined, false)).toBe(true); + }); - it('returns empty when currentUserLogin is on the Expensify team (exception)', () => { - const details = buildPersonalDetails(['am@expensify.com', 'bob@acme.com']); - const policies = buildPolicies([{owner: 'customer@acme.com', employeeLogins: ['am@expensify.com', 'bob@acme.com']}]); - expect(getExpensifyTeamExclusions(details, policies, 'staff@expensify.com')).toEqual({}); - }); + it('returns false when prohibitedExpenses matches all defaults', () => { + expect(hasConfiguredRules(createMock({prohibitedExpenses: {...CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES}}), undefined, false)).toBe(false); + }); - it('excludes all Expensify-team logins from personalDetails when current user is non-Expensify and has no Expensify-owned policies', () => { - const details = buildPersonalDetails(['am@expensify.com', 'guide@team.expensify.com', 'bob@acme.com']); - const policies = buildPolicies([{owner: 'customer@acme.com', employeeLogins: ['bob@acme.com']}]); - const result = getExpensifyTeamExclusions(details, policies, 'customer@acme.com'); - expect(result['am@expensify.com']).toBe(true); - expect(result['guide@team.expensify.com']).toBe(true); - expect(result['bob@acme.com']).toBeUndefined(); - }); + it('returns false when prohibitedExpenses is an empty object', () => { + expect(hasConfiguredRules(createMock({prohibitedExpenses: {}}), undefined, false)).toBe(false); + }); + }); - it('preserves Expensify-team members of an Expensify-owned policy the current user belongs to', () => { - const details = buildPersonalDetails(['lead@expensify.com', 'unrelated_am@expensify.com', 'contractor@acme.com']); - const policies = buildPolicies([{owner: 'lead@expensify.com', employeeLogins: ['contractor@acme.com', 'lead@expensify.com']}]); - const result = getExpensifyTeamExclusions(details, policies, 'contractor@acme.com'); - expect(result['lead@expensify.com']).toBeUndefined(); - expect(result['unrelated_am@expensify.com']).toBe(true); - expect(result['contractor@acme.com']).toBeUndefined(); - }); + it('returns true when only Classic category rules exist', () => { + const categories = {Travel: {name: 'Travel', enabled: true, maxAmountNoReceipt: 0}}; + expect(hasConfiguredRules(createMock({}), categories, false)).toBe(true); + }); - it('does not preserve Expensify-team members of policies the current user is not in', () => { - const details = buildPersonalDetails(['lead@expensify.com', 'bob@acme.com']); - const policies = buildPolicies([{owner: 'lead@expensify.com', employeeLogins: ['lead@expensify.com', 'someone_else@acme.com']}]); - const result = getExpensifyTeamExclusions(details, policies, 'bob@acme.com'); - expect(result['lead@expensify.com']).toBe(true); + it('returns false when categories have no active rule fields', () => { + const categories = {Advertising: {name: 'Advertising', enabled: true, 'GL Code': '1234'}}; + expect(hasConfiguredRules(createMock({}), categories, false)).toBe(false); + }); }); - it('does not preserve when policy owner is non-Expensify', () => { - const details = buildPersonalDetails(['am@expensify.com', 'bob@acme.com']); - const policies = buildPolicies([{owner: 'bob@acme.com', employeeLogins: ['am@expensify.com', 'bob@acme.com']}]); - const result = getExpensifyTeamExclusions(details, policies, 'bob@acme.com'); - expect(result['am@expensify.com']).toBe(true); - }); + describe('isMaxExpenseAmountSet', () => { + it('returns false when the amount was never set', () => { + expect(isMaxExpenseAmountSet(undefined)).toBe(false); + }); - it('handles undefined and empty inputs gracefully', () => { - expect(getExpensifyTeamExclusions(undefined, undefined, 'customer@acme.com')).toEqual({}); - // The null values deliberately exercise the runtime guard for malformed Onyx data. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Null is the malformed-input scenario under test. - const nullPersonalDetails = null as unknown as Parameters[0]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Null is the malformed-input scenario under test. - const nullPolicies = null as unknown as Parameters[1]; - expect(getExpensifyTeamExclusions(nullPersonalDetails, nullPolicies, 'customer@acme.com')).toEqual({}); - expect(getExpensifyTeamExclusions({}, {}, 'customer@acme.com')).toEqual({}); - }); + it('returns false when the amount is explicitly disabled', () => { + expect(isMaxExpenseAmountSet(CONST.DISABLED_MAX_EXPENSE_VALUE)).toBe(false); + }); + + it('returns true for an explicit 0, which means the receipt is always required', () => { + expect(isMaxExpenseAmountSet(0)).toBe(true); + }); - it('lowercases comparison keys for current user and employeeList matching', () => { - const details = buildPersonalDetails(['AM@expensify.com', 'Bob@Acme.com']); - const policies = buildPolicies([{owner: 'lead@expensify.com', employeeLogins: ['AM@expensify.com', 'Contractor@Acme.com']}]); - const result = getExpensifyTeamExclusions(details, policies, 'Contractor@Acme.com'); - expect(result['am@expensify.com']).toBeUndefined(); - expect(result['bob@acme.com']).toBeUndefined(); + it('returns true for a positive amount', () => { + expect(isMaxExpenseAmountSet(5000)).toBe(true); + }); }); }); - describe('tryNavigateToSubmitWorkspaceUpgrade', () => { - const submitPolicyForNavTest: Policy = {...createRandomPolicy(99003, CONST.POLICY.TYPE.SUBMIT), id: 'policy-submit-nav-test'}; - const teamPolicyForNavTest: Policy = {...createRandomPolicy(99004, CONST.POLICY.TYPE.TEAM), id: 'policy-team-nav-test'}; - const featureAlias = CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias; + describe('navigation', () => { + describe('tryNavigateToSubmitWorkspaceUpgrade', () => { + const submitPolicyForNavTest: Policy = {...createRandomPolicy(99003, CONST.POLICY.TYPE.SUBMIT), id: 'policy-submit-nav-test'}; + const teamPolicyForNavTest: Policy = {...createRandomPolicy(99004, CONST.POLICY.TYPE.TEAM), id: 'policy-team-nav-test'}; + const featureAlias = CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias; - beforeEach(() => { - jest.clearAllMocks(); - }); + beforeEach(() => { + jest.clearAllMocks(); + }); - it('returns false and does not navigate when isEnabling is false', () => { - expect(tryNavigateToSubmitWorkspaceUpgrade(submitPolicyForNavTest, false, featureAlias)).toBe(false); - expect(Navigation.navigate).not.toHaveBeenCalled(); - }); + it('returns false and does not navigate when isEnabling is false', () => { + expect(tryNavigateToSubmitWorkspaceUpgrade(submitPolicyForNavTest, false, featureAlias)).toBe(false); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); - it('returns false when policy is not Submit', () => { - expect(tryNavigateToSubmitWorkspaceUpgrade(teamPolicyForNavTest, true, featureAlias)).toBe(false); - expect(Navigation.navigate).not.toHaveBeenCalled(); - }); + it('returns false when policy is not Submit', () => { + expect(tryNavigateToSubmitWorkspaceUpgrade(teamPolicyForNavTest, true, featureAlias)).toBe(false); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); - it('navigates to workspace upgrade and returns true for Submit policy regardless of beta', () => { - const policyID = submitPolicyForNavTest.id; - const expectedRoute = ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, featureAlias, ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); + it('navigates to workspace upgrade and returns true for Submit policy regardless of beta', () => { + const policyID = submitPolicyForNavTest.id; + const expectedRoute = ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, featureAlias, ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); - expect(tryNavigateToSubmitWorkspaceUpgrade(submitPolicyForNavTest, true, featureAlias)).toBe(true); + expect(tryNavigateToSubmitWorkspaceUpgrade(submitPolicyForNavTest, true, featureAlias)).toBe(true); - expect(Navigation.navigate).toHaveBeenCalledTimes(1); - expect(Navigation.navigate).toHaveBeenCalledWith(expectedRoute); + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(expectedRoute); + }); }); }); @@ -5880,73 +7284,6 @@ describe('PolicyUtils', () => { }); }); - describe('isXeroActiveMatchingSource (R4)', () => { - it('returns true when only Xero is connected', () => { - expect(isXeroActiveMatchingSource(buildXeroPolicy())).toBe(true); - }); - - it('returns false when QBO is the active matching source even if Xero is also connected', () => { - // The exact dual-connection state the helper was introduced for: QBO is in - // vendor-matching export mode (CC), and Xero is connected with contacts. The - // label flip / picker copy must stay on "Vendor" because the user is actually - // seeing QBO vendors via getMatchingVendors' precedence rule. - const xeroPolicy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); - const policy = createMock({ - ...xeroPolicy, - connections: { - ...xeroPolicy.connections, - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, - data: {vendors: [{id: 'v-1', name: 'Acme QBO', currency: 'USD'}]}, - }, - }, - }); - expect(isXeroActiveMatchingSource(policy)).toBe(false); - }); - - it('returns false when Intacct is the active matching source even if Xero is also connected', () => { - const xeroPolicy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); - const policy = createMock({ - ...xeroPolicy, - connections: { - ...xeroPolicy.connections, - [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: { - config: {export: {nonReimbursable: CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE}}, - data: {vendors: [{id: 'iv-1', name: 'V001', value: 'Acme Intacct'}]}, - }, - }, - }); - expect(isXeroActiveMatchingSource(policy)).toBe(false); - }); - - it('returns true when QBO is connected but in a non-matching export mode (Vendor Bill) + Xero is connected', () => { - // QBO is connected but not in CC/DC mode, so isQBOVendorMatchingActive is false. - // Xero is connected, so it takes over as the active source via getMatchingVendors' - // fall-through. - const xeroPolicy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); - const policy = createMock({ - ...xeroPolicy, - connections: { - ...xeroPolicy.connections, - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL}, - data: {vendors: [{id: 'v-1', name: 'Acme QBO', currency: 'USD'}]}, - }, - }, - }); - expect(isXeroActiveMatchingSource(policy)).toBe(true); - }); - - it('returns false when Xero is not connected', () => { - const policy = {...createRandomPolicy(0), connections: {}}; - expect(isXeroActiveMatchingSource(policy)).toBe(false); - }); - - it('returns false when policy is undefined', () => { - expect(isXeroActiveMatchingSource(undefined)).toBe(false); - }); - }); - describe('isXeroVendorMatchingActive (R4)', () => { it('returns true when Xero is connected with isConfigured=true', () => { expect(isXeroVendorMatchingActive(buildXeroPolicy())).toBe(true); @@ -5961,7 +7298,7 @@ describe('PolicyUtils', () => { }); it('returns true on a dual-connected workspace where Xero is configured and QBO is the active matching source', () => { - // Distinct from isXeroActiveMatchingSource: the supplier-picker page should still be + // The default supplier picker page should still be // reachable when Xero is connected & configured, even if QBO is currently driving // vendor matching for the workspace's transactions. The admin needs to manage the // Xero default supplier independently of which integration is "active" right now. @@ -6124,8 +7461,8 @@ describe('PolicyUtils', () => { it('returns Xero empty state copy when Xero is the active integration', () => { const policy = buildXeroPolicy(); expect(getVendorEmptyState(policy, translate)).toEqual({ - title: translate('workspace.xero.noSuppliersFound'), - subtitle: translate('workspace.xero.noSuppliersFoundDescription'), + title: translate('workspace.xero.noVendorsFound'), + subtitle: translate('workspace.xero.noVendorsFoundDescription'), }); }); @@ -6183,149 +7520,171 @@ describe('PolicyUtils', () => { expect(result).toHaveLength(1); expect(result.at(0)?.name).toBe('Only'); }); - }); - }); - describe('hasPolicyRulesError', () => { - // Whether a merchant rule failed is reduced by the caller's selector and covered in - // ExpenseDefaultRuleUtilsTest, so only the agent rules and the pass-through are checked here. - const POLICY_ID = 'policy-with-rules'; + it('breaks name ties using externalID when id is absent', () => { + const vendors = [ + {externalID: 'vendor_b', name: 'Acme'}, + {externalID: 'vendor_a', name: 'Acme'}, + ]; - it('returns false for an undefined policy', () => { - expect(hasPolicyRulesError(undefined, false)).toBe(false); + const result = sortVendors(vendors, localeCompare); + expect(result.map((v) => v.externalID)).toEqual(['vendor_a', 'vendor_b']); + }); }); - it('returns false when no merchant or agent rules exist', () => { - const policy: Policy = {...createRandomPolicy(0), id: POLICY_ID, rules: {}}; - expect(hasPolicyRulesError(policy, false)).toBe(false); - }); + describe('getDefaultVendorID', () => { + it('resolves QBO credit card default vendor when destination is credit_card', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, + nonReimbursableCreditCardDefaultVendor: 'qbo_vendor_1', + }, + }, + }), + }); - it('returns false when agent rules exist but none have errors', () => { - const policy: Policy = { - ...createRandomPolicy(0), - id: POLICY_ID, - rules: {agentRules: {ai1: {ruleID: 'ai1', prompt: 'p', created: '2026-06-08'}}}, - }; - expect(hasPolicyRulesError(policy, false)).toBe(false); - }); + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.QBO)).toBe('qbo_vendor_1'); + }); - it('returns true when a merchant rule has errors', () => { - const policy: Policy = {...createRandomPolicy(0), id: POLICY_ID, rules: {}}; - expect(hasPolicyRulesError(policy, true)).toBe(true); - }); + it('returns undefined for QBO when destination is not credit_card', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD, + nonReimbursableCreditCardDefaultVendor: 'qbo_vendor_1', + }, + }, + }), + }); - it('returns true when an agent rule has errors', () => { - const policy: Policy = { - ...createRandomPolicy(0), - id: POLICY_ID, - rules: { - agentRules: {ai1: {ruleID: 'ai1', prompt: 'p', created: '2026-06-08', errors: {123: 'boom'}}}, - }, - }; - expect(hasPolicyRulesError(policy, false)).toBe(true); - }); - }); + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.QBO)).toBeUndefined(); + }); - describe('getExcludedUsers', () => { - it('marks every active policy member as excluded', () => { - const result = getExcludedUsers(employeeList); - expect(result['owner@test.com']).toBe(true); - expect(result['admin@test.com']).toBe(true); - expect(result['employee@test.com']).toBe(true); - }); + it('resolves Sage Intacct credit card charge default vendor', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: { + config: { + export: { + nonReimbursableCreditCardChargeDefaultVendor: 'intacct_vendor_1', + }, + }, + }, + }), + }); - it('always excludes Expensify emails', () => { - const result = getExcludedUsers(employeeList); - for (const email of CONST.EXPENSIFY_EMAILS) { - expect(result[email]).toBe(true); - } - }); + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT)).toBe('intacct_vendor_1'); + }); - it('does not exclude a member that is pending delete', () => { - const list: PolicyEmployeeList = { - 'employee@test.com': {email: 'employee@test.com', role: 'user', submitsTo: '', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, - }; - const result = getExcludedUsers(list); - expect(result['employee@test.com']).toBeUndefined(); - }); + it('resolves Xero default vendor', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: { + defaultVendor: 'xero_contact_1', + }, + }, + }), + }); - it('does not exclude a member that has errors', () => { - const list: PolicyEmployeeList = { - 'employee@test.com': {email: 'employee@test.com', role: 'user', submitsTo: '', errors: {error1: 'Something went wrong'}}, - }; - const result = getExcludedUsers(list); - expect(result['employee@test.com']).toBeUndefined(); - }); + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.XERO)).toBe('xero_contact_1'); + }); - it('returns only Expensify emails when the employee list is undefined', () => { - const result = getExcludedUsers(undefined); - expect(Object.keys(result)).toEqual([...CONST.EXPENSIFY_EMAILS]); - }); - }); + it('resolves Rillet default vendor', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.RILLET]: { + config: { + export: { + defaultVendorID: 'rillet_vendor_1', + }, + }, + }, + }), + }); - describe('isQBORefreshTokenExpiringSoonSelector', () => { - const buildQBOPolicy = (role: string, refreshTokenExpiresAt: number): Policy => - Object.assign(createRandomPolicy(1), { - role, - connections: {quickbooksOnline: {config: {credentials: {companyID: '12345', refreshTokenExpiresAt}}, lastSync: {isAuthenticationError: false}}}, + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.RILLET)).toBe('rillet_vendor_1'); }); - const expiringSoon = Math.floor(Date.now() / 1000) + 3 * 86400; - - it('returns true for an admin whose QBO refresh token expires within the warning window', () => { - expect(isQBORefreshTokenExpiringSoonSelector(buildQBOPolicy(CONST.POLICY.ROLE.ADMIN, expiringSoon))).toBe(true); - }); - it('returns false for a member, since only admins can reconnect', () => { - expect(isQBORefreshTokenExpiringSoonSelector(buildQBOPolicy(CONST.POLICY.ROLE.USER, expiringSoon))).toBe(false); - }); + it('resolves DualEntry default vendor', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]: { + config: { + export: { + defaultVendorID: 'dualentry_vendor_1', + }, + }, + }, + }), + }); - it('returns false when the token is still far from expiring or the policy is undefined', () => { - expect(isQBORefreshTokenExpiringSoonSelector(buildQBOPolicy(CONST.POLICY.ROLE.ADMIN, Math.floor(Date.now() / 1000) + 60 * 86400))).toBe(false); - expect(isQBORefreshTokenExpiringSoonSelector(undefined)).toBe(false); - }); - }); + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.DUALENTRY)).toBe('dualentry_vendor_1'); + }); - describe('isMergeHRCompleteSetupNeededSelector', () => { - // Only the fields read by the selector are modeled here; `Object.assign` attaches the connection - // to a real policy without an unsafe cast. - type MergeHRTestConnection = { - lastSync?: {syncStatus?: string}; - data?: {groups?: unknown[]}; - config?: {groups?: unknown[] | null}; - }; - const buildMergeHRPolicy = (seed: number, mergeHR: MergeHRTestConnection): Policy => Object.assign(createRandomPolicy(seed), {connections: {merge_hris: mergeHR}}); - const mergeHRBase = { - lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE}, - data: {groups: [{id: 'g1', name: 'Engineering'}]}, - }; + it('resolves Campfire default vendor', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + config: { + export: { + defaultVendorID: 'campfire_vendor_1', + }, + }, + }, + }), + }); - it('returns false when policy is undefined', () => { - expect(isMergeHRCompleteSetupNeededSelector(undefined)).toBe(false); - }); + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE)).toBe('campfire_vendor_1'); + }); - it('returns false when policy has no merge_hris connection', () => { - const policy = createRandomPolicy(1); - expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); - }); + it('resolves Business Central default vendor', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL]: { + config: { + export: { + defaultVendorID: 'bc_vendor_1', + }, + }, + }, + }), + }); - it('returns false when sync is not done', () => { - const policy = buildMergeHRPolicy(2, {...mergeHRBase, lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.SYNCING}}); - expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); - }); + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL)).toBe('bc_vendor_1'); + }); - it('returns false when sync is done but there are no groups', () => { - const policy = buildMergeHRPolicy(3, {...mergeHRBase, data: {groups: []}}); - expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); - }); + it('resolves Certinia default vendor', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.CERTINIA]: { + config: { + export: { + vendorAccount: 'certinia_acc_1', + }, + }, + }, + }), + }); - it('returns false when setup is already complete (groups configured)', () => { - const policy = buildMergeHRPolicy(4, {...mergeHRBase, config: {groups: ['g1']}}); - expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); - }); + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.CERTINIA)).toBe('certinia_acc_1'); + }); - it('returns true when sync is done, groups exist, and setup is not yet complete', () => { - const policy = buildMergeHRPolicy(5, mergeHRBase); - expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(true); + it('returns undefined when policy has no connections', () => { + expect(getDefaultVendorID(undefined, CONST.POLICY.CONNECTIONS.NAME.QBO)).toBeUndefined(); + }); }); }); @@ -6708,327 +8067,142 @@ describe('PolicyUtils', () => { }); }); }); -}); - -describe('isMaxExpenseAmountSet', () => { - it('returns false when the amount was never set', () => { - expect(isMaxExpenseAmountSet(undefined)).toBe(false); - }); - - it('returns false when the amount is explicitly disabled', () => { - expect(isMaxExpenseAmountSet(CONST.DISABLED_MAX_EXPENSE_VALUE)).toBe(false); - }); - - it('returns true for an explicit 0, which means the receipt is always required', () => { - expect(isMaxExpenseAmountSet(0)).toBe(true); - }); - - it('returns true for a positive amount', () => { - expect(isMaxExpenseAmountSet(5000)).toBe(true); - }); -}); - -describe('arePolicyRulesEnabled', () => { - const corporateBase = createMock({id: 'policy1', type: CONST.POLICY.TYPE.CORPORATE}); - const teamBase = createMock({id: 'policy2', type: CONST.POLICY.TYPE.TEAM}); - - it('returns true for a corporate policy with areRulesEnabled explicitly true', () => { - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: true})).toBe(true); - }); - - it('returns false for a corporate policy with areRulesEnabled explicitly false', () => { - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: false})).toBe(false); - }); - - it('returns false for a corporate policy with areRulesEnabled undefined and no categories', () => { - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined})).toBe(false); - }); - - it('returns false for a corporate policy with areRulesEnabled undefined and categories with no rule fields', () => { - const categories = {Advertising: {name: 'Advertising', enabled: true, 'GL Code': '1234'}}; - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(false); - }); - - it('returns true for a corporate policy with areRulesEnabled undefined and a category with maxExpenseAmount set', () => { - const categories = {Advertising: {name: 'Advertising', enabled: true, maxExpenseAmount: 50000}}; - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); - }); - - it('returns true for a corporate policy with areRulesEnabled undefined and a category with maxAmountNoReceipt = 0', () => { - const categories = {Travel: {name: 'Travel', enabled: true, maxAmountNoReceipt: 0}}; - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); - }); - - it('returns true for a corporate policy with areRulesEnabled undefined and a category with areCommentsRequired = true', () => { - const categories = {Meals: {name: 'Meals', enabled: true, areCommentsRequired: true}}; - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); - }); - - it('returns true for a corporate policy with areRulesEnabled undefined and a category with commentHint set', () => { - const categories = {Car: {name: 'Car', enabled: true, commentHint: 'Enter purpose'}}; - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); - }); - - it('returns false for a team policy with areRulesEnabled undefined', () => { - expect(arePolicyRulesEnabled({...teamBase, areRulesEnabled: undefined})).toBe(false); - }); - - it('returns true for a team policy with areRulesEnabled explicitly true', () => { - expect(arePolicyRulesEnabled({...teamBase, areRulesEnabled: true}, undefined)).toBe(true); - }); - - it('returns false for a team policy with areRulesEnabled explicitly false', () => { - expect(arePolicyRulesEnabled({...teamBase, areRulesEnabled: false})).toBe(false); - }); -}); - -describe('getDefaultChatEnabledPolicy', () => { - const submitPolicy = {...createRandomPolicy(1, CONST.POLICY.TYPE.SUBMIT), id: 'submit1'}; - const teamPolicy = {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), id: 'team1'}; - const corporatePolicy = {...createRandomPolicy(3, CONST.POLICY.TYPE.CORPORATE), id: 'corporate1'}; - const personalPolicy = {...createRandomPolicy(4, CONST.POLICY.TYPE.PERSONAL), id: 'personal1'}; - - it('returns the active policy when it is a Submit workspace, even with multiple eligible workspaces', () => { - // Regression: a Submit active policy must be recognized as the report-creation default. Previously - // isPaidGroupPolicy excluded it, returning undefined here and wrongly forcing the workspace selector. - expect(getDefaultChatEnabledPolicy([submitPolicy, teamPolicy], submitPolicy)).toBe(submitPolicy); - }); - - it('returns the active policy when it is a paid (Collect/Control) workspace', () => { - expect(getDefaultChatEnabledPolicy([teamPolicy, corporatePolicy], teamPolicy)).toBe(teamPolicy); - }); - - it('returns the only eligible workspace when the active policy is personal', () => { - expect(getDefaultChatEnabledPolicy([submitPolicy], personalPolicy)).toBe(submitPolicy); - }); - - it('returns undefined when the active policy is not a group policy and there are multiple eligible workspaces', () => { - expect(getDefaultChatEnabledPolicy([submitPolicy, teamPolicy], personalPolicy)).toBeUndefined(); - }); - - it('returns undefined when there are no eligible workspaces', () => { - expect(getDefaultChatEnabledPolicy([], undefined)).toBeUndefined(); - }); - it('does not return the active policy when it is not in the eligible list (e.g. Submit beta off), falling back to the eligible workspace', () => { - expect(getDefaultChatEnabledPolicy([teamPolicy], submitPolicy)).toBe(teamPolicy); - }); + describe('domains', () => { + describe('getPolicyIDFromDomainName', () => { + it('extracts the policy ID from an Expensify workspace-feed domain name', () => { + expect(getPolicyIDFromDomainName('expensify-policyA1B2C3.exfy')).toBe('A1B2C3'); + }); - it('returns undefined when the active policy is ineligible and there are multiple eligible workspaces', () => { - expect(getDefaultChatEnabledPolicy([teamPolicy, corporatePolicy], submitPolicy)).toBeUndefined(); - }); -}); + it('converts the extracted policy ID to uppercase', () => { + expect(getPolicyIDFromDomainName(`expensify-policy${'a1b2c3'}.exfy`)).toBe('A1B2C3'); + }); -describe('getDefaultChatEnabledPolicySelection', () => { - // createRandomPolicy randomizes these, so eligibility comes out flaky - const eligibleFields = {role: CONST.POLICY.ROLE.ADMIN, isJoinRequestPending: false, pendingAction: undefined, archivedDate: undefined}; - const teamPolicy = {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), ...eligibleFields, id: 'team1'}; - const corporatePolicy = {...createRandomPolicy(3, CONST.POLICY.TYPE.CORPORATE), ...eligibleFields, id: 'corporate1'}; - const collection = { - [`${ONYXKEYS.COLLECTION.POLICY}${teamPolicy.id}`]: teamPolicy, - [`${ONYXKEYS.COLLECTION.POLICY}${corporatePolicy.id}`]: corporatePolicy, - }; - - it('resolves the active policy from the collection by ID', () => { - expect(getDefaultChatEnabledPolicySelection(collection, undefined, corporatePolicy.id)).toEqual({ - defaultChatEnabledPolicyID: corporatePolicy.id, - hasMultipleChatEnabledPolicies: true, - }); - }); + it('returns undefined for a domain name that is not a workspace feed', () => { + expect(getPolicyIDFromDomainName('example.com')).toBeUndefined(); + }); - it('returns no default when the active policy is unknown and multiple workspaces are eligible', () => { - expect(getDefaultChatEnabledPolicySelection(collection, undefined, undefined)).toEqual({defaultChatEnabledPolicyID: undefined, hasMultipleChatEnabledPolicies: true}); - }); + it('returns undefined for an empty string', () => { + expect(getPolicyIDFromDomainName('')).toBeUndefined(); + }); - it('returns the only eligible workspace and reports a single workspace', () => { - expect(getDefaultChatEnabledPolicySelection({[`${ONYXKEYS.COLLECTION.POLICY}${teamPolicy.id}`]: teamPolicy}, undefined, undefined)).toEqual({ - defaultChatEnabledPolicyID: teamPolicy.id, - hasMultipleChatEnabledPolicies: false, + it('returns undefined when the extension is missing', () => { + expect(getPolicyIDFromDomainName('expensify-policyA1B2C3')).toBeUndefined(); + }); }); - }); -}); -describe('getPolicyIDFromDomainName', () => { - it('extracts the policy ID from an Expensify workspace-feed domain name', () => { - expect(getPolicyIDFromDomainName('expensify-policyA1B2C3.exfy')).toBe('A1B2C3'); - }); - - it('converts the extracted policy ID to uppercase', () => { - expect(getPolicyIDFromDomainName(`expensify-policy${'a1b2c3'}.exfy`)).toBe('A1B2C3'); - }); - - it('returns undefined for a domain name that is not a workspace feed', () => { - expect(getPolicyIDFromDomainName('example.com')).toBeUndefined(); - }); - - it('returns undefined for an empty string', () => { - expect(getPolicyIDFromDomainName('')).toBeUndefined(); - }); - - it('returns undefined when the extension is missing', () => { - expect(getPolicyIDFromDomainName('expensify-policyA1B2C3')).toBeUndefined(); - }); -}); + describe('getPolicyForAssignedCard', () => { + const policy: Policy = {...createRandomPolicy(0), id: 'A1B2C3', policyAccountID: 88801}; + const policies = {[`${ONYXKEYS.COLLECTION.POLICY}A1B2C3`]: policy}; -describe('getPolicyForAssignedCard', () => { - const policy: Policy = {...createRandomPolicy(0), id: 'A1B2C3', policyAccountID: 88801}; - const policies = {[`${ONYXKEYS.COLLECTION.POLICY}A1B2C3`]: policy}; + // The workspace is what decides whether the cardholder is shown the fix link or told to ask an admin, so a card + // has to find it whatever the company named their domain. + it('finds the workspace for a card on a domain that is not a workspace feed', () => { + const card = {domainName: 'acme-corp.com', fundID: '88801'}; - // The workspace is what decides whether the cardholder is shown the fix link or told to ask an admin, so a card - // has to find it whatever the company named their domain. - it('finds the workspace for a card on a domain that is not a workspace feed', () => { - const card = {domainName: 'acme-corp.com', fundID: '88801'}; - - expect(getPolicyForAssignedCard(card, policies)).toEqual(policy); - }); + expect(getPolicyForAssignedCard(card, policies)).toEqual(policy); + }); - it('finds the workspace for a card on a workspace-feed domain', () => { - const card = {domainName: 'expensify-policyA1B2C3.exfy', fundID: '88801'}; + it('finds the workspace for a card on a workspace-feed domain', () => { + const card = {domainName: 'expensify-policyA1B2C3.exfy', fundID: '88801'}; - expect(getPolicyForAssignedCard(card, policies)).toEqual(policy); - }); + expect(getPolicyForAssignedCard(card, policies)).toEqual(policy); + }); - it('falls back to the domain name for a card that has no fundID', () => { - const card = {domainName: 'expensify-policyA1B2C3.exfy'}; + it('falls back to the domain name for a card that has no fundID', () => { + const card = {domainName: 'expensify-policyA1B2C3.exfy'}; - expect(getPolicyForAssignedCard(card, policies)).toEqual(policy); - }); + expect(getPolicyForAssignedCard(card, policies)).toEqual(policy); + }); - it('returns undefined when no workspace matches the card', () => { - const card = {domainName: 'acme-corp.com', fundID: '99999'}; + it('returns undefined when no workspace matches the card', () => { + const card = {domainName: 'acme-corp.com', fundID: '99999'}; - expect(getPolicyForAssignedCard(card, policies)).toBeUndefined(); - }); + expect(getPolicyForAssignedCard(card, policies)).toBeUndefined(); + }); - it('returns undefined when there are no policies', () => { - const card = {domainName: 'acme-corp.com', fundID: '88801'}; + it('returns undefined when there are no policies', () => { + const card = {domainName: 'acme-corp.com', fundID: '88801'}; - expect(getPolicyForAssignedCard(card, {})).toBeUndefined(); + expect(getPolicyForAssignedCard(card, {})).toBeUndefined(); + }); + }); }); -}); -describe('getDefaultWorkspacePlanType', () => { - const submitPolicy = {...createRandomPolicy(1, CONST.POLICY.TYPE.SUBMIT), id: 'submit1'}; - const teamPolicy = {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), id: 'team1'}; - const corporatePolicy = {...createRandomPolicy(3, CONST.POLICY.TYPE.CORPORATE), id: 'corporate1'}; - const personalPolicy = {...createRandomPolicy(4, CONST.POLICY.TYPE.PERSONAL), id: 'personal1'}; - - it.each([ - {description: 'the collection is null', policies: null, expected: CONST.POLICY.TYPE.TEAM}, - {description: 'the collection is undefined', policies: undefined, expected: CONST.POLICY.TYPE.TEAM}, - {description: 'the collection is empty', policies: {}, expected: CONST.POLICY.TYPE.TEAM}, - {description: 'the collection only holds empty entries', policies: {policy_1: undefined, policy_2: undefined}, expected: CONST.POLICY.TYPE.TEAM}, - { - description: 'the collection holds Submit, Collect and Personal workspaces', - policies: {policy_submit1: submitPolicy, policy_team1: teamPolicy, policy_personal1: personalPolicy}, - expected: CONST.POLICY.TYPE.TEAM, - }, - {description: 'the collection holds a single Control workspace', policies: {policy_corporate1: corporatePolicy}, expected: CONST.POLICY.TYPE.CORPORATE}, - { - description: 'the collection holds a Control workspace mixed in with other plan types', - policies: {policy_submit1: submitPolicy, policy_team1: teamPolicy, policy_corporate1: corporatePolicy, policy_personal1: personalPolicy}, - expected: CONST.POLICY.TYPE.CORPORATE, - }, - { - description: 'the collection holds a Control workspace between empty entries', - policies: {policy_1: undefined, policy_corporate1: corporatePolicy, policy_2: undefined}, - expected: CONST.POLICY.TYPE.CORPORATE, - }, - ])('returns $expected when $description', ({policies, expected}) => { - expect(getDefaultWorkspacePlanType(policies)).toBe(expected); - }); -}); + describe('travel', () => { + describe('getTravelStep', () => { + const currentUserLogin = 'admin@acme.com'; + const travelPolicy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Travel workspace'), + owner: 'owner@acme.com', + role: CONST.POLICY.ROLE.ADMIN, + pendingAction: undefined, + employeeList: { + [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN}, + }, + }; + const travelPolicies: OnyxCollection = {[`${ONYXKEYS.COLLECTION.POLICY}${travelPolicy.id}`]: travelPolicy}; + const travelSettings: TravelSettings = {accountIDs: {}, hasAcceptedTerms: false}; + const pendingTravelSettings: TravelSettings = {...travelSettings, lastTravelSignupRequestTime: testDate}; -describe('hasActiveExpensifyCard', () => { - it('returns false when policy is undefined', () => { - expect(hasActiveExpensifyCard(undefined, 'cardholder@test.com')).toBe(false); - }); + it('returns the get started step when the workspace admins only use public email domains', () => { + // Given a paid workspace whose owner and admins are on a public email domain, and a pending signup request + const policy: Policy = { + ...travelPolicy, + owner: 'owner@gmail.com', + employeeList: {'admin@gmail.com': {email: 'admin@gmail.com', role: CONST.POLICY.ROLE.ADMIN}}, + }; - it('returns true when the backend flags the member as holding an active Expensify Card', () => { - const policy: Policy = { - ...createRandomPolicy(0), - employeeList: { - 'cardholder@test.com': {email: 'cardholder@test.com', hasActiveExpensifyCard: true}, - }, - }; - expect(hasActiveExpensifyCard(policy, 'cardholder@test.com')).toBe(true); - }); + // When getting the travel step + // Then the user starts from the beginning, since there is no private domain to provision travel for + expect(getTravelStep(policy, pendingTravelSettings, false, travelPolicies, currentUserLogin)).toBe(CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL); + }); - it('returns false when the flag is false or missing', () => { - const policy: Policy = { - ...createRandomPolicy(0), - employeeList: { - 'former-cardholder@test.com': {email: 'former-cardholder@test.com', hasActiveExpensifyCard: false}, - 'employee@test.com': {email: 'employee@test.com'}, - }, - }; - expect(hasActiveExpensifyCard(policy, 'former-cardholder@test.com')).toBe(false); - expect(hasActiveExpensifyCard(policy, 'employee@test.com')).toBe(false); - }); + it('returns the get started step when the user has no active paid workspaces', () => { + // Given a paid workspace with a private admin domain and a pending signup request, but no active workspaces for the user + // When getting the travel step + // Then the user starts from the beginning, since travel needs an active paid workspace + expect(getTravelStep(travelPolicy, pendingTravelSettings, false, {}, currentUserLogin)).toBe(CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL); + }); - it('returns false when the member is not in the employeeList', () => { - const policy: Policy = { - ...createRandomPolicy(0), - employeeList: { - 'cardholder@test.com': {email: 'cardholder@test.com', hasActiveExpensifyCard: true}, - }, - }; - expect(hasActiveExpensifyCard(policy, 'someone-else@test.com')).toBe(false); - }); + it('returns the get started step when the workspace is not a paid group workspace', () => { + // Given a personal workspace and a pending signup request, even though the user has another active paid workspace + const policy: Policy = {...travelPolicy, type: CONST.POLICY.TYPE.PERSONAL}; - it('returns true when the secondary login is checked and the backend flags its paired primary login', () => { - const policy: Policy = { - ...createRandomPolicy(0), - primaryLoginsInvited: {'secondary@test.com': 'primary@test.com'}, - employeeList: { - 'secondary@test.com': {email: 'secondary@test.com'}, - 'primary@test.com': {email: 'primary@test.com', hasActiveExpensifyCard: true}, - }, - }; - expect(hasActiveExpensifyCard(policy, 'secondary@test.com')).toBe(true); - }); -}); + // When getting the travel step + // Then the user starts from the beginning, since travel needs a paid group workspace + expect(getTravelStep(policy, pendingTravelSettings, false, travelPolicies, currentUserLogin)).toBe(CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL); + }); -describe('isMemberInHomeAndOfficeWorkspace', () => { - it('only allows members of home and office workspaces', () => { - // Given a workspace whose employee list contains one member - const memberLogin = 'member@example.com'; - const policy = createMock({ - commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE}, - employeeList: {[memberLogin]: {email: memberLogin}}, - }); - const otherWorkspace = {...policy, commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE}}; - - // When workspace mode and membership are checked - const isEligibleMember = isMemberInHomeAndOfficeWorkspace(policy, memberLogin); - const isEligibleForOtherWorkspaceMode = isMemberInHomeAndOfficeWorkspace(otherWorkspace, memberLogin); - const isEligibleForUnknownMember = isMemberInHomeAndOfficeWorkspace(policy, 'unknown@example.com'); - - // Then only the known member in a home and office workspace is eligible - expect(isEligibleMember).toBe(true); - expect(isEligibleForOtherWorkspaceMode).toBe(false); - expect(isEligibleForUnknownMember).toBe(false); - }); -}); + it('returns the book or manage step when the workspace has accepted the travel terms', () => { + // Given a paid workspace whose admin accepted the travel terms, and a pending signup request + const policy: Policy = {...travelPolicy, travelSettings: {hasAcceptedTerms: true}}; -describe('shouldHideDynamicExternalWorkflowPeople', () => { - it('returns false when the policy is undefined', () => { - expect(shouldHideDynamicExternalWorkflowPeople(undefined)).toBe(false); - }); + // When getting the travel step + // Then the user can book or manage trips right away, since accepted terms take priority over the request + expect(getTravelStep(policy, pendingTravelSettings, false, travelPolicies, currentUserLogin)).toBe(CONST.TRAVEL.STEPS.BOOK_OR_MANAGE_YOUR_TRIP); + }); - it('returns true for a Dynamic External Workflow policy with the flag set', () => { - const policy: Policy = {...createRandomPolicy(0), approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, dynamicExternalWorkflowHidePeople: true}; - expect(shouldHideDynamicExternalWorkflowPeople(policy)).toBe(true); - }); + it('returns the reviewing request step when a signup request is pending and the verified beta is off', () => { + // Given a paid workspace without accepted terms and a pending travel signup request + // When getting the travel step with the travel verified beta disabled + // Then the user sees that their request is being reviewed + expect(getTravelStep(travelPolicy, pendingTravelSettings, false, travelPolicies, currentUserLogin)).toBe(CONST.TRAVEL.STEPS.REVIEWING_REQUEST); + }); - // The backend only returns the flag when it is `true`, so an absent flag is the normal DEW case. - it('returns false for a Dynamic External Workflow policy without the flag', () => { - const policy: Policy = {...createRandomPolicy(0), approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL}; - expect(shouldHideDynamicExternalWorkflowPeople(policy)).toBe(false); - }); + it('returns the get started step for a pending signup request when the verified beta is on', () => { + // Given a paid workspace without accepted terms and a pending travel signup request + // When getting the travel step with the travel verified beta enabled + // Then the user starts from the beginning, since the beta skips the manual review step + expect(getTravelStep(travelPolicy, pendingTravelSettings, true, travelPolicies, currentUserLogin)).toBe(CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL); + }); - it('returns false when a stale flag is left on a policy that no longer uses a Dynamic External Workflow', () => { - const policy: Policy = {...createRandomPolicy(0), approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, dynamicExternalWorkflowHidePeople: true}; - expect(shouldHideDynamicExternalWorkflowPeople(policy)).toBe(false); + it('returns the get started step when there is no pending signup request', () => { + // Given a paid workspace without accepted terms and no signup request + // When getting the travel step + // Then the user starts from the beginning + expect(getTravelStep(travelPolicy, travelSettings, false, travelPolicies, currentUserLogin)).toBe(CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL); + }); + }); }); }); diff --git a/tests/unit/PolicyVendorActionTest.ts b/tests/unit/PolicyVendorActionTest.ts new file mode 100644 index 000000000000..bac101974290 --- /dev/null +++ b/tests/unit/PolicyVendorActionTest.ts @@ -0,0 +1,184 @@ +import {clearVendorErrors, openPolicyVendorsPage, setPolicyVendorsEnabled} from '@libs/actions/Policy/Vendor'; +import {read, write} from '@libs/API'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import Log from '@libs/Log'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PolicyVendors} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +jest.mock('@libs/API'); +jest.mock('@expensify/react-native-hybrid-app', () => ({ + __esModule: true, + default: {isHybridApp: () => false}, +})); + +const mockRead = jest.mocked(read); +const mockWrite = jest.mocked(write); +const policyID = 'test_policy_1'; +const policyVendorsKey = `${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}` as const; + +describe('PolicyVendorAction', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('setPolicyVendorsEnabled', () => { + it('does nothing when vendorIDs is empty', () => { + setPolicyVendorsEnabled({ + policyID, + vendorIDs: [], + enabled: true, + }); + + expect(mockWrite).not.toHaveBeenCalled(); + }); + + it('dispatches SetPolicyVendorsEnabled with optimistic, success, and failure data', () => { + const initialPolicyVendors: PolicyVendors = { + vendor1: { + externalID: 'vendor1', + name: 'Acme Corp', + enabled: false, + origin: CONST.POLICY.CONNECTIONS.NAME.QBO, + }, + vendor2: { + externalID: 'vendor2', + name: 'Beta LLC', + enabled: false, + origin: CONST.POLICY.CONNECTIONS.NAME.QBO, + }, + }; + + setPolicyVendorsEnabled({ + policyID, + vendorIDs: ['vendor1', 'vendor2'], + enabled: true, + policyVendors: initialPolicyVendors, + }); + + expect(mockWrite).toHaveBeenCalledTimes(1); + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.SET_POLICY_VENDORS_ENABLED, + { + policyID, + vendorIDs: JSON.stringify(['vendor1', 'vendor2']), + enabled: true, + }, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyVendorsKey, + value: { + vendor1: expect.objectContaining({ + enabled: true, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errors: null, + }), + vendor2: expect.objectContaining({ + enabled: true, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errors: null, + }), + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyVendorsKey, + value: { + vendor1: expect.objectContaining({ + enabled: true, + pendingAction: null, + pendingFields: { + enabled: null, + }, + errors: null, + }), + vendor2: expect.objectContaining({ + enabled: true, + pendingAction: null, + pendingFields: { + enabled: null, + }, + errors: null, + }), + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyVendorsKey, + value: { + vendor1: expect.objectContaining({ + pendingAction: null, + pendingFields: { + enabled: null, + }, + errors: expect.anything() as unknown, + }), + vendor2: expect.objectContaining({ + pendingAction: null, + pendingFields: { + enabled: null, + }, + errors: expect.anything() as unknown, + }), + }, + }, + ], + }, + ); + }); + }); + + describe('clearVendorErrors', () => { + it('merges null errors and pending fields to Onyx', () => { + const onyxMergeSpy = jest.spyOn(Onyx, 'merge'); + + clearVendorErrors(policyID, 'vendor1'); + + expect(onyxMergeSpy).toHaveBeenCalledWith(policyVendorsKey, { + vendor1: { + errors: null, + pendingAction: null, + pendingFields: null, + errorFields: null, + }, + }); + + onyxMergeSpy.mockRestore(); + }); + }); + + describe('openPolicyVendorsPage', () => { + it('dispatches OpenPolicyVendorsPage read command with policyID', () => { + openPolicyVendorsPage(policyID); + + expect(mockRead).toHaveBeenCalledTimes(1); + expect(mockRead).toHaveBeenCalledWith(READ_COMMANDS.OPEN_POLICY_VENDORS_PAGE, {policyID}); + }); + + it('logs a warning and does not call read when policyID is empty', () => { + const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {}); + + openPolicyVendorsPage(''); + + expect(logWarnSpy).toHaveBeenCalledWith('openPolicyVendorsPage invalid params', {policyID: ''}); + expect(mockRead).not.toHaveBeenCalled(); + + logWarnSpy.mockRestore(); + }); + }); +}); diff --git a/tests/unit/PopoverWithMeasuredContentPositionTest.tsx b/tests/unit/PopoverWithMeasuredContentPositionTest.tsx new file mode 100644 index 000000000000..82b4969b2f44 --- /dev/null +++ b/tests/unit/PopoverWithMeasuredContentPositionTest.tsx @@ -0,0 +1,121 @@ +import {render} from '@testing-library/react-native'; + +import type {PopoverAnchorPosition} from '@components/Modal/types'; +import PopoverWithMeasuredContentBase from '@components/PopoverWithMeasuredContent/PopoverWithMeasuredContentBase'; +import Text from '@components/Text'; + +import CONST from '@src/CONST'; + +import type {ReactNode} from 'react'; +import type {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; + +import React, {createRef} from 'react'; + +const WINDOW_HEIGHT = 600; +const POPOVER_HEIGHT = 400; +const POPOVER_WIDTH = 300; +const WINDOW_MARGIN = CONST.MODAL.POPOVER_MENU_PADDING; + +let mockAnchorPosition: PopoverAnchorPosition | undefined; + +jest.mock('@hooks/useWindowDimensions', () => () => ({windowWidth: 1000, windowHeight: 600})); + +jest.mock('@components/Popover', () => { + const MockPopover = ({anchorPosition, children}: {anchorPosition: PopoverAnchorPosition; children: ReactNode}) => { + mockAnchorPosition = anchorPosition; + return children; + }; + return MockPopover; +}); + +/** Renders the popover with static dimensions and returns the anchor position it passes to `Popover` */ +function renderPopoverAt(vertical: number, verticalAlignment: ValueOf, windowMargin?: number): PopoverAnchorPosition | undefined { + mockAnchorPosition = undefined; + render( + {}} + anchorRef={createRef()} + anchorPosition={{horizontal: 0, vertical}} + anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: verticalAlignment}} + popoverDimensions={{height: POPOVER_HEIGHT, width: POPOVER_WIDTH}} + windowMargin={windowMargin} + > + content + , + ); + return mockAnchorPosition; +} + +describe('PopoverWithMeasuredContentBase vertical positioning', () => { + describe('BOTTOM alignment (popover opens above the anchor)', () => { + it('pins the popover bottom edge to the anchor when the popover fits above it', () => { + // Given an anchor 450px down a 600px window, which leaves room for the 400px popover above it + const anchorY = 450; + + // When the popover opens above the anchor + const position = renderPopoverAt(anchorY, CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM); + + // Then its bottom edge sits on the anchor, so it ends right above the cell instead of covering it + expect(position?.bottom).toBe(WINDOW_HEIGHT - anchorY); + }); + + it('caps the popover so its top edge stays inside the window when it does not fit above the anchor', () => { + // Given an anchor with only 300px of space above it, where pinning the bottom edge would push the top 100px off-screen + const anchorY = 300; + + // When the popover opens above the anchor + const position = renderPopoverAt(anchorY, CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM); + + // Then it moves down just enough to keep its top edge on-screen, because a clipped top would hide content + expect(position?.bottom).toBe(WINDOW_HEIGHT - POPOVER_HEIGHT); + }); + + it('keeps `windowMargin` of breathing room from the window edge when clamped', () => { + // Given an anchor with too little space above it, so the popover has to be clamped inside the window + const anchorY = 300; + + // When the popover opens above the anchor with a window margin + const position = renderPopoverAt(anchorY, CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, WINDOW_MARGIN); + + // Then it keeps that margin from the top edge, matching the gap the shrinking pickers leave + expect(position?.bottom).toBe(WINDOW_HEIGHT - POPOVER_HEIGHT - WINDOW_MARGIN); + }); + + it('does not add breathing room when the popover already fits above the anchor', () => { + // Given an anchor with enough space above it for the whole popover + const anchorY = 450; + + // When the popover opens above the anchor with a window margin + const position = renderPopoverAt(anchorY, CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, WINDOW_MARGIN); + + // Then it stays pinned to the anchor, because the margin only applies when the popover is clamped + expect(position?.bottom).toBe(WINDOW_HEIGHT - anchorY); + }); + }); + + describe('TOP alignment (popover opens below the anchor)', () => { + it('places the popover top edge at the anchor when it fits below', () => { + // Given an anchor 100px down, which leaves room for the whole popover below it + const anchorY = 100; + + // When the popover opens below the anchor + const position = renderPopoverAt(anchorY, CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP); + + // Then its top edge sits on the anchor, so it starts right below the cell + expect(position?.top).toBe(anchorY); + }); + + it('keeps `windowMargin` of breathing room from the bottom edge when clamped', () => { + // Given an anchor 500px down, so the 400px popover has to be pulled back up to fit inside the window + const anchorY = 500; + + // When the popover opens below the anchor with a window margin + const position = renderPopoverAt(anchorY, CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, WINDOW_MARGIN); + + // Then it keeps that margin from the bottom edge instead of sitting flush against it + expect(position?.top).toBe(WINDOW_HEIGHT - POPOVER_HEIGHT - WINDOW_MARGIN); + }); + }); +}); diff --git a/tests/unit/ProactiveAppReviewModalManagerTest.tsx b/tests/unit/ProactiveAppReviewModalManagerTest.tsx new file mode 100644 index 000000000000..884a44a1b87b --- /dev/null +++ b/tests/unit/ProactiveAppReviewModalManagerTest.tsx @@ -0,0 +1,222 @@ +import {act, render} from '@testing-library/react-native'; + +import ProactiveAppReviewModalManager from '@components/ProactiveAppReviewModalManager'; + +import {KEYS_TO_PRESERVE_ON_SIGN_OUT} from '@libs/actions/SignInRedirect'; +import * as API from '@libs/API'; +import DateUtils from '@libs/DateUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +type ModalProps = { + isVisible: boolean; + onPositive: () => void; + onNegative: () => void; + onSkip: () => void; +}; + +const modal: {props?: ModalProps} = {}; + +/** Read through a call so the assertions are not narrowed by the resets between renders. */ +function getModalProps(): ModalProps | undefined { + return modal.props; +} + +jest.mock('@components/ProactiveAppReviewModal', () => { + return (props: ModalProps) => { + modal.props = props; + return null; + }; +}); +jest.mock('@libs/actions/StoreReview'); +jest.mock('@libs/API'); +// The prompt renders before the personal details list hydrates, so the hook still returns the +// CONST.DEFAULT_NUMBER_ID placeholder when the user answers. That is the state the reported loop +// happens in, so the tests must run against it rather than a conveniently populated account. +jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({accountID: 0, email: undefined})); + +const mockAPI = jest.mocked(API); + +const ACCOUNT_ID = 1; +const FORTY_DAYS_AGO = DateUtils.getDBTime(Date.now() - 40 * 24 * 60 * 60 * 1000); + +/** The authoritative nvp_appReview an account in the #99564 state receives on every full app load. */ +const STALE_SERVER_NVP = {trigger: 'submit', response: 'negative', lastPrompt: FORTY_DAYS_AGO} as const; + +function renderManager() { + return render(); +} + +describe('ProactiveAppReviewModalManager', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + return waitForBatchedUpdates(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + modal.props = undefined; + // Emulate the real API layer: apply optimisticData to Onyx, then a 200 with empty onyxData. + // @ts-expect-error test mock + mockAPI.write.mockImplementation((command, params, onyxData) => { + if (!onyxData?.optimisticData) { + return; + } + Onyx.update(onyxData.optimisticData); + }); + return Onyx.clear() + .then(() => Onyx.merge(ONYXKEYS.SESSION, {accountID: ACCOUNT_ID, email: 'test@expensify.com'})) + .then(waitForBatchedUpdates); + }); + + it('stays closed after a negative response when the authoritative NVP comes back without lastPrompt', async () => { + // Given the server asked us to prompt + await Onyx.merge(ONYXKEYS.NVP_APP_REVIEW, {trigger: 'submit'}); + await waitForBatchedUpdates(); + + renderManager(); + await waitForBatchedUpdates(); + + expect(getModalProps()?.isVisible).toBe(true); + + // When the user taps "Not really." + await act(async () => { + getModalProps()?.onNegative(); + await waitForBatchedUpdates(); + }); + + expect(getModalProps()?.isVisible).toBe(false); + + // And when the authoritative nvp_appReview lands (jsonCode 200, response persisted, no lastPrompt) + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_APP_REVIEW, {trigger: 'submit', response: 'negative'}); + await waitForBatchedUpdates(); + }); + + // Then the modal must stay closed + expect(getModalProps()?.isVisible).toBe(false); + }); + + it('stays closed after skipping when the authoritative NVP comes back without lastPrompt', async () => { + await Onyx.merge(ONYXKEYS.NVP_APP_REVIEW, {trigger: 'submit'}); + await waitForBatchedUpdates(); + + renderManager(); + await waitForBatchedUpdates(); + + expect(getModalProps()?.isVisible).toBe(true); + + await act(async () => { + getModalProps()?.onSkip(); + await waitForBatchedUpdates(); + }); + + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_APP_REVIEW, {trigger: 'submit', response: 'skip'}); + await waitForBatchedUpdates(); + }); + + expect(getModalProps()?.isVisible).toBe(false); + }); + + it('does not re-open while the stale server NVP keeps landing during sign-in (#99564)', async () => { + // Given an account the server keeps describing as "prompted 40 days ago, answered negative". + // On sign-in that payload lands more than once while the rest of the account data streams in. + await Onyx.set(ONYXKEYS.NVP_APP_REVIEW, STALE_SERVER_NVP); + await waitForBatchedUpdates(); + + renderManager(); + await waitForBatchedUpdates(); + + expect(getModalProps()?.isVisible).toBe(true); + + await act(async () => { + getModalProps()?.onNegative(); + await waitForBatchedUpdates(); + }); + + expect(getModalProps()?.isVisible).toBe(false); + + // When the same authoritative payload lands again, overwriting the optimistic lastPrompt + for (let i = 0; i < 3; i++) { + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_APP_REVIEW, STALE_SERVER_NVP); + await waitForBatchedUpdates(); + }); + + // Then the modal must not come back + expect(getModalProps()?.isVisible).toBe(false); + } + }); + + it('stamps the prompt record with the signed-in account even before personal details hydrate (#99564)', async () => { + // Given an account that is eligible for the 30-day re-prompt and whose personal details have not loaded yet + await Onyx.set(ONYXKEYS.NVP_APP_REVIEW, STALE_SERVER_NVP); + await waitForBatchedUpdates(); + + renderManager(); + await waitForBatchedUpdates(); + + expect(getModalProps()?.isVisible).toBe(true); + + // When the user answers the prompt + await act(async () => { + getModalProps()?.onNegative(); + await waitForBatchedUpdates(); + }); + + // Then the record is scoped to the session's account, not the CONST.DEFAULT_NUMBER_ID placeholder, + // so the gate can still recognize it after the next sign-in + const record = await new Promise<{accountID: number; lastPrompt: string} | undefined>((resolve) => { + const connection = Onyx.connectWithoutView({ + key: ONYXKEYS.APP_REVIEW_LAST_PROMPT, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + expect(record?.accountID).toBe(ACCOUNT_ID); + }); + + it('does not re-open on the next sign-in after the user already answered on this device (#99564)', async () => { + await Onyx.set(ONYXKEYS.NVP_APP_REVIEW, STALE_SERVER_NVP); + await waitForBatchedUpdates(); + + const firstRender = renderManager(); + await waitForBatchedUpdates(); + + expect(getModalProps()?.isVisible).toBe(true); + + await act(async () => { + getModalProps()?.onNegative(); + await waitForBatchedUpdates(); + }); + + expect(getModalProps()?.isVisible).toBe(false); + firstRender.unmount(); + + // When the user signs out and signs back in, so the server's untouched copy is all that is + // left of nvp_appReview. Clear with the real sign-out list rather than a hand-written one: + // passing [APP_REVIEW_LAST_PROMPT] here would assert the gate while assuming the very thing + // under test, and would stay green if the key were dropped from the sign-out path. + await act(async () => { + await Onyx.clear(KEYS_TO_PRESERVE_ON_SIGN_OUT); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: ACCOUNT_ID, email: 'test@expensify.com'}); + await Onyx.set(ONYXKEYS.NVP_APP_REVIEW, STALE_SERVER_NVP); + await waitForBatchedUpdates(); + }); + + modal.props = undefined; + renderManager(); + await waitForBatchedUpdates(); + + // Then the prompt the user already answered must not come back + expect(getModalProps()?.isVisible).toBe(false); + }); +}); diff --git a/tests/unit/ReceiptUploadRetryHandlerTest.ts b/tests/unit/ReceiptUploadRetryHandlerTest.ts index b500b62903a7..7d7df2da6166 100644 --- a/tests/unit/ReceiptUploadRetryHandlerTest.ts +++ b/tests/unit/ReceiptUploadRetryHandlerTest.ts @@ -1,12 +1,13 @@ import retryReceiptUpload, {canRetryReceiptUpload} from '@libs/ReceiptUploadRetryHandler'; import buildReplaceReceiptRetryPayload from '@libs/ReceiptUploadRetryHandler/buildReplaceReceiptRetryPayload'; import buildRetryPayload, {canBuildRetryPayload} from '@libs/ReceiptUploadRetryHandler/buildRetryPayload'; +import buildTrackExpenseRetryPayload from '@libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload'; import resolveReceiptFile from '@libs/ReceiptUploadRetryHandler/resolveReceiptFile'; import type {ReceiptRetryContext} from '@libs/ReceiptUploadRetryHandler/types'; import {replaceReceipt} from '@userActions/IOU/Receipt'; import type {ReplaceReceiptRetryParams} from '@userActions/IOU/Receipt'; -import {requestMoney} from '@userActions/IOU/TrackExpense'; +import {requestMoney, trackExpense} from '@userActions/IOU/TrackExpense'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -21,7 +22,7 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; jest.mock('@libs/ReceiptUploadRetryHandler/resolveReceiptFile', () => ({__esModule: true, default: jest.fn()})); jest.mock('@userActions/IOU/Receipt', () => ({...jest.requireActual>('@userActions/IOU/Receipt'), replaceReceipt: jest.fn()})); -jest.mock('@userActions/IOU/TrackExpense', () => ({...jest.requireActual>('@userActions/IOU/TrackExpense'), requestMoney: jest.fn()})); +jest.mock('@userActions/IOU/TrackExpense', () => ({...jest.requireActual>('@userActions/IOU/TrackExpense'), requestMoney: jest.fn(), trackExpense: jest.fn()})); const CURRENT_USER_ACCOUNT_ID = 1; const OTHER_ACCOUNT_ID = 2; @@ -31,6 +32,7 @@ const CHAT_REPORT_ID = '9000000000000001'; const POLICY_ID = 'A0000000000000001'; const IOU_ACTION_ID = '6000000000000001'; const THREAD_REPORT_ID = '5000000000000001'; +const SELF_DM_REPORT_ID = '2000000000000001'; const receiptFile: FileObject = {name: 'receipt.jpg', type: 'image/jpeg', uri: 'file:///receipts/receipt.jpg'}; @@ -76,6 +78,7 @@ function buildContext(transaction: Transaction, receiptErrorOverrides: Partial = {}, +): ReceiptRetryContext { + // A tracked expense in the self-DM is unreported, and its amount is stored negated. + const transaction = buildFailedTransaction({reportID: CONST.REPORT.UNREPORTED_REPORT_ID, amount: -1200}); + return {...buildContext(transaction, {action: CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE, ...receiptErrorOverrides}), iouReport}; +} + describe('buildRetryPayload', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -190,14 +202,14 @@ describe('buildRetryPayload', () => { expect(canRetry).toBe(false); }); - it('offers no retry for a trackExpense failure, whose convert-and-submit path the handler does not replay', () => { + it('does not rebuild a trackExpense failure as a RequestMoney call, because track has its own builder', () => { // Given a failed TrackExpense const context = buildContext(buildFailedTransaction(), {action: CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE}); // When the RequestMoney builder is asked whether it can rebuild it const canRetry = canBuildRetryPayload(context); - // Then it refuses, because the track flow is not a RequestMoney call + // Then it refuses, because track has its own builder expect(canRetry).toBe(false); }); @@ -358,4 +370,94 @@ describe('buildRetryPayload', () => { expect(jest.mocked(replaceReceipt).mock.invocationCallOrder.at(0)).toBeLessThan(clearReceiptError.mock.invocationCallOrder.at(0) ?? 0); }); }); + + describe('trackExpense retry', () => { + beforeEach(() => { + jest.mocked(resolveReceiptFile).mockResolvedValue(receiptFile); + jest.mocked(trackExpense).mockReset(); + jest.mocked(requestMoney).mockReset(); + }); + + it('offers a retry for a failed track in the self-DM', () => { + // Given a scan tracked in the self-DM whose upload failed + const context = buildTrackExpenseContext(); + + // When the receipt view asks whether it can retry + const canRetry = canRetryReceiptUpload(context); + + // Then Try again is offered + expect(canRetry).toBe(true); + }); + + it('offers no retry for a track outside the self-DM, because a retry there would also have to leave the report totals alone', () => { + // Given a track failure whose expense sits on a workspace expense report + const context = buildTrackExpenseContext({reportID: IOU_REPORT_ID, chatReportID: CHAT_REPORT_ID, policyID: POLICY_ID, type: CONST.REPORT.TYPE.EXPENSE} as Report); + + // When the receipt view asks whether it can retry + const canRetry = canRetryReceiptUpload(context); + + // Then only Save is offered + expect(canRetry).toBe(false); + }); + + it('reuses the failed transaction, IOU action and thread, so the retry does not create a second tracked expense', async () => { + // Given a failed track in the self-DM, whose thread still holds the CREATED action of the first attempt + const threadCreatedActionID = '1000000000000001'; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${THREAD_REPORT_ID}`, { + [threadCreatedActionID]: {reportActionID: threadCreatedActionID, actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, created: '2026-09-01 00:00:00.000'}, + }); + await waitForBatchedUpdates(); + const context = buildTrackExpenseContext(); + + // When the retry payload is rebuilt + const payload = buildTrackExpenseRetryPayload(context, receiptFile); + + // Then it points at the records the failed attempt created, and passes the amount positive as trackExpense expects + expect(payload?.optimisticTransactionID).toBe(TRANSACTION_ID); + expect(payload?.currentReportActionID).toBe(IOU_ACTION_ID); + expect(payload?.existingTransactionThreadReportID).toBe(THREAD_REPORT_ID); + expect(payload?.existingTransactionThreadCreatedReportActionID).toBe(threadCreatedActionID); + expect(payload?.report?.reportID).toBe(SELF_DM_REPORT_ID); + expect(payload?.transactionParams.amount).toBe(1200); + }); + + it('reuses the whisper ID saved with the receipt error, so the retry does not add a second whisper', () => { + // Given a failed track whose receipt error saved the ID of the whisper the failure deleted + const whisperReportActionID = '1100000000000001'; + const context = buildTrackExpenseContext(undefined, {retryParams: JSON.stringify({actionableWhisperReportActionID: whisperReportActionID})}); + + // When the retry payload is rebuilt + const payload = buildTrackExpenseRetryPayload(context, receiptFile); + + // Then the whisper is rebuilt under that ID, matching the copy the server may already have + expect(payload?.existingActionableWhisperReportActionID).toBe(whisperReportActionID); + }); + + it('still offers a retry for a receipt error saved before the whisper ID was stored, and creates a new whisper', () => { + // Given a failed track whose receipt error has no retry params, as errors saved before this change do + const context = buildTrackExpenseContext(); + + // When the retry payload is rebuilt + const payload = buildTrackExpenseRetryPayload(context, receiptFile); + + // Then the retry still works and falls back to a new whisper ID + expect(payload).toBeDefined(); + expect(payload?.existingActionableWhisperReportActionID).toBeUndefined(); + }); + + it('dispatches trackExpense as a retry and clears the error after it', async () => { + // Given a failed track in the self-DM + const clearReceiptError = jest.fn(() => Promise.resolve()); + + // When Try again is pressed + const outcome = await retryReceiptUpload(buildTrackExpenseContext(), clearReceiptError); + + // Then trackExpense is sent once as a silent retry, not requestMoney, and the error is cleared only after it + expect(outcome).toBe('dispatched'); + expect(jest.mocked(trackExpense)).toHaveBeenCalledTimes(1); + expect(jest.mocked(trackExpense)).toHaveBeenCalledWith(expect.objectContaining({isRetry: true, shouldPlaySound: false})); + expect(jest.mocked(requestMoney)).not.toHaveBeenCalled(); + expect(jest.mocked(trackExpense).mock.invocationCallOrder.at(0)).toBeLessThan(clearReceiptError.mock.invocationCallOrder.at(0) ?? 0); + }); + }); }); diff --git a/tests/unit/RecruitingUtilsTest.ts b/tests/unit/RecruitingUtilsTest.ts index 58efa26abab6..2c6abd4dee2f 100644 --- a/tests/unit/RecruitingUtilsTest.ts +++ b/tests/unit/RecruitingUtilsTest.ts @@ -3,6 +3,7 @@ import { getConnectedATSProvider, getMergeATSApprovalMode, getMergeATSApproverField, + getMergeATSApproverFields, getMergeATSFilterLabel, getMergeATSFilterOptions, isAnyRecruitingConnected, @@ -36,6 +37,7 @@ const {TAGS, STAGES, OFFICES} = CONST.MERGE.ATS_FILTER_TYPE; const POLICY_ID = 'ABC123'; const GREENHOUSE: MergeATSProviderSlug = 'greenhouse'; +const ASHBY: MergeATSProviderSlug = 'ashby'; const STUB_ICON: IconAsset = {uri: 'stub'}; const APPROVER_LOGIN = 'approver@test.com'; const ERROR_TIMESTAMP = 123; @@ -441,6 +443,40 @@ describe('RecruitingUtils', () => { }); }); + describe('getMergeATSApproverFields', () => { + it('offers only the recruiter and recruiting coordinator for Greenhouse', () => { + // Given a policy connected to Greenhouse, which has no hiring manager field + const policy = makeMergeATSPolicy({config: {integration: GREENHOUSE}}); + + // When the approver fields are read + // Then only the fields Greenhouse supports are offered + expect(getMergeATSApproverFields(policy)).toEqual([CONST.MERGE.ATS_APPROVER_FIELD.RECRUITER, CONST.MERGE.ATS_APPROVER_FIELD.RECRUITING_COORDINATOR]); + }); + + it('also offers the hiring manager for Ashby', () => { + // Given a policy connected to Ashby, which exposes a hiring manager field + const policy = makeMergeATSPolicy({config: {integration: ASHBY}}); + + // When the approver fields are read + // Then the hiring manager is offered between the recruiter and the recruiting coordinator + expect(getMergeATSApproverFields(policy)).toEqual([ + CONST.MERGE.ATS_APPROVER_FIELD.RECRUITER, + CONST.MERGE.ATS_APPROVER_FIELD.HIRING_MANAGER, + CONST.MERGE.ATS_APPROVER_FIELD.RECRUITING_COORDINATOR, + ]); + }); + + it('offers nothing when the provider is unknown', () => { + // Given a connection whose integration slug we can't resolve, since the backend sets it + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- deliberately simulating a slug we cannot resolve + const policy = makeMergeATSPolicy({config: {integration: undefined as unknown as MergeATSProviderSlug}}); + + // When the approver fields are read + // Then no fields are offered + expect(getMergeATSApproverFields(policy)).toEqual([]); + }); + }); + describe('shouldShowRecruitingConnectionError', () => { it('returns false when the user is not an admin', () => { const policy = makeMergeATSPolicy({ @@ -695,15 +731,6 @@ describe('getRecruitingCards', () => { ); }); - it('falls back to "not set" for the approver when basic mode has none', () => { - // Given a connection in basic mode with no final approver chosen - // When the default approver row is built - // Then the missing approver is called out rather than left blank - expect(getDefaultApproverTitle({config: {approvalMode: CONST.MERGE.APPROVAL_MODE.BASIC, finalApprover: null}})).toBe( - 'workspace.merge.approvalModes.basic • workspace.merge.notSet', - ); - }); - it('resolves the approver login to their display name', () => { // Given a final approver who is a known workspace member // When the default approver row is built @@ -738,6 +765,24 @@ describe('getRecruitingCards', () => { ).toBe(`workspace.merge.approvalModes.advanced • workspace.recruiting.approverFields.recruitingCoordinator -> ${APPROVER_LOGIN}`); }); + it('translates the hiring manager field on the Ashby card in advanced mode', () => { + // Given an Ashby connection in advanced mode reading the approver from the hiring manager field + const policy = makeMergeATSPolicy({ + config: { + integration: ASHBY, + approvalMode: CONST.MERGE.APPROVAL_MODE.ADVANCED, + approverField: CONST.MERGE.ATS_APPROVER_FIELD.HIRING_MANAGER, + finalApprover: APPROVER_LOGIN, + }, + }); + + // When the Ashby card is built + const card = getRecruitingCards(makeGetRecruitingCardsParams({policy})).find((c) => c.key === `merge_ats_${ASHBY}`); + + // Then its default approver row names the hiring manager field + expect(getRow(card, 'approvalMode')?.title).toBe(`workspace.merge.approvalModes.advanced • workspace.recruiting.approverFields.hiringManager -> ${APPROVER_LOGIN}`); + }); + it('reads "not set" for the ATS field when advanced mode has none', () => { // Given a connection in advanced mode with no approver field chosen // When the default approver row is built diff --git a/tests/unit/RegisterPrefetchOnAppStartTest.ts b/tests/unit/RegisterPrefetchOnAppStartTest.ts new file mode 100644 index 000000000000..425b9985b08e --- /dev/null +++ b/tests/unit/RegisterPrefetchOnAppStartTest.ts @@ -0,0 +1,57 @@ +import registerPrefetchOnAppStart from '@libs/Prefetch/registerPrefetchOnAppStart'; + +import {prefetchOnAppStart} from 'react-native-nitro-fetch'; + +const URL = 'https://www.expensify.com.dev/api/ReconnectApp?'; +const PREFETCH_KEY = 'ReconnectApp:1'; + +type ReactNativeFormDataPart = {fieldName: string; string: string}; + +/** Jest's FormData has no getParts, so this mimics the RN FormData the app sends on native */ +function createReactNativeFormData(parts: ReactNativeFormDataPart[]): FormData { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return {append: jest.fn(), getParts: () => parts} as unknown as FormData; +} + +function getSavedPrefetchBody(): RequestInit['body'] { + const [, fetchParams] = jest.mocked(prefetchOnAppStart).mock.calls.at(0) ?? []; + return fetchParams?.body; +} + +describe('registerPrefetchOnAppStart', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('saves the prefetch body without appversion and keeps the other fields', () => { + // Given a ReconnectApp body with appversion, as built by enhanceParameters + const body = createReactNativeFormData([ + {fieldName: 'authToken', string: 'token'}, + {fieldName: 'appversion', string: '9.4.89-0'}, + {fieldName: 'clientUpdateID', string: '123'}, + ]); + + // When the request is registered for the next app start + registerPrefetchOnAppStart({prefetchKey: PREFETCH_KEY, command: 'ReconnectApp', url: URL, fetchParams: {method: 'POST', body}}); + + // Then the saved body has no appversion, so a newer version replaying it after an update isn't sent to the server as the old one + const savedBody = getSavedPrefetchBody(); + if (!(savedBody instanceof FormData)) { + throw new Error('Expected the saved prefetch body to be FormData'); + } + expect(savedBody.has('appversion')).toBe(false); + expect(savedBody.get('authToken')).toBe('token'); + expect(savedBody.get('clientUpdateID')).toBe('123'); + }); + + it('saves a body that is not RN FormData unchanged', () => { + // Given a body that is a plain string, which has no parts to filter + const body = 'appversion=9.4.89-0'; + + // When the request is registered for the next app start + registerPrefetchOnAppStart({prefetchKey: PREFETCH_KEY, command: 'ReconnectApp', url: URL, fetchParams: {method: 'POST', body}}); + + // Then the body is saved as is, because only RN FormData bodies are rebuilt + expect(getSavedPrefetchBody()).toBe(body); + }); +}); diff --git a/tests/unit/RemoveSearchRowFieldsFromTransactionsTest.ts b/tests/unit/RemoveSearchRowFieldsFromTransactionsTest.ts new file mode 100644 index 000000000000..a253ef3ac3e4 --- /dev/null +++ b/tests/unit/RemoveSearchRowFieldsFromTransactionsTest.ts @@ -0,0 +1,90 @@ +import RemoveSearchRowFieldsFromTransactions from '@libs/migrations/RemoveSearchRowFieldsFromTransactions'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const baseTransaction: Transaction = { + transactionID: '1', + reportID: '100', + amount: -1000, + currency: CONST.CURRENCY.USD, + merchant: 'Original merchant', + modifiedMerchant: 'Edited merchant', + created: '2026-01-01', +}; + +describe('RemoveSearchRowFieldsFromTransactions', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); + beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); + + it('removes Search row display fields leaked into a cached transaction', async () => { + // Given a transaction cached after it was moved from Search, which saved the row's display fields on it + const leakedTransaction = { + ...baseTransaction, + formattedMerchant: 'Original merchant', + formattedFrom: 'Rory', + formattedTo: 'Carlos', + formattedTotal: 1000, + date: '2026-01-01', + keyForList: '1', + action: CONST.SEARCH.ACTION_TYPES.VIEW, + from: {accountID: 1}, + to: {accountID: 2}, + } as Transaction; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}1`, leakedTransaction); + + // When the migration runs at startup + await RemoveSearchRowFieldsFromTransactions(); + await waitForBatchedUpdates(); + + // Then the display fields are gone, so the edited merchant is shown again, and the real fields are untouched + const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}1`); + expect(transaction).toEqual(baseTransaction); + }); + + it('cleans a cached transaction by its Onyx key even when it has no transactionID', async () => { + // Given a partial cached entry that holds leaked Search row fields but no transactionID + const partialEntry = {formattedMerchant: 'Original merchant', modifiedMerchant: 'Edited merchant'}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}2`, partialEntry); + + // When the migration runs + await RemoveSearchRowFieldsFromTransactions(); + await waitForBatchedUpdates(); + + // Then the leaked field is removed through the entry's key and the real field is kept + const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}2`); + expect(transaction).not.toHaveProperty('formattedMerchant'); + expect(transaction?.modifiedMerchant).toBe('Edited merchant'); + }); + + it('still resolves when the cleanup write fails, so startup is never blocked', async () => { + // Given a transaction with leaked Search row fields and a storage write that fails + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}1`, {...baseTransaction, formattedMerchant: 'Original merchant'} as Transaction); + const mergeCollectionSpy = jest.spyOn(Onyx, 'mergeCollection').mockRejectedValueOnce(new Error('Storage failure')); + + // When the migration runs at startup + // Then it resolves anyway, since the app only continues loading once every migration settles + await expect(RemoveSearchRowFieldsFromTransactions()).resolves.toBeUndefined(); + expect(mergeCollectionSpy).toHaveBeenCalledTimes(1); + mergeCollectionSpy.mockRestore(); + }); + + it('leaves clean transactions alone', async () => { + // Given a transaction without any Search row fields + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}1`, baseTransaction); + + // When the migration runs + await RemoveSearchRowFieldsFromTransactions(); + await waitForBatchedUpdates(); + + // Then the transaction is unchanged + const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}1`); + expect(transaction).toEqual(baseTransaction); + }); +}); diff --git a/tests/unit/RenderHTMLTest.tsx b/tests/unit/RenderHTMLTest.tsx index 5e0b71da0221..e37836b9248a 100644 --- a/tests/unit/RenderHTMLTest.tsx +++ b/tests/unit/RenderHTMLTest.tsx @@ -7,12 +7,31 @@ import React from 'react'; import {View} from 'react-native'; jest.mock('@hooks/useWindowDimensions', () => () => ({windowWidth: 400})); +const mockInheritedDefaultTextProps = {selectable: false, textBreakStrategy: 'simple'}; +const mockInheritedDefaultViewProps = {testID: 'html-view'}; +let mockConfigProps: + | { + defaultTextProps?: {selectable?: boolean; textBreakStrategy?: string}; + defaultViewProps?: {testID?: string}; + enableExperimentalBRCollapsing?: boolean; + renderers?: Record; + renderersProps?: {a?: {onPress?: unknown}}; + } + | undefined; jest.mock('react-native-render-html', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const {View: MockView} = require('react-native'); return { - RenderHTMLConfigProvider: ({children}: {children: React.ReactNode}) => children, + RenderHTMLConfigProvider: ({children, ...configProps}: {children: React.ReactNode}) => { + mockConfigProps = configProps; + return children; + }, RenderHTMLSource: () => , + useSharedProps: () => ({ + defaultTextProps: mockInheritedDefaultTextProps, + defaultViewProps: mockInheritedDefaultViewProps, + enableExperimentalBRCollapsing: true, + }), }; }); @@ -20,6 +39,11 @@ const mockUseHasTextAncestor = jest.fn(() => false); jest.mock('@hooks/useHasTextAncestor', () => () => mockUseHasTextAncestor()); describe('RenderHTML', () => { + beforeEach(() => { + mockConfigProps = undefined; + mockUseHasTextAncestor.mockReturnValue(false); + }); + it('throws when rendered inside a Text ancestor', () => { mockUseHasTextAncestor.mockReturnValue(true); expect(() => @@ -41,4 +65,38 @@ describe('RenderHTML', () => { ), ).not.toThrow(); }); + + it('enables selection while preserving inherited HTML configuration', () => { + render( + , + ); + + expect(mockConfigProps).toEqual( + expect.objectContaining({ + defaultTextProps: expect.objectContaining({selectable: true, textBreakStrategy: 'simple'}), + defaultViewProps: mockInheritedDefaultViewProps, + enableExperimentalBRCollapsing: true, + }), + ); + expect(mockConfigProps?.renderers).toHaveProperty('mention-user'); + expect(mockConfigProps?.renderers).toHaveProperty('emoji'); + }); + + it('preserves the existing renderer path when handling link presses', () => { + const onLinkPress = jest.fn(); + + render( + , + ); + + expect(mockConfigProps?.renderersProps?.a?.onPress).toBe(onLinkPress); + expect(mockConfigProps?.renderers).not.toHaveProperty('a'); + expect(mockConfigProps?.renderers).toHaveProperty('bullet-item'); + }); }); diff --git a/tests/unit/ReplaceOptimisticAgentAccountIDTest.ts b/tests/unit/ReplaceOptimisticAgentAccountIDTest.ts index 9bfd1a981955..dcbd410fe381 100644 --- a/tests/unit/ReplaceOptimisticAgentAccountIDTest.ts +++ b/tests/unit/ReplaceOptimisticAgentAccountIDTest.ts @@ -88,9 +88,9 @@ describe('ReplaceOptimisticAgentAccountID middleware', () => { data: { firstName: 'Concierge', optimisticAccountID: String(optimisticAccountID), - agentPromptKey: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, + agentPromptKey: `${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, nvpUpdates: { - [`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`]: {prompt: 'Book my flights'}, + [`${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`]: {prompt: 'Book my flights'}, }, }, requestIndex, @@ -102,9 +102,9 @@ describe('ReplaceOptimisticAgentAccountID middleware', () => { const persistedData = getAll().at(0)?.data; expect(persistedData?.optimisticAccountID).toBe(String(realAccountID)); - expect(persistedData?.agentPromptKey).toBe(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${realAccountID}`); + expect(persistedData?.agentPromptKey).toBe(`${ONYXKEYS.COLLECTION.AGENT}${realAccountID}`); expect(persistedData?.nvpUpdates).toStrictEqual({ - [`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${realAccountID}`]: {prompt: 'Book my flights'}, + [`${ONYXKEYS.COLLECTION.AGENT}${realAccountID}`]: {prompt: 'Book my flights'}, }); expect(persistedData?.firstName).toBe('Concierge'); }); @@ -115,11 +115,11 @@ describe('ReplaceOptimisticAgentAccountID middleware', () => { command: 'UpdateAgentPrompt', data: {agentAccountID: optimisticAccountID, prompt: 'Book my flights', apiRequestType: 'write'}, successData: [ - {onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, value: {pendingAction: null, promptErrors: null}}, - {onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${otherAgentAccountID}`, value: {pendingAction: null}}, + {onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, value: {pendingAction: null, promptErrors: null}}, + {onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.AGENT}${otherAgentAccountID}`, value: {pendingAction: null}}, ], failureData: [ - {onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, value: {prompt: 'Old prompt', pendingAction: null}}, + {onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, value: {prompt: 'Old prompt', pendingAction: null}}, {onyxMethod: 'merge', key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: {[optimisticAccountID]: {displayName: 'Concierge'}}}, ], requestIndex, @@ -132,11 +132,11 @@ describe('ReplaceOptimisticAgentAccountID middleware', () => { const persistedRequest = getAll().at(0); expect(persistedRequest?.data?.agentAccountID).toBe(realAccountID); expect(persistedRequest?.successData).toStrictEqual([ - {onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${realAccountID}`, value: {pendingAction: null, promptErrors: null}}, - {onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${otherAgentAccountID}`, value: {pendingAction: null}}, + {onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.AGENT}${realAccountID}`, value: {pendingAction: null, promptErrors: null}}, + {onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.AGENT}${otherAgentAccountID}`, value: {pendingAction: null}}, ]); expect(persistedRequest?.failureData).toStrictEqual([ - {onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${realAccountID}`, value: {prompt: 'Old prompt', pendingAction: null}}, + {onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.AGENT}${realAccountID}`, value: {prompt: 'Old prompt', pendingAction: null}}, {onyxMethod: 'merge', key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: {[realAccountID]: {displayName: 'Concierge'}}}, ]); }); @@ -247,10 +247,10 @@ describe('ReplaceOptimisticAgentAccountID middleware', () => { page: 1, offset: 0, prompt: '', - agentPromptKey: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${digitHeavyAccountID}`, + agentPromptKey: `${ONYXKEYS.COLLECTION.AGENT}${digitHeavyAccountID}`, apiRequestType: 'write', }, - successData: [{onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${digitHeavyAccountID}`, value: {pendingAction: null, prompt: ''}}], + successData: [{onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.AGENT}${digitHeavyAccountID}`, value: {pendingAction: null, prompt: ''}}], requestIndex, }; } diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index aef87b402583..f87de12ce49b 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -20,6 +20,7 @@ import Onyx from 'react-native-onyx'; import type {CompanyAddressOriginalMessage, UpdateACHAccountOriginalMessage} from '../../src/libs/ReportActionsUtils'; import type {Card, DecisionName, PersonalDetails, PersonalDetailsList, Report, ReportAction, ReportActions} from '../../src/types/onyx'; import type {OriginalMessageExportIntegration} from '../../src/types/onyx/OriginalMessage'; +import type {CompanyAddress} from '../../src/types/onyx/Policy'; import type {ReportCollectionDataSet} from '../../src/types/onyx/Report'; import type {ReportActionsCollectionDataSet} from '../../src/types/onyx/ReportAction'; @@ -29,6 +30,7 @@ import CONST from '../../src/CONST'; import * as ReportActionsUtils from '../../src/libs/ReportActionsUtils'; import { getAddedCardFeedMessage, + getAddedOfficeLocationMessage, getAssignedCompanyCardMessage, getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, @@ -41,6 +43,8 @@ import { getCurrencyConversionFeeMessage, getCurrencyDefaultTaxUpdateMessage, getCustomTaxNameUpdateMessage, + getDeletedOfficeLocationMessage, + getUpdatedEmployeeOfficeLocationMessage, getForeignCurrencyDefaultTaxUpdateMessage, getHumanAgentAccountIDFromReportAction, getHumanAgentFirstName, @@ -73,6 +77,7 @@ import { getUpdatedMemberWorkArrangementMessage, getUpdatedCardFeedLiabilityMessage, getUpdatedCardFeedStatementPeriodMessage, + getUpdatedOfficeLocationMessage, hasNextActionMadeBySameActor, hasReasoning, isConsecutiveActionMadeByPreviousActor, @@ -99,6 +104,8 @@ type TakeControlOriginalMessageFixture = NonNullable; +type AddCustomUnitRateAction = ReportAction; + type LegacyReportActionFields = { message?: string; originalMessage?: string; @@ -1781,7 +1788,134 @@ describe('ReportActionsUtils', () => { }); }); + describe('getWorkspaceCustomUnitRateAddedMessage', () => { + function buildAddCustomUnitRateAction(originalMessage: AddCustomUnitRateAction['originalMessage']): AddCustomUnitRateAction { + return { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CUSTOM_UNIT_RATE, + reportActionID: 'add-custom-unit-rate-1', + created: '2026-09-01 12:00:00.000', + message: [{type: CONST.REPORT.MESSAGE.TYPE.COMMENT, text: '', html: ''}], + originalMessage, + }; + } + + const fuelBandRateMessage = { + customUnitName: CONST.CUSTOM_UNITS.NAME_DISTANCE, + rateName: 'Cars & Vans (Petrol/Hybrid) - Up to 1400cc', + rate: 55, + currency: CONST.CURRENCY.GBP, + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + startDate: '2026-09-01', + }; + + it('should append the reclaimable tax amount and tax rate name when the log has tax details', () => { + // Given an added rate log for a UK fuel-band rate, whose reclaimable share is the 14p fuel rate out of the 55p rate + const action = buildAddCustomUnitRateAction({...fuelBandRateMessage, taxClaimablePercentage: 14 / 55, taxRateName: 'Standard VAT (20%)'}); + + // When building the message + const message = ReportActionsUtils.getWorkspaceCustomUnitRateAddedMessage(translateLocal, undefined, action); + + // Then it should show the reclaimable amount per mile, rate × taxClaimablePercentage, under the tax rate name + expect(message).toBe( + 'added "Cars & Vans (Petrol/Hybrid) - Up to 1400cc" rate of £0.55/mile, valid from September 1, 2026, with £0.14/mile tax reclaimable under "Standard VAT (20%)"', + ); + }); + + it('should show a zero reclaimable amount when the reclaimable share is zero', () => { + // Given an added rate log for a fuel band with no reclaimable fuel cost + const action = buildAddCustomUnitRateAction({ + ...fuelBandRateMessage, + rateName: 'Cars & Vans (Electric) - Home charged', + taxClaimablePercentage: 0, + taxRateName: 'Standard VAT (20%)', + }); + + // When building the message + const message = ReportActionsUtils.getWorkspaceCustomUnitRateAddedMessage(translateLocal, undefined, action); + + // Then the tax details should still be shown, because the log carries them + expect(message).toBe( + 'added "Cars & Vans (Electric) - Home charged" rate of £0.55/mile, valid from September 1, 2026, with £0.00/mile tax reclaimable under "Standard VAT (20%)"', + ); + }); + + it('should not append tax details when the log has no tax rate name', () => { + // Given an added rate log with a reclaimable share but no tax rate name + const action = buildAddCustomUnitRateAction({...fuelBandRateMessage, taxClaimablePercentage: 14 / 55}); + + // When building the message + const message = ReportActionsUtils.getWorkspaceCustomUnitRateAddedMessage(translateLocal, undefined, action); + + // Then it should render the same text as a log without tax details + expect(message).toBe('added "Cars & Vans (Petrol/Hybrid) - Up to 1400cc" rate of £0.55/mile, valid from September 1, 2026'); + }); + }); + describe('getMessageOfOldDotReportAction', () => { + it('should return the removed field message when a field with a value is cleared', () => { + // Given a field change whose new value is empty while the old value was set, which is what clearing a text field sends + const action: Parameters[1] = { + reportActionID: '1', + created: '2024-01-01 00:00:00.000', + actionName: CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD, + originalMessage: {fieldName: 'Project', oldValue: 'Apollo', newValue: ''}, + }; + + // When the message is built + const message = ReportActionsUtils.getMessageOfOldDotReportAction(translateLocal, action); + + // Then it says the field was removed and shows the old value, instead of `changed Project to ""` + expect(message).toBe('removed Project (previously "Apollo")'); + }); + + it('should return the cleared field message when a field without a saved value is cleared', () => { + // Given a field change where both values are empty, which is what clearing a field that only showed its default can send + const action: Parameters[1] = { + reportActionID: '1', + created: '2024-01-01 00:00:00.000', + actionName: CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD, + originalMessage: {fieldName: 'Project', oldValue: '', newValue: ''}, + }; + + // When the message is built + const message = ReportActionsUtils.getMessageOfOldDotReportAction(translateLocal, action); + + // Then it says the field was removed, instead of `set Project to ""` + expect(message).toBe('removed Project'); + }); + + it('should return the set field message when a field without a value is filled in', () => { + // Given a field change whose old value is empty + const action: Parameters[1] = { + reportActionID: '1', + created: '2024-01-01 00:00:00.000', + actionName: CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD, + originalMessage: {fieldName: 'Project', oldValue: '', newValue: 'Apollo'}, + }; + + // When the message is built + const message = ReportActionsUtils.getMessageOfOldDotReportAction(translateLocal, action); + + // Then it says the field was set, because there is no previous value to show + expect(message).toBe('set Project to "Apollo"'); + }); + + it('should return the changed field message when a field value is replaced', () => { + // Given a field change where both the old and the new value are set + const action: Parameters[1] = { + reportActionID: '1', + created: '2024-01-01 00:00:00.000', + actionName: CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD, + originalMessage: {fieldName: 'Project', oldValue: 'Apollo', newValue: 'Gemini'}, + }; + + // When the message is built + const message = ReportActionsUtils.getMessageOfOldDotReportAction(translateLocal, action); + + // Then it shows both the new and the previous value + expect(message).toBe('changed Project to "Gemini" (previously "Apollo")'); + }); + it('should return the ACH bounce message with return reason when provided', () => { const returnReason = 'R03 - No Account/Unable to Locate Account'; const action: Parameters[1] = { @@ -2202,6 +2336,23 @@ describe('ReportActionsUtils', () => { expect(ReportActionsUtils.isDeletedAction(reportAction)).toBe(false); }); + it('should return false for POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_DISPLAY_NAME action with an empty message html', () => { + // Given the display name whisper as the backend sends it after a reaction, with an empty message html + const reportAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_DISPLAY_NAME, + reportActionID: '1', + created: '2026-10-13 12:00:03.573', + message: [{html: '', text: '', type: CONST.REPORT.MESSAGE.TYPE.COMMENT, whisperedTo: [590]}], + originalMessage: {oldValue: 'cathy@boulderdev.com', newValue: 'Cathy Miller', whisperedTo: [590]}, + }; + + // When checking whether it is deleted + const isDeleted = ReportActionsUtils.isDeletedAction(reportAction); + + // Then it is not, because its text comes from originalMessage + expect(isDeleted).toBe(false); + }); + it('should return false for CARDFROZEN action with a backend-provided message fragment', () => { const reportAction: ReportAction = { actionName: CONST.REPORT.ACTIONS.TYPE.CARD_FROZEN, @@ -2908,6 +3059,139 @@ describe('ReportActionsUtils', () => { expect(actual).toBe(true); }); + it('should return false for MARKED_REIMBURSED with a sibling IOU PAY action in the passed collection when the report timeline is not loaded into Onyx', () => { + // Given a MARKED_REIMBURSED action with stale write-time flags on a report whose actions are not in the Onyx collection + const reportID = 'reportOnlyInSearchSnapshot'; + const markedReimbursedAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + reportActionID: '1', + reportID, + created: '2025-01-01 00:00:00', + message: [{type: 'TEXT', style: 'normal', text: 'Marked as reimbursed'}], + originalMessage: {}, + }; + // And a caller-provided collection (e.g. a Search snapshot) that contains a sibling IOU PAY action + const payAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + reportActionID: '2', + reportID, + created: '2025-01-01 00:00:01', + message: [{type: 'TEXT', style: 'normal', text: 'paid'}], + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUReportID: reportID, amount: 100, currency: CONST.CURRENCY.USD}, + } as ReportAction; + const snapshotReportActions = { + [markedReimbursedAction.reportActionID]: markedReimbursedAction, + [payAction.reportActionID]: payAction, + }; + + // When visibility is checked with the caller's collection + const actual = ReportActionsUtils.isReportActionVisible(markedReimbursedAction, reportID, true, undefined, undefined, snapshotReportActions); + + // Then the MARKED_REIMBURSED action should NOT be visible, because the sibling is found in the passed collection + expect(actual).toBe(false); + }); + + it('should return false for MARKED_REIMBURSED when Onyx only has the action and the passed collection has the sibling IOU PAY action', async () => { + // Given a partially loaded Onyx history that contains the MARKED_REIMBURSED action but no PAY action + const reportID = 'reportPartiallyLoadedInOnyx'; + const markedReimbursedAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + reportActionID: '1', + reportID, + created: '2025-01-01 00:00:00', + message: [{type: 'TEXT', style: 'normal', text: 'Marked as reimbursed'}], + originalMessage: {}, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [markedReimbursedAction.reportActionID]: markedReimbursedAction, + }); + // And a caller-provided collection (e.g. a Search snapshot) that contains the sibling IOU PAY action + const payAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + reportActionID: '2', + reportID, + created: '2025-01-01 00:00:01', + message: [{type: 'TEXT', style: 'normal', text: 'paid'}], + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUReportID: reportID, amount: 100, currency: CONST.CURRENCY.USD}, + } as ReportAction; + const snapshotReportActions = { + [markedReimbursedAction.reportActionID]: markedReimbursedAction, + [payAction.reportActionID]: payAction, + }; + + // When visibility is checked with the caller's collection + const actual = ReportActionsUtils.isReportActionVisible(markedReimbursedAction, reportID, true, undefined, undefined, snapshotReportActions); + + // Then the MARKED_REIMBURSED action should NOT be visible, because Onyx can't answer and the caller's collection holds the sibling + expect(actual).toBe(false); + }); + + it('should return true for MARKED_REIMBURSED when the loaded Onyx history separates the PAY action with a cancellation, even if the passed collection has the PAY action', async () => { + // Given a loaded Onyx history where a PAY was canceled before the manual MARKED_REIMBURSED action + const reportID = 'reportWithCanceledPayInOnyx'; + const payAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + reportActionID: '1', + reportID, + created: '2025-01-01 00:00:00', + message: [{type: 'TEXT', style: 'normal', text: 'paid'}], + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUReportID: reportID, amount: 100, currency: CONST.CURRENCY.USD}, + } as ReportAction; + const canceledAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED, + reportActionID: '2', + reportID, + created: '2025-01-01 00:00:10', + message: [{type: 'TEXT', style: 'normal', text: 'canceled payment'}], + originalMessage: {}, + } as ReportAction; + const markedReimbursedAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + reportActionID: '3', + reportID, + created: '2025-01-01 00:00:20', + message: [{type: 'TEXT', style: 'normal', text: 'Marked as reimbursed'}], + originalMessage: {}, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [payAction.reportActionID]: payAction, + [canceledAction.reportActionID]: canceledAction, + [markedReimbursedAction.reportActionID]: markedReimbursedAction, + }); + // And a filtered caller-provided collection that omits the cancellation + const snapshotReportActions = { + [payAction.reportActionID]: payAction, + [markedReimbursedAction.reportActionID]: markedReimbursedAction, + }; + + // When visibility is checked with the caller's collection + const actual = ReportActionsUtils.isReportActionVisible(markedReimbursedAction, reportID, true, undefined, undefined, snapshotReportActions); + + // Then the MARKED_REIMBURSED action should stay visible, because the complete Onyx history shows it belongs to a new payment attempt + expect(actual).toBe(true); + }); + + it('should return true for MARKED_REIMBURSED when the passed collection has no PAY action', () => { + // Given a MARKED_REIMBURSED action on a report whose actions are not in the Onyx collection + const reportID = 'reportOnlyInSearchSnapshotWithoutPay'; + const markedReimbursedAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + reportActionID: '1', + reportID, + created: '2025-01-01 00:00:00', + message: [{type: 'TEXT', style: 'normal', text: 'Marked as reimbursed'}], + originalMessage: {}, + }; + + // When visibility is checked with a caller-provided collection that only contains the MARKED_REIMBURSED action + const actual = ReportActionsUtils.isReportActionVisible(markedReimbursedAction, reportID, true, undefined, undefined, { + [markedReimbursedAction.reportActionID]: markedReimbursedAction, + }); + + // Then the MARKED_REIMBURSED action should stay visible, because there is no PAY action to replace it + expect(actual).toBe(true); + }); + it('should return true for TAKE_CONTROL when automaticAction is false', () => { const reportAction = buildTakeControlActionFixture( { @@ -3090,6 +3374,23 @@ describe('ReportActionsUtils', () => { expect(ReportActionsUtils.shouldReportActionBeVisible(action, action.reportActionID, true)).toBe(true); }); + it('should keep a merged expense deleted action with an empty message visible', () => { + // Given the action the backend adds to the submitter's report when an admin merges their expenses, which has no message fragments + const action = createMock>({ + actionName: CONST.REPORT.ACTIONS.TYPE.MERGED_EXPENSE_DELETED, + reportActionID: '1', + message: [], + }); + + // When checking whether the action is deleted and whether it is visible + const isDeleted = ReportActionsUtils.isDeletedAction(action); + const isVisible = ReportActionsUtils.shouldReportActionBeVisible(action, action.reportActionID, true); + + // Then it is not treated as deleted and is shown in the report + expect(isDeleted).toBe(false); + expect(isVisible).toBe(true); + }); + it('should return false if the action is a hold or unhold action', () => { const action: ReportAction = { ...createRandomReportAction(0), @@ -4720,6 +5021,29 @@ describe('ReportActionsUtils', () => { ])('names both the new and the previous method for %s from %s', (newValue, oldValue, expected) => { expect(getUpdatedCommuterExclusionsMessage(translateLocal, buildMethodChangeAction(newValue, oldValue))).toBe(expected); }); + + it('formats the previous distance using its original unit after a unit change', () => { + // Given a unit-only commuter exclusion change from 5 miles to 5 kilometers + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_COMMUTER_EXCLUSIONS, + reportActionID: '1', + created: '', + originalMessage: { + updatedField: CONST.POLICY.COMMUTER_EXCLUSION_TYPE.FIXED_DISTANCE, + newValue: 5, + oldValue: 5, + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + oldUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + message: [], + } as ReportAction; + + // When NewDot formats the workspace audit action + const message = getUpdatedCommuterExclusionsMessage(translateLocal, action); + + // Then it preserves each value's own unit in the message + expect(message).toBe('changed fixed distance exclusion to 5.00 kilometers per claim (previously 5.00 miles)'); + }); }); describe('getPolicyWorkArrangementMessage', () => { @@ -4848,6 +5172,101 @@ describe('ReportActionsUtils', () => { }); }); + describe('office location change logs', () => { + const headquartersAddress: CompanyAddress = {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}; + const newJerseyAddress: CompanyAddress = {addressStreet: '900 Asbury Ave\nSuite 2', city: 'Ocean City', state: 'NJ', zipCode: '08226', country: 'US'}; + + it('names the added office and its address', () => { + // Given an office added to the workspace + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION, + reportActionID: '1', + created: '', + originalMessage: {officeID: 'OFFICE1', name: 'Headquarters', address: headquartersAddress, isDefault: true}, + }; + + // When the change log is rendered + // Then it reads the same as the message Auth stores for it + expect(getAddedOfficeLocationMessage(translateLocal, action)).toBe('added the office location "Headquarters" (88 Kearny St, San Francisco, CA 94108)'); + }); + + it.each([ + ['a rename', {updatedField: CONST.POLICY.OFFICE_LOCATION_FIELD.NAME, name: 'HQ', newValue: 'HQ', oldValue: 'Headquarters'}, 'renamed the office location "Headquarters" to "HQ"'], + [ + 'an address change', + {updatedField: CONST.POLICY.OFFICE_LOCATION_FIELD.ADDRESS, name: 'HQ', newAddress: newJerseyAddress, oldAddress: headquartersAddress}, + 'changed the address of the office location "HQ" to "900 Asbury Ave, Suite 2, Ocean City, NJ 08226" (previously "88 Kearny St, San Francisco, CA 94108")', + ], + ['a new primary office', {updatedField: CONST.POLICY.OFFICE_LOCATION_FIELD.IS_DEFAULT, name: 'HQ', newValue: true, oldValue: false}, 'set the office location "HQ" as primary'], + [ + 'the company address becoming primary', + {updatedField: CONST.POLICY.OFFICE_LOCATION_FIELD.IS_DEFAULT, name: 'HQ', newValue: false, oldValue: true}, + 'set the company address as primary', + ], + ])('describes %s', (_description, originalMessage, expected) => { + // Given an office update that changed a single field + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION, + reportActionID: '1', + created: '', + originalMessage: {officeID: 'OFFICE1', ...originalMessage}, + }; + + // When the change log is rendered + // Then it describes the field that changed, the same way Auth does + expect(getUpdatedOfficeLocationMessage(translateLocal, action)).toBe(expected); + }); + + it('names the deleted office', () => { + // Given an office deleted from the workspace + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION, + reportActionID: '1', + created: '', + originalMessage: {officeID: 'OFFICE2', name: 'New Jersey Office'}, + }; + + // When the change log is rendered + // Then it reads the same as the message Auth stores for it + expect(getDeletedOfficeLocationMessage(translateLocal, action)).toBe('removed the office location "New Jersey Office"'); + }); + + it.each([ + [ + 'an office assigned to a member who followed the primary office', + {name: 'Bob Smith', email: 'bob@smith.com', officeID: 'OFFICE2', officeName: 'New Jersey Office', oldOfficeID: '', oldOfficeName: ''}, + `changed Bob Smith's office location to "New Jersey Office" (previously the primary office)`, + ], + [ + 'a member moved from the company address to an office', + {name: 'Bob Smith', email: 'bob@smith.com', officeID: 'OFFICE1', officeName: 'Headquarters', oldOfficeID: CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, oldOfficeName: ''}, + `changed Bob Smith's office location to "Headquarters" (previously the company address)`, + ], + [ + 'a cleared assignment', + {name: 'Bob Smith', email: 'bob@smith.com', officeID: '', officeName: '', oldOfficeID: 'OFFICE1', oldOfficeName: 'Headquarters'}, + `changed Bob Smith's office location to the primary office (previously "Headquarters")`, + ], + [ + 'a member without a name moved off an office that was removed since', + {email: 'bob@smith.com', officeID: CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, officeName: '', oldOfficeID: 'OFFICE9', oldOfficeName: ''}, + `changed bob@smith.com's office location to the company address (previously the primary office)`, + ], + ])('describes %s', (_description, originalMessage, expected) => { + // Given a member whose office assignment changed + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION, + reportActionID: '1', + created: '', + originalMessage: {accountID: 1, ...originalMessage}, + }; + + // When the change log is rendered + // Then it reads the same as the message Auth stores for it + expect(getUpdatedEmployeeOfficeLocationMessage(translateLocal, action)).toBe(expected); + }); + }); + describe('getUpdatedCardFeedLiabilityMessage', () => { it('should return enabled message when liabilityType is ALLOW', () => { const action = { @@ -6041,6 +6460,13 @@ describe('ReportActionsUtils', () => { message: [], }) as ReportAction; + const buildFastACHAction = () => + buildReimbursedAction({ + paymentMethod: 'Fast_ACH', + creditBankAccountLast4: '1111', + expectedDate: '2025-03-15', + }); + it('shows the funding bank account from the masked accountNumber when debitBankAccountLast4 is absent', () => { // Given a reimbursed action carrying the raw masked accountNumber, as delivered by real-time Pusher updates const action = buildReimbursedAction({ @@ -6087,7 +6513,7 @@ describe('ReportActionsUtils', () => { creditedCurrency: 'USD', }); - const result = ReportActionsUtils.getReimbursedMessage(translateLocal, undefined, action, 2, undefined, undefined, convertToDisplayString); + const result = ReportActionsUtils.getReimbursedMessage(translateLocal, undefined, action, 2, undefined, undefined, convertToDisplayString, 2); // Then the message reports the credited amount instead of the report total and names both accounts expect(result).toBe(translateLocal('iou.reimbursedCrossBorder', {amount: '$80.50', debitBankAccount: '9999', creditBankAccount: '5678'})); @@ -6102,7 +6528,7 @@ describe('ReportActionsUtils', () => { creditedAmount: 8050, }); - const result = ReportActionsUtils.getReimbursedMessage(translateLocal, undefined, action, 2, undefined, undefined, convertToDisplayString); + const result = ReportActionsUtils.getReimbursedMessage(translateLocal, undefined, action, 2, undefined, undefined, convertToDisplayString, 2); // Then we describe the payment without an amount rather than guessing a currency expect(result).toBe( @@ -6124,7 +6550,7 @@ describe('ReportActionsUtils', () => { creditedCurrency: 'USD', }); - const result = ReportActionsUtils.getReimbursedMessage(translateLocal, undefined, action, 2, 'submitter@expensify.com', undefined, convertToDisplayString); + const result = ReportActionsUtils.getReimbursedMessage(translateLocal, undefined, action, 2, 'submitter@expensify.com', undefined, convertToDisplayString, 2); // Then the message announces the submitter taking the report off hold rather than the credited amount expect(result).toBe( @@ -6188,6 +6614,46 @@ describe('ReportActionsUtils', () => { const resultOtherUser = ReportActionsUtils.getReimbursedMessage(translateLocal, undefined, action, ownerAccountID, submitterLogin, undefined, convertToDisplayString, 999); expect(resultOtherUser).toContain(submitterLogin); }); + + it('names the submitter when the signed-in session owns the report but the current user does not', async () => { + // Given a signed-in session whose account owns the report + const ownerAccountID = 42; + const submitterLogin = 'submitter@example.com'; + const action = buildFastACHAction(); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: ownerAccountID}); + await waitForBatchedUpdates(); + + // When the message is built for a different current user + const result = ReportActionsUtils.getReimbursedMessage(translateLocal, undefined, action, ownerAccountID, submitterLogin, undefined, convertToDisplayString, 999); + + // Then it names the submitter, and matches what the same call produces with no session at all + expect(result).toContain(submitterLogin); + expect(result).not.toContain('your'); + + await Onyx.set(ONYXKEYS.SESSION, null); + await waitForBatchedUpdates(); + expect(ReportActionsUtils.getReimbursedMessage(translateLocal, undefined, action, ownerAccountID, submitterLogin, undefined, convertToDisplayString, 999)).toBe(result); + }); + + it('shows "your" wording when the report has no owner and the current user is unknown', () => { + // Given a report with no owner, so the submitter also resolves to the default account ID + const submitterLogin = 'submitter@example.com'; + + // When the current user is unknown and passed as the default account ID + const result = ReportActionsUtils.getReimbursedMessage( + translateLocal, + undefined, + buildFastACHAction(), + undefined, + submitterLogin, + undefined, + convertToDisplayString, + CONST.DEFAULT_NUMBER_ID, + ); + + // Then both sides resolve to the same ID and the message addresses the current user directly + expect(result).toContain('your'); + }); }); describe('getAutoReimbursementMessage', () => { @@ -7726,5 +8192,59 @@ describe('ReportActionsUtils', () => { it('returns undefined for an empty report', () => { expect(ReportActionsUtils.getLatestConciergeFeedbackActionID([], persisted([]))).toBeUndefined(); }); + + it('returns the newest comment of Concierge and of each custom agent', () => { + // Given a report where Concierge and two custom agents each wrote twice, and a human wrote last + const firstAgentAccountID = 1001; + const secondAgentAccountID = 1002; + const sorted = [ + conciergeComment('700', '2026-09-07 00:00:00.000', {actorAccountID: 12345}), + conciergeComment('600', '2026-09-06 00:00:00.000', {actorAccountID: firstAgentAccountID}), + conciergeComment('500', '2026-09-05 00:00:00.000'), + conciergeComment('400', '2026-09-04 00:00:00.000', {actorAccountID: secondAgentAccountID}), + conciergeComment('300', '2026-09-03 00:00:00.000', {actorAccountID: firstAgentAccountID}), + conciergeComment('200', '2026-09-02 00:00:00.000', {actorAccountID: secondAgentAccountID}), + conciergeComment('100', '2026-09-01 00:00:00.000'), + ]; + + // When the comments that show the prompt are picked + const actionIDs = ReportActionsUtils.getFeedbackPromptActionIDs(sorted, persisted(sorted), [firstAgentAccountID, secondAgentAccountID]); + + // Then each AI author gets one prompt on its newest comment, so an agent reply does not take the prompt away from Concierge, and the human gets none + expect([...actionIDs].sort()).toEqual(['400', '500', '600']); + }); + + it('hides the prompt of an agent whose newest comment is not in Onyx yet, and keeps the prompt of Concierge', () => { + // Given an agent comment that exists only on the client, above saved comments of Concierge and of the same agent + const agentAccountID = 1001; + const unsavedAgentComment = conciergeComment('300', '2026-09-03 00:00:00.000', {actorAccountID: agentAccountID}); + const concierge = conciergeComment('200', '2026-09-02 00:00:00.000'); + const olderAgentComment = conciergeComment('100', '2026-09-01 00:00:00.000', {actorAccountID: agentAccountID}); + const sorted = [unsavedAgentComment, concierge, olderAgentComment]; + + // When the comments that show the prompt are picked + const actionIDs = ReportActionsUtils.getFeedbackPromptActionIDs(sorted, persisted([concierge, olderAgentComment]), [agentAccountID]); + + // Then the older agent comment does not stand in for the unsaved one, which would ask the user to rate an older reply + expect(actionIDs).toEqual(['200']); + }); + + it('picks the newest comment of the given author out of a report actions collection', () => { + // Given a parent report where an agent wrote twice after Concierge + const agentAccountID = 1001; + const concierge = conciergeComment('100', '2026-09-01 00:00:00.000'); + const olderAgentComment = conciergeComment('200', '2026-09-02 00:00:00.000', {actorAccountID: agentAccountID}); + const newerAgentComment = conciergeComment('300', '2026-09-03 00:00:00.000', {actorAccountID: agentAccountID}); + const collection = { + [concierge.reportActionID]: concierge, + [olderAgentComment.reportActionID]: olderAgentComment, + [newerAgentComment.reportActionID]: newerAgentComment, + }; + + // When a thread checks the message it hangs off against the newest comment of that message's author + // Then each author gets its own newest comment, so the agent's comments do not hide Concierge's + expect(ReportActionsUtils.getLatestConciergeFeedbackActionIDFromReportActions(collection, agentAccountID)).toBe('300'); + expect(ReportActionsUtils.getLatestConciergeFeedbackActionIDFromReportActions(collection, CONST.ACCOUNT_ID.CONCIERGE)).toBe('100'); + }); }); }); diff --git a/tests/unit/ReportAlternateTextUtilsTest.ts b/tests/unit/ReportAlternateTextUtilsTest.ts index ba0a8fab9eff..4db2403eeb41 100644 --- a/tests/unit/ReportAlternateTextUtilsTest.ts +++ b/tests/unit/ReportAlternateTextUtilsTest.ts @@ -24,6 +24,10 @@ import { getRequiresCategoryMessage, getRequiresTagMessage, getPolicyWorkArrangementMessage, + getAddedOfficeLocationMessage, + getUpdatedOfficeLocationMessage, + getDeletedOfficeLocationMessage, + getUpdatedEmployeeOfficeLocationMessage, getUnassignedCompanyCardMessage, getUpdatedAutoHarvestingMessage, getUpdatedCardFeedLiabilityMessage, @@ -495,6 +499,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, lastAction: agentPromptUpdatedAction, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, rules: undefined, }); @@ -622,6 +627,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, lastAction: createdAction, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe('-$25.00 for Dinner'); @@ -691,6 +697,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, lastAction: createdAction, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe('-$45.00 for Visible comment'); @@ -745,6 +752,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, lastAction: createdAction, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe('-$25.00'); @@ -799,6 +807,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, lastAction: createdAction, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe('$0.00 for Zero amount'); @@ -853,6 +862,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, lastAction: createdAction, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe('-€25.00 for Lunch'); @@ -906,6 +916,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, lastAction: createdAction, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(''); @@ -947,6 +958,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, lastAction: createdAction, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(''); @@ -1014,6 +1026,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, lastAction: createdAction, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(''); @@ -1067,6 +1080,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, lastAction: createdAction, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe('-$25.00 for Missing currency'); @@ -1107,6 +1121,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); const {fromReportID, toReportID} = parseMovedTransactionReportIDs(movedTransactionAction); expect(lastMessage).toBe(Parser.htmlToText(getMovedTransactionMessage({translate: translateLocal, fromReportID, toReportID}))); @@ -1140,6 +1155,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(Parser.htmlToText(translate(CONST.LOCALES.EN, 'iou.automaticallySubmitted'))); }); @@ -1173,6 +1189,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(Parser.htmlToText(translate(CONST.LOCALES.EN, 'iou.automaticallyApproved'))); }); @@ -1208,6 +1225,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(translateLocal('iou.forwarded', memo)); }); @@ -1240,6 +1258,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(Parser.htmlToText(translate(CONST.LOCALES.EN, 'iou.automaticallyForwarded'))); }); @@ -1270,6 +1289,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(Parser.htmlToText(translate(CONST.LOCALES.EN, 'workspaceActions.forcedCorporateUpgrade'))); }); @@ -1299,6 +1319,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getCustomTaxNameUpdateMessage(translateLocal, action)); }); @@ -1327,6 +1348,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getCurrencyDefaultTaxUpdateMessage(translateLocal, action)); }); @@ -1360,6 +1382,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); // Then it should be built from the translation rather than the text the backend provided @@ -1389,6 +1412,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, conciergeReportID: undefined, + formatPhoneNumber, }); expect(lastMessage).toBe(getAddAgentRuleMessage(translateLocal, action)); }); @@ -1416,6 +1440,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, conciergeReportID: undefined, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getUpdateAgentRuleMessage(translateLocal, action)); }); @@ -1443,6 +1468,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, conciergeReportID: undefined, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getDeleteAgentRuleMessage(translateLocal, action)); }); @@ -1474,6 +1500,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getForeignCurrencyDefaultTaxUpdateMessage(translateLocal, action)); }); @@ -1502,6 +1529,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getRequireCompanyCardsEnabledMessage(translateLocal, action)); }); @@ -1530,6 +1558,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getRequiresCategoryMessage(translateLocal, action)); }); @@ -1561,6 +1590,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(expectedMessage); }); @@ -1592,11 +1622,63 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); // Then the preview reads the same as the in-chat change log rather than the raw stored text expect(lastMessage).toBe(getPolicyWorkArrangementMessage(translateLocal, action)); }); + it.each([ + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION, + {officeID: 'OFFICE1', name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}}, + getAddedOfficeLocationMessage, + ], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION, + {officeID: 'OFFICE1', name: 'HQ', updatedField: CONST.POLICY.OFFICE_LOCATION_FIELD.NAME, newValue: 'HQ', oldValue: 'Headquarters'}, + getUpdatedOfficeLocationMessage, + ], + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION, {officeID: 'OFFICE1', name: 'Headquarters'}, getDeletedOfficeLocationMessage], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION, + {accountID: 1, email: 'member@test.com', name: 'Member One', officeID: 'OFFICE2', officeName: 'New Jersey Office', oldOfficeID: '', oldOfficeName: ''}, + getUpdatedEmployeeOfficeLocationMessage, + ], + ] as const)('%s action', async (actionName, originalMessage, getMessage) => { + // Given an office location change log, whose stored text is Auth's English message + const report: Report = createRandomReport(0, undefined); + const action: ReportAction = { + ...createRandomReportAction(1), + actionName, + message: [{type: 'COMMENT', text: 'stored message'}], + originalMessage, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { + [action.reportActionID]: action, + }); + + // When the sidebar resolves the report's last message + const lastMessage = getLastMessageTextForReport({ + rules: undefined, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + personalDetails: undefined, + translate: translateLocal, + report, + lastActorDetails: null, + policy: undefined, + isReportArchived: false, + + currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, + }); + + // Then the preview reads the same translated text as the in-chat change log rather than the stored text + expect(lastMessage).toBe(getMessage(translateLocal, action)); + }); it('UPDATE_AUTO_HARVESTING action', async () => { const report: Report = createRandomReport(0, undefined); const action: ReportAction = { @@ -1622,9 +1704,44 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getUpdatedAutoHarvestingMessage(translateLocal, action)); }); + it('UPDATE_MEMBER_WORK_ARRANGEMENT action', () => { + const report: Report = createRandomReport(0, undefined); + const action: ReportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT, + message: [{type: 'COMMENT', text: ''}], + originalMessage: {name: 'Member One', oldValue: false, newValue: true}, + }; + + const lastMessage = getLastMessageTextForReport({ + rules: undefined, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + personalDetails: undefined, + translate: translateLocal, + report, + lastActorDetails: null, + policy: undefined, + isReportArchived: false, + lastAction: action, + currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, + }); + + expect(lastMessage).toBe( + translateLocal('workspaceActions.updatedMemberWorkArrangement', { + displayName: 'Member One', + newArrangement: translateLocal('workspace.people.officeBased'), + oldArrangement: translateLocal('workspace.people.noRegularWorkspace'), + }), + ); + }); it('UPDATE_CUSTOM_UNIT_RATE action', async () => { const report: Report = createRandomReport(0, undefined); const action: ReportAction = { @@ -1656,6 +1773,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getWorkspaceCustomUnitRateUpdatedMessage(translateLocal, undefined, action)); }); @@ -1684,6 +1802,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getRequiresTagMessage(translateLocal, action)); }); @@ -1711,6 +1830,7 @@ describe('ReportAlternateTextUtils', () => { policy: undefined, isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getAddedCardFeedMessage(translateLocal, action)); }); @@ -1738,6 +1858,7 @@ describe('ReportAlternateTextUtils', () => { policy: undefined, isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getRemovedCardFeedMessage(translateLocal, action)); }); @@ -1765,6 +1886,7 @@ describe('ReportAlternateTextUtils', () => { policy: undefined, isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getRenamedCardFeedMessage(translateLocal, action)); }); @@ -1796,6 +1918,7 @@ describe('ReportAlternateTextUtils', () => { policy: undefined, isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getAssignedCompanyCardMessage(translateLocal, action)); }); @@ -1827,6 +1950,7 @@ describe('ReportAlternateTextUtils', () => { policy: undefined, isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getUnassignedCompanyCardMessage(translateLocal, action)); }); @@ -1857,6 +1981,7 @@ describe('ReportAlternateTextUtils', () => { policy: undefined, isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getUpdatedCardFeedLiabilityMessage(translateLocal, action)); }); @@ -1888,6 +2013,7 @@ describe('ReportAlternateTextUtils', () => { policy: undefined, isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getUpdatedCardFeedStatementPeriodMessage(translateLocal, action)); }); @@ -1916,6 +2042,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(Parser.htmlToText(getChangedApproverActionMessage(translateLocal, takeControlAction))); }); @@ -1944,6 +2071,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(Parser.htmlToText(getChangedApproverActionMessage(translateLocal, rerouteAction))); }); @@ -1972,6 +2100,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(Parser.htmlToText(getMovedActionMessage(translateLocal, movedAction, report))); }); @@ -2004,6 +2133,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); // Then it should return the DYNAMIC_EXTERNAL_WORKFLOW_ROUTED message @@ -2038,6 +2168,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(result).toBe(expectedVisibleText); }); @@ -2090,6 +2221,8 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, lastAction: addCommentAction, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, }); // Then the mention should fall back to the hidden placeholder @@ -2117,6 +2250,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(translateLocal('report.noActivityYet')); }); @@ -2154,6 +2288,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); const transactions = [scannedTransaction]; const scanningTransactions = transactions.filter((transaction) => isScanning(transaction)); @@ -2204,6 +2339,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(result).toBe(''); }); @@ -2256,6 +2392,7 @@ describe('ReportAlternateTextUtils', () => { reportMetadata, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.queuedToSubmitViaDEW')); }); @@ -2296,6 +2433,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(customErrorMessage); }); @@ -2333,6 +2471,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.error.genericCreateFailureMessage')); }); @@ -2378,6 +2517,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: true, currentUserLogin: '', + formatPhoneNumber, }); expect(lastMessage).toBe(translateLocal('reportArchiveReasons.policyDeleted', {policyName: policy.name})); @@ -2422,6 +2562,7 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: true, currentUserLogin: '', + formatPhoneNumber, }); expect(lastMessage).toBe(translateLocal('reportArchiveReasons.removedFromPolicy', {displayName: 'Hidden', policyName: policy.name})); @@ -2462,6 +2603,8 @@ describe('ReportAlternateTextUtils', () => { isReportArchived: true, currentUserLogin: '', + formatPhoneNumber, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, }); expect(lastMessage).toContain('UnavailableMarker'); @@ -2499,6 +2642,7 @@ describe('ReportAlternateTextUtils', () => { personalDetails: undefined, isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe('changed the "Office Supplies" category default tax rate to "Tax Rate 1 (5%)" (previously "Tax Exempt (0%)")'); @@ -2535,6 +2679,7 @@ describe('ReportAlternateTextUtils', () => { policy: undefined, isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe('changed the default spend category for "Airlines" to "Travel" (previously "Insurance")'); @@ -2571,6 +2716,7 @@ describe('ReportAlternateTextUtils', () => { policy: undefined, isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getOverLimitForwardsToUpdateMessage(translateLocal, action, convertToDisplayString)); }); @@ -2606,6 +2752,7 @@ describe('ReportAlternateTextUtils', () => { policy: undefined, isReportArchived: false, currentUserLogin: CURRENT_USER_LOGIN, + formatPhoneNumber, }); expect(lastMessage).toBe(getApprovalLimitUpdateMessage(translateLocal, action, convertToDisplayString)); }); diff --git a/tests/unit/ReportFetchHandlerTest.tsx b/tests/unit/ReportFetchHandlerTest.tsx index 6372602d5bd3..7b96e63efda2 100644 --- a/tests/unit/ReportFetchHandlerTest.tsx +++ b/tests/unit/ReportFetchHandlerTest.tsx @@ -120,6 +120,75 @@ describe('ReportFetchHandler', () => { expect(mockOpenReport).not.toHaveBeenCalled(); }); + it.each([false, true])('keeps an optimistic support ticket pending without fetching it when offline is %s', async (isOffline) => { + // Given a support ticket that can render locally but has not been created on the server + mockIsOffline = isOffline; + setRouteParams({reportID: REPORT_ID, isPendingCreation: 'true'}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}); + await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, {isCreatingSupportTicket: true}); + await waitForBatchedUpdates(); + + // When the customer opens the pending ticket + renderHandler(); + await waitForBatchedUpdates(); + + // Then the client neither requests a nonexistent report nor releases the pending route + expect(mockOpenReport).not.toHaveBeenCalled(); + expect(mockSetParams).not.toHaveBeenCalledWith({isPendingCreation: undefined}); + }); + + it('resumes fetching after the server creates an optimistic support ticket', async () => { + // Given a mounted support ticket waiting for creation + setRouteParams({reportID: REPORT_ID, isPendingCreation: 'true'}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}); + await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, {isCreatingSupportTicket: true}); + await waitForBatchedUpdates(); + const {rerender} = renderHandler(); + await waitForBatchedUpdates(); + expect(mockOpenReport).not.toHaveBeenCalled(); + expect(mockSetParams).not.toHaveBeenCalledWith({isPendingCreation: undefined}); + + // When creation succeeds, the pending route can be released + await Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, {isCreatingSupportTicket: false}); + await waitForBatchedUpdates(); + expect(mockSetParams).toHaveBeenCalledWith({isPendingCreation: undefined}); + setRouteParams({reportID: REPORT_ID}); + rerender(); + await waitForBatchedUpdates(); + + // Then fetching the server-created report is safe + expect(mockOpenReport).toHaveBeenCalledWith(expect.objectContaining({reportID: REPORT_ID})); + }); + + it.each([false, true])('recovers a pending support ticket after reloading while offline is %s', async (isOffline) => { + // Given a pending ticket whose creation request belongs to the current app session + mockIsOffline = isOffline; + setRouteParams({reportID: REPORT_ID, isPendingCreation: 'true'}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}); + await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, {isCreatingSupportTicket: true}); + await waitForBatchedUpdates(); + const {unmount} = renderHandler(); + await waitForBatchedUpdates(); + expect(mockOpenReport).not.toHaveBeenCalled(); + + // When the app reloads, the report, URL, and stale persisted metadata survive. + // RAM-only state and the in-flight request do not. + unmount(); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${REPORT_ID}`, {isOptimisticReport: true}); + await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, null); + await waitForBatchedUpdates(); + const {rerender} = renderHandler(); + await waitForBatchedUpdates(); + + // Then the stale route is released and the report can resolve to server content or not-found once online + expect(mockSetParams).toHaveBeenCalledWith({isPendingCreation: undefined}); + mockIsOffline = false; + setRouteParams({reportID: REPORT_ID}); + rerender(); + await waitForBatchedUpdates(); + expect(mockOpenReport).toHaveBeenCalledWith(expect.objectContaining({reportID: REPORT_ID})); + }); + it('expires a stale isPendingCreation flag when the focused screen never receives a report row', async () => { // Given a restored route that still carries isPendingCreation but has no submit writing the report setRouteParams({reportID: REPORT_ID, isPendingCreation: 'true'}); diff --git a/tests/unit/ReportFieldOptionsListUtilsTest.ts b/tests/unit/ReportFieldOptionsListUtilsTest.ts index 04ad47cc42cf..b1a5827f32f0 100644 --- a/tests/unit/ReportFieldOptionsListUtilsTest.ts +++ b/tests/unit/ReportFieldOptionsListUtilsTest.ts @@ -20,43 +20,54 @@ describe('ReportFieldOptionsListUtils', () => { describe('getReportFieldOptionsSection', () => { it('pins the frozen initial value to its own "Selected" section on top and excludes it from the "All" section', () => { + // Given a list opened on "Charlie", with the live selection still matching the value it opened on + // When the sections are built const sections = getReportFieldOptionsSection({ options: OPTIONS, recentlyUsedOptions: [], - // Live selection matches the frozen one on open. selectedOptions: [{text: 'Charlie'}], initiallySelectedValue: 'Charlie', searchValue: '', translate: translateLocal, }); + // Then "Charlie" is pinned to the top in its own checked section, so the user can see what the field + // currently holds without scrolling a long option list to find it const selectedSection = sectionByIndex(sections, 1); expect(selectedSection?.data.map((option) => option.keyForList)).toEqual(['Charlie']); expect(selectedSection?.data.at(0)?.isSelected).toBe(true); - // The pinned value must not be duplicated in the "All" section. + + // Then it is not repeated in the "All" section, because the same option listed twice reads as two + // different options the user has to tell apart expect(sectionByIndex(sections, 3)?.data.map((option) => option.keyForList)).not.toContain('Charlie'); }); it('keeps the frozen value pinned while the live selection drives the checkmark', () => { + // Given a list that opened on "Charlie" and on which the user has since picked "Delta" + // When the sections are rebuilt const sections = getReportFieldOptionsSection({ options: OPTIONS, recentlyUsedOptions: [], - // The user picked "Delta" (live), but the list opened on "Charlie" (frozen). selectedOptions: [{text: 'Delta'}], initiallySelectedValue: 'Charlie', searchValue: '', translate: translateLocal, }); + // Then "Charlie" stays pinned but loses its checkmark. Pinning on the live selection instead would make + // the option the user just clicked jump to the top of the list under their cursor const selectedSection = sectionByIndex(sections, 1); - // "Charlie" stays pinned (frozen) but is no longer checked... expect(selectedSection?.data.map((option) => option.keyForList)).toEqual(['Charlie']); expect(selectedSection?.data.at(0)?.isSelected).toBe(false); - // ...and the live selection "Delta" is checked where it sits in the "All" section. + + // Then the checkmark follows the live selection to where "Delta" naturally sits, so the list still shows + // the user what they picked expect(sectionByIndex(sections, 3)?.data.find((option) => option.keyForList === 'Delta')?.isSelected).toBe(true); }); it('keeps the pinned value at the top of the search results when it matches', () => { + // Given a list pinned to "Rate B" and a search term that every option matches + // When the sections are built const sections = getReportFieldOptionsSection({ options: ['Rate A', 'Rate B', 'Rate C'], recentlyUsedOptions: [], @@ -66,14 +77,62 @@ describe('ReportFieldOptionsListUtils', () => { translate: translateLocal, }); - // Search collapses to a single section; the pinned value leads regardless of its natural position. + // Then searching collapses everything into one section but the pinned value still leads it, so the + // current value does not disappear into the middle of the results the moment the user types const searchData = sectionByIndex(sections, 0)?.data.map((option) => option.keyForList) ?? []; expect(searchData.at(0)).toBe('Rate B'); + + // Then every matching option is still offered exactly once, because pinning reorders the results rather + // than adding a copy of the current value to them expect(searchData).toEqual(expect.arrayContaining(['Rate A', 'Rate B', 'Rate C'])); expect(searchData).toHaveLength(3); }); + it('leaves the selected value in place when pinning is turned off', () => { + // Given a caller that opts out of pinning, which the inline field does for a short option list + // When the sections are built + const sections = getReportFieldOptionsSection({ + options: OPTIONS, + recentlyUsedOptions: [], + selectedOptions: [{text: 'Charlie'}], + initiallySelectedValue: 'Charlie', + searchValue: '', + translate: translateLocal, + shouldPinSelectedOption: false, + }); + + // Then there is no "Selected" section and the options keep their natural order, because a handful of + // options is easier to scan in a stable order than one that moves the current value to the top + expect(sectionByIndex(sections, 1)).toBeUndefined(); + expect(sectionByIndex(sections, 3)?.data.map((option) => option.keyForList)).toEqual(OPTIONS); + + // Then the current value is still checked, so turning off pinning changes the ordering only and does not + // cost the user the ability to see what the field holds + expect(sectionByIndex(sections, 3)?.data.find((option) => option.keyForList === 'Charlie')?.isSelected).toBe(true); + }); + + it('drops the section titles when they are turned off', () => { + // Given a caller that opts out of section titles and data that would otherwise produce two titled + // sections, "Recent" and "All" + // When the sections are built + const sections = getReportFieldOptionsSection({ + options: OPTIONS, + recentlyUsedOptions: ['Delta'], + selectedOptions: [], + initiallySelectedValue: '', + searchValue: '', + translate: translateLocal, + shouldShowSectionTitles: false, + }); + + // Then both sections are still built but neither carries a title, so a small popover is not made to spend + // its limited height on headers while the options themselves stay grouped + expect(sections.map((section) => section.title)).toEqual(['', '']); + }); + it('does not force-show the pinned value when it does not match the search', () => { + // Given a list pinned to "Rate B" and a search term that only "Zulu" matches + // When the sections are built const sections = getReportFieldOptionsSection({ options: ['Rate A', 'Rate B', 'Zulu'], recentlyUsedOptions: [], @@ -83,6 +142,8 @@ describe('ReportFieldOptionsListUtils', () => { translate: translateLocal, }); + // Then only "Zulu" is offered. Pinning decides where a matching value sits, so it must not smuggle a + // non-matching option into results the user has explicitly filtered down expect(sectionByIndex(sections, 0)?.data.map((option) => option.keyForList)).toEqual(['Zulu']); }); }); diff --git a/tests/unit/ReportNameUtilsTest.ts b/tests/unit/ReportNameUtilsTest.ts index 9ae674d6c75c..80abad3ddaec 100644 --- a/tests/unit/ReportNameUtilsTest.ts +++ b/tests/unit/ReportNameUtilsTest.ts @@ -1,6 +1,14 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import {translate} from '@libs/Localize'; +import { + getAddedOfficeLocationMessage, + getDeletedOfficeLocationMessage, + getPolicyWorkArrangementMessage, + getUpdatedEmployeeOfficeLocationMessage, + getUpdatedMemberWorkArrangementMessage, + getUpdatedOfficeLocationMessage, +} from '@libs/ReportActionsUtils'; import { buildReportNameFromParticipantNames, // eslint-disable-next-line no-restricted-imports -- testing computeReportName directly @@ -208,6 +216,17 @@ describe('ReportNameUtils', () => { }); describe('computeReportName - support tickets', () => { + test('shows the support ticket title before server details arrive', () => { + // Given a support ticket that exists only optimistically + const supportTicket = {reportID: 'supportTicketReportID', type: CONST.REPORT.TYPE.SUPPORT_TICKET}; + + // When its name is displayed before creation completes + const name = computeReportName(supportTicket); + + // Then the customer sees a support ticket title without needing participant details + expect(name).toBe('Support ticket'); + }); + test('uses the support rep login when their display name is unavailable', () => { const supportTicket = createMock({ reportID: 'supportTicketReportID', @@ -1001,6 +1020,59 @@ describe('ReportNameUtils', () => { expect(disabledName).toBe('disabled the expense categorization requirement'); }); + test.each([ + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_POLICY_WORK_ARRANGEMENT, {newValue: true, oldValue: false}, getPolicyWorkArrangementMessage], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT, + {accountID: 1, email: 'member@test.com', name: 'Member One', newValue: true, oldValue: false}, + getUpdatedMemberWorkArrangementMessage, + ], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION, + {officeID: 'OFFICE1', name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}}, + getAddedOfficeLocationMessage, + ], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION, + {officeID: 'OFFICE1', name: 'HQ', updatedField: CONST.POLICY.OFFICE_LOCATION_FIELD.NAME, newValue: 'HQ', oldValue: 'Headquarters'}, + getUpdatedOfficeLocationMessage, + ], + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION, {officeID: 'OFFICE1', name: 'Headquarters'}, getDeletedOfficeLocationMessage], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION, + {accountID: 1, email: 'member@test.com', name: 'Member One', officeID: 'OFFICE2', officeName: 'New Jersey Office', oldOfficeID: '', oldOfficeName: ''}, + getUpdatedEmployeeOfficeLocationMessage, + ], + ] as const)('%s parent action', (actionName, originalMessage, getMessage) => { + // Given a thread on a work arrangement or office location change log, whose stored text is Auth's English message + const thread: Report = createWorkspaceThread(171); + const parentId = String(thread.parentReportID); + const actionId = String(thread.parentReportActionID); + const parentAction: ReportAction = { + ...createRandomReportAction(171), + actionName, + reportActionID: actionId, + message: [{type: 'COMMENT', text: 'stored message', html: 'stored message'}], + originalMessage, + }; + + // When the thread is named after that change log + const name = computeReportName( + thread, + emptyCollections.reports, + emptyCollections.policies, + undefined, + undefined, + participantsPersonalDetails, + {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentId}`]: {[actionId]: parentAction}}, + currentUserAccountID, + ); + + // Then the name reads the same as the in-chat change log rather than the stored text + expect(name).toBe(getMessage(translateLocal, parentAction)); + expect(name).not.toBe('stored message'); + }); + test('UPDATE_REQUIRES_TAG parent action', () => { const thread: Report = createWorkspaceThread(152); const parentId = String(thread.parentReportID); diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index 32ad04c2491d..4fc31cd29725 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -51,6 +51,9 @@ jest.mock('@components/ConfirmedRoute.tsx'); jest.mock('@libs/PolicyUtils', () => ({ ...jest.requireActual('@libs/PolicyUtils'), + // This factory runs while PolicyUtils is still loading inside an import cycle, so the barrel's `export *` helpers + // aren't on it yet. Spread them from their own modules, which load fully. + ...jest.requireActual>('@libs/PolicyUtils/members'), isPolicyAdmin: jest.fn().mockImplementation((policy?: Policy) => policy?.role === 'admin'), getValidConnectedIntegration: jest.fn(), isPaidGroupPolicy: jest.fn().mockReturnValue(true), @@ -1089,6 +1092,94 @@ describe('getPrimaryAction', () => { ).not.toBe(CONST.REPORT.PRIMARY_ACTIONS.PAY); }); + it('should return PAY for non-payer admin when a bank account is connected (reimburseYes)', async () => { + // Given a non-payer admin viewing an approved expense report. + // When the primary payment action is resolved. + // Then configured reimbursement allows Pay without making the admin the designated payer. + const payerEmail = 'payer@vba-test.com'; + const submitterEmail = 'member@vba-test.com'; + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID: POLICY_ID, + ownerAccountID: CURRENT_USER_ACCOUNT_ID + 10, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + total: -300, + isWaitingOnBankAccount: false, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const policy = createMock({ + id: POLICY_ID, + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + owner: payerEmail, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + achAccount: {reimburser: payerEmail}, + employeeList: { + [CURRENT_USER_EMAIL]: {role: CONST.POLICY.ROLE.ADMIN}, + [payerEmail]: {role: CONST.POLICY.ROLE.ADMIN}, + [submitterEmail]: {role: CONST.POLICY.ROLE.USER}, + }, + }); + const transaction = createMock({ + reportID: `${REPORT_ID}`, + }); + + expect( + getReportPrimaryAction({ + currentUserLogin: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + report, + ownerLogin: submitterEmail, + chatReport, + reportTransactions: [transaction], + violations: {}, + bankAccountList: {}, + policy, + isChatReportArchived: false, + rules: undefined, + }), + ).toBe(CONST.REPORT.PRIMARY_ACTIONS.PAY); + }); + + it('should not return PAY for expense report waiting for the cancelled payment to finish processing', async () => { + // Given a closed expense report whose cancelled payment is still being sent back to the payer + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + total: -300, + isWaitingForCancelledReimbursement: true, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const policy = createMock({ + role: CONST.POLICY.ROLE.ADMIN, + }); + const transaction = createMock({ + reportID: `${REPORT_ID}`, + }); + + // When the primary action is calculated for the admin + const primaryAction = getReportPrimaryAction({ + rules: undefined, + currentUserLogin: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + report, + ownerLogin: '', + chatReport, + reportTransactions: [transaction], + violations: {}, + bankAccountList: {}, + policy, + isChatReportArchived: false, + }); + + // Then PAY is not offered because the backend rejects paying until that money posts + expect(primaryAction).not.toBe(CONST.REPORT.PRIMARY_ACTIONS.PAY); + }); + it('should return PAY for non-reimburser payments admin in manual reimbursement mode when owner is payer', async () => { const ownerEmail = 'owner@manual-test.com'; const report = createMock({ @@ -1346,6 +1437,42 @@ describe('getPrimaryAction', () => { ).not.toBe(CONST.REPORT.PRIMARY_ACTIONS.PAY); }); + it('should return empty string for expense report with only non-reimbursable transactions when preventPayoutNonReimbursableReports is true', async () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + total: -3000, + nonReimbursableTotal: -3000, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const policy = createMock({ + role: CONST.POLICY.ROLE.ADMIN, + preventPayoutNonReimbursableReports: true, + }); + const transaction = createMock({ + reportID: `${REPORT_ID}`, + reimbursable: false, + }); + + expect( + getReportPrimaryAction({ + currentUserLogin: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + report, + ownerLogin: '', + chatReport, + reportTransactions: [transaction], + violations: {}, + bankAccountList: {}, + policy, + isChatReportArchived: false, + rules: undefined, + }), + ).toBe(''); + }); + it('should return EXPORT TO ACCOUNTING for finished reports', async () => { const report = createMock({ reportID: REPORT_ID, diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 16471032dbf1..b0cb3e759876 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -13,7 +13,7 @@ import * as ReportActionsUtils from '@src/libs/ReportActionsUtils'; import * as ReportUtils from '@src/libs/ReportUtils'; import * as TransactionUtils from '@src/libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report, ReportAction, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {BankAccountList, Policy, Report, ReportAction, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; import type {Connections} from '@src/types/onyx/Policy'; import type {ValueOf} from 'type-fest'; @@ -114,6 +114,10 @@ jest.mock('@libs/PolicyUtils', () => ({ // aren't on it yet. Spread them from their own modules, which load fully. ...jest.requireActual>('@libs/PolicyUtils/policyType'), ...jest.requireActual>('@libs/PolicyUtils/permissions'), + ...jest.requireActual>('@libs/PolicyUtils/workflows'), + ...jest.requireActual>('@libs/PolicyUtils/approvals'), + ...jest.requireActual>('@libs/PolicyUtils/payments'), + ...jest.requireActual>('@libs/PolicyUtils/members'), isPreferredExporter: jest.fn().mockReturnValue(true), hasAccountingConnections: jest.fn().mockReturnValue(true), isPolicyAdmin: jest.fn().mockReturnValue(true), @@ -150,6 +154,7 @@ describe('getSecondaryAction', () => { const result = [ CONST.REPORT.SECONDARY_ACTIONS.EXPORT, CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_PDF, + CONST.REPORT.SECONDARY_ACTIONS.SHARE, CONST.REPORT.SECONDARY_ACTIONS.PRINT, CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS, ]; @@ -521,6 +526,135 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(false); }); + describe('CANCEL_PAYMENT for a non-payer admin on a workspace with a connected bank account', () => { + const DESIGNATED_PAYER_EMAIL = 'payer@vba-test.com'; + const WORKSPACE_BANK_ACCOUNT_ID = 1111; + const ADMIN_OWN_BANK_ACCOUNT_ID = 2222; + const PAY_ACTION_ID = 'pay_action_id'; + + // Reimbursement is configured (reimburseYes) and someone else is the reimburser, so `isPayer` is false for + // ADMIN_EMAIL and Cancel can only come from the non-payer admin path. + const policy = createMock({ + id: POLICY_ID, + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + owner: DESIGNATED_PAYER_EMAIL, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + reimburser: DESIGNATED_PAYER_EMAIL, + outputCurrency: CONST.CURRENCY.USD, + achAccount: {reimburser: DESIGNATED_PAYER_EMAIL, bankAccountID: WORKSPACE_BANK_ACCOUNT_ID}, + employeeList: { + [ADMIN_EMAIL]: {role: CONST.POLICY.ROLE.ADMIN}, + [DESIGNATED_PAYER_EMAIL]: {role: CONST.POLICY.ROLE.ADMIN}, + }, + }); + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID: POLICY_ID, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + total: -10000, + isWaitingOnBankAccount: false, + // Auth reports whether this viewer can cancel the bank reimbursement. + canCancelReimbursement: true, + }); + const transaction = createMock({reportID: REPORT_ID, amount: 10000}); + + function buildPayAction(paymentType: ValueOf, bankAccountID?: number): ReportAction { + return createMock({ + reportActionID: PAY_ACTION_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: ADMIN_ACCOUNT_ID, + message: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, paymentType, bankAccountID, accountNumber: 'XXXX1234'}, + created: '2020-01-01 00:00:00.000', + }); + } + + async function getActionsForAdmin(payAction: ReportAction, bankAccountList: BankAccountList, reportOverrides: Partial = {}) { + const viewerReport = {...report, ...reportOverrides}; + // beforeEach's Onyx.clear isn't awaited; explicit clear guarantees a clean policy collection for this seed. + await Onyx.clear(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, viewerReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[PAY_ACTION_ID]: payAction}); + await waitForBatchedUpdates(); + + return getSecondaryReportActions({ + currentUserLogin: ADMIN_EMAIL, + currentUserAccountID: ADMIN_ACCOUNT_ID, + submitterLogin: EMPLOYEE_EMAIL, + report: viewerReport, + chatReport, + reportTransactions: [transaction], + originalTransaction: createMock({}), + violations: {}, + bankAccountList, + policy, + reportActions: [payAction], + rules: undefined, + cardList: undefined, + }); + } + + it('includes CANCEL_PAYMENT for a paid-elsewhere payment without server cancellation permission', async () => { + // Given a non-payer admin whose report was marked as paid elsewhere. + // When Auth does not mark a bank reimbursement as cancellable. + const result = await getActionsForAdmin(buildPayAction(CONST.IOU.PAYMENT_TYPE.ELSEWHERE), {}, {canCancelReimbursement: false}); + + // Then the admin can still undo the manual payment. + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); + }); + + it('includes CANCEL_PAYMENT when Auth permits cancellation with a matching bank account ID', async () => { + // Given a non-payer admin with the bank account recorded on an optimistic pay action. + // When Auth permits cancellation for this viewer. + const result = await getActionsForAdmin(buildPayAction(CONST.IOU.PAYMENT_TYPE.VBBA, ADMIN_OWN_BANK_ACCOUNT_ID), { + [ADMIN_OWN_BANK_ACCOUNT_ID]: {methodID: ADMIN_OWN_BANK_ACCOUNT_ID, bankCurrency: CONST.CURRENCY.USD, bankCountry: CONST.COUNTRY.US}, + }); + + // Then cancellation remains available before the pay action is reloaded. + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); + }); + + it('includes CANCEL_PAYMENT when Auth permits cancellation with different shared bank account IDs', async () => { + // Given a payment whose funding account ID differs from the viewer's shared account ID. + // When Auth permits cancellation for this viewer. + const result = await getActionsForAdmin(buildPayAction(CONST.IOU.PAYMENT_TYPE.VBBA, ADMIN_OWN_BANK_ACCOUNT_ID), { + [WORKSPACE_BANK_ACCOUNT_ID]: {methodID: WORKSPACE_BANK_ACCOUNT_ID, bankCurrency: CONST.CURRENCY.USD, bankCountry: CONST.COUNTRY.US}, + }); + + // Then the per-viewer account IDs do not hide cancellation. + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); + }); + + it('includes CANCEL_PAYMENT after reloading a pay action without a bank account ID', async () => { + // Given a persisted pay action with accountNumber but no bankAccountID and no loaded bank accounts. + const payAction = buildPayAction(CONST.IOU.PAYMENT_TYPE.VBBA); + + // When Auth permits cancellation for this viewer after a cold load. + const result = await getActionsForAdmin(payAction, {}); + + // Then cancellation does not depend on optimistic payment metadata or locally loaded accounts. + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); + }); + + it.each([false, undefined])('excludes CANCEL_PAYMENT when server cancellation permission is %s despite a matching bank account', async (canCancelReimbursement) => { + // Given a non-payer admin with the funding account in their bank account list. + // When Auth does not grant cancellation permission for this viewer. + const result = await getActionsForAdmin( + buildPayAction(CONST.IOU.PAYMENT_TYPE.VBBA, ADMIN_OWN_BANK_ACCOUNT_ID), + {[ADMIN_OWN_BANK_ACCOUNT_ID]: {methodID: ADMIN_OWN_BANK_ACCOUNT_ID, bankCurrency: CONST.CURRENCY.USD, bankCountry: CONST.COUNTRY.US}}, + {canCancelReimbursement}, + ); + + // Then local access cannot enable cancellation of a bank reimbursement. + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(false); + }); + }); + it('does not include CANCEL_PAYMENT past the NACHA cutoff after a paid-elsewhere payment was cancelled and re-paid via bank', async () => { // Cancelling doesn't remove the original pay action, so the report keeps a stale ELSEWHERE action next to the // newer bank one. Cancel eligibility must follow the latest (bank) payment and respect its cutoff, not fall back @@ -2222,6 +2356,42 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(false); }); + it('does not include UNAPPROVE option while the cancelled payment is processing', () => { + // Given an approved expense report whose cancelled payment is still being sent back to the payer + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: EMPLOYEE_ACCOUNT_ID, + isWaitingForCancelledReimbursement: true, + }); + const policy = createMock({ + approver: EMPLOYEE_EMAIL, + role: CONST.POLICY.ROLE.ADMIN, + }); + + // When the secondary actions are calculated for the manager + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: [], + originalTransaction: createMock({}), + violations: {}, + bankAccountList: {}, + policy, + rules: undefined, + cardList: undefined, + }); + + // Then UNAPPROVE is not offered because the backend rejects unapproving until that money posts + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(false); + }); + it('does not include UNAPPROVE option for non-admin on DEW policy', () => { // Given an approved expense report on a DEW policy where the current user is the manager but not an admin const report = createMock({ @@ -3448,6 +3618,45 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT)).toBe(false); }); + it('does not include RECEIVED_PAYMENT when report has only non-reimbursable expenses and policy prevents paying non reimbursable reports ', () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + total: -100, + nonReimbursableTotal: -100, + }); + const transaction = createMock({ + transactionID: 'TRANSACTION_ID_932', + comment: {}, + reimbursable: false, + }); + const policy = createMock({ + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + preventPayoutNonReimbursableReports: true, + }); + + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: EMPLOYEE_EMAIL, + report, + chatReport, + reportTransactions: [transaction], + originalTransaction: createMock({}), + violations: {}, + bankAccountList: {}, + policy, + reportActions: [], + rules: undefined, + cardList: undefined, + }); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RECEIVED_PAYMENT)).toBe(false); + }); + it('includes HOLD option ', () => { const report = createMock({ reportID: REPORT_ID, @@ -6170,7 +6379,7 @@ describe('getSecondaryTransactionThreadActions', () => { const reports = [createMock({reportID: '1', type: CONST.REPORT.TYPE.EXPENSE})]; const policies = createMock([{id: 'policy1', role: CONST.POLICY.ROLE.ADMIN}]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined); expect(result).toBe(false); }); @@ -6184,7 +6393,7 @@ describe('getSecondaryTransactionThreadActions', () => { ]; const policies = createMock([{id: 'policy1', role: CONST.POLICY.ROLE.ADMIN}]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined); expect(result).toBe(false); }); @@ -6198,7 +6407,7 @@ describe('getSecondaryTransactionThreadActions', () => { {id: 'policy3', role: CONST.POLICY.ROLE.ADMIN}, ]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined); expect(result).toBe(false); }); @@ -6210,10 +6419,10 @@ describe('getSecondaryTransactionThreadActions', () => { jest.spyOn(ReportUtils, 'isMoneyRequestReportEligibleForMerge').mockReturnValue(false); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined); expect(result).toBe(false); - expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), false, undefined); + expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), false, undefined, undefined); }); it('should return true for single transaction when report is eligible for merge', () => { @@ -6223,10 +6432,10 @@ describe('getSecondaryTransactionThreadActions', () => { jest.spyOn(ReportUtils, 'isMoneyRequestReportEligibleForMerge').mockReturnValue(true); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined); expect(result).toBe(true); - expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), true, undefined); + expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), true, undefined, undefined); }); it('should return true for two eligible transactions', () => { @@ -6249,7 +6458,7 @@ describe('getSecondaryTransactionThreadActions', () => { jest.spyOn(ReportUtils, 'isMoneyRequestReportEligibleForMerge').mockReturnValue(true); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined); expect(result).toBe(true); }); @@ -6274,7 +6483,7 @@ describe('getSecondaryTransactionThreadActions', () => { jest.spyOn(ReportUtils, 'isMoneyRequestReportEligibleForMerge').mockReturnValue(true); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined); expect(result).toBe(false); }); @@ -6284,7 +6493,7 @@ describe('getSecondaryTransactionThreadActions', () => { const reports = [createMock({reportID: '1', type: CONST.REPORT.TYPE.EXPENSE, policyID: 'nonexistent'})]; const policies = createMock([{id: 'policy1', role: CONST.POLICY.ROLE.ADMIN}]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined); // Should return true because when policy is not found, function doesn't prevent merging // (since we have 1 transaction, it will return true after the policy check) @@ -6298,10 +6507,10 @@ describe('getSecondaryTransactionThreadActions', () => { jest.spyOn(ReportUtils, 'isMoneyRequestReportEligibleForMerge').mockReturnValue(true); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined); expect(result).toBe(true); - expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), true, undefined); + expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), true, undefined, undefined); }); it('should return false for non-admin user with ineligible reports', () => { @@ -6311,10 +6520,10 @@ describe('getSecondaryTransactionThreadActions', () => { jest.spyOn(ReportUtils, 'isMoneyRequestReportEligibleForMerge').mockReturnValue(false); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined); expect(result).toBe(false); - expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), false, undefined); + expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledWith(reports.at(0), false, undefined, undefined); }); it('should return false when one of multiple reports is not eligible', () => { @@ -6332,12 +6541,12 @@ describe('getSecondaryTransactionThreadActions', () => { .mockReturnValueOnce(true) // First report eligible .mockReturnValueOnce(false); // Second report not eligible - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined); expect(result).toBe(false); expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenCalledTimes(2); - expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenNthCalledWith(1, reports.at(0), true, undefined); - expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenNthCalledWith(2, reports.at(1), false, undefined); + expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenNthCalledWith(1, reports.at(0), true, undefined, undefined); + expect(ReportUtils.isMoneyRequestReportEligibleForMerge).toHaveBeenNthCalledWith(2, reports.at(1), false, undefined, undefined); }); describe('preventing merge for transactions belonging to different users', () => { @@ -6360,7 +6569,7 @@ describe('getSecondaryTransactionThreadActions', () => { const reports: Report[] = []; const policies = createMock([{id: 'policy1', role: CONST.POLICY.ROLE.ADMIN}]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined); expect(result).toBe(true); }); @@ -6383,7 +6592,7 @@ describe('getSecondaryTransactionThreadActions', () => { ]); const policies = createMock([{id: 'policy1', role: CONST.POLICY.ROLE.ADMIN}]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, EMPLOYEE_ACCOUNT_ID); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined, EMPLOYEE_ACCOUNT_ID); expect(result).toBe(true); }); @@ -6406,7 +6615,7 @@ describe('getSecondaryTransactionThreadActions', () => { ]); const policies = createMock([{id: 'policy1', role: CONST.POLICY.ROLE.ADMIN}]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, EMPLOYEE_ACCOUNT_ID); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined, EMPLOYEE_ACCOUNT_ID); expect(result).toBe(false); }); @@ -6426,7 +6635,7 @@ describe('getSecondaryTransactionThreadActions', () => { const reports = createMock([{reportID: 'report2', ownerAccountID: EMPLOYEE_ACCOUNT_ID, type: CONST.REPORT.TYPE.EXPENSE, policyID: 'policy1'}]); const policies = createMock([{id: 'policy1', role: CONST.POLICY.ROLE.ADMIN}]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, EMPLOYEE_ACCOUNT_ID); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined, EMPLOYEE_ACCOUNT_ID); expect(result).toBe(true); }); @@ -6446,7 +6655,7 @@ describe('getSecondaryTransactionThreadActions', () => { const reports = createMock([{reportID: 'report1', ownerAccountID: EMPLOYEE_ACCOUNT_ID, type: CONST.REPORT.TYPE.EXPENSE, policyID: 'policy1'}]); const policies = createMock([{id: 'policy1', role: CONST.POLICY.ROLE.ADMIN}]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, EMPLOYEE_ACCOUNT_ID); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined, EMPLOYEE_ACCOUNT_ID); expect(result).toBe(true); }); @@ -6466,7 +6675,7 @@ describe('getSecondaryTransactionThreadActions', () => { const reports = createMock([{reportID: 'report2', ownerAccountID: EMPLOYEE_ACCOUNT_ID, type: CONST.REPORT.TYPE.EXPENSE, policyID: 'policy1'}]); const policies = createMock([{id: 'policy1'}]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, MANAGER_ACCOUNT_ID); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined, MANAGER_ACCOUNT_ID); expect(result).toBe(false); }); @@ -6486,7 +6695,7 @@ describe('getSecondaryTransactionThreadActions', () => { const reports = createMock([{reportID: 'report1', ownerAccountID: EMPLOYEE_ACCOUNT_ID, type: CONST.REPORT.TYPE.EXPENSE, policyID: 'policy1'}]); const policies = createMock([{id: 'policy1', role: CONST.POLICY.ROLE.ADMIN}]); - const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, MANAGER_ACCOUNT_ID); + const result = isMergeActionForSelectedTransactions(transactions, reports, policies, undefined, undefined, MANAGER_ACCOUNT_ID); expect(result).toBe(false); }); diff --git a/tests/unit/ReportSelectorTest.ts b/tests/unit/ReportSelectorTest.ts index 86c8d51a3e67..004ff62647d2 100644 --- a/tests/unit/ReportSelectorTest.ts +++ b/tests/unit/ReportSelectorTest.ts @@ -1,6 +1,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {createMoveExpenseReportNVPSelector, getStableReportSelector, policyChatRoomsSelector} from '@src/selectors/Report'; +import {createMoveExpenseReportNVPSelector, getReportHasParentAccess, getStableReportSelector, policyChatRoomsSelector} from '@src/selectors/Report'; import type {Report} from '@src/types/onyx'; describe('policyChatRoomsSelector', () => { @@ -157,3 +157,26 @@ describe('getStableReportSelector', () => { expect(getStableReportSelector({reportID: '1'} as Report)?.permissions).toBeUndefined(); }); }); + +describe('getReportHasParentAccess', () => { + it('returns the hasParentAccess value of the report', () => { + // Given reports with and without parent access + const reportWithAccess = {reportID: '1', hasParentAccess: true} as Report; + const reportWithoutAccess = {reportID: '2', hasParentAccess: false} as Report; + + // When the selector reads them + // Then it returns the raw flag, so callers can tell an explicit `false` apart from a missing value + expect(getReportHasParentAccess(reportWithAccess)).toBe(true); + expect(getReportHasParentAccess(reportWithoutAccess)).toBe(false); + }); + + it('returns undefined when the report or the flag is missing', () => { + // Given a missing report and a report without the hasParentAccess field + const reportWithoutFlag = {reportID: '1'} as Report; + + // When the selector reads them + // Then it returns undefined, which ParentNavigationSubtitle treats as having access + expect(getReportHasParentAccess(undefined)).toBeUndefined(); + expect(getReportHasParentAccess(reportWithoutFlag)).toBeUndefined(); + }); +}); diff --git a/tests/unit/ReportUtilsGetIconsTest.ts b/tests/unit/ReportUtilsGetIconsTest.ts index 5edd49674bfb..9f20280483bf 100644 --- a/tests/unit/ReportUtilsGetIconsTest.ts +++ b/tests/unit/ReportUtilsGetIconsTest.ts @@ -124,9 +124,6 @@ beforeAll(() => { ...FAKE_POLICIES, }, }); - // @ts-expect-error Until we add NVP_PRIVATE_DOMAINS to ONYXKEYS, we need to mock it here - - Onyx.connect({key: ONYXKEYS.NVP_PRIVATE_DOMAINS, callback: () => {}}); }); describe('getIcons', () => { diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 2d5deae14384..4af6656f0f29 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -35,6 +35,7 @@ import { buildOptimisticApprovedReportAction, buildOptimisticCancelPaymentReportAction, buildOptimisticCardAssignedReportAction, + buildOptimisticChangeFieldAction, buildOptimisticChatReport, buildOptimisticClosedReportAction, buildOptimisticCreatedReportAction, @@ -97,6 +98,7 @@ import { getBillableAndTaxTotal, getChatByParticipants, getChatListItemReportName, + getChatReportFromMoneyRequestReport, getChatRoomSubtitle, getChildReportNotificationPreference, getDefaultGroupAvatar, @@ -126,6 +128,7 @@ import { getParentNavigationSubtitle, getParentReport, getParsedComment, + getParticipantsAccountIDsForDisplay, getParticipantsList, getPayeeName, getPendingChatMembers, @@ -181,6 +184,7 @@ import { isApproverOfOutstandingPolicyReports, isArchivedNonExpenseReport, isArchivedReport, + isAwaitingFirstLevelApproval, isChatUsedForOnboarding, isClosedExpenseReportWithNoExpenses, isConciergeChatReport, @@ -193,6 +197,7 @@ import { isJoinRequestInAdminRoom, isMoneyRequestReportEligibleForMerge, isOneOnOneChat, + isOneTransactionThread, isPayer, isPayOptional, isPolicyRelatedReport, @@ -203,6 +208,7 @@ import { isRootGroupChat, isSelfDMOrSelfDMThread, isSortableColumnName, + isThreadMemberProtectedByParentReport, isUnread, isUploadingAttachmentRemovedFromDraft, isWorkspaceMemberLeavingWorkspaceRoom, @@ -373,6 +379,7 @@ jest.mock('@libs/PolicyUtils', () => { // aren't on it yet. Spread them from their own modules, which load fully. ...jest.requireActual>('@libs/PolicyUtils/customUnits'), ...jest.requireActual>('@libs/PolicyUtils/tax'), + ...jest.requireActual>('@libs/PolicyUtils/members'), isPolicyAdmin: jest.fn().mockImplementation((policy?: Policy) => policy?.role === 'admin'), isPaidGroupPolicy, // isGroupPolicy delegates to the isPaidGroupPolicy mock so tests that tweak isPaidGroupPolicy still drive group-policy checks @@ -605,11 +612,16 @@ describe('ReportUtils', () => { describe('getIOUReportActionDisplayMessage', () => { const iouReportID = '1234567890'; const policyID = 332; + const reimburserEmail = 'reimburser@example.com'; + const reimburserAccountID = 332001; + // The workspace account is only a valid fallback for a payment made by the designated payer, so the pay action + // has to come from the reimburser for these messages to name it. const reportAction = { ...createRandomReportAction(44), actionName: CONST.REPORT.ACTIONS.TYPE.IOU, reportID: iouReportID, + actorAccountID: reimburserAccountID, originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, paymentType: CONST.IOU.PAYMENT_TYPE.VBBA}, }; @@ -623,10 +635,18 @@ describe('ReportUtils', () => { routingNumber: '011401533', addressName: 'Bank Workspace', bankName: 'Test Bank', - reimburser: 'reimburser@example.com', + reimburser: reimburserEmail, }, }; + beforeAll(async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[reimburserAccountID]: {accountID: reimburserAccountID, login: reimburserEmail}}); + }); + + afterAll(async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[reimburserAccountID]: null}); + }); + it('should return the right message when payment type is ACH', async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policyWithBank); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, iouReport); @@ -635,16 +655,16 @@ describe('ReportUtils', () => { const last4Digits = policyWithBank.achAccount?.accountNumber.slice(-4); const paidSystemMessage = translate(CONST.LOCALES.EN, 'iou.businessBankAccount', '', last4Digits); - expect(getIOUReportActionDisplayMessage(translateLocal, reportAction, convertToDisplayString, policyWithBank.achAccount?.accountNumber, undefined)).toBe(paidSystemMessage); + expect(getIOUReportActionDisplayMessage(translateLocal, reportAction, convertToDisplayString, policyWithBank, undefined)).toBe(paidSystemMessage); }); - it('should use the passed ACH account number (not module-level allPolicies) for the bank account last 4 digits', () => { - // Given an ACH account number passed explicitly, with no matching policy read from Onyx + it('should use the passed policy (not module-level allPolicies) for the bank account last 4 digits', () => { + // Given a policy passed explicitly, with no matching policy read from Onyx const last4Digits = policyWithBank.achAccount?.accountNumber.slice(-4); const paidSystemMessage = translate(CONST.LOCALES.EN, 'iou.businessBankAccount', '', last4Digits); - // Then the ACH last 4 digits are resolved from the passed account number alone - expect(getIOUReportActionDisplayMessage(translateLocal, reportAction, convertToDisplayString, policyWithBank.achAccount?.accountNumber, undefined)).toBe(paidSystemMessage); + // Then the ACH last 4 digits are resolved from the passed policy alone + expect(getIOUReportActionDisplayMessage(translateLocal, reportAction, convertToDisplayString, policyWithBank, undefined)).toBe(paidSystemMessage); }); it('should show the bank account from the action accountNumber instead of the policy default', async () => { @@ -660,9 +680,7 @@ describe('ReportUtils', () => { const paidSystemMessage = translate(CONST.LOCALES.EN, 'iou.businessBankAccount', '', '4321'); // Then the message shows the last 4 digits of that account, not the policy default - expect(getIOUReportActionDisplayMessage(translateLocal, actionWithAccountNumber, convertToDisplayString, policyWithBank.achAccount?.accountNumber, undefined)).toBe( - paidSystemMessage, - ); + expect(getIOUReportActionDisplayMessage(translateLocal, actionWithAccountNumber, convertToDisplayString, policyWithBank, undefined)).toBe(paidSystemMessage); }); it('should show the cross-border FX message with the credited amount and both account last-4s', async () => { @@ -686,7 +704,7 @@ describe('ReportUtils', () => { debitBankAccount: '6789', creditBankAccount: '3335', }); - expect(getIOUReportActionDisplayMessage(translateLocal, crossBorderAction, convertToDisplayString, policyWithBank.achAccount?.accountNumber, undefined)).toBe(expectedMessage); + expect(getIOUReportActionDisplayMessage(translateLocal, crossBorderAction, convertToDisplayString, policyWithBank, undefined)).toBe(expectedMessage); }); it('should return received payment when submitter marked payment received', () => { @@ -701,7 +719,7 @@ describe('ReportUtils', () => { }, }; - expect(getIOUReportActionDisplayMessage(translateLocal, paymentReceivedReportAction, convertToDisplayString, policyWithBank.achAccount?.accountNumber, undefined)).toBe( + expect(getIOUReportActionDisplayMessage(translateLocal, paymentReceivedReportAction, convertToDisplayString, policyWithBank, undefined)).toBe( translateLocal('iou.receivedPaymentReportAction'), ); }); @@ -717,9 +735,7 @@ describe('ReportUtils', () => { }, }; - expect(getIOUReportActionDisplayMessage(translateLocal, paidElsewhereReportAction, convertToDisplayString, policyWithBank.achAccount?.accountNumber, undefined)).toBe( - translateLocal('iou.paidElsewhere'), - ); + expect(getIOUReportActionDisplayMessage(translateLocal, paidElsewhereReportAction, convertToDisplayString, policyWithBank, undefined)).toBe(translateLocal('iou.paidElsewhere')); }); it('should return an empty string for a non-money-request action', () => { @@ -728,10 +744,10 @@ describe('ReportUtils', () => { actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, }; - expect(getIOUReportActionDisplayMessage(translateLocal, createdAction, convertToDisplayString, policyWithBank.achAccount?.accountNumber, undefined)).toBe(''); + expect(getIOUReportActionDisplayMessage(translateLocal, createdAction, convertToDisplayString, policyWithBank, undefined)).toBe(''); }); - it('should use the passed ACH account number for the last 4 digits of an automatic VBBA payment', () => { + it('should use the passed policy for the last 4 digits of an automatic VBBA payment', () => { // Given an automatically-paid VBBA action with no accountNumber on the action itself const automaticVBBAAction = { ...reportAction, @@ -740,8 +756,8 @@ describe('ReportUtils', () => { const last4Digits = policyWithBank.achAccount?.accountNumber.slice(-4); const expected = translate(CONST.LOCALES.EN, 'iou.automaticallyPaidWithBusinessBankAccount', '', last4Digits); - // Then the workspace-rules message resolves the last 4 digits from the passed account number - expect(getIOUReportActionDisplayMessage(translateLocal, automaticVBBAAction, convertToDisplayString, policyWithBank.achAccount?.accountNumber, undefined)).toBe(expected); + // Then the workspace-rules message resolves the last 4 digits from the passed policy + expect(getIOUReportActionDisplayMessage(translateLocal, automaticVBBAAction, convertToDisplayString, policyWithBank, undefined)).toBe(expected); }); it('should return the workspace-rules message for an automatic Expensify payment', () => { @@ -750,11 +766,32 @@ describe('ReportUtils', () => { originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, paymentType: CONST.IOU.PAYMENT_TYPE.EXPENSIFY, automaticAction: true}, }; - expect(getIOUReportActionDisplayMessage(translateLocal, automaticExpensifyAction, convertToDisplayString, policyWithBank.achAccount?.accountNumber, undefined)).toBe( + expect(getIOUReportActionDisplayMessage(translateLocal, automaticExpensifyAction, convertToDisplayString, policyWithBank, undefined)).toBe( translate(CONST.LOCALES.EN, 'iou.automaticallyPaidWithExpensify', ''), ); }); + it('should not attribute a non-payer admin VBBA payment to the workspace bank account', () => { + // Given a VBBA pay action with no account on it, made by an admin who is not the designated payer + const nonPayerAdminAction = {...reportAction, actorAccountID: reimburserAccountID + 1}; + + // Then the message does not guess the workspace account, since the admin paid from an account of their own + expect(getIOUReportActionDisplayMessage(translateLocal, nonPayerAdminAction, convertToDisplayString, policyWithBank, undefined, undefined, reimburserAccountID)).toBe( + translate(CONST.LOCALES.EN, 'iou.businessBankAccount', '', ''), + ); + }); + + it('should keep the workspace bank account when the reimburser is not in personal details', () => { + // Given a policy whose reimburser this viewer has never interacted with (no personal details loaded) + const policyWithUnknownReimburser: Policy = {...policyWithBank, reimburser: 'unknown-reimburser@example.com'}; + const last4Digits = policyWithBank.achAccount?.accountNumber.slice(-4); + + // Then the payer can't be ruled out, so the message keeps the workspace account instead of blanking the digits + expect(getIOUReportActionDisplayMessage(translateLocal, reportAction, convertToDisplayString, policyWithUnknownReimburser, undefined)).toBe( + translate(CONST.LOCALES.EN, 'iou.businessBankAccount', '', last4Digits), + ); + }); + it('should return the approved amount when the IOU report is approved', async () => { // Given an approved (not yet settled) expense report and a matching CREATE action const approvedReportID = '9988776655'; @@ -1864,6 +1901,61 @@ describe('ReportUtils', () => { }); }); + describe('isThreadMemberProtectedByParentReport', () => { + const senderAccountID = 1; + const receiverAccountID = 2; + const invitedAccountID = 3; + const iouReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.IOU, + ownerAccountID: senderAccountID, + managerID: receiverAccountID, + }; + + it('should protect the sender and receiver of the parent IOU report', () => { + // Given an IOU report where the sender owns the report and the receiver manages it + // When checking whether they can be removed from one of the report's expense threads + // Then both are protected, because they keep access through the IOU report and would be added back + expect(isThreadMemberProtectedByParentReport(iouReport, senderAccountID)).toBe(true); + expect(isThreadMemberProtectedByParentReport(iouReport, receiverAccountID)).toBe(true); + }); + + it('should not protect a member who was invited into the thread', () => { + // Given an IOU report that the invited member isn't part of + // When checking whether the invited member can be removed from an expense thread + // Then they aren't protected, so removing invited members keeps working + expect(isThreadMemberProtectedByParentReport(iouReport, invitedAccountID)).toBe(false); + }); + + it('should not protect anyone when the parent is not an IOU report', () => { + // Given an expense, invoice, and chat report with the same submitter and manager + const expenseReport: Report = {...iouReport, type: CONST.REPORT.TYPE.EXPENSE}; + const invoiceReport: Report = {...iouReport, type: CONST.REPORT.TYPE.INVOICE}; + const chatReport: Report = {...iouReport, type: CONST.REPORT.TYPE.CHAT}; + + // When checking whether the submitter or manager can be removed from one of their threads + // Then nobody is protected, because only IOU report threads are in scope, so the existing room member rules still apply + expect(isThreadMemberProtectedByParentReport(expenseReport, senderAccountID)).toBe(false); + expect(isThreadMemberProtectedByParentReport(expenseReport, receiverAccountID)).toBe(false); + expect(isThreadMemberProtectedByParentReport(invoiceReport, senderAccountID)).toBe(false); + expect(isThreadMemberProtectedByParentReport(chatReport, senderAccountID)).toBe(false); + }); + + it('should not protect anyone when the parent report is missing', () => { + // Given a thread whose parent report isn't loaded + // When checking whether a member can be removed from the thread + // Then nobody is protected, so the existing room member rules still apply + expect(isThreadMemberProtectedByParentReport(undefined, senderAccountID)).toBe(false); + }); + + it('should not protect anyone when the accountID is missing', () => { + // Given an IOU report with a sender and a receiver + // When checking a member whose accountID is unknown + // Then they aren't protected, so an unknown member can't match a missing ownerAccountID or managerID + expect(isThreadMemberProtectedByParentReport(iouReport, undefined)).toBe(false); + }); + }); + describe('sortOutstandingReportsBySelected', () => { it('should return -1 when report1 is selected and report2 is not', () => { const report1 = LHNTestUtils.getFakeReport(); @@ -3270,6 +3362,44 @@ describe('ReportUtils', () => { expect(reportName).toBe('Deleted report'); }); + test('should only consider a closed expense report with zero totals empty when it has no linked transactions', () => { + // Given a closed expense report whose totals are zero and has no transactionCount, + // so only the linked transactions can tell us whether it has expenses + const expenseReport: Report = { + ...baseExpenseReport, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + total: 0, + transactionCount: 0, + }; + + // When we check it with and without linked transactions + const withTransactions = isClosedExpenseReportWithNoExpenses(expenseReport, true); + const withoutTransactions = isClosedExpenseReportWithNoExpenses(expenseReport, false); + + // Then only the report without linked transactions is considered closed with no expenses + expect(withTransactions).toBe(false); + expect(withoutTransactions).toBe(true); + }); + + test('should not show deleted report name for closed expense report that has a linked transaction', () => { + // Given a closed expense report with zero totals and a transaction that belongs to it + const expenseReport: Report = { + ...baseExpenseReport, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + total: 0, + transactionCount: 0, + }; + const transaction: Transaction = {...createRandomTransaction(1), reportID: expenseReport.reportID}; + const policies = {[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: policy}; + const transactions = {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction}; + + // When computing the report name with the transaction available + const reportName = computeReportName(expenseReport, undefined, policies, transactions, undefined, participantsPersonalDetails); + + // Then the transaction lookup by reportID prevents the "Deleted report" fallback + expect(reportName).not.toBe('Deleted report'); + }); + test('closed expense report with total and transactions not loaded', () => { // Given a closed (submitted) expense report with a total and no transactions yet loaded const expenseReport: Report = { @@ -3279,7 +3409,7 @@ describe('ReportUtils', () => { }; // Then it should not be considered closed without expenses, because it has a total - expect(isClosedExpenseReportWithNoExpenses(expenseReport)).toBe(false); + expect(isClosedExpenseReportWithNoExpenses(expenseReport, false)).toBe(false); }); test('closed expense report with zero total but non-reimbursable total exists', () => { @@ -3293,7 +3423,7 @@ describe('ReportUtils', () => { }; // Then it should not be considered closed without expenses, because nonReimbursableTotal indicates expenses exist - expect(isClosedExpenseReportWithNoExpenses(expenseReport)).toBe(false); + expect(isClosedExpenseReportWithNoExpenses(expenseReport, false)).toBe(false); }); test('should handle paid elsewhere money request', () => { @@ -4057,7 +4187,7 @@ describe('ReportUtils', () => { originalMessage: {amount: 1000, currency: CONST.CURRENCY.USD}, }); - const result = getReimbursementDeQueuedOrCanceledActionMessage(translateWithHiddenMarker, reportAction, hiddenAccountID, convertToDisplayString); + const result = getReimbursementDeQueuedOrCanceledActionMessage(translateWithHiddenMarker, formatPhoneNumber, reportAction, hiddenAccountID, convertToDisplayString); expect(result).toContain('HiddenMarker'); }); @@ -4070,7 +4200,7 @@ describe('ReportUtils', () => { originalMessage: {amount: 1000, currency: CONST.CURRENCY.USD, cancellationReason: CONST.REPORT.CANCEL_PAYMENT_REASONS.ADMIN}, }); - const result = getReimbursementDeQueuedOrCanceledActionMessage(translateWithAdminMarker, reportAction, 780020, convertToDisplayString); + const result = getReimbursementDeQueuedOrCanceledActionMessage(translateWithAdminMarker, formatPhoneNumber, reportAction, 780020, convertToDisplayString); expect(result).toBe('AdminCanceledMarker'); }); }); @@ -4555,11 +4685,51 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, }) ?? {}; expect(reportAction?.childReportID).toBe(approvableExpenseReportID); expect(actionBadge).toBe(CONST.REPORT.ACTION_BADGE.APPROVE); }); + + it('drops the approve badge when the caller passes metadata with an approve already pending', async () => { + // Given a dynamic external workflow whose approve request for the chat report is already in flight + const policyExpenseChat = await seedTwoChildExpenses(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, {approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL}); + + // When the caller passes that report metadata + const {actionBadge} = + getReasonAndReportActionThatRequiresAttention({ + optionOrReport: policyExpenseChat, + currentUserLogin: currentUserEmail, + currentUserAccountID, + transactionViolations: undefined, + reportMetadata: {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE}, + }) ?? {}; + + // Then no approve badge is offered, because approving again would be a duplicate + expect(actionBadge).toBeUndefined(); + }); + + it('should fall back to the report metadata in Onyx while callers have not been threaded yet', async () => { + // Given the same pending approve stored in Onyx rather than passed in + const policyExpenseChat = await seedTwoChildExpenses(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, {approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${policyExpenseChat.reportID}`, {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE}); + + // When the caller asks for the badge without passing the metadata + const {actionBadge} = + getReasonAndReportActionThatRequiresAttention({ + optionOrReport: policyExpenseChat, + currentUserLogin: currentUserEmail, + currentUserAccountID, + transactionViolations: undefined, + reportMetadata: undefined, + }) ?? {}; + + // Then the approve badge is still suppressed, because the fallback reads the pending approve from Onyx + expect(actionBadge).toBeUndefined(); + }); }); it('returns true for expense report awaiting user payment/reimbursement', async () => { @@ -5034,6 +5204,40 @@ describe('ReportUtils', () => { }); }); + it('it is an empty open expense report the user owns that has no link to its workspace chat', async () => { + // Given a paid group policy + const paidPolicy: Policy = { + ...createRandomPolicy(97769), + id: 'emptyExpenseReportWithoutChatPolicy', + type: CONST.POLICY.TYPE.TEAM, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${paidPolicy.id}`, paidPolicy); + + // And an empty open expense report the current user owns, as received from another session: its only participant is the owner and it + // carries neither parentReportID nor chatReportID, so its workspace chat can't be resolved from the report itself + const report: Report = { + ...LHNTestUtils.getFakeReport(), + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + policyID: paidPolicy.id, + ownerAccountID: currentUserAccountID, + managerID: currentUserAccountID, + participants: buildParticipantsFromAccountIDs([currentUserAccountID]), + parentReportID: undefined, + chatReportID: undefined, + }; + + // When getting the money request options with only the current user as a participant + const moneyRequestOptions = temporary_getMoneyRequestOptions(report, paidPolicy, [currentUserAccountID], undefined); + + // Then the user can still add an expense, matching the "Add expense" button rendered on the report, instead of hitting the Not here page + expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT)).toBe(true); + expect(moneyRequestOptions.includes(CONST.IOU.TYPE.TRACK)).toBe(true); + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${paidPolicy.id}`, null); + }); + it('it is an IOU report in submitted state', () => { const report = { ...LHNTestUtils.getFakeReport(), @@ -5493,7 +5697,23 @@ describe('ReportUtils', () => { lastReadTime: '2023-07-08 07:15:44.030', participants: {[currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}, }; - expect(getMostRecentlyVisitedReport(reports, {})).toEqual(latestReport); + expect(getMostRecentlyVisitedReport(reports, {}, false)).toEqual(latestReport); + }); + + it('should keep a hidden public room only when the user is anonymous', () => { + // Given a public room the user does not participate in, so it is hidden for them + const publicRoom: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_ROOM), + visibility: CONST.REPORT.VISIBILITY.PUBLIC, + isPinned: false, + participants: {}, + lastReadTime: '2023-07-08 07:15:44.030', + }; + + // When a regular user and an anonymous visitor look for the most recently visited report + // Then only the anonymous visitor, who opened the room from a public link, gets it back + expect(getMostRecentlyVisitedReport([publicRoom], {}, false)).toBeUndefined(); + expect(getMostRecentlyVisitedReport([publicRoom], {}, true)).toEqual(publicRoom); }); }); @@ -6279,9 +6499,9 @@ describe('ReportUtils', () => { participants: buildParticipantsFromAccountIDs([currentUserAccountID, ownerAccountID]), }; - expect(canLeaveChat(report, undefined, unrelatedAccountID)).toBe(true); - expect(canLeaveChat(report, undefined, ownerAccountID)).toBe(false); - expect(canLeaveChat(report, undefined, managerAccountID)).toBe(false); + expect(canLeaveChat(report, undefined, unrelatedAccountID, false, false)).toBe(true); + expect(canLeaveChat(report, undefined, ownerAccountID, false, false)).toBe(false); + expect(canLeaveChat(report, undefined, managerAccountID, false, false)).toBe(false); }); }); @@ -6374,7 +6594,7 @@ describe('ReportUtils', () => { }, }); }); - expect(canDeleteMoneyRequestReport(invoiceReport, [transaction], [iouAction], currentUserAccountID, undefined, undefined, undefined)).toBe(true); + expect(canDeleteMoneyRequestReport(invoiceReport, [transaction], [iouAction], currentUserAccountID, undefined, undefined, undefined, undefined)).toBe(true); }); describe('draft reports', () => { @@ -6393,14 +6613,14 @@ describe('ReportUtils', () => { const transaction = {...createRandomTransaction(901), reportID: draftReport.reportID, managedCard: false}; const iouAction = buildIOUActionForTransaction(draftReport.reportID, transaction.transactionID, 777); - expect(canDeleteMoneyRequestReport(draftReport, [transaction], [iouAction], currentUserAccountID, undefined, adminPolicy, undefined, true)).toBe(true); + expect(canDeleteMoneyRequestReport(draftReport, [transaction], [iouAction], currentUserAccountID, undefined, undefined, adminPolicy, undefined, true)).toBe(true); }); it('should not allow an admin to delete a single expense on a draft report they do not own', () => { const transaction = {...createRandomTransaction(901), reportID: draftReport.reportID, managedCard: false}; const iouAction = buildIOUActionForTransaction(draftReport.reportID, transaction.transactionID, 777); - expect(canDeleteMoneyRequestReport(draftReport, [transaction], [iouAction], currentUserAccountID, undefined, adminPolicy, undefined)).toBe(false); + expect(canDeleteMoneyRequestReport(draftReport, [transaction], [iouAction], currentUserAccountID, undefined, undefined, adminPolicy, undefined)).toBe(false); }); it('should allow the submitter to delete their own draft report', () => { @@ -6408,14 +6628,14 @@ describe('ReportUtils', () => { const transaction = {...createRandomTransaction(902), reportID: ownDraftReport.reportID, managedCard: false}; const iouAction = buildIOUActionForTransaction(ownDraftReport.reportID, transaction.transactionID, currentUserAccountID); - expect(canDeleteMoneyRequestReport(ownDraftReport, [transaction], [iouAction], currentUserAccountID, undefined, memberPolicy, undefined)).toBe(true); + expect(canDeleteMoneyRequestReport(ownDraftReport, [transaction], [iouAction], currentUserAccountID, undefined, undefined, memberPolicy, undefined)).toBe(true); }); it('should not allow a non-admin who does not own the transaction to delete a draft report', () => { const transaction = {...createRandomTransaction(903), reportID: draftReport.reportID, managedCard: false}; const iouAction = buildIOUActionForTransaction(draftReport.reportID, transaction.transactionID, 777); - expect(canDeleteMoneyRequestReport(draftReport, [transaction], [iouAction], currentUserAccountID, undefined, memberPolicy, undefined)).toBe(false); + expect(canDeleteMoneyRequestReport(draftReport, [transaction], [iouAction], currentUserAccountID, undefined, undefined, memberPolicy, undefined)).toBe(false); }); it('should allow an admin to delete a draft report holding cash and third-party card expenses regardless of liability', () => { @@ -6437,6 +6657,7 @@ describe('ReportUtils', () => { [cashIOUAction, cardIOUAction], currentUserAccountID, undefined, + undefined, adminPolicy, undefined, true, @@ -6463,6 +6684,7 @@ describe('ReportUtils', () => { [cashIOUAction, cardIOUAction], currentUserAccountID, undefined, + undefined, adminPolicy, undefined, true, @@ -6481,7 +6703,9 @@ describe('ReportUtils', () => { }; const iouAction = buildIOUActionForTransaction(ownDraftReport.reportID, expensifyCardTransaction.transactionID, currentUserAccountID); - expect(canDeleteMoneyRequestReport(ownDraftReport, [expensifyCardTransaction], [iouAction], currentUserAccountID, undefined, memberPolicy, undefined, true)).toBe(false); + expect(canDeleteMoneyRequestReport(ownDraftReport, [expensifyCardTransaction], [iouAction], currentUserAccountID, undefined, undefined, memberPolicy, undefined, true)).toBe( + false, + ); }); }); @@ -6521,7 +6745,7 @@ describe('ReportUtils', () => { }); }); - expect(canDeleteMoneyRequestReport(expenseReport, [], [], currentUserAccountID, undefined, undefined, undefined)).toBe(true); + expect(canDeleteMoneyRequestReport(expenseReport, [], [], currentUserAccountID, undefined, undefined, undefined, undefined)).toBe(true); }); it('should allow card expense deletion as the policy admin', async () => { @@ -6568,7 +6792,94 @@ describe('ReportUtils', () => { }); }); - expect(canDeleteMoneyRequestReport(expenseReport, [cardTransaction], [], currentUserAccountID, undefined, adminPolicy, undefined, false)).toBe(true); + expect(canDeleteMoneyRequestReport(expenseReport, [cardTransaction], [], currentUserAccountID, undefined, undefined, adminPolicy, undefined, false)).toBe(true); + }); + + describe('reportOwnerLogin threading', () => { + const THREADING_OWNER_ACCOUNT_ID = 811; + const THREADING_OWNER_EMAIL = 'threading.owner@vikings.net'; + const THREADING_APPROVER_ACCOUNT_ID = 812; + const THREADING_APPROVER_EMAIL = 'threading.approver@vikings.net'; + const THREADING_OTHER_MEMBER_EMAIL = 'threading.other@vikings.net'; + const THREADING_POLICY_ID = 'threading-policy'; + + /** + * The owner submits to the approver holding the report, and the other member submits to someone else. That + * lets a test tell apart the login the caller passed from the one the personal details store holds. + */ + async function setUpApprovalChain() { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [THREADING_OWNER_ACCOUNT_ID]: {accountID: THREADING_OWNER_ACCOUNT_ID, login: THREADING_OWNER_EMAIL}, + [THREADING_APPROVER_ACCOUNT_ID]: {accountID: THREADING_APPROVER_ACCOUNT_ID, login: THREADING_APPROVER_EMAIL}, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${THREADING_POLICY_ID}`, { + id: THREADING_POLICY_ID, + type: CONST.POLICY.TYPE.TEAM, + approver: THREADING_APPROVER_EMAIL, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + employeeList: { + [THREADING_OWNER_EMAIL]: {email: THREADING_OWNER_EMAIL, submitsTo: THREADING_APPROVER_EMAIL}, + [THREADING_OTHER_MEMBER_EMAIL]: {email: THREADING_OTHER_MEMBER_EMAIL, submitsTo: THREADING_OWNER_EMAIL}, + }, + }); + } + + /** A processing expense report submitted by the current user and sitting with its first approver. */ + function buildSubmittedReport(): Report { + return { + ...createRandomReport(910, undefined), + policyID: THREADING_POLICY_ID, + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + ownerAccountID: currentUserAccountID, + managerID: THREADING_APPROVER_ACCOUNT_ID, + }; + } + + it('should decide first level approval from the reportOwnerLogin the caller passes', async () => { + // Given a submitted report whose owner submits to the approver currently holding it + await setUpApprovalChain(); + const report = buildSubmittedReport(); + const transaction = {...createRandomTransaction(910), reportID: report.reportID, managedCard: false}; + const iouAction = buildIOUActionForTransaction(report.reportID, transaction.transactionID, currentUserAccountID); + + // When the owner login reaches canDeleteMoneyRequestReport, the report is still awaiting its first level + expect(canDeleteMoneyRequestReport(report, [transaction], [iouAction], currentUserAccountID, undefined, THREADING_OWNER_EMAIL, undefined, undefined)).toBe(true); + + // Then passing a member who submits elsewhere flips the answer, which proves the login is carried + // through rather than resolved from the personal details store inside the call + expect(canDeleteMoneyRequestReport(report, [transaction], [iouAction], currentUserAccountID, undefined, THREADING_OTHER_MEMBER_EMAIL, undefined, undefined)).toBe(false); + }); + + it('should resolve the report owner from the personalDetails canDeleteReportAction is given', async () => { + // Given the same submitted report, reachable by reportID, and its report preview action + await setUpApprovalChain(); + const report = buildSubmittedReport(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + const transaction = {...createRandomTransaction(911), reportID: report.reportID, managedCard: false}; + const iouAction = buildIOUActionForTransaction(report.reportID, transaction.transactionID, currentUserAccountID); + const reportPreviewAction = { + ...createRandomReportAction(911), + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + actorAccountID: THREADING_APPROVER_ACCOUNT_ID, + } as ReportAction; + const transactions = {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction}; + const childReportActions = {[iouAction.reportActionID]: iouAction}; + + // When the list maps the owner to a login that submits to the approver holding the report + const ownerDetails = {[currentUserAccountID]: {accountID: currentUserAccountID, login: THREADING_OWNER_EMAIL}}; + expect( + canDeleteReportAction(reportPreviewAction, report.reportID, transaction, transactions, childReportActions, currentUserAccountID, undefined, ownerDetails, undefined), + ).toBe(true); + + // Then a list mapping the owner to a member who submits elsewhere flips the answer, which proves the + // owner login is resolved from the list the caller passed + const otherDetails = {[currentUserAccountID]: {accountID: currentUserAccountID, login: THREADING_OTHER_MEMBER_EMAIL}}; + expect( + canDeleteReportAction(reportPreviewAction, report.reportID, transaction, transactions, childReportActions, currentUserAccountID, undefined, otherDetails, undefined), + ).toBe(false); + }); }); }); @@ -8015,6 +8326,68 @@ describe('ReportUtils', () => { ).toBe(false); }); + it('should allow editing the RECEIPT of a manual expense, but not a scan expense, while the receipt is SCAN_READY', async () => { + // Given an OPEN expense report the approver can edit, with an expense whose receipt is waiting to be scanned + const openExpenseReport: Report = { + reportID: '12360', + policyID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: submitterAccountID, + managerID: ownerAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const scanReadyTransaction: Transaction = { + transactionID: 'txn12360', + reportID: openExpenseReport.reportID, + amount: 1000, + currency: CONST.CURRENCY.USD, + comment: {comment: 'Test expense'}, + created: '2025-01-01', + merchant: 'Test Merchant', + receipt: {source: 'https://example.com/receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCAN_READY}, + }; + + const moneyRequestAction: ReportAction = { + reportActionID: 'action12360', + actorAccountID: submitterAccountID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: { + IOUReportID: openExpenseReport.reportID, + IOUTransactionID: scanReadyTransaction.transactionID, + amount: 1000, + currency: CONST.CURRENCY.USD, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }, + message: [{type: 'COMMENT', html: 'USD 10.00 expense', text: 'USD 10.00 expense', isEdited: false, whisperedTo: [], isDeletedParentAction: false, deleted: ''}], + created: '2025-01-01 12:00:00', + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${openExpenseReport.reportID}`, openExpenseReport); + await waitForBatchedUpdates(); + + const canEditReceipt = (transaction: Transaction) => + canEditFieldOfMoneyRequest({ + rules: undefined, + reportAction: moneyRequestAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, + transaction, + report: openExpenseReport, + policy: policyWithWorkflow, + reportNameValuePairs: undefined, + }); + + // When checking whether the receipt can be edited during the scan, once for a manual expense and once for a scan expense + const canEditManualReceipt = canEditReceipt({...scanReadyTransaction, iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL}); + const canEditScanReceipt = canEditReceipt({...scanReadyTransaction, iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN}); + + // Then the backend scan the user never sees doesn't block the manual expense's receipt edit, + // but the scan expense's receipt edit stays blocked until the scan finishes because the user sees that scan in progress + expect(canEditManualReceipt).toBe(true); + expect(canEditScanReceipt).toBe(false); + }); + it('should NOT allow workflow approver to edit money request on SUBMITTED expense report', async () => { const submittedExpenseReport: Report = { reportID: '12349', @@ -10503,7 +10876,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); // Then the owner cannot delete it because the card transaction's liability type restricts deletion - expect(canDeleteReportAction(moneyRequestAction, expenseReport.reportID, transaction, undefined, undefined, currentUserAccountID, undefined, undefined)).toBe(false); + expect(canDeleteReportAction(moneyRequestAction, expenseReport.reportID, transaction, undefined, undefined, currentUserAccountID, undefined, undefined, undefined)).toBe(false); }); it('should let an admin delete a draft report they did not submit from its report preview', async () => { @@ -10527,7 +10900,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}9002-policy`, {...createRandomPolicy(1), id: '9002-policy', role: CONST.POLICY.ROLE.ADMIN}); // Then the admin can delete it, because deleting a preview deletes the whole report - expect(canDeleteReportAction(reportPreviewAction, draftReport.reportID, undefined, undefined, undefined, currentUserAccountID, undefined, undefined)).toBe(true); + expect(canDeleteReportAction(reportPreviewAction, draftReport.reportID, undefined, undefined, undefined, currentUserAccountID, undefined, undefined, undefined)).toBe(true); }); it('should return true for demo transaction', () => { @@ -10571,7 +10944,7 @@ describe('ReportUtils', () => { }, }; - expect(canDeleteReportAction(moneyRequestAction, '1', transaction, undefined, undefined, currentUserAccountID, undefined, undefined)).toBe(true); + expect(canDeleteReportAction(moneyRequestAction, '1', transaction, undefined, undefined, currentUserAccountID, undefined, undefined, undefined)).toBe(true); }); it('should return false for unreported card expense imported with deleting disabled', async () => { @@ -10617,7 +10990,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); // Then it should return false since the unreported card expense is imported with deleting disabled - expect(canDeleteReportAction(trackExpenseAction, selfDMReport.reportID, transaction, undefined, undefined, currentUserAccountID, undefined, undefined)).toBe(false); + expect(canDeleteReportAction(trackExpenseAction, selfDMReport.reportID, transaction, undefined, undefined, currentUserAccountID, undefined, undefined, undefined)).toBe(false); }); it("should return false for ADD_COMMENT report action the current user (admin of the personal policy) didn't comment", async () => { @@ -10644,7 +11017,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${adminPolicy.id}`, adminPolicy); - expect(canDeleteReportAction(reportAction, report.reportID, undefined, undefined, undefined, currentUserAccountID, undefined, undefined)).toBe(false); + expect(canDeleteReportAction(reportAction, report.reportID, undefined, undefined, undefined, currentUserAccountID, undefined, undefined, undefined)).toBe(false); }); }); @@ -10760,6 +11133,76 @@ describe('ReportUtils', () => { }); }); + describe('getChatReportFromMoneyRequestReport', () => { + const ownerAccountID = 97769; + const policyID = 'getChatReportFromMoneyRequestReportPolicy'; + const policyExpenseChat: Report = { + ...createRandomReport(97769001, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), + type: CONST.REPORT.TYPE.CHAT, + ownerAccountID, + policyID, + }; + + beforeAll(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); + }); + + afterAll(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, null); + }); + + it('should return the report itself when it is not a money request report', () => { + // Given a chat report, which is already the destination of any expense created from it + // When resolving its chat report + // Then the report itself is returned + expect(getChatReportFromMoneyRequestReport(policyExpenseChat)).toBe(policyExpenseChat); + }); + + it('should return the linked chat when the expense report has a chatReportID', () => { + // Given an expense report that carries its link to the workspace chat + const expenseReport: Report = { + ...createRandomReport(97769002, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + chatReportID: policyExpenseChat.reportID, + }; + + // When resolving its chat report + // Then the linked chat is returned + expect(getChatReportFromMoneyRequestReport(expenseReport)?.reportID).toBe(policyExpenseChat.reportID); + }); + + it("should fall back to the owner's policy expense chat when the expense report has no chatReportID", () => { + // Given an expense report received from another session, which arrives without chatReportID and parentReportID + const expenseReport: Report = { + ...createRandomReport(97769003, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + chatReportID: undefined, + parentReportID: undefined, + ownerAccountID, + policyID, + }; + + // When resolving its chat report + // Then its workspace chat is recovered from its own owner and policy, so the expense is not sent anywhere else + expect(getChatReportFromMoneyRequestReport(expenseReport)?.reportID).toBe(policyExpenseChat.reportID); + }); + + it('should return undefined when an IOU report has no chatReportID', () => { + // Given an IOU report without chatReportID, whose DM cannot be recovered from a policy + const iouReport: Report = { + ...createRandomReport(97769004, undefined), + type: CONST.REPORT.TYPE.IOU, + chatReportID: undefined, + ownerAccountID, + policyID, + }; + + // When resolving its chat report + // Then nothing is returned instead of guessing a workspace chat + expect(getChatReportFromMoneyRequestReport(iouReport)).toBeUndefined(); + }); + }); + describe('findLastAccessedReport', () => { let archivedReport: Report; let normalReport: Report; @@ -10808,7 +11251,7 @@ describe('ReportUtils', () => { const reportNameValuePairsCollection = { [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${archivedReport.reportID}`]: {private_isArchived: DateUtils.getDBTime()}, }; - const result = findLastAccessedReport(false, undefined, false, undefined, reportNameValuePairsCollection); + const result = findLastAccessedReport(false, undefined, false, false, undefined, reportNameValuePairsCollection); // Even though the archived report has a more recent lastVisitTime, // the function should filter it out and return the normal report @@ -10831,11 +11274,11 @@ describe('ReportUtils', () => { // When resolving with visit data present // Then the visited report wins, because any visit time outranks a lastReadTime-only report - expect(findLastAccessedReport(false, undefined, false, undefined, undefined, reports)?.reportID).toBe(normalReport.reportID); + expect(findLastAccessedReport(false, undefined, false, false, undefined, undefined, reports)?.reportID).toBe(normalReport.reportID); // When the only visited report is excluded // Then the lookup falls back to the most recently read eligible report - expect(findLastAccessedReport(false, undefined, false, normalReport.reportID, undefined, reports)?.reportID).toBe(unvisitedReport.reportID); + expect(findLastAccessedReport(false, undefined, false, false, normalReport.reportID, undefined, reports)?.reportID).toBe(unvisitedReport.reportID); }); }); describe('findLastAccessedReport should return owned report if no reports was accessed before', () => { @@ -10883,7 +11326,7 @@ describe('ReportUtils', () => { }); it('findLastAccessedReport should return owned report if no reports was accessed before', () => { - const result = findLastAccessedReport(false, undefined); + const result = findLastAccessedReport(false, undefined, false); // Even though the archived report has a more recent lastVisitTime, // the function should filter it out and return the normal report @@ -10922,33 +11365,33 @@ describe('ReportUtils', () => { it('should resolve a report from the passed collection while the stored reports are still empty', () => { // Nothing is in Onyx yet, so the copy the function reads by default holds no reports. - expect(findLastAccessedReport(false, undefined)).toBeUndefined(); + expect(findLastAccessedReport(false, undefined, false)).toBeUndefined(); const reports: OnyxCollection = { [`${ONYXKEYS.COLLECTION.REPORT}${providedReport.reportID}`]: providedReport, }; - expect(findLastAccessedReport(false, undefined, false, undefined, undefined, reports)?.reportID).toBe(providedReport.reportID); + expect(findLastAccessedReport(false, undefined, false, false, undefined, undefined, reports)?.reportID).toBe(providedReport.reportID); }); it('should prefer the passed collection over the stored reports', async () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${storedReport.reportID}`, storedReport); await waitForBatchedUpdates(); - expect(findLastAccessedReport(false, undefined)?.reportID).toBe(storedReport.reportID); + expect(findLastAccessedReport(false, undefined, false)?.reportID).toBe(storedReport.reportID); const reports: OnyxCollection = { [`${ONYXKEYS.COLLECTION.REPORT}${providedReport.reportID}`]: providedReport, }; - expect(findLastAccessedReport(false, undefined, false, undefined, undefined, reports)?.reportID).toBe(providedReport.reportID); + expect(findLastAccessedReport(false, undefined, false, false, undefined, undefined, reports)?.reportID).toBe(providedReport.reportID); }); it('should fall back to the stored reports when no collection is passed', async () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${storedReport.reportID}`, storedReport); await waitForBatchedUpdates(); - expect(findLastAccessedReport(false, undefined)?.reportID).toBe(storedReport.reportID); + expect(findLastAccessedReport(false, undefined, false)?.reportID).toBe(storedReport.reportID); }); }); @@ -11884,40 +12327,123 @@ describe('ReportUtils', () => { }); }); - describe('isReportOutstanding', () => { - it('should return true for submitted reports if the report is awaiting for first level approval', async () => { - const report: Report = { - ...createRandomReport(1, undefined), - policyID: policy.id, - type: CONST.REPORT.TYPE.EXPENSE, - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - managerID: currentUserAccountID, - }; + describe('isAwaitingFirstLevelApproval', () => { + const REPORT_OWNER_ACCOUNT_ID = 801; + const REPORT_OWNER_EMAIL = 'owner@vikings.net'; + const FIRST_APPROVER_ACCOUNT_ID = 802; + const FIRST_APPROVER_EMAIL = 'first.approver@vikings.net'; + const SECOND_APPROVER_ACCOUNT_ID = 803; + const SECOND_APPROVER_EMAIL = 'second.approver@vikings.net'; + const OTHER_MEMBER_EMAIL = 'other.member@vikings.net'; + + /** + * The owner submits to the first approver, and the other member submits to the second one. The policy default + * approver is the second approver, so an owner login that matches no employee resolves somewhere different again. + * That lets each test tell apart the passed login, the stored login, and the default. + */ + async function setUpApprovalChain() { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [REPORT_OWNER_ACCOUNT_ID]: {accountID: REPORT_OWNER_ACCOUNT_ID, login: REPORT_OWNER_EMAIL}, + [FIRST_APPROVER_ACCOUNT_ID]: {accountID: FIRST_APPROVER_ACCOUNT_ID, login: FIRST_APPROVER_EMAIL}, + [SECOND_APPROVER_ACCOUNT_ID]: {accountID: SECOND_APPROVER_ACCOUNT_ID, login: SECOND_APPROVER_EMAIL}, + }); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { ...policy, - approver: currentUserEmail, - }); - await Onyx.set(`${ONYXKEYS.PERSONAL_DETAILS_LIST}`, { - [currentUserAccountID]: { - accountID: currentUserAccountID, - login: currentUserEmail, + approver: SECOND_APPROVER_EMAIL, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + employeeList: { + [REPORT_OWNER_EMAIL]: {email: REPORT_OWNER_EMAIL, submitsTo: FIRST_APPROVER_EMAIL}, + [OTHER_MEMBER_EMAIL]: {email: OTHER_MEMBER_EMAIL, submitsTo: SECOND_APPROVER_EMAIL}, }, }); - expect(isReportOutstanding(report, policy.id, undefined, {})).toBe(true); - }); - it('should return false for submitted reports if we specify it', () => { - const report: Report = { - ...createRandomReport(1, undefined), + } + + /** A processing report owned by REPORT_OWNER_ACCOUNT_ID and currently sitting with the first approver. */ + function buildProcessingReport(): Report { + return { + ...createRandomReport(900, undefined), policyID: policy.id, type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + ownerAccountID: REPORT_OWNER_ACCOUNT_ID, + managerID: FIRST_APPROVER_ACCOUNT_ID, }; - expect(isReportOutstanding(report, policy.id, undefined, {}, false)).toBe(false); - }); - it('should return true for submitted reports if top most report ID is processing', async () => { - const report: Report = { + } + + it('should prefer the passed reportOwnerLogin over the login held in the personal details store', async () => { + // Given a report owned by an account whose stored login submits to the report manager + await setUpApprovalChain(); + const report = buildProcessingReport(); + + // When a different member's login is passed explicitly, and that member submits to someone else + const result = isAwaitingFirstLevelApproval(report, undefined, OTHER_MEMBER_EMAIL); + + // Then the passed login decides the approver, so the report is not awaiting its first level. + // Were the parameter ignored in favour of the store, the stored owner would match the manager and this would be true. + expect(result).toBe(false); + }); + + it('should fall back to the personal details store when reportOwnerLogin is undefined', async () => { + // Given the same report, and no login passed by the caller + await setUpApprovalChain(); + const report = buildProcessingReport(); + + // When the owner login is left unresolved + const result = isAwaitingFirstLevelApproval(report, undefined, undefined); + + // Then the store supplies the owner login, which submits to the manager + expect(result).toBe(true); + }); + + it('should treat an empty reportOwnerLogin as unresolved rather than looking it up', async () => { + // Given the same report, and a caller holding an optimistic personal detail, whose login is an empty string + await setUpApprovalChain(); + const report = buildProcessingReport(); + + // When that empty login is passed + const result = isAwaitingFirstLevelApproval(report, undefined, ''); + + // Then it is discarded in favour of the store, rather than reaching the approval lookup and matching no + // employee, which would fall through to the policy default approver and wrongly report false + expect(result).toBe(true); + }); + }); + + describe('isReportOutstanding', () => { + it('should return true for submitted reports if the report is awaiting for first level approval', async () => { + const report: Report = { + ...createRandomReport(1, undefined), + policyID: policy.id, + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: currentUserAccountID, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + ...policy, + approver: currentUserEmail, + }); + await Onyx.set(`${ONYXKEYS.PERSONAL_DETAILS_LIST}`, { + [currentUserAccountID]: { + accountID: currentUserAccountID, + login: currentUserEmail, + }, + }); + expect(isReportOutstanding(report, policy.id, undefined, {})).toBe(true); + }); + it('should return false for submitted reports if we specify it', () => { + const report: Report = { + ...createRandomReport(1, undefined), + policyID: policy.id, + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }; + expect(isReportOutstanding(report, policy.id, undefined, {}, false)).toBe(false); + }); + it('should return true for submitted reports if top most report ID is processing', async () => { + const report: Report = { ...createRandomReport(1, undefined), policyID: policy.id, type: CONST.REPORT.TYPE.EXPENSE, @@ -12410,6 +12936,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, }); @@ -12445,6 +12972,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived, allReportActions: reportActions, cardList, @@ -12544,6 +13072,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, }); @@ -12591,6 +13120,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, }); @@ -12634,6 +13164,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, }); expect(resultWithout?.reason).not.toBe(CONST.REQUIRES_ATTENTION_REASONS.HAS_DEW_APPROVE_FAILED); @@ -12644,6 +13175,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, allReportActions: allReportActionsParam, }); @@ -12693,6 +13225,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, allReportActions: allReportActionsParam, }); @@ -12707,6 +13240,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID: ownerA, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, allReportActions: allReportActionsParam, }); @@ -12715,6 +13249,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID: bystanderC, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, allReportActions: allReportActionsParam, }); @@ -12737,6 +13272,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, allReportActions: allReportActionsParam, }); @@ -12745,6 +13281,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID: ownerA, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, allReportActions: allReportActionsParam, }); @@ -12753,6 +13290,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID: bystanderC, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, allReportActions: allReportActionsParam, }); @@ -12776,6 +13314,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID: accountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, allReportActions: allReportActionsParam, }), @@ -12800,6 +13339,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, allReportActions: allReportActionsParam, }); @@ -12842,6 +13382,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, allReportActions: allReportActionsParam, }); @@ -12875,6 +13416,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, }); @@ -12912,6 +13454,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, }); @@ -12966,6 +13509,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, }); @@ -13019,6 +13563,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, }); @@ -13076,6 +13621,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, }); @@ -13131,6 +13677,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, }); @@ -13185,6 +13732,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, }); @@ -13240,6 +13788,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, }); expect(resultWithoutParam?.reason).toBe(CONST.REQUIRES_ATTENTION_REASONS.IS_WAITING_FOR_ASSIGNEE_TO_COMPLETE_ACTION); @@ -13256,6 +13805,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, policies: policiesParam, }); @@ -13309,6 +13859,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, }); @@ -13371,6 +13922,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, }); // Then the action badge wins over the mention, even though the mention came first @@ -13412,6 +13964,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, }); // Then the Pay badge is shown and links to the IOU preview @@ -13450,6 +14003,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, }); // Then the mention reason is returned with the oldest unread mention, so the LHN can deep link to it @@ -13479,6 +14033,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, }); // Then the IOU action still wins over the mention, even though no badge could be computed for it @@ -13507,6 +14062,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, }); // Then the mention still returns a reason instead of the invoice room branch returning null @@ -13555,6 +14111,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, }); // Then the green dot links to the only mention the user can see and act on @@ -13586,6 +14143,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, }); // Then @here counts as a mention of the current user, so the green dot links to it @@ -13619,6 +14177,7 @@ describe('ReportUtils', () => { currentUserLogin: 'someone-else@example.com', currentUserAccountID: passedAccountID, transactionViolations: undefined, + reportMetadata: undefined, }); // Then the mention is found through the passed account ID @@ -13654,6 +14213,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, }); // Then the assignee expense action still wins over the mention, even without a badge @@ -13709,6 +14269,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, }); @@ -13789,6 +14350,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: isReportArchived.current, }); @@ -13832,6 +14394,44 @@ describe('ReportUtils', () => { // Then it cannot be edited expect(result).toBeFalsy(); }); + + it.each([CONST.POLICY.ROLE.AUDITOR, CONST.POLICY.ROLE.PAYMENTS_ADMIN])('should return false for a %s on a policy room they belong to', async (role) => { + // Given a policy room the current user belongs to, in a workspace where they only have read access to all reports + const report: Report = { + ...createRandomReport(40003, CONST.REPORT.CHAT_TYPE.POLICY_ROOM), + participants: buildParticipantsFromAccountIDs([currentUserAccountID, 1]), + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + + // When it's checked if the description can be edited + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.reportID)); + const result = canEditReportDescription(report, {...policy, type: CONST.POLICY.TYPE.CORPORATE, role}, isReportArchived.current); + + // Then it cannot be edited, because effective chat write access doesn't include room administration + expect(result).toBeFalsy(); + }); + }); + + describe('canEditReportPolicy for roles with read-only access to all reports', () => { + it.each([ + [CONST.POLICY.ROLE.AUDITOR, false], + [CONST.POLICY.ROLE.PAYMENTS_ADMIN, false], + [CONST.POLICY.ROLE.ADMIN, true], + ])('should handle an open invoice report for a %s', (role, expected) => { + // Given an open invoice report in a workspace where the current user has the role + const invoiceReport: Report = { + ...createRandomReport(40004, undefined), + type: CONST.REPORT.TYPE.INVOICE, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + // When it's checked if the report's workspace can be changed + const result = canEditReportPolicy(invoiceReport, {...policy, type: CONST.POLICY.TYPE.CORPORATE, role}); + + // Then only a role that can administer reports may change it + expect(result).toBe(expected); + }); }); describe('isDeprecatedGroupDM', () => { @@ -14957,7 +15557,7 @@ describe('ReportUtils', () => { it('should return true for root group chat', () => { const report: Report = createRandomReport(1, CONST.REPORT.CHAT_TYPE.GROUP); - expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(true); + expect(canLeaveChat(report, undefined, currentUserAccountID, false, false)).toBe(true); }); it('should return false for a task off a group chat', () => { @@ -14969,7 +15569,7 @@ describe('ReportUtils', () => { participants: buildParticipantsFromAccountIDs([currentUserAccountID]), }; - expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(false); + expect(canLeaveChat(report, undefined, currentUserAccountID, false, false)).toBe(false); }); it('should return true for policy expense chat if the user is not the owner and the user is not an admin', () => { @@ -14984,18 +15584,33 @@ describe('ReportUtils', () => { role: CONST.POLICY.ROLE.USER, }; - expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false)).toBe(true); + expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false, false)).toBe(true); }); - it('should return false if the chat is public room and the user is the guest', async () => { + it('should return true for the same public room when the user is not anonymous', () => { + // Given a public policy room the current user participates in, viewed by a regular (non-anonymous) user const report: Report = { ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_ROOM), visibility: CONST.REPORT.VISIBILITY.PUBLIC, + participants: buildParticipantsFromAccountIDs([currentUserAccountID]), }; - await Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail, accountID: currentUserAccountID, authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS}); + // When checking whether the user can leave it + // Then the user can leave it, which is what makes the anonymous-guest case meaningful + expect(canLeaveChat(report, undefined, currentUserAccountID, false, false)).toBe(true); + }); + + it('should return false if the chat is public room and the user is the guest', () => { + // Given the same public room, but viewed by an anonymous (signed-out) guest + const report: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_ROOM), + visibility: CONST.REPORT.VISIBILITY.PUBLIC, + participants: buildParticipantsFromAccountIDs([currentUserAccountID]), + }; - expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(false); + // When checking whether the guest can leave it + // Then the anonymous flag passed by the caller blocks leaving + expect(canLeaveChat(report, undefined, currentUserAccountID, true, false)).toBe(false); }); it('should return false if the report is hidden for the current user', async () => { @@ -15012,7 +15627,7 @@ describe('ReportUtils', () => { await Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail, accountID: currentUserAccountID}); - expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(false); + expect(canLeaveChat(report, undefined, currentUserAccountID, false, false)).toBe(false); }); it('should return false for selfDM reports', () => { @@ -15021,7 +15636,7 @@ describe('ReportUtils', () => { type: CONST.REPORT.TYPE.CHAT, }; - expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(false); + expect(canLeaveChat(report, undefined, currentUserAccountID, false, false)).toBe(false); }); it('should return false for the public announce room if the user is a member of the policy', () => { @@ -15035,7 +15650,7 @@ describe('ReportUtils', () => { role: CONST.POLICY.ROLE.USER, }; - expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false)).toBe(false); + expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false, false)).toBe(false); }); it('should return true for the invoice room if the user is not the sender or receiver', async () => { @@ -15056,7 +15671,7 @@ describe('ReportUtils', () => { role: CONST.POLICY.ROLE.USER, }; - expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false)).toBe(true); + expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false, false)).toBe(true); }); it('should return true for chat thread if the user is joined', async () => { @@ -15070,7 +15685,7 @@ describe('ReportUtils', () => { await Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail, accountID: currentUserAccountID}); - expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(true); + expect(canLeaveChat(report, undefined, currentUserAccountID, false, false)).toBe(true); }); it('should return true for user created policy room', async () => { @@ -15086,7 +15701,7 @@ describe('ReportUtils', () => { role: CONST.POLICY.ROLE.USER, }; - expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false)).toBe(true); + expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false, false)).toBe(true); }); }); @@ -15184,6 +15799,7 @@ describe('ReportUtils', () => { }); it('should respect workspace membership for restricted visibility rooms', async () => { + // Given a workspace and reports with restricted and public visibility const policyID = '123456'; await Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail, accountID: currentUserAccountID}); @@ -15200,6 +15816,13 @@ describe('ReportUtils', () => { employeeList: {}, }; + const policyWithGuestUser: Policy = { + ...createRandomPolicy(3), + id: policyID, + role: CONST.POLICY.ROLE.GUEST, + employeeList: {}, + }; + const restrictedReport: Report = { ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_ROOM), type: CONST.REPORT.TYPE.CHAT, @@ -15219,9 +15842,16 @@ describe('ReportUtils', () => { visibility: CONST.REPORT.VISIBILITY.PUBLIC, }; + // When checking whether a user can join restricted rooms + // Then regular workspace members can join, but non-members and guests cannot expect(canJoinChat(restrictedReport, undefined, policyWithoutCurrentUser, undefined)).toBe(false); expect(canJoinChat(restrictedReport, undefined, policyWithCurrentUser, undefined)).toBe(true); + expect(canJoinChat(restrictedReport, undefined, policyWithGuestUser, undefined)).toBe(false); + + // When checking whether a user can join public rooms + // Then non-members and guests can both join expect(canJoinChat(publicReport, undefined, policyWithoutCurrentUser, undefined)).toBe(true); + expect(canJoinChat(publicReport, undefined, policyWithGuestUser, undefined)).toBe(true); }); }); @@ -15965,7 +16595,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, chatReport); // When we check if the report is eligible for merge - const result = isMoneyRequestReportEligibleForMerge(mockReportID, true, undefined); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, true, undefined, undefined); // Then it should return false because it's not a money request report expect(result).toBe(false); @@ -15976,7 +16606,7 @@ describe('ReportUtils', () => { const nonExistentReportID = 'nonexistent123'; // When we check if the report is eligible for merge - const result = isMoneyRequestReportEligibleForMerge(nonExistentReportID, true, undefined); + const result = isMoneyRequestReportEligibleForMerge(nonExistentReportID, true, undefined, undefined); // Then it should return false because the report doesn't exist expect(result).toBe(false); @@ -15994,7 +16624,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, iouReport); // When we check if the report is eligible for merge as a submitter - const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined, undefined); // Then it should return true because submitters can merge processing IOU reports expect(result).toBe(false); @@ -16012,7 +16642,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport); // When we check if the report is eligible for merge as an admin - const result = isMoneyRequestReportEligibleForMerge(mockReportID, true, undefined); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, true, undefined, undefined); // Then it should return true because admins can merge open expense reports expect(result).toBe(true); @@ -16029,7 +16659,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport); // When we check if the report is eligible for merge as an admin - const result = isMoneyRequestReportEligibleForMerge(mockReportID, true, undefined); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, true, undefined, undefined); // Then it should return true because admins can merge processing expense reports expect(result).toBe(true); @@ -16046,7 +16676,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport); // When we check if the report is eligible for merge as an admin - const result = isMoneyRequestReportEligibleForMerge(mockReportID, true, undefined); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, true, undefined, undefined); // Then it should return false because approved reports are not eligible for merge expect(result).toBe(false); @@ -16066,7 +16696,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport); // When we check if the report is eligible for merge as a submitter - const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined, undefined); // Then it should return true because submitters can merge open expense reports expect(result).toBe(true); @@ -16104,7 +16734,7 @@ describe('ReportUtils', () => { }); // When we check if the report is eligible for merge as a submitter - const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined, undefined); // Then it should return true because submitters can merge processing expense reports expect(result).toBe(true); @@ -16122,7 +16752,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport); // When we check if the report is eligible for merge as a submitter - const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined, undefined); // Then the result depends on the actual approval level logic in the implementation expect(typeof result).toBe('boolean'); @@ -16140,7 +16770,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport); // When we check if the report is eligible for merge as a non-submitter - const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined, undefined); // Then it should return false because the user is not the submitter and not an admin expect(result).toBe(false); @@ -16163,7 +16793,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport); // When we check if the report is eligible for merge as a manager - const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined, undefined); // Then it should return true because managers can merge processing expense reports expect(result).toBe(true); @@ -16182,7 +16812,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport); // When we check if the report is eligible for merge as a manager - const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined, undefined); // Then it should return false because managers can only merge processing expense reports, not open ones expect(result).toBe(false); @@ -16201,7 +16831,7 @@ describe('ReportUtils', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport); // When we check if the report is eligible for merge as a non-manager - const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined); + const result = isMoneyRequestReportEligibleForMerge(mockReportID, false, undefined, undefined); // Then it should return false because the user is not the manager, submitter, or admin expect(result).toBe(false); @@ -16823,6 +17453,39 @@ describe('ReportUtils', () => { }); }); + describe('getParticipantsAccountIDsForDisplay', () => { + const reportWithPendingDeleteMember: Report = { + ...createRandomReport(90), + participants: { + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + }; + const reportMetadata: OnyxEntry = { + pendingChatMembers: [{accountID: '2', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}], + }; + + it('should exclude the member pending removal when the caller passes the report metadata', () => { + // Given a report whose metadata marks member 2 as pending removal + // When the caller asks for the participants to display and passes that metadata + const participantAccountIDs = getParticipantsAccountIDsForDisplay(reportWithPendingDeleteMember, reportMetadata, false, true); + + // Then member 2 is left out + expect(participantAccountIDs).toEqual([1]); + }); + + it('should not read the report metadata from Onyx when the caller does not pass it', async () => { + // Given the same pending removal stored in Onyx rather than passed in + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportWithPendingDeleteMember.reportID}`, reportMetadata); + + // When the caller asks for the participants to display without passing the metadata + const participantAccountIDs = getParticipantsAccountIDsForDisplay(reportWithPendingDeleteMember, undefined, false, true); + + // Then member 2 is still listed: the function no longer falls back to reading Onyx itself + expect(participantAccountIDs).toEqual([1, 2]); + }); + }); + describe('getReportURLForCurrentContext', () => { const flushPromises = () => new Promise((resolve) => { @@ -16864,20 +17527,22 @@ describe('ReportUtils', () => { expect(getReportURLForCurrentContext(reportID)).toBe(`${environmentURL}/${ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID, backTo: 'search?q=type:report'})}`); }); - it('decodes the backTo parameter only once so a nested backTo stays encoded', () => { + it('returns to the current expense when its backTo points to a sibling expense', () => { // Given an expense RHP opened from search and then paged through twice with the carousel arrows, so each press nested the previous route in backTo const reportID = '333'; mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); const firstExpenseRoute = ROUTES.SEARCH_REPORT.getRoute({reportID: '1', anchorTransactionID: '10', backTo: 'search?q=type:expense'}); const secondExpenseRoute = ROUTES.SEARCH_REPORT.getRoute({reportID: '2', anchorTransactionID: '20', backTo: firstExpenseRoute}); - mockGetActiveRoute.mockReturnValue(ROUTES.SEARCH_REPORT.getRoute({reportID: '1', anchorTransactionID: '10', backTo: secondExpenseRoute})); + const currentExpenseRoute = ROUTES.SEARCH_REPORT.getRoute({reportID: '1', anchorTransactionID: '10', backTo: secondExpenseRoute}); + mockGetActiveRoute.mockReturnValue(currentExpenseRoute); const getQuery = (route: string) => new URLSearchParams(route.slice(route.indexOf('?') + 1)); // When building the report link from that RHP const url = getReportURLForCurrentContext(reportID); - // Then the link's backTo is the previous expense route as-is, so its own backTo stays a single query key; a duplicate key would be parsed as an array and crash the RHP on back - expect(url).toBe(`${environmentURL}/${ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID, backTo: secondExpenseRoute})}`); + // Then the link's backTo is the expense currently shown, not the sibling stored in its backTo, so going back from the report reopens the same expense + expect(url).toBe(`${environmentURL}/${ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID, backTo: currentExpenseRoute})}`); + // And its own backTo stays a single query key; a duplicate key would be parsed as an array and crash the RHP on back expect(getQuery(getQuery(url).get('backTo') ?? '').getAll('backTo')).toHaveLength(1); }); @@ -16922,56 +17587,106 @@ describe('ReportUtils', () => { describe('isWorkspaceMemberLeavingWorkspaceRoom', () => { test('should return false when not a policy employee', () => { + // Given a restricted policy expense chat and a user who is not a policy employee const report = { ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), visibility: CONST.REPORT.VISIBILITY.RESTRICTED, isOwnPolicyExpenseChat: true, }; - expect(isWorkspaceMemberLeavingWorkspaceRoom(report, false, true)).toBe(false); + + // When checking if the member is leaving the workspace room + const result = isWorkspaceMemberLeavingWorkspaceRoom(report, false, true); + + // Then it should return false + expect(result).toBe(false); }); test('should return true for restricted room when policy employee', () => { + // Given a restricted room and a user who is a policy employee const report = { ...createRandomReport(2, undefined), visibility: CONST.REPORT.VISIBILITY.RESTRICTED, }; - expect(isWorkspaceMemberLeavingWorkspaceRoom(report, true, false)).toBe(true); + + // When checking if the member is leaving the workspace room + const result = isWorkspaceMemberLeavingWorkspaceRoom(report, true, false); + + // Then it should return true + expect(result).toBe(true); }); test('should return true for policy expense chat when own chat and policy employee', () => { + // Given a public policy expense chat owned by the user who is a policy employee const report = { ...createRandomReport(3, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), visibility: CONST.REPORT.VISIBILITY.PUBLIC, isOwnPolicyExpenseChat: true, }; - expect(isWorkspaceMemberLeavingWorkspaceRoom(report, true, false)).toBe(true); + + // When checking if the member is leaving the workspace room + const result = isWorkspaceMemberLeavingWorkspaceRoom(report, true, false); + + // Then it should return true + expect(result).toBe(true); }); test('should return true for policy expense chat when policy admin and policy employee', () => { + // Given a public policy expense chat not owned by the user, but the user is a policy admin and employee const report = { ...createRandomReport(4, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), visibility: CONST.REPORT.VISIBILITY.PUBLIC, isOwnPolicyExpenseChat: false, }; - expect(isWorkspaceMemberLeavingWorkspaceRoom(report, true, true)).toBe(true); + + // When checking if the member is leaving the workspace room + const result = isWorkspaceMemberLeavingWorkspaceRoom(report, true, true); + + // Then it should return true + expect(result).toBe(true); }); test('should return false for non-restricted, non-policy expense chat', () => { + // Given a public group chat const report = { ...createRandomReport(5, CONST.REPORT.CHAT_TYPE.GROUP), visibility: CONST.REPORT.VISIBILITY.PUBLIC, isOwnPolicyExpenseChat: false, }; - expect(isWorkspaceMemberLeavingWorkspaceRoom(report, true, false)).toBe(false); + + // When checking if the member is leaving the workspace room + const result = isWorkspaceMemberLeavingWorkspaceRoom(report, true, false); + + // Then it should return false + expect(result).toBe(false); }); test('should return false for non-restricted policy expense chat when not own chat and not admin', () => { + // Given a public policy expense chat not owned by the user and the user is not an admin const report = { ...createRandomReport(6, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), visibility: CONST.REPORT.VISIBILITY.PUBLIC, isOwnPolicyExpenseChat: false, }; - expect(isWorkspaceMemberLeavingWorkspaceRoom(report, true, false)).toBe(false); + + // When checking if the member is leaving the workspace room + const result = isWorkspaceMemberLeavingWorkspaceRoom(report, true, false); + + // Then it should return false + expect(result).toBe(false); + }); + + test('should return false for restricted room when user is a policy guest', () => { + // Given a restricted room and a user who is a policy guest + const report = { + ...createRandomReport(7, undefined), + visibility: CONST.REPORT.VISIBILITY.RESTRICTED, + }; + + // When checking if the guest member is leaving the workspace room + const result = isWorkspaceMemberLeavingWorkspaceRoom(report, true, false, true); + + // Then it should return false + expect(result).toBe(false); }); }); @@ -18305,6 +19020,29 @@ describe('ReportUtils', () => { expect(expenseReport.reportName).toBe(`${fakePolicy.name} owes ${convertToDisplayString(-total, currency)}`); }); + it('should keep the English workspace fallback in the report name when another locale is loaded', async () => { + // Given a chat report that cannot be resolved, so the report name falls back to the "unavailable workspace" copy + const chatReportID = '9999'; + const total = 100; + + // When the report is built while Spanish is the loaded locale + await IntlStore.load(CONST.LOCALES.ES).then(waitForBatchedUpdates); + const expenseReport = buildOptimisticExpenseReport({ + rules: undefined, + chatReportID, + policyID: undefined, + payeeAccountID: 1, + total, + currency: CONST.CURRENCY.USD, + isASAPSubmitBetaEnabled: true, + getCurrencyDecimals: getCurrencyDecimalsLocal, + }); + + // Then the name stays English, because it is persisted on the report and read back by every viewer regardless of their locale + expect(expenseReport.reportName).toContain(`${translate(CONST.LOCALES.EN, 'workspace.common.unavailable')} owes `); + expect(expenseReport.reportName).not.toContain(translate(CONST.LOCALES.ES, 'workspace.common.unavailable')); + }); + it('should set reportName to "New Report" when policy field list is empty', async () => { // Given a policy with an empty field list const policyID = '200'; @@ -19053,6 +19791,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: false, }); expect(reasonForAttention?.reason).toBe(CONST.REQUIRES_ATTENTION_REASONS.HAS_CHILD_REPORT_AWAITING_ACTION); @@ -19106,6 +19845,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: false, }); expect(reasonForAttention?.reason).toBe(undefined); @@ -19937,7 +20677,13 @@ describe('ReportUtils', () => { * Builds a submitted (or open) expense report owned by the current user, carrying the supplied violations on a * single transaction, and returns the pieces needed to call getViolatingReportIDForRBRInLHN. */ - async function setUpCompanyCardRequiredScenario(scenarioKey: string, violations: TransactionViolation[], isOpen = false, transactionOverrides: Partial = {}) { + async function setUpCompanyCardRequiredScenario( + scenarioKey: string, + violations: TransactionViolation[], + isOpen = false, + transactionOverrides: Partial = {}, + isInstantSubmit = false, + ) { const policyID = `policy-rbr-company-card-${scenarioKey}`; const chatReportID = `chat-rbr-company-card-${scenarioKey}`; const expenseReportID = `expense-rbr-company-card-${scenarioKey}`; @@ -19953,6 +20699,8 @@ describe('ReportUtils', () => { approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, employeeList: {[currentUserEmail]: {role: CONST.POLICY.ROLE.USER}}, owner: currentUserEmail, + // Instant-submit workspaces create reports already submitted, so the RBR must stay after submit + ...(isInstantSubmit ? {autoReporting: true, autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT} : {}), }; const chatReport: Report = { @@ -20012,10 +20760,17 @@ describe('ReportUtils', () => { async (violationType) => { await Onyx.clear(); - const {chatReport, transactionViolationsCollection} = await setUpCompanyCardRequiredScenario(`only-${violationType}`, [ - {name: CONST.VIOLATIONS.COMPANY_CARD_REQUIRED, type: violationType, showInReview: true}, - ]); + // Given a submitted instant-submit report with only companyCardRequired + const {chatReport, transactionViolationsCollection} = await setUpCompanyCardRequiredScenario( + `only-${violationType}`, + [{name: CONST.VIOLATIONS.COMPANY_CARD_REQUIRED, type: violationType, showInReview: true}], + false, + {}, + true, + ); + // When the Inbox checks for the red dot + // Then the dot stays off, because the submitter cannot fix this after submit expect(getViolatingReportIDForRBRInLHN(chatReport, transactionViolationsCollection)).toBeNull(); await Onyx.clear(); @@ -20036,26 +20791,32 @@ describe('ReportUtils', () => { await Onyx.clear(); }); - it('should still surface RBR on a submitted report when a resolvable violation sits next to companyCardRequired', async () => { + it('should still surface RBR on an instant-submit submitted report when a resolvable violation sits next to companyCardRequired', async () => { await Onyx.clear(); - const {chatReport, expenseReportID, transactionViolationsCollection} = await setUpCompanyCardRequiredScenario('alongside-resolvable', [ - {name: CONST.VIOLATIONS.COMPANY_CARD_REQUIRED, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, - {name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, - ]); + // Given a submitted instant-submit report with companyCardRequired and a violation the submitter can fix + const {chatReport, expenseReportID, transactionViolationsCollection} = await setUpCompanyCardRequiredScenario( + 'alongside-resolvable', + [ + {name: CONST.VIOLATIONS.COMPANY_CARD_REQUIRED, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, + {name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, + ], + false, + {}, + true, + ); + // When the Inbox checks for the red dot + // Then the dot stays on, because the fixable violation is still there expect(getViolatingReportIDForRBRInLHN(chatReport, transactionViolationsCollection)).toBe(expenseReportID); await Onyx.clear(); }); - // The visibility check and the type checks have to judge the same set of violations. If only the type checks - // drop `companyCardRequired`, the visibility check still sees it and lets it vouch for a violation the - // submitter cannot act on either, leaving the dot lit with nothing behind it. it('should not surface RBR when the violation left next to companyCardRequired is hidden while the category is being analyzed', async () => { await Onyx.clear(); - // Inside the 60-second auto-categorization grace period, so `shouldShowViolation` hides `missingCategory`. + // Given a submitted instant-submit report where the category violation is still hidden while it is being analyzed const pendingAutoCategorizationTime = new Date().toISOString().replace('T', ' ').slice(0, 19); const {chatReport, transactionViolationsCollection} = await setUpCompanyCardRequiredScenario( @@ -20066,24 +20827,62 @@ describe('ReportUtils', () => { ], false, {category: '', comment: {pendingAutoCategorizationTime}}, + true, ); + // When the Inbox checks for the red dot + // Then the dot stays off, because nothing left is something the submitter can fix expect(getViolatingReportIDForRBRInLHN(chatReport, transactionViolationsCollection)).toBeNull(); await Onyx.clear(); }); - // Guards the scope of the change: `modifiedAmount` has always been excluded from the notice check only, and it - // also arrives typed `violation` (the `modifiedAmount` checks in TransactionPreviewUtils match `VIOLATION` or - // `NOTICE`). Widening its exclusion to the hard-violation bucket would silently drop the RBR here. There is no - // evidence the back end ever types `modifiedAmount` as `warning`, so that bucket is deliberately not asserted. - it('should still surface RBR on a submitted report when the only violation is a modifiedAmount violation', async () => { + it('should still surface RBR on an instant-submit submitted report when the only violation is a modifiedAmount violation', async () => { await Onyx.clear(); - const {chatReport, expenseReportID, transactionViolationsCollection} = await setUpCompanyCardRequiredScenario('modified-amount-violation', [ - {name: CONST.VIOLATIONS.MODIFIED_AMOUNT, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, + // Given a submitted instant-submit report with only a modifiedAmount violation + const {chatReport, expenseReportID, transactionViolationsCollection} = await setUpCompanyCardRequiredScenario( + 'modified-amount-violation', + [{name: CONST.VIOLATIONS.MODIFIED_AMOUNT, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}], + false, + {}, + true, + ); + + // When the Inbox checks for the red dot + // Then the dot stays on, because this modifiedAmount can still be fixed + expect(getViolatingReportIDForRBRInLHN(chatReport, transactionViolationsCollection)).toBe(expenseReportID); + + await Onyx.clear(); + }); + + it('should not surface RBR on a non-instant-submit submitted report even with a resolvable violation', async () => { + await Onyx.clear(); + + // Given a submitted report that is not instant submit, with a violation the submitter can fix + const {chatReport, transactionViolationsCollection} = await setUpCompanyCardRequiredScenario('submitted-non-instant', [ + {name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, ]); + // When the Inbox checks for the red dot + // Then the dot stays off, because only instant submit keeps Fix after submit + expect(getViolatingReportIDForRBRInLHN(chatReport, transactionViolationsCollection)).toBeNull(); + + await Onyx.clear(); + }); + + it('should surface RBR on a draft report with a resolvable violation regardless of submit frequency', async () => { + await Onyx.clear(); + + // Given a draft report with a violation the submitter can fix + const {chatReport, expenseReportID, transactionViolationsCollection} = await setUpCompanyCardRequiredScenario( + 'draft-non-instant', + [{name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}], + true, + ); + + // When the Inbox checks for the red dot + // Then the dot stays on, because a draft can still be fixed expect(getViolatingReportIDForRBRInLHN(chatReport, transactionViolationsCollection)).toBe(expenseReportID); await Onyx.clear(); @@ -20487,6 +21286,7 @@ describe('ReportUtils', () => { currentUserLogin: currentUserEmail, currentUserAccountID, transactionViolations: undefined, + reportMetadata: undefined, isReportArchived: false, }); expect(reasonForAttention?.reason).toBe(CONST.REQUIRES_ATTENTION_REASONS.HAS_CHILD_REPORT_AWAITING_ACTION); @@ -20926,6 +21726,8 @@ describe('ReportUtils', () => { describe('settled report paid with a business bank account', () => { const settledPolicyID = '445'; + const reimburserEmail = 'reimburser@example.com'; + const reimburserAccountID = 445001; const settledPolicy: Policy = { ...createRandomPolicy(Number(settledPolicyID), CONST.POLICY.TYPE.TEAM), id: settledPolicyID, @@ -20935,7 +21737,7 @@ describe('ReportUtils', () => { routingNumber: '011401533', addressName: 'Settled Workspace', bankName: 'Test Bank', - reimburser: 'reimburser@example.com', + reimburser: reimburserEmail, }, }; const settledReport: Report = { @@ -20952,17 +21754,25 @@ describe('ReportUtils', () => { amount: 10000, currency: CONST.CURRENCY.USD, }; + // The workspace account is only a valid fallback for a payment made by the designated payer, so the + // action has to come from the reimburser for these previews to name it. const payReportAction: ReportAction = { - ...LHNTestUtils.getFakeReportAction(), + ...LHNTestUtils.getFakeReportAction(reimburserEmail), + actorAccountID: reimburserAccountID, actionName: CONST.REPORT.ACTIONS.TYPE.IOU, originalMessage: payOriginalMessage, }; beforeEach(async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[reimburserAccountID]: {accountID: reimburserAccountID, login: reimburserEmail}}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${settledPolicyID}`, settledPolicy); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${settledReport.reportID}`, settledReport); }); + afterEach(async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[reimburserAccountID]: null}); + }); + it('shows the bank account from the action accountNumber instead of the policy default', () => { // Given a pay action that carries the masked accountNumber of the bank account actually used const actionWithAccountNumber: ReportAction = { @@ -20988,6 +21798,57 @@ describe('ReportUtils', () => { expect(result).toBe(translate(CONST.LOCALES.EN, 'iou.businessBankAccount', '', '0000')); }); + describe('when the payment names the bank account it came from', () => { + // A paying admin picks the account to pay from, so a workspace payment now carries a bankAccountID the + // same way an invoice payment does. That must not turn the preview into the invoice wording + // ("paid $100.00 with personal account 6281"). It is a workspace payment and reads "paid with bank + // account 6281", which is also what PaymentContent renders on the action itself. + const actionNamingAccount: ReportAction = { + ...payReportAction, + originalMessage: {...payOriginalMessage, bankAccountID: 6281001, accountNumber: 'XXXXXX6281'}, + }; + + it('keeps the bank account wording in the localized preview', () => { + // Given a workspace payment that records the selected account ID and masked number. + // When the localized preview is generated. + // Then it uses workspace payment wording and the selected account’s digits. + const englishTranslate: LocalizedTranslate = (path, ...parameters) => translate(CONST.LOCALES.EN, path, ...parameters); + + const result = getReportPreviewMessage(englishTranslate, convertToDisplayString, { + reportOrID: settledReport, + iouReportAction: actionNamingAccount, + originalReportAction: actionNamingAccount, + policy: settledPolicy, + }); + + expect(result).toBe(translate(CONST.LOCALES.EN, 'iou.businessBankAccount', '', '6281')); + }); + + it('keeps the bank account wording in the stored report action message', () => { + // Given a workspace payment that names the admin’s selected bank account. + // When the stored English report action message is generated. + // Then it retains workspace payment wording rather than invoice wording. + const result = getReportPreviewReportActionMessage( + {reportOrID: settledReport, iouReportAction: actionNamingAccount, originalReportAction: actionNamingAccount, policy: settledPolicy}, + getCurrencyDecimalsLocal, + ); + + expect(result).toBe(translate(CONST.LOCALES.EN, 'iou.businessBankAccount', '', '6281')); + }); + + it('resolves the account through bankAccountList when the action only carries its ID', () => { + // Given the payer paid from an account other than the workspace one, and the action recorded only its ID + const actionNamingAccountID: ReportAction = {...payReportAction, originalMessage: {...payOriginalMessage, bankAccountID: 5678001}}; + const bankAccountList = createMock({5678001: {accountData: {accountNumber: 'XXXXXX5678'}}}); + const englishTranslate: LocalizedTranslate = (path, ...parameters) => translate(CONST.LOCALES.EN, path, ...parameters); + const params = {reportOrID: settledReport, iouReportAction: actionNamingAccountID, originalReportAction: actionNamingAccountID, policy: settledPolicy, bankAccountList}; + + // Then both previews name that account, matching PaymentContent, rather than the workspace default + expect(getReportPreviewMessage(englishTranslate, convertToDisplayString, params)).toBe(translate(CONST.LOCALES.EN, 'iou.businessBankAccount', '', '5678')); + expect(getReportPreviewReportActionMessage(params, getCurrencyDecimalsLocal)).toBe(translate(CONST.LOCALES.EN, 'iou.businessBankAccount', '', '5678')); + }); + }); + it('matches the localized getReportPreviewMessage output when translated to English', () => { const englishTranslate: LocalizedTranslate = (path, ...parameters) => translate(CONST.LOCALES.EN, path, ...parameters); const params = {reportOrID: settledReport, iouReportAction: payReportAction, originalReportAction: payReportAction, policy: settledPolicy}; @@ -21066,11 +21927,7 @@ describe('ReportUtils', () => { // The localized preview differs between English and Spanish... expect(getReportPreviewMessage(spanishTranslate, convertToDisplayString, params)).not.toBe(getReportPreviewMessage(englishTranslate, convertToDisplayString, params)); // ...but the report-action-message variant is always the English text, regardless of the loaded locale - - // TODO: Re-enable this assertion once getReportPreviewReportActionMessage is refactored - // This will be done in the next PR https://github.com/Expensify/App/issues/66430. - - // expect(getReportPreviewReportActionMessage(params, getCurrencyDecimalsLocal)).toBe(getReportPreviewMessage(englishTranslate, convertToDisplayString, params)); + expect(getReportPreviewReportActionMessage(params, getCurrencyDecimalsLocal)).toBe(getReportPreviewMessage(englishTranslate, convertToDisplayString, params)); }); it('routes the participant display name through the injected translate function', async () => { @@ -21121,6 +21978,30 @@ describe('ReportUtils', () => { expect(result).toContain('owes'); }); + it('names a participant with no display name using the English "Hidden" copy when another locale is loaded', async () => { + // Given an IOU report whose payer has no name, so the payer resolves to the "hidden" copy + const hiddenManagerAccountID = 5544332; + const report: Report = { + ...LHNTestUtils.getFakeReport(), + reportID: 'preview-en-hidden-report', + type: CONST.REPORT.TYPE.IOU, + currency: CONST.CURRENCY.USD, + managerID: hiddenManagerAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[hiddenManagerAccountID]: {accountID: hiddenManagerAccountID, login: '', displayName: ''}}); + + // When the message is built while Spanish is the loaded locale, which the cached module-level translation used to follow + await IntlStore.load(CONST.LOCALES.ES).then(waitForBatchedUpdates); + const result = getReportPreviewReportActionMessage({reportOrID: report}, getCurrencyDecimalsLocal); + + // Then the message stored on the report action names the participant in English, like the rest of its copy + expect(result).toContain(translate(CONST.LOCALES.EN, 'common.hidden')); + expect(result).not.toContain(translate(CONST.LOCALES.ES, 'common.hidden')); + }); + it('returns the English "spent" message when the report contains a non-reimbursable transaction, and matches en.ts', async () => { const report: Report = { ...LHNTestUtils.getFakeReport(), @@ -23450,6 +24331,10 @@ describe('ReportUtils', () => { }); describe('getPolicyName', () => { + // `unavailableTranslation` is required. The cases below all resolve a real policy name, so this is only the copy that + // must not surface in their assertions. + const unavailableTranslation = 'Unavailable workspace'; + const testPolicy: Policy = { ...createRandomPolicy(1), id: 'policy123', @@ -23482,6 +24367,7 @@ describe('ReportUtils', () => { const result = getPolicyName({ report: testReport, policy: testPolicy, + unavailableTranslation, }); expect(result).toBe('Test Policy Name'); }); @@ -23496,6 +24382,7 @@ describe('ReportUtils', () => { report: testReport, policy: testPolicy, policies: [otherPolicy], + unavailableTranslation, }); expect(result).toBe('Test Policy Name'); }); @@ -23512,6 +24399,7 @@ describe('ReportUtils', () => { const result = getPolicyName({ report: reportWithoutPolicyName, policy: testPolicy, + unavailableTranslation, }); expect(result).toBe('Test Policy Name'); }); @@ -23525,6 +24413,7 @@ describe('ReportUtils', () => { const result = getPolicyName({ report: testReport, policies, + unavailableTranslation, }); expect(result).toBe('Found In Array'); }); @@ -23537,6 +24426,7 @@ describe('ReportUtils', () => { const result = getPolicyName({ report: testReport, policies, + unavailableTranslation, }); expect(result).toBe('Report Policy Name'); }); @@ -23547,7 +24437,7 @@ describe('ReportUtils', () => { policyID: 'nonexistent', policyName: 'Fallback Policy Name', }; - const result = getPolicyName({report}); + const result = getPolicyName({report, unavailableTranslation}); expect(result).toBe('Fallback Policy Name'); }); @@ -23558,7 +24448,7 @@ describe('ReportUtils', () => { policyName: undefined, oldPolicyName: 'Old Fallback Name', }; - const result = getPolicyName({report}); + const result = getPolicyName({report, unavailableTranslation}); expect(result).toBe('Old Fallback Name'); }); @@ -23578,6 +24468,7 @@ describe('ReportUtils', () => { const result = getPolicyName({ report: childReport, reports: [parentReport, childReport], + unavailableTranslation, }); expect(result).toBe('Parent Policy Name'); }); @@ -23586,6 +24477,7 @@ describe('ReportUtils', () => { const result = getPolicyName({ report: testReport, policies: [], + unavailableTranslation, }); expect(result).toBe('Report Policy Name'); }); @@ -23594,6 +24486,7 @@ describe('ReportUtils', () => { const result = getPolicyName({ report: testReport, policy: testPolicy, + unavailableTranslation, }); expect(result).toBe('Test Policy Name'); }); @@ -23613,7 +24506,7 @@ describe('ReportUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${onyxPolicy.id}`, onyxPolicy); await waitForBatchedUpdates(); - const result = getPolicyName({report}); + const result = getPolicyName({report, unavailableTranslation}); expect(result).toBe('Policy From Onyx'); // Cleanup @@ -23630,6 +24523,7 @@ describe('ReportUtils', () => { const result = getPolicyName({ report, policies: [], + unavailableTranslation, }); expect(result).toBe('Report Fallback Name'); }); @@ -23666,7 +24560,7 @@ describe('ReportUtils', () => { }; // No policy or policies passed, but allPolicies has the policy via Onyx - const result = getPolicyName({report}); + const result = getPolicyName({report, unavailableTranslation}); expect(result).toBe('Guard Test Policy'); // Cleanup @@ -23684,11 +24578,27 @@ describe('ReportUtils', () => { expect(result).toBe('Custom Unavailable'); }); - it('should prefer unavailableTranslation param over cached module-level translation', () => { + it('should use unavailableTranslation param when the report is missing entirely', () => { const result = getPolicyName({report: null, unavailableTranslation: 'Passed In'}); expect(result).toBe('Passed In'); }); + it('should return the passed copy rather than the loaded locale, now that the module-level cache is gone', async () => { + // Given a report whose policy cannot be resolved, so the fallback copy is what gets returned + const report: Report = { + ...createRandomReport(1, undefined), + policyID: 'nonexistent', + policyName: undefined, + oldPolicyName: undefined, + }; + + // When Spanish is the loaded locale, which used to drive the cached module-level translation + await IntlStore.load(CONST.LOCALES.ES).then(waitForBatchedUpdates); + + // Then the caller's string is returned untouched: the fallback no longer reads translations behind the caller's back + expect(getPolicyName({report, unavailableTranslation: translate(CONST.LOCALES.EN, 'workspace.common.unavailable')})).toBe('Unavailable workspace'); + }); + it('should not use unavailableTranslation when returnEmptyIfNotFound is true', () => { const report: Report = { ...createRandomReport(1, undefined), @@ -23947,6 +24857,149 @@ describe('ReportUtils', () => { }); }); + describe('isOneTransactionThread', () => { + const parentReport = createMock({reportID: 'one-transaction-parent', type: CONST.REPORT.TYPE.EXPENSE, chatReportID: 'one-transaction-chat'}); + const threadReport = createMock({reportID: 'one-transaction-thread', parentReportID: parentReport.reportID, parentReportActionID: '1'}); + const iouAction = createMock>({ + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + childReportID: threadReport.reportID, + message: [{type: 'COMMENT', html: 'USD 1.00 expense', text: 'USD 1.00 expense'}], + originalMessage: {IOUTransactionID: 'txn-1', IOUReportID: parentReport.reportID, type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, amount: 100, currency: CONST.CURRENCY.USD}, + }); + const secondIOUAction = createMock>({ + reportActionID: '2', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + childReportID: 'another-transaction-thread', + message: [{type: 'COMMENT', html: 'USD 2.00 expense', text: 'USD 2.00 expense'}], + originalMessage: {IOUTransactionID: 'txn-2', IOUReportID: parentReport.reportID, type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, amount: 200, currency: CONST.CURRENCY.USD}, + }); + const sentMoneyAction = createMock>({ + reportActionID: '3', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + childReportID: threadReport.reportID, + message: [{type: 'COMMENT', html: 'sent USD 1.00', text: 'sent USD 1.00'}], + originalMessage: { + IOUTransactionID: 'txn-3', + IOUReportID: parentReport.reportID, + type: CONST.IOU.REPORT_ACTION_TYPE.PAY, + IOUDetails: {amount: 100, currency: CONST.CURRENCY.USD, comment: ''}, + amount: 100, + currency: CONST.CURRENCY.USD, + }, + }); + const parentReportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport.reportID}` as const; + + afterEach(async () => { + await Onyx.merge(parentReportActionsKey, null); + await waitForBatchedUpdates(); + }); + + it('should return false when the report or the parent report is missing', () => { + // Given the parent's actions hold a single IOU action pointing to the thread + const parentReportActions = {[iouAction.reportActionID]: iouAction}; + + // When either the thread or its parent report is not loaded + const resultWithoutParent = isOneTransactionThread(threadReport, undefined, iouAction, undefined, parentReportActions); + const resultWithoutReport = isOneTransactionThread(undefined, parentReport, iouAction, undefined, parentReportActions); + + // Then there is nothing to match, so neither is a one-transaction thread + expect(resultWithoutParent).toBe(false); + expect(resultWithoutReport).toBe(false); + }); + + it('should return true when the passed parent report actions hold a single IOU action pointing to the report', () => { + // Given the parent report's actions contain exactly one IOU action whose child is the thread + const parentReportActions = {[iouAction.reportActionID]: iouAction}; + + // When checking the thread with those actions passed in + const result = isOneTransactionThread(threadReport, parentReport, iouAction, undefined, parentReportActions); + + // Then the thread is the parent's only transaction thread + expect(result).toBe(true); + }); + + it('should return false when the parent report has more than one IOU action', () => { + // Given the parent report holds two expenses + const parentReportActions = {[iouAction.reportActionID]: iouAction, [secondIOUAction.reportActionID]: secondIOUAction}; + + // When checking the thread of one of them + const result = isOneTransactionThread(threadReport, parentReport, iouAction, undefined, parentReportActions); + + // Then it is not a one-transaction thread because the parent has multiple transactions + expect(result).toBe(false); + }); + + it('should return false when the only IOU action belongs to a different thread', () => { + // Given the parent's single IOU action links to another child report + const parentReportActions = {[secondIOUAction.reportActionID]: secondIOUAction}; + + // When checking a thread that the action does not point to + const result = isOneTransactionThread(threadReport, parentReport, iouAction, undefined, parentReportActions); + + // Then the report is not that parent's transaction thread + expect(result).toBe(false); + }); + + it('should return false when the thread parent action is a sent money action', () => { + // Given the parent's single IOU action is a "pay" with IOUDetails, meaning money was sent rather than requested + const parentReportActions = {[sentMoneyAction.reportActionID]: sentMoneyAction}; + + // When checking the thread created by that action + const result = isOneTransactionThread(threadReport, parentReport, sentMoneyAction, undefined, parentReportActions); + + // Then sent-money threads are never treated as one-transaction threads + expect(result).toBe(false); + }); + + it('should accept the parent report actions as an array', () => { + // Given the caller holds the parent's actions as a sorted array, like usePaginatedReportActions returns + const singleAction = [iouAction]; + const twoActions = [iouAction, secondIOUAction]; + + // When checking the thread with the arrays passed in + const resultForSingleAction = isOneTransactionThread(threadReport, parentReport, iouAction, undefined, singleAction); + const resultForTwoActions = isOneTransactionThread(threadReport, parentReport, iouAction, undefined, twoActions); + + // Then the results match the object form + expect(resultForSingleAction).toBe(true); + expect(resultForTwoActions).toBe(false); + }); + + it('should read the passed parent report actions rather than the actions stored in Onyx', async () => { + // Given Onyx says the parent report has two expenses + await Onyx.merge(parentReportActionsKey, {[iouAction.reportActionID]: iouAction, [secondIOUAction.reportActionID]: secondIOUAction}); + await waitForBatchedUpdates(); + + // When the caller passes only the single IOU action + const result = isOneTransactionThread(threadReport, parentReport, iouAction, undefined, {[iouAction.reportActionID]: iouAction}); + + // Then the passed actions win over the Onyx-stored ones + expect(result).toBe(true); + }); + + it('should fall back to the actions stored in Onyx when parentReportActions is not passed', async () => { + // Given Onyx holds a single IOU action for the parent report + await Onyx.merge(parentReportActionsKey, {[iouAction.reportActionID]: iouAction}); + await waitForBatchedUpdates(); + + // When a caller that has not been migrated yet omits parentReportActions + const result = isOneTransactionThread(threadReport, parentReport, iouAction); + + // Then the transitional fallback still resolves the thread from Onyx + expect(result).toBe(true); + }); + + it('should return false when parentReportActions is not passed and Onyx has no actions for the parent report', () => { + // Given nothing is stored for the parent report and the caller passes no actions + // When checking the thread + const result = isOneTransactionThread(threadReport, parentReport, iouAction); + + // Then there is no IOU action to match the thread against + expect(result).toBe(false); + }); + }); + describe('getOriginalReportID', () => { it('should return undefined when reportID is undefined', () => { const reportAction = createRandomReportAction(1); @@ -24080,6 +25133,45 @@ describe('ReportUtils', () => { }); }); + describe('buildOptimisticChangeFieldAction', () => { + const textField: PolicyReportField = { + name: 'text', + fieldID: 'text_field', + defaultValue: 'Initial', + orderWeight: 1, + type: 'text', + deletable: true, + target: CONST.REPORT.TYPE.EXPENSE, + values: [], + keys: [], + externalIDs: [], + disabledOptions: [], + isTax: false, + }; + + it('should not use the default value as the old value when the field was never saved on the report', () => { + // Given a text field that has no saved value on the report, so the user sees its workspace default + const previousReportField = {...textField, value: undefined}; + + // When the user clears it + const action = buildOptimisticChangeFieldAction({...textField, value: ''}, previousReportField, 1); + + // Then the action leaves the old value empty, matching the server's action so the message doesn't change when it arrives + expect(action).toMatchObject({originalMessage: {oldValue: undefined, newValue: ''}}); + }); + + it('should use the saved value as the old value when the field has one', () => { + // Given a text field whose saved value differs from its default + const previousReportField = {...textField, value: 'Saved'}; + + // When the user clears it + const action = buildOptimisticChangeFieldAction({...textField, value: ''}, previousReportField, 1); + + // Then the action records the saved value, not the default + expect(action).toMatchObject({originalMessage: {oldValue: 'Saved', newValue: ''}}); + }); + }); + describe('hasVisibleReportFieldViolations', () => { const policyID = 'policy-field-violations'; @@ -24777,6 +25869,20 @@ describe('ReportUtils', () => { expect(getTransactionSortValue(nonBillable, CONST.SEARCH.TABLE_COLUMNS.BILLABLE, mockReport, mockPolicy)).toBe(0); }); + it('should return 1 for an expense on hold and 0 for one that is not', () => { + // Given an expense on hold and an expense whose hold was removed, which leaves an empty hold + const held = createMockTransaction({comment: {hold: '123'}}); + const unheld = createMockTransaction({comment: {hold: ''}}); + + // When their held sort values are read + const heldSortValue = getTransactionSortValue(held, CONST.SEARCH.TABLE_COLUMNS.HELD, mockReport, mockPolicy); + const unheldSortValue = getTransactionSortValue(unheld, CONST.SEARCH.TABLE_COLUMNS.HELD, mockReport, mockPolicy); + + // Then only the expense on hold sorts as held + expect(heldSortValue).toBe(1); + expect(unheldSortValue).toBe(0); + }); + it('should return amount for TOTAL_AMOUNT column', () => { const transaction = createMockTransaction({amount: 5000}); const result = getTransactionSortValue(transaction, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, mockReport, mockPolicy); @@ -27009,67 +28115,67 @@ describe('ReportUtils', () => { }); it('returns false for an empty selection', () => { - expect(canMergeReports([], USER_ID, undefined, undefined, adminPolicy)).toBe(false); + expect(canMergeReports([], USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(false); }); it('returns false when only 1 report is selected', () => { - expect(canMergeReports([makeOpenReport()], USER_ID, undefined, undefined, adminPolicy)).toBe(false); + expect(canMergeReports([makeOpenReport()], USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(false); }); it('returns false when currentUserAccountID is 0 (falsy)', () => { - expect(canMergeReports([makeOpenReport(), makeOpenReport()], 0, undefined, undefined, adminPolicy)).toBe(false); + expect(canMergeReports([makeOpenReport(), makeOpenReport()], 0, undefined, undefined, undefined, adminPolicy)).toBe(false); }); // Same ownerAccountID (cross-account not supported) it('returns false when reports have different ownerAccountIDs', () => { const r1 = makeOpenReport({ownerAccountID: OWNER_ID}); const r2 = makeOpenReport({ownerAccountID: OWNER_ID + 1}); - expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, adminPolicy)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(false); }); it('returns false when the first report has no ownerAccountID', () => { const r1 = makeOpenReport({ownerAccountID: undefined}); const r2 = makeOpenReport({ownerAccountID: OWNER_ID}); - expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, adminPolicy)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(false); }); // Same policyID (cross-workspace not supported) it('returns false when reports belong to different workspaces', () => { const r1 = makeOpenReport({policyID: 'p1'}); const r2 = makeOpenReport({policyID: 'p2'}); - expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, adminPolicy)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(false); }); it('returns false when the first report has no policyID', () => { const r1 = makeOpenReport({policyID: undefined}); const r2 = makeOpenReport({policyID: POLICY_ID}); - expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, adminPolicy)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(false); }); // Same workflow state it('returns false when mixing Open and Processing reports', () => { const open = makeOpenReport(); const processing = makeProcessingReport(); - expect(canMergeReports([open, processing], USER_ID, undefined, undefined, adminPolicy)).toBe(false); + expect(canMergeReports([open, processing], USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(false); }); it('returns false when stateNum matches but statusNum differs', () => { const r1 = makeOpenReport({stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}); const r2 = makeOpenReport({stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.CLOSED}); - expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, adminPolicy)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(false); }); // Same managerID for Processing reports it('returns false when Processing reports have different managerIDs', () => { const r1 = makeProcessingReport({managerID: MANAGER_ID}); const r2 = makeProcessingReport({managerID: MANAGER_ID + 99}); - expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, adminPolicy)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(false); }); it('returns false when a Processing report has no managerID', () => { const r1 = makeProcessingReport({managerID: MANAGER_ID}); const r2 = makeProcessingReport({managerID: undefined}); - expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, adminPolicy)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(false); }); /** @@ -27081,7 +28187,7 @@ describe('ReportUtils', () => { const r1 = makeOpenReport({managerID: undefined}); const r2 = makeOpenReport({managerID: 999}); - expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, adminPolicy)).toBe(true); + expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(true); }); // Terminal states (settled / approved / closed) @@ -27092,7 +28198,7 @@ describe('ReportUtils', () => { statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, isWaitingOnBankAccount: false, } as Report; - expect(canMergeReports([makeOpenReport(), settled], USER_ID, undefined, undefined, adminPolicy)).toBe(false); + expect(canMergeReports([makeOpenReport(), settled], USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(false); }); it('returns false when a report is approved', () => { @@ -27101,7 +28207,7 @@ describe('ReportUtils', () => { stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED, } as Report; - expect(canMergeReports([makeOpenReport(), approved], USER_ID, undefined, undefined, adminPolicy)).toBe(false); + expect(canMergeReports([makeOpenReport(), approved], USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(false); }); it('returns false when a report is closed', () => { @@ -27109,14 +28215,14 @@ describe('ReportUtils', () => { ...makeOpenReport(), statusNum: CONST.REPORT.STATUS_NUM.CLOSED, } as Report; - expect(canMergeReports([makeOpenReport(), closed], USER_ID, undefined, undefined, adminPolicy)).toBe(false); + expect(canMergeReports([makeOpenReport(), closed], USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(false); }); // The user must be able to write to each report it('returns false when the current user is not able to write to each report', () => { const r1 = makeOpenReport({permissions: [CONST.REPORT.PERMISSIONS.READ]}); const r2 = makeOpenReport(); - expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, adminPolicy)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(false); }); // The user must be the report owner, a workspace admin, or the current approver. @@ -27128,7 +28234,7 @@ describe('ReportUtils', () => { const r2 = makeOpenReport(); // When a stranger who is neither the owner, the workspace admin, nor the approver tries to merge them - const result = canMergeReports([r1, r2], STRANGER_ID, undefined, undefined, memberPolicy); + const result = canMergeReports([r1, r2], STRANGER_ID, undefined, undefined, undefined, memberPolicy); // Then the merge is rejected, because allowing it would let an unrelated user rewrite someone else's reports expect(result).toBe(false); @@ -27141,8 +28247,8 @@ describe('ReportUtils', () => { const r2 = makeOpenReport(); // When the merge is attempted as the workspace admin, and again as a plain member who owns the reports - const asAdmin = canMergeReports([r1, r2], USER_ID, undefined, undefined, adminPolicy); - const asMember = canMergeReports([r1, r2], USER_ID, undefined, undefined, memberPolicy); + const asAdmin = canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, adminPolicy); + const asMember = canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, memberPolicy); // Then both are allowed, because an Open report is editable by its owner regardless of workspace role expect(asAdmin).toBe(true); @@ -27155,8 +28261,8 @@ describe('ReportUtils', () => { const r2 = makeProcessingReport(); // When the merge is attempted as the workspace admin, and again as a plain member who owns the reports - const asAdmin = canMergeReports([r1, r2], USER_ID, undefined, undefined, adminPolicy); - const asMember = canMergeReports([r1, r2], USER_ID, undefined, undefined, memberPolicy); + const asAdmin = canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, adminPolicy); + const asMember = canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, memberPolicy); // Then only the admin may merge: a submitter loses edit rights once the report is awaiting approval, // so letting them merge would let them alter a report that is already in an approver's queue @@ -27172,7 +28278,7 @@ describe('ReportUtils', () => { // When the approver merges them while holding only a member role, so eligibility can be granted // by the approver branch alone. An admin policy would short-circuit before the manager is checked // and the test would still pass even if that check regressed. - const result = canMergeReports([r1, r2], MANAGER_ID, undefined, undefined, memberPolicy); + const result = canMergeReports([r1, r2], MANAGER_ID, undefined, undefined, undefined, memberPolicy); // Then the merge is allowed, because the approver owns the decision on reports sitting in their queue expect(result).toBe(true); @@ -27180,7 +28286,7 @@ describe('ReportUtils', () => { it('returns true for three or more valid Open reports', () => { const reports = [makeOpenReport(), makeOpenReport(), makeOpenReport()]; - expect(canMergeReports(reports, USER_ID, undefined, undefined, adminPolicy)).toBe(true); + expect(canMergeReports(reports, USER_ID, undefined, undefined, undefined, adminPolicy)).toBe(true); }); it('returns true for two valid Open reports when reportNameValuePairs is provided', () => { @@ -27189,7 +28295,7 @@ describe('ReportUtils', () => { const reportNameValuePairs: OnyxCollection = { [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${r1.reportID}`]: {}, }; - expect(canMergeReports([r1, r2], USER_ID, undefined, reportNameValuePairs, adminPolicy)).toBe(true); + expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, reportNameValuePairs, adminPolicy)).toBe(true); }); it('returns false when a report is not writable even when reportNameValuePairs is provided', () => { @@ -27198,7 +28304,7 @@ describe('ReportUtils', () => { const reportNameValuePairs: OnyxCollection = { [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${r1.reportID}`]: {}, }; - expect(canMergeReports([r1, r2], USER_ID, undefined, reportNameValuePairs, adminPolicy)).toBe(false); + expect(canMergeReports([r1, r2], USER_ID, undefined, undefined, reportNameValuePairs, adminPolicy)).toBe(false); }); /** @@ -27221,7 +28327,7 @@ describe('ReportUtils', () => { const r2 = makeProcessingReport(); // When the caller passes a policy on which that same user is only a member - const result = canMergeReports([r1, r2], USER_ID, undefined, undefined, memberPolicy); + const result = canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, memberPolicy); // Then the passed policy wins and the merge is rejected, so the cache can never widen a user's rights expect(result).toBe(false); @@ -27235,7 +28341,7 @@ describe('ReportUtils', () => { const r2 = makeProcessingReport(); // When the caller passes the policy on which that user really is an admin - const result = canMergeReports([r1, r2], USER_ID, undefined, undefined, adminPolicy); + const result = canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, adminPolicy); // Then the merge is allowed, so a stale cache can never strip rights the caller's data grants expect(result).toBe(true); @@ -27247,7 +28353,7 @@ describe('ReportUtils', () => { const r2 = makeProcessingReport(); // When the merge is attempted with no policy available to the caller - const result = canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined); + const result = canMergeReports([r1, r2], USER_ID, undefined, undefined, undefined, undefined); // Then it is rejected, because admin-only merges must not be granted on the basis of absent data expect(result).toBe(false); diff --git a/tests/unit/RequestConflictUtilsTest.ts b/tests/unit/RequestConflictUtilsTest.ts index 9e61d3d0c91d..bd1c154e091f 100644 --- a/tests/unit/RequestConflictUtilsTest.ts +++ b/tests/unit/RequestConflictUtilsTest.ts @@ -7,6 +7,7 @@ import { resolveOpenAppDuplicationConflictAction, resolveOpenReportDuplicationConflictAction, resolveReconnectDuplicationConflictAction, + resolveVacationDelegateDeletionConflicts, } from '@libs/actions/RequestConflictUtils'; import {WRITE_COMMANDS} from '@libs/API/types'; @@ -236,6 +237,34 @@ describe('RequestConflictUtils', () => { }); }); + describe('resolveVacationDelegateDeletionConflicts', () => { + it('drops a queued self-service set and keeps a domain admin set for a member', () => { + // Given a self-service set queued offline (e.g. a changed clear after date) next to a domain admin's set for a member + const persistedRequests = [ + {command: WRITE_COMMANDS.SET_VACATION_DELEGATE, data: {vacationDelegateEmail: 'delegate@example.com', clearAfter: '2026-10-02 06:59:59'}}, + {command: 'OpenReport'}, + {command: WRITE_COMMANDS.SET_VACATION_DELEGATE, data: {vacationDelegateEmail: 'delegate@example.com', domainAccountID: 123}}, + ]; + + // When the user removes their own delegate + const result = resolveVacationDelegateDeletionConflicts(persistedRequests); + + // Then only the self-service set is dropped, since its response would bring the removed delegate back, and the removal is still sent + expect(result).toEqual({conflictAction: {type: 'delete', indices: [0], pushNewRequest: true}}); + }); + + it('pushes when no self-service set is queued', () => { + // Given a queue with only unrelated requests and a domain admin's set for a member + const persistedRequests = [{command: 'OpenReport'}, {command: WRITE_COMMANDS.SET_VACATION_DELEGATE, data: {domainAccountID: 123}}]; + + // When the user removes their own delegate + const result = resolveVacationDelegateDeletionConflicts(persistedRequests); + + // Then nothing is dropped and the removal is pushed as usual + expect(result).toEqual({conflictAction: {type: 'push'}}); + }); + }); + const openApp = (): AnyRequest => ({command: WRITE_COMMANDS.OPEN_APP}); const fullReconnect = (): AnyRequest => ({command: WRITE_COMMANDS.RECONNECT_APP}); const drop = {conflictAction: {type: 'noAction'}}; diff --git a/tests/unit/ReusableDistanceRoutesUtilsTest.ts b/tests/unit/ReusableDistanceRoutesUtilsTest.ts index 81265d4e64bc..d20df43e4dc9 100644 --- a/tests/unit/ReusableDistanceRoutesUtilsTest.ts +++ b/tests/unit/ReusableDistanceRoutesUtilsTest.ts @@ -1,7 +1,9 @@ -import type {ReusableDistanceRoute} from '../../src/types/onyx'; +import type {ReusableDistanceRoute, Transaction} from '../../src/types/onyx'; import type {WaypointCollection} from '../../src/types/onyx/Transaction'; -import {filterRoutes, getOrderedWaypoints, getRouteEndpoints} from '../../src/libs/ReusableDistanceRoutesUtils'; +import CONST from '../../src/CONST'; +import {filterRoutes, getAvailableReusableDistanceRoutes, getOrderedWaypoints, getRouteEndpoints, getRouteThumbnailSource} from '../../src/libs/ReusableDistanceRoutesUtils'; +import ONYXKEYS from '../../src/ONYXKEYS'; const createRoute = (transactionID: string, addresses: string[]): ReusableDistanceRoute => ({ transactionID, @@ -67,4 +69,76 @@ describe('ReusableDistanceRoutesUtils', () => { expect(getRouteEndpoints(route)).toEqual({start: 'Start, Austin, TX', end: 'End, Houston, TX'}); }); }); + + describe('getRouteThumbnailSource', () => { + it('appends 1024.jpg suffix for image receipts', () => { + // Given an image receipt URL + const receiptSource = 'https://expensify.com/receipts/w_test.png'; + + // When getting the thumbnail source + const thumbnail = getRouteThumbnailSource(receiptSource); + + // Then .1024.jpg is appended + expect(thumbnail).toBe('https://expensify.com/receipts/w_test.png.1024.jpg'); + }); + }); + + describe('getAvailableReusableDistanceRoutes', () => { + const baseTransaction: Transaction = { + transactionID: 'tx-1', + amount: 100, + created: '2026-09-01', + currency: CONST.CURRENCY.USD, + merchant: 'Test Merchant', + reportID: '1', + }; + + it('excludes routes whose source transaction has pending distance receipt regeneration when online', () => { + // Given a newly created route whose transaction has pending waypoints while online + const route = createRoute('tx-1', ['A', 'B']); + const transactions: Record = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}tx-1`]: { + ...baseTransaction, + pendingFields: {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + }, + }; + + // When resolving available routes while online + const available = getAvailableReusableDistanceRoutes([route], transactions, false); + + // Then the pending route is excluded until the server generates the map image + expect(available).toEqual([]); + }); + + it('includes routes whose source transaction has pending distance receipt regeneration when offline', () => { + // Given a newly created route while offline + const route = createRoute('tx-1', ['A', 'B']); + const transactions: Record = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}tx-1`]: { + ...baseTransaction, + pendingFields: {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + }, + }; + + // When resolving available routes while offline + const available = getAvailableReusableDistanceRoutes([route], transactions, true); + + // Then the route is included for offline reuse + expect(available).toEqual([route]); + }); + + it('includes routes whose source transaction has completed distance receipt generation when online', () => { + // Given a route whose transaction has no pending distance receipt regeneration + const route = createRoute('tx-1', ['A', 'B']); + const transactions: Record = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}tx-1`]: baseTransaction, + }; + + // When resolving available routes while online + const available = getAvailableReusableDistanceRoutes([route], transactions, false); + + // Then the route is included + expect(available).toEqual([route]); + }); + }); }); diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx index 5908379c751f..f68d7220e8fc 100644 --- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -39,9 +39,6 @@ jest.mock('@components/ButtonWithDropdownMenu', () => ({ __esModule: true, default: (props: MockButtonProps) => mockButtonWithDropdownMenu(props), })); -jest.mock('@components/DecisionModal', () => () => null); -jest.mock('@components/HoldOrRejectEducationalModal', () => () => null); -jest.mock('@components/HoldSubmitterEducationalModal', () => () => null); jest.mock('@components/ReportPDFDownloadModal', () => () => null); jest.mock('@components/KYCWall', () => ({ __esModule: true, @@ -78,7 +75,6 @@ jest.mock('@hooks/useSearchBulkActions', () => ({ selectedTransactionReportIDs: [], selectedReportIDs: [], businessBankAccountOptions: [], - emptyReportsCount: 0, isDuplicateOptionVisible: false, isDuplicateReportOptionVisible: false, allTransactions: {}, diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 7c054525c2d4..0c9e395e01c4 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -24,6 +24,7 @@ import { getFilterDisplayValue, getFilterFormValues, getFilterFromQuery, + getMerchantFilterFormValues, getFooterSelectionFromQuery, getQueryWithFooterSelection, getQueryWithoutFooterSelections, @@ -56,12 +57,13 @@ import { shouldResetSort, shouldResetSortForViewChange, sortOptionsWithEmptyValue, - withExactMatchFilterKeys, } from '@src/libs/SearchQueryUtils'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; +import type {MerchantMatchType} from '@src/types/form/SearchAdvancedFiltersForm'; import type * as OnyxTypes from '@src/types/onyx'; import type {Connections} from '@src/types/onyx/Policy'; @@ -480,8 +482,8 @@ describe('SearchQueryUtils', () => { }); test('rebuilds a single value containing a comma as one value', () => { - expect(getQueryWithUpdatedValues(String.raw`merchant:Globex\,Ltd`)).toEqual(`${defaultQuery} merchant:"Globex,Ltd"`); - expect(getQueryWithUpdatedValues('merchant:"Globex,Ltd"')).toEqual(`${defaultQuery} merchant:"Globex,Ltd"`); + expect(getQueryWithUpdatedValues(String.raw`merchant:Globex\,Ltd`)).toEqual(`${defaultQuery} merchant*:"Globex,Ltd"`); + expect(getQueryWithUpdatedValues('merchant:"Globex,Ltd"')).toEqual(`${defaultQuery} merchant*:"Globex,Ltd"`); }); test('rebuilds a comma separated list as separate values', () => { @@ -544,7 +546,7 @@ describe('SearchQueryUtils', () => { const result = getQueryWithUpdatedValues(userQuery); - expect(result).toEqual('type:expense sortBy:groupCategory sortOrder:asc view:pie groupBy:category merchant:Amazon'); + expect(result).toEqual('type:expense sortBy:groupCategory sortOrder:asc view:pie groupBy:category merchant*:Amazon'); }); test('deduplicates conflicting type filters keeping the last occurrence', () => { @@ -608,6 +610,76 @@ describe('SearchQueryUtils', () => { expect(result).toEqual('type:expense -transactionStatus:pending'); }); + test('declined filter value', () => { + // Given the Rejected filter set to yes on an expense report search + const filterValues: Partial = { + type: 'expense-report', + declined: 'yes', + }; + + // When the query string is built from the form + const result = buildQueryStringFromFilterFormValues(filterValues); + + // Then the filter is written with the declined key used by the backend + expect(result).toEqual('type:expense-report declined:yes'); + }); + + test('negated declined filter value', () => { + // Given the Rejected filter negated on an expense search + const filterValues: Partial = { + type: 'expense', + declinedNot: 'no', + }; + + // When the query string is built from the form + const result = buildQueryStringFromFilterFormValues(filterValues); + + // Then the declined key gets the "-" prefix + expect(result).toEqual('type:expense -declined:no'); + }); + + test('held filter value', () => { + // Given the Held filter set to yes on an expense search + const filterValues: Partial = { + type: 'expense', + held: 'yes', + }; + + // When the query string is built from the form + const result = buildQueryStringFromFilterFormValues(filterValues); + + // Then the filter is written with the held key + expect(result).toEqual('type:expense held:yes'); + }); + + test('negated held filter value', () => { + // Given the Held filter negated on an expense search + const filterValues: Partial = { + type: 'expense', + heldNot: 'no', + }; + + // When the query string is built from the form + const result = buildQueryStringFromFilterFormValues(filterValues); + + // Then the held key gets the "-" prefix + expect(result).toEqual('type:expense -held:no'); + }); + + test('held filter is dropped for expense reports', () => { + // Given the Held filter set on an expense report search, which only supports it for expenses + const filterValues: Partial = { + type: 'expense-report', + held: 'yes', + }; + + // When the query string is built from the form + const result = buildQueryStringFromFilterFormValues(filterValues); + + // Then the held filter is not written + expect(result).toEqual('type:expense-report'); + }); + test('negated receipt type filter value', () => { const filterValues: Partial = { type: 'expense', @@ -641,7 +713,44 @@ describe('SearchQueryUtils', () => { const result = buildQueryStringFromFilterFormValues(filterValues); - expect(result).toEqual('type:expense policyID:67890 merchant:Amazon description:Electronics laptop category:electronics,gadgets'); + expect(result).toEqual('type:expense policyID:67890 merchant*:Amazon description:Electronics laptop category:electronics,gadgets'); + }); + + test.each([CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, undefined])('builds a contains merchant query when the merchant operator is %s', (merchantOperator) => { + // Given a merchant filter stored as contains, or with no operator + const filterValues: Partial = {type: 'expense', merchant: 'Amazon', merchantOperator}; + + // When the query string is built + const result = buildQueryStringFromFilterFormValues(filterValues); + + // Then it uses contains, because partial matching is the default merchant behavior + expect(result).toEqual('type:expense merchant*:Amazon'); + }); + + test('preserves multiple merchant clauses when the form value is unchanged', () => { + const queryJSON = buildSearchQueryJSON('type:expense merchant*:Amazon merchant*:Uber'); + const filterValues: Partial = { + type: 'expense', + merchant: 'Uber', + merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + }; + + const result = buildQueryStringFromFilterFormValues(filterValues, {flatFilters: queryJSON?.flatFilters}); + + expect(result).toEqual('type:expense merchant*:Amazon merchant*:Uber'); + }); + + test('applies a changed merchant operator to every merchant clause', () => { + const queryJSON = buildSearchQueryJSON('type:expense merchant*:Amazon merchant*:Uber'); + const filterValues: Partial = { + type: 'expense', + merchant: 'Uber', + merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }; + + const result = buildQueryStringFromFilterFormValues(filterValues, {flatFilters: queryJSON?.flatFilters}); + + expect(result).toEqual('type:expense merchant=Amazon merchant=Uber'); }); test('currencies and categories', () => { @@ -955,7 +1064,7 @@ describe('SearchQueryUtils', () => { const result = buildQueryStringFromFilterFormValues(filterValues, {sortBy: 'amount', sortOrder: 'asc'}); - expect(result).toEqual('sortBy:amount sortOrder:asc type:expense merchant:Amazon limit:25'); + expect(result).toEqual('sortBy:amount sortOrder:asc type:expense merchant*:Amazon limit:25'); }); test('omits limit when not provided', () => { @@ -1372,7 +1481,7 @@ describe('SearchQueryUtils', () => { reportAttributes: undefined, }); - expect(result).toBe('type:expense status:all merchant:Uber'); + expect(result).toBe('type:expense status:all merchant*:Uber'); }); test('maps workspace names and maintains manual order', () => { @@ -1409,7 +1518,7 @@ describe('SearchQueryUtils', () => { reportAttributes: undefined, }); - expect(result).toBe('workspace:"Team Space" type:expense merchant:Starbucks'); + expect(result).toBe('workspace:"Team Space" type:expense merchant*:Starbucks'); }); test('rawQuery overrides canonical filter values when provided', () => { @@ -1532,6 +1641,24 @@ describe('SearchQueryUtils', () => { }); describe('buildFilterFormValuesFromQuery', () => { + test.each([ + ['merchant=Uber', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO], + ['merchant:Uber', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS], + ['merchant*:Uber', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS], + ])('restores the merchant operator from %s', (merchantQuery, merchantOperator) => { + // Given a query with an explicit exact, legacy, or contains merchant filter + const queryJSON = buildSearchQueryJSON(`sortBy:date sortOrder:desc type:expense ${merchantQuery}`); + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + // When the form values are built from the query + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); + + // Then only `=` restores Equal to, because legacy `:` keeps its partial-match behavior + expect(result).toEqual({type: 'expense', merchant: 'Uber', merchantOperator}); + }); + test('category filter includes empty values', () => { const policyID = generatePolicyID(); const queryString = 'sortBy:date sortOrder:desc type:expense category:none,Uncategorized,Maintenance'; @@ -2157,6 +2284,98 @@ describe('SearchQueryUtils', () => { expect(result.transactionStatus).toBeUndefined(); }); + test('declined filter populates the form with a yes or no value', () => { + // Given a query that filters expenses on whether they were rejected + const queryJSON = buildSearchQueryJSON('sortBy:date sortOrder:desc type:expense declined:yes'); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + // When the form values are built from the query + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); + + // Then the Rejected filter holds the yes value + expect(result.declined).toEqual('yes'); + }); + + test('declined filter ignores values other than yes and no', () => { + // Given a hand-typed query with a value the boolean filter does not accept + const queryJSON = buildSearchQueryJSON('sortBy:date sortOrder:desc type:expense declined:maybe'); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + // When the form values are built from the query + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); + + // Then the invalid value is dropped instead of being shown as a selection + expect(result.declined).toBeUndefined(); + }); + + test('negated declined filter populates declinedNot', () => { + // Given a query that excludes rejected expense reports with the "-" prefix + const queryJSON = buildSearchQueryJSON('sortBy:date sortOrder:desc type:expense-report -declined:yes'); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + // When the form values are built from the query + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); + + // Then the value lands in the negated form key only + expect(result.declinedNot).toEqual('yes'); + expect(result.declined).toBeUndefined(); + }); + + test('held filter populates the form with a yes or no value', () => { + // Given a query that filters expenses on whether they are on hold + const queryJSON = buildSearchQueryJSON('sortBy:date sortOrder:desc type:expense held:yes'); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + // When the form values are built from the query + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); + + // Then the Held filter holds the yes value + expect(result.held).toEqual('yes'); + }); + + test('held filter ignores values other than yes and no', () => { + // Given a hand-typed query with a value the boolean filter does not accept + const queryJSON = buildSearchQueryJSON('sortBy:date sortOrder:desc type:expense held:maybe'); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + // When the form values are built from the query + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); + + // Then the invalid value is dropped instead of being shown as a selection + expect(result.held).toBeUndefined(); + }); + + test('negated held filter populates heldNot', () => { + // Given a query that excludes held expenses with the "-" prefix + const queryJSON = buildSearchQueryJSON('sortBy:date sortOrder:desc type:expense -held:yes'); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + // When the form values are built from the query + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); + + // Then the value lands in the negated form key only + expect(result.heldNot).toEqual('yes'); + expect(result.held).toBeUndefined(); + }); + test('negated receipt type filter populates receiptTypeNot', () => { // Negation in the query syntax uses the "-" prefix, which round-trips to the receiptTypeNot form value. const queryString = 'sortBy:date sortOrder:desc type:expense -receipt-type:hotel'; @@ -2300,6 +2519,88 @@ describe('SearchQueryUtils', () => { }); }); + describe('anyApproval date filter', () => { + const anyApprovalFormKeys = [ + FILTER_KEYS.ANY_APPROVAL_ON, + FILTER_KEYS.ANY_APPROVAL_AFTER, + FILTER_KEYS.ANY_APPROVAL_BEFORE, + FILTER_KEYS.ANY_APPROVAL_RANGE, + FILTER_KEYS.ANY_APPROVAL_NOT, + ] as const; + + test.each([ + { + modifier: 'ON', + formKey: FILTER_KEYS.ANY_APPROVAL_ON, + formValue: CONST.SEARCH.DATE_PRESETS.LAST_MONTH, + expectedQuery: `type:expense ${CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL}:${CONST.SEARCH.DATE_PRESETS.LAST_MONTH}`, + }, + { + modifier: 'AFTER', + formKey: FILTER_KEYS.ANY_APPROVAL_AFTER, + formValue: '2026-04-01', + expectedQuery: `type:expense ${CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL}>2026-04-01`, + }, + { + modifier: 'BEFORE', + formKey: FILTER_KEYS.ANY_APPROVAL_BEFORE, + formValue: '2026-04-30', + expectedQuery: `type:expense ${CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL}<2026-04-30`, + }, + { + modifier: 'RANGE', + formKey: FILTER_KEYS.ANY_APPROVAL_RANGE, + formValue: '2026-04-01,2026-04-30', + expectedQuery: `type:expense ${CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL}>=2026-04-01 ${CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL}<=2026-04-30`, + }, + { + modifier: 'negated', + formKey: FILTER_KEYS.ANY_APPROVAL_NOT, + formValue: CONST.SEARCH.DATE_PRESETS.LAST_MONTH, + expectedQuery: `type:expense -${CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL}:${CONST.SEARCH.DATE_PRESETS.LAST_MONTH}`, + }, + ])('round-trips the $modifier form', ({formKey, formValue, expectedQuery}) => { + // Given an expense search with an anyApproval date filter + const filterValues: Partial = { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + [formKey]: formValue, + }; + + // When the form is converted to a query and back + const queryString = buildQueryStringFromFilterFormValues(filterValues); + expect(queryString).toBe(expectedQuery); + + const queryJSON = buildSearchQueryJSON(queryString); + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); + + // Then the original form key is preserved and the other anyApproval keys are empty + expect(result[formKey]).toBe(formValue); + for (const otherKey of anyApprovalFormKeys) { + if (otherKey !== formKey) { + expect(result[otherKey]).toBeUndefined(); + } + } + }); + + test('anyApproval date filters are dropped on an expense-report search', () => { + // Given an expense-report search with an anyApproval date filter + const filterValues: Partial = { + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + [FILTER_KEYS.ANY_APPROVAL_ON]: CONST.SEARCH.DATE_PRESETS.LAST_MONTH, + }; + + // When the form is converted to a query + const result = buildQueryStringFromFilterFormValues(filterValues); + + // Then anyApproval is stripped because Auth only supports it for type:expense + expect(result).toBe(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}`); + }); + }); + describe('shouldHighlight', () => { it('returns false if either input is empty', () => { expect(shouldHighlight('', 'test')).toBe(false); @@ -2736,7 +3037,8 @@ describe('SearchQueryUtils', () => { // Captured before the footer keys existed. These hashes key saved searches and snapshots, so they must not drift. expect(buildSearchQueryJSON('type:expense')?.hash).toEqual(959171759); expect(buildSearchQueryJSON('type:expense-report')?.hash).toEqual(1657503970); - expect(buildSearchQueryJSON('type:expense merchant:Amazon')?.hash).toEqual(1692688529); + // `merchant:` parses as contains and hashes as `merchant*:`, so this hash differs from the one before the match type existed. + expect(buildSearchQueryJSON('type:expense merchant:Amazon')?.hash).toEqual(1712664595); }); }); @@ -2931,6 +3233,28 @@ describe('SearchQueryUtils', () => { expect(isSearchQuerySavable(undefined, defaultQueryJSON)).toBe(false); }); + it('returns false when only a Spend footer selection differs from the default query', () => { + // Given a saved search on the reimbursable total + const defaultQueryJSON = buildSearchQueryJSON('type:expense category:travel footerTotal:reimbursable'); + + // When only the footer selections change, which the footer's own Reset undoes + const currentQueryJSON = buildSearchQueryJSON('type:expense category:travel footerTotal:billable footerCount:reports footerCurrency:EUR'); + + // Then the header doesn't offer Save + expect(isSearchQuerySavable(currentQueryJSON, defaultQueryJSON)).toBe(false); + }); + + it('still returns true when a row filter changes alongside a footer selection', () => { + // Given a search on one category + const defaultQueryJSON = buildSearchQueryJSON('type:expense category:travel'); + + // When the category changes along with the footer total + const currentQueryJSON = buildSearchQueryJSON('type:expense category:meals footerTotal:billable'); + + // Then the header offers Save, since a row filter changed + expect(isSearchQuerySavable(currentQueryJSON, defaultQueryJSON)).toBe(true); + }); + it('returns false when the query equals the default query', () => { // Given a query that matches its default const defaultQueryJSON = buildSearchQueryJSON('type:expense category:travel'); @@ -3032,6 +3356,16 @@ describe('SearchQueryUtils', () => { }); }); + describe('contains filter parsing', () => { + it('treats negated contains as a keyword', () => { + const queryJSON = buildSearchQueryJSON('type:expense -merchant*:Uber'); + const keywordFilter = queryJSON?.flatFilters.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD); + + expect(keywordFilter?.filters.some((filter) => filter.value === '-merchant*:Uber')).toBe(true); + expect(queryJSON?.flatFilters.some((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT)).toBe(false); + }); + }); + describe('getFilterDisplayValue', () => { const mockCardList = {}; const mockCardFeeds = {}; @@ -3318,7 +3652,7 @@ describe('SearchQueryUtils', () => { const result = buildSearchQueryString(queryJSON); expect(result).toContain('view:pie'); - expect(result).toContain('merchant:Amazon'); + expect(result).toContain('merchant*:Amazon'); }); test('serializes the compare root key', () => { @@ -3392,12 +3726,12 @@ describe('SearchQueryUtils', () => { expect(keywordFilter?.filters.at(0)?.value).toBe(keyword); }); - test('does not add quotes to non-keyword filter values', () => { - const queryJSON = buildSearchQueryJSON('type:expense merchant:Amazon'); + test('uses the explicit exact operator for exact merchant filters', () => { + const queryJSON = buildSearchQueryJSON('type:expense merchant=Amazon'); const result = buildSearchQueryString(queryJSON); - expect(result).toContain('merchant:Amazon'); + expect(result).toContain('merchant=Amazon'); expect(result).not.toContain('"Amazon"'); }); }); @@ -3460,6 +3794,217 @@ describe('SearchQueryUtils', () => { }); describe('buildFilterQueryWithSortDefaults', () => { + describe('Merchant predicates', () => { + function updateQuery(input: string, updates: Partial) { + const originalQuery = buildSearchQueryJSON(input); + if (!originalQuery) { + throw new Error('Invalid test query'); + } + + const form = buildFilterFormValuesFromQuery(originalQuery, {}, {}, {}, {}, {}, {}); + const result = buildFilterQueryWithSortDefaults({...form, ...updates}, originalQuery, originalQuery); + const updatedQuery = buildSearchQueryJSON(result ?? ''); + if (!updatedQuery) { + throw new Error('Invalid updated query'); + } + + return {originalQuery, updatedQuery}; + } + + test.each([ + // Mixed operators, both orders + 'merchant="Coffee Shop" merchant*:Coffee', + 'merchant*:Coffee merchant="Coffee Shop"', + // Mixed operators with lists and quoted commas + 'merchant="Coffee Shop",Uber merchant*:Coffee,Tea', + 'merchant="Coffee, Shop" merchant*:"Coffee, Shop"', + // Three clauses, and positive mixed with negated + 'merchant=I merchant*:I merchant*:g', + 'merchant=I merchant*:I -merchant:Ig', + // Same operator repeated + 'merchant*:Coffee merchant*:Shop', + // Single clause of each kind + 'merchant=I', + 'merchant*:I', + 'merchant=Amazon,Uber', + // Negated + '-merchant:Amazon -merchant:Prime', + '-merchant:"Coffee, Shop",Uber', + ])('preserves Merchant predicates when changing Currency: %s', (input) => { + // Given a query with Merchant predicates + // When only the Currency filter changes + const {originalQuery, updatedQuery} = updateQuery(input, {currency: ['USD']}); + + // Then every Merchant predicate is kept as it was and the Currency filter is added + expect(updatedQuery.flatFilters.filter((filter) => filter.key === 'merchant')).toEqual(originalQuery.flatFilters.filter((filter) => filter.key === 'merchant')); + expect(updatedQuery.flatFilters).toContainEqual({key: 'currency', filters: [{operator: 'eq', value: 'USD'}]}); + }); + + test.each>([{description: 'Lunch'}, {dateAfter: '2026-01-01'}, {groupBy: CONST.SEARCH.GROUP_BY.MONTH, view: CONST.SEARCH.VIEW.BAR}])( + 'preserves Merchant predicates when changing other filters: %j', + (updates) => { + const {originalQuery, updatedQuery} = updateQuery('merchant=I merchant*:I', updates); + + expect(updatedQuery.flatFilters.filter((filter) => filter.key === 'merchant')).toEqual(originalQuery.flatFilters); + }, + ); + + test('preserves Merchant predicates through consecutive form updates', () => { + const firstUpdate = updateQuery('merchant=I merchant*:I', {description: 'Lunch'}); + const secondUpdate = updateQuery(buildSearchQueryString(firstUpdate.updatedQuery), {currency: ['USD']}); + + expect(secondUpdate.updatedQuery.flatFilters.filter((filter) => filter.key === 'merchant')).toEqual(firstUpdate.originalQuery.flatFilters); + expect(secondUpdate.updatedQuery.flatFilters).toContainEqual({key: 'description', filters: [{operator: 'eq', value: 'Lunch'}]}); + }); + + test.each<{input: string; operator: MerchantMatchType; expectedValue: string | string[]}>([ + {input: 'merchant*:I,Ig', operator: 'eq', expectedValue: ['I', 'Ig']}, + {input: 'merchant=I,Ig', operator: 'contains', expectedValue: ['I', 'Ig']}, + {input: 'merchant:I,Ig', operator: 'eq', expectedValue: ['I', 'Ig']}, + {input: 'merchant*:"I,Ig"', operator: 'eq', expectedValue: 'I,Ig'}, + {input: 'merchant="I,Ig"', operator: 'contains', expectedValue: 'I,Ig'}, + {input: 'merchant*:"Coffee, Shop",Uber', operator: 'eq', expectedValue: ['Coffee, Shop', 'Uber']}, + {input: 'merchant="Coffee, Shop",Uber', operator: 'contains', expectedValue: ['Coffee, Shop', 'Uber']}, + ])('preserves Merchant values when changing only the match type: %j', ({input, operator, expectedValue}) => { + const {updatedQuery} = updateQuery(input, {merchantOperator: operator}); + + expect(updatedQuery.filters).toEqual({operator, left: 'merchant', right: expectedValue}); + }); + + test.each<{input: string; operator: MerchantMatchType}>([ + {input: 'merchant=I merchant*:I,Ig', operator: 'eq'}, + {input: 'merchant*:I merchant=I,Ig', operator: 'contains'}, + ])('applies a changed match type to every Merchant predicate: %j', ({input, operator}) => { + const {updatedQuery} = updateQuery(input, {merchantOperator: operator}); + + expect(updatedQuery.flatFilters.filter((filter) => filter.key === 'merchant')).toEqual([ + {key: 'merchant', filters: [{operator, value: 'I'}]}, + { + key: 'merchant', + filters: [ + {operator, value: 'I'}, + {operator, value: 'Ig'}, + ], + }, + ]); + }); + + test.each([ + 'merchant:Amazon merchant:Prime', + 'merchant=Amazon merchant=Prime', + 'merchant=Amazon merchant*:Prime', + 'merchant:Amazon,Prime', + 'merchant:"Coffee, Shop",Uber merchant:Prime', + ])('applies negation to every original Merchant value: %s', (input) => { + const originalQuery = buildSearchQueryJSON(input); + if (!originalQuery) { + throw new Error('Invalid test query'); + } + const form = buildFilterFormValuesFromQuery(originalQuery, {}, {}, {}, {}, {}, {}); + const {updatedQuery} = updateQuery(input, { + merchant: undefined, + merchantNot: form.merchantNot ?? form.merchant, + merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }); + + expect(updatedQuery.flatFilters).toEqual( + originalQuery?.flatFilters.map((filter) => ({ + ...filter, + filters: filter.filters.map((item) => ({...item, operator: 'neq'})), + })), + ); + }); + + test.each(['-merchant:Amazon -merchant:Prime', '-merchant:Amazon,Prime', '-merchant:"Coffee, Shop",Uber -merchant:Prime', 'merchant:Amazon -merchant:Prime'])( + 'applies positive matching to every original Merchant value: %s', + (input) => { + const originalQuery = buildSearchQueryJSON(input); + if (!originalQuery) { + throw new Error('Invalid test query'); + } + const form = buildFilterFormValuesFromQuery(originalQuery, {}, {}, {}, {}, {}, {}); + const {updatedQuery} = updateQuery(input, { + merchant: form.merchantNot, + merchantNot: undefined, + merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }); + + expect(updatedQuery.flatFilters).toEqual( + originalQuery?.flatFilters.map((filter) => ({ + ...filter, + filters: filter.filters.map((item) => ({...item, operator: 'eq'})), + })), + ); + }, + ); + + test.each(['merchant:Amazon merchant:Prime', '-merchant:Amazon -merchant:Prime', 'merchant=Amazon -merchant:Prime'])( + 'removes every Merchant predicate without changing other filters: %s', + (input) => { + const {updatedQuery} = updateQuery(`type:expense description:Lunch ${input}`, {merchant: undefined, merchantNot: undefined}); + + expect(updatedQuery.type).toBe('expense'); + expect(updatedQuery.flatFilters).toEqual([{key: 'description', filters: [{operator: 'eq', value: 'Lunch'}]}]); + }, + ); + + test('preserves both Merchant values through negation and match type round trips', () => { + const negative = updateQuery('merchant:Amazon merchant:Prime', {merchant: undefined, merchantNot: 'Prime', merchantOperator: 'eq'}); + const positive = updateQuery(buildSearchQueryString(negative.updatedQuery), {merchant: 'Prime', merchantNot: undefined, merchantOperator: 'eq'}); + const contains = updateQuery(buildSearchQueryString(positive.updatedQuery), {merchantOperator: 'contains'}); + + expect(contains.updatedQuery.filters).toEqual(negative.originalQuery.filters); + }); + + test('keeps a Merchant list through successive match type and Currency changes without mutating the original query', () => { + const {originalQuery, updatedQuery} = updateQuery('merchant*:I,Ig', {merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}); + const currencyUpdate = updateQuery(buildSearchQueryString(updatedQuery), {currency: ['USD']}); + + expect(originalQuery.filters).toEqual({operator: 'contains', left: 'merchant', right: ['I', 'Ig']}); + expect(currencyUpdate.updatedQuery.flatFilters).toContainEqual({ + key: 'merchant', + filters: [ + {operator: 'eq', value: 'I'}, + {operator: 'eq', value: 'Ig'}, + ], + }); + expect(currencyUpdate.updatedQuery.flatFilters).toContainEqual({key: 'currency', filters: [{operator: 'eq', value: 'USD'}]}); + }); + + test.each<{updates: Partial; expected: string}>([ + {updates: {merchant: 'Tea'}, expected: 'merchant*:Tea'}, + {updates: {merchant: 'I,Ig', merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}, expected: 'merchant="I,Ig"'}, + {updates: {merchant: undefined}, expected: ''}, + ])('replaces or removes a Merchant list on an explicit change: %j', ({updates, expected}) => { + const {updatedQuery} = updateQuery('merchant*:Amazon,Uber', updates); + + expect(updatedQuery.flatFilters.filter((filter) => filter.key === 'merchant')).toEqual(buildSearchQueryJSON(expected)?.flatFilters); + }); + + test.each<{updates: Partial; expected: string}>([ + {updates: {merchant: 'Tea'}, expected: 'merchant*:Tea'}, + {updates: {merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}, expected: 'merchant=I'}, + {updates: {merchant: undefined}, expected: ''}, + {updates: {merchant: undefined, merchantNot: 'I'}, expected: '-merchant:I'}, + {updates: {type: CONST.SEARCH.DATA_TYPES.CHAT}, expected: ''}, + ])('replaces or removes Merchant predicates on an explicit change: %j', ({updates, expected}) => { + const {updatedQuery} = updateQuery('merchant=I merchant*:I', updates); + + expect(updatedQuery.flatFilters.filter((filter) => filter.key === 'merchant')).toEqual(buildSearchQueryJSON(expected)?.flatFilters); + }); + + test('clears all Merchant predicates when resetting advanced filters', () => { + const queryJSON = buildSearchQueryJSON('merchant=I merchant*:I'); + if (!queryJSON) { + throw new Error('Invalid test query'); + } + + const updatedQuery = buildSearchQueryJSON(buildQueryStringWithResetFilters(queryJSON, undefined)); + + expect(updatedQuery?.flatFilters).toEqual([]); + }); + }); + test('groupBy change replaces stale sortBy with new default', () => { const result = buildFilterQueryWithSortDefaults( {type: 'expense', groupBy: CONST.SEARCH.GROUP_BY.MONTH, view: CONST.SEARCH.VIEW.BAR}, @@ -4435,19 +4980,109 @@ describe('SearchQueryUtils', () => { }); }); - describe('applyContainsOperatorToTextFields', () => { - it('should transform merchant eq to contains', () => { - const queryJSON = buildSearchQueryJSON('type:expense merchant:coffee'); + describe('merchant multi-value query serialization', () => { + test.each(['merchant*:Amazon,Uber', 'merchant:Amazon,Uber'])('preserves array AST for %s', (input) => { + const queryJSON = buildSearchQueryJSON(input); if (!queryJSON?.filters) { - throw new Error('Expected filters to be defined'); + throw new Error('Expected merchant filters to be defined'); } - const transformed = applyContainsOperatorToTextFields(queryJSON.filters); - const merchantNode = findNode(transformed, 'merchant'); + + const merchantNode = findNode(queryJSON.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT); if (!merchantNode) { throw new Error('Expected merchant node to be found in AST'); } + expect(merchantNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); - expect(merchantNode.right).toBe('coffee'); + expect(merchantNode.right).toEqual(['Amazon', 'Uber']); + + const builtQuery = buildSearchQueryString(queryJSON); + const updatedQuery = getQueryWithUpdatedValues(input, true); + if (!updatedQuery) { + throw new Error('Expected updated query to be defined'); + } + + expect(updatedQuery).toBe(builtQuery); + expect(buildSearchQueryJSON(builtQuery)?.filters).toEqual(queryJSON.filters); + expect(buildSearchQueryJSON(updatedQuery)?.filters).toEqual(queryJSON.filters); + + const canonicalQueryJSON = buildSearchQueryJSON(updatedQuery); + if (!canonicalQueryJSON?.filters) { + throw new Error('Expected canonical merchant filters to be defined'); + } + + expect(serializeQueryJSONForBackend(canonicalQueryJSON)).toBe(JSON.stringify(canonicalQueryJSON)); + expect(getQueryWithUpdatedValues(updatedQuery, true)).toBe(updatedQuery); + expect(buildSearchQueryString(buildSearchQueryJSON(updatedQuery))).toBe(updatedQuery); + }); + + test.each([ + ['merchant=Amazon,Uber', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, ['Amazon', 'Uber'], 'merchant=Amazon,Uber'], + ['-merchant:Amazon,Uber', CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO, ['Amazon', 'Uber'], '-merchant:Amazon,Uber'], + ['merchant*:Amazon', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, 'Amazon', 'merchant*:Amazon'], + ['merchant*:"Amazon,Uber"', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, 'Amazon,Uber', 'merchant*:"Amazon,Uber"'], + ])('keeps merchant operator semantics for %s', (input, operator, value, expectedQuery) => { + const queryJSON = buildSearchQueryJSON(input); + if (!queryJSON?.filters) { + throw new Error('Expected merchant filters to be defined'); + } + + const merchantNode = findNode(queryJSON.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT); + if (!merchantNode) { + throw new Error('Expected merchant node to be found in AST'); + } + + expect(merchantNode.operator).toBe(operator); + expect(merchantNode.right).toEqual(value); + const rebuiltQueryString = buildSearchQueryString(queryJSON); + expect(rebuiltQueryString).toContain(expectedQuery); + expect(buildSearchQueryJSON(rebuiltQueryString)?.filters).toEqual(queryJSON.filters); + }); + + test('preserves separate merchant contains filters when rebuilding', () => { + const queryJSON = buildSearchQueryJSON('merchant*:Amazon merchant*:Uber'); + if (!queryJSON?.filters) { + throw new Error('Expected merchant query to be defined'); + } + + expect(queryJSON.filters.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.AND); + expect(buildSearchQueryJSON(buildSearchQueryString(queryJSON))?.filters).toEqual(queryJSON.filters); + }); + + test('keeps category comma lists on the exact operator', () => { + const queryJSON = buildSearchQueryJSON('category:Travel,Meals'); + if (!queryJSON?.filters) { + throw new Error('Expected category filters to be defined'); + } + + const categoryNode = findNode(queryJSON.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY); + if (!categoryNode) { + throw new Error('Expected category node to be found in AST'); + } + + expect(categoryNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + expect(categoryNode.right).toEqual(['Travel', 'Meals']); + expect(buildSearchQueryString(queryJSON)).toContain('category:Travel,Meals'); + }); + }); + + describe('applyContainsOperatorToTextFields', () => { + it.each([ + ['merchant=coffee', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO], + ['merchant:coffee', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS], + ['merchant*:coffee', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS], + ])('should preserve the parsed merchant operator for %s', (merchantQuery, merchantOperator) => { + // Given a query with an explicit exact, legacy, or contains merchant filter + const queryJSON = buildSearchQueryJSON(`type:expense ${merchantQuery}`); + if (!queryJSON?.filters) { + throw new Error('Expected filters to be defined'); + } + + // When text field operators are normalized + const merchantNode = findNode(applyContainsOperatorToTextFields(queryJSON.filters), 'merchant'); + + // Then the parsed merchant operator is kept, because the parser already maps legacy `:` to contains + expect(merchantNode?.operator).toBe(merchantOperator); + expect(merchantNode?.right).toBe('coffee'); }); it('should transform description eq to contains', () => { @@ -4489,7 +5124,7 @@ describe('SearchQueryUtils', () => { expect(merchantNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO); }); - it('should transform merchant but not category in a compound query', () => { + it('should preserve legacy merchant contains and category exact in a compound query', () => { const queryJSON = buildSearchQueryJSON('type:expense merchant:coffee category:travel'); if (!queryJSON?.filters) { throw new Error('Expected filters to be defined'); @@ -4511,62 +5146,57 @@ describe('SearchQueryUtils', () => { }); describe('serializeQueryJSONForBackend', () => { - it('should apply contains to merchant in AST filters', () => { - const queryJSON = buildSearchQueryJSON('type:expense merchant:coffee'); + it.each([ + ['merchant:coffee', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS], + ['merchant*:coffee', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS], + ['merchant=coffee', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO], + ])('should preserve the merchant operator in AST filters for %s', (merchantQuery, merchantOperator) => { + // Given a query with a legacy, contains, or explicit exact merchant filter + const queryJSON = buildSearchQueryJSON(`type:expense ${merchantQuery}`); if (!queryJSON) { throw new Error('Expected queryJSON to be defined'); } - const normalizedFilters = applyContainsOperatorToTextFields(queryJSON.filters); - expect(serializeQueryJSONForBackend(queryJSON)).toBe(JSON.stringify({...queryJSON, filters: normalizedFilters})); - const merchantNode = findNode(normalizedFilters, 'merchant'); - if (!merchantNode) { - throw new Error('Expected merchant node to be found in AST'); - } - expect(merchantNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); + + // When the query is serialized for the backend + const result = serializeQueryJSONForBackend(queryJSON); + + // Then the merchant filter is sent unchanged with its parsed operator + expect(result).toBe(JSON.stringify(queryJSON)); + expect(findNode(queryJSON.filters, 'merchant')?.operator).toBe(merchantOperator); }); - it('should apply contains to merchant in rawFilterList', () => { - const rawFilterList = [{key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: 'coffee'}]; + it.each([CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS])('should preserve the %s merchant operator in rawFilterList', (operator) => { + // Given a raw merchant filter with an exact or contains operator + const rawFilterList = [{key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, operator, value: 'coffee'}]; + + // When the query is serialized for the backend + const result = serializeQueryJSONForBackend({filters: undefined, rawFilterList}); + + // Then the merchant operator is not rewritten + expect(result).toBe(JSON.stringify({filters: undefined, rawFilterList})); + }); + + it('should apply contains to description in rawFilterList', () => { + const rawFilterList = [{key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: 'coffee'}]; const normalizedRawFilterList = rawFilterList.map((filter) => ({...filter, operator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS})); expect(serializeQueryJSONForBackend({filters: undefined, rawFilterList})).toBe(JSON.stringify({filters: undefined, rawFilterList: normalizedRawFilterList})); }); - it('should preserve exact merchant matches in AST filters', () => { - const queryJSON = buildSearchQueryJSON('type:expense merchant:coffee'); + it('should keep merchant eq and turn description eq into contains', () => { + // Given a query with an exact merchant list and a description filter, both parsed as `eq` + const queryJSON = buildSearchQueryJSON('type:expense merchant=Amazon,"Amazon Marketplace" description:order'); if (!queryJSON) { throw new Error('Expected queryJSON to be defined'); } - const exactMatchFilterKeys = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); - expect(serializeQueryJSONForBackend(queryJSON, exactMatchFilterKeys)).toBe(JSON.stringify(queryJSON)); - const merchantNode = findNode(queryJSON.filters, 'merchant'); - if (!merchantNode) { - throw new Error('Expected merchant node to be found in AST'); - } - expect(merchantNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); - }); - it('should preserve exact merchant matches in rawFilterList', () => { - const rawFilterList = [{key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: 'coffee'}]; - const exactMatchFilterKeys = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); - expect(serializeQueryJSONForBackend({filters: undefined, rawFilterList}, exactMatchFilterKeys)).toBe(JSON.stringify({filters: undefined, rawFilterList})); - }); + // When the query is serialized for the backend + const result = serializeQueryJSONForBackend(queryJSON); - it('should preserve multiple exact merchant matches while keeping description as contains', () => { - const queryJSON = buildSearchQueryJSON('type:expense merchant:Amazon,"Amazon Marketplace" description:order'); - if (!queryJSON) { - throw new Error('Expected queryJSON to be defined'); - } - const exactMatchFilterKeys = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); - const normalizedFilters = applyContainsOperatorToTextFields(queryJSON.filters, exactMatchFilterKeys); - expect(serializeQueryJSONForBackend(queryJSON, exactMatchFilterKeys)).toBe(JSON.stringify({...queryJSON, filters: normalizedFilters})); - const merchantNode = findNode(normalizedFilters, 'merchant'); - const descriptionNode = findNode(normalizedFilters, 'description'); - if (!merchantNode || !descriptionNode) { - throw new Error('Expected merchant and description nodes to be found in AST'); - } - expect(merchantNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); - expect(merchantNode.right).toEqual(['Amazon', 'Amazon Marketplace']); - expect(descriptionNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); + // Then merchant keeps exact matching, because the parser already tells `merchant=` from `merchant:`, + // and description still uses partial matching on the backend + expect(result).toContain('"operator":"eq","left":"merchant","right":["Amazon","Amazon Marketplace"]'); + expect(result).toContain('"operator":"contains","left":"description","right":"order"'); + expect(result).not.toContain('"operator":"contains","left":"merchant"'); }); it('should not affect non-text fields in rawFilterList', () => { @@ -4575,22 +5205,6 @@ describe('SearchQueryUtils', () => { }); }); - describe('withExactMatchFilterKeys', () => { - it('should give exact queries distinct snapshot hashes', () => { - const partialQuery = buildSearchQueryJSON('type:expense merchant:Amazon'); - if (!partialQuery) { - throw new Error('Expected partial query to be defined'); - } - - const exactQuery = withExactMatchFilterKeys(partialQuery, [CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); - - expect(exactQuery.hash).not.toBe(partialQuery.hash); - expect(exactQuery.recentSearchHash).not.toBe(partialQuery.recentSearchHash); - expect(exactQuery.similarSearchHash).not.toBe(partialQuery.similarSearchHash); - expect(exactQuery.exactMatchFilterKeys).toEqual([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); - }); - }); - describe('getKeywordQueryWithCurrentSearchContext', () => { it('should prepend current search context to keyword-only input', () => { const currentQueryJSON = buildSearchQueryJSON('type:trip status:outstanding'); @@ -4634,6 +5248,14 @@ describe('SearchQueryUtils', () => { expect(result).toContain('type:trip'); }); + it('should escape input that uses the contains operator so it is treated as a keyword', () => { + const currentQueryJSON = buildSearchQueryJSON('type:trip status:all'); + + const result = currentQueryJSON ? getKeywordQueryWithCurrentSearchContext('merchant*:Uber', currentQueryJSON) : ''; + expect(result).toContain('"merchant*:Uber"'); + expect(result).toContain('type:trip'); + }); + it('should stop escaping after the unquoted filter value', () => { const currentQueryJSON = buildSearchQueryJSON('type:trip status:all'); @@ -5014,6 +5636,34 @@ describe('SearchQueryUtils', () => { }); }); + describe('getMerchantFilterFormValues', () => { + it('keeps the match type when not negated', () => { + // Given a Merchant filter that is not negated + // When the form values are built + const result = getMerchantFilterFormValues('coffee', false, CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + + // Then the match type is kept, because it decides between Contains and Equal to + expect(result).toEqual({ + [FILTER_KEYS.MERCHANT]: 'coffee', + [FILTER_KEYS.MERCHANT_NOT]: undefined, + [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }); + }); + + it('clears the match type when negated', () => { + // Given a negated Merchant filter + // When the form values are built + const result = getMerchantFilterFormValues('coffee', true, CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + + // Then the match type is cleared, because a negated Merchant filter always uses the not-equal operator + expect(result).toEqual({ + [FILTER_KEYS.MERCHANT]: undefined, + [FILTER_KEYS.MERCHANT_NOT]: 'coffee', + [FILTER_KEYS.MERCHANT_OPERATOR]: undefined, + }); + }); + }); + describe('getFilterFromQuery', () => { test('returns all comma-separated values for a non-negated filter', () => { const queryJSON = buildSearchQueryJSON('type:expense policyID:123,456'); @@ -5451,6 +6101,42 @@ describe('SearchQueryUtils', () => { const lastQuery = 'type:invoice status:all'; expect(getValidLastQuery(lastQuery, defaultSearchQuery)).toBe(defaultSearchQuery); }); + + describe('with the Spend footer selections', () => { + it('keeps a last query switched back to the default total, which leaves the total out', () => { + // Given a saved search on the reimbursable total + const savedQuery = 'type:expense status:all footerTotal:reimbursable'; + + // When it is switched back to the plain total, which leaves the total out of the last query + const lastQuery = 'type:expense status:all'; + + // Then the last choice comes back, the same as when switching to any other total + expect(getValidLastQuery(lastQuery, savedQuery)).toBe(lastQuery); + expect(getValidLastQuery('type:expense status:all footerTotal:non-reimbursable', savedQuery)).toBe('type:expense status:all footerTotal:non-reimbursable'); + }); + + it('keeps a last query switched back to the default count, which leaves the count out', () => { + // Given a saved search on the report count + const savedQuery = 'type:expense status:all footerCount:reports'; + + // When it is switched back to the expense count, which leaves the count out of the last query + const lastQuery = 'type:expense status:all'; + + // Then the last choice comes back + expect(getValidLastQuery(lastQuery, savedQuery)).toBe(lastQuery); + }); + + it('still drops a last query that lost a real filter', () => { + // Given a saved search with a footer total + const savedQuery = 'type:expense status:all footerTotal:reimbursable'; + + // When its last query drops the status filter + const lastQuery = 'type:expense footerTotal:reimbursable'; + + // Then the saved query comes back, since a row filter still decides + expect(getValidLastQuery(lastQuery, savedQuery)).toBe(savedQuery); + }); + }); }); describe('doesQueryMatchDefaultFilterKeysAndType', () => { @@ -5535,6 +6221,21 @@ describe('SearchQueryUtils', () => { end: '2025-03-09', }); }); + + test('zero-pads dates typed without leading zeros so they compare correctly with other dates', () => { + // Given a query whose dates are typed without leading zeros, which the search parser accepts + const queryJSON = buildSearchQueryJSON('type:expense date>=2026-1-5 date<=2026-2-9'); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + // When the date range is read from the query + const range = getDateFilterRange(queryJSON); + + // Then both bounds are zero-padded yyyy-MM-dd dates, like the chart groups and today they are compared with + expect(range).toEqual({start: '2026-01-05', end: '2026-02-09'}); + }); }); describe('isSearchBeforeViolationsSnapshotStarted', () => { diff --git a/tests/unit/Search/SearchResultsProviderLiveLoadingTest.tsx b/tests/unit/Search/SearchResultsProviderLiveLoadingTest.tsx index 0bc1c4fbce45..3d23cef6ba13 100644 --- a/tests/unit/Search/SearchResultsProviderLiveLoadingTest.tsx +++ b/tests/unit/Search/SearchResultsProviderLiveLoadingTest.tsx @@ -22,9 +22,11 @@ jest.mock('@libs/SearchUIUtils', () => ({ getViolationsFromSearchData: () => ({}), })); +const EMPTY_TODO_METADATA = {count: 0, total: 0, currency: undefined}; +let mockTodoMetadata: Record = EMPTY_TODO_METADATA; jest.mock('@hooks/useTodoSearchResults', () => ({ __esModule: true, - default: () => ({data: {}, metadata: {count: 0, total: 0, currency: undefined}}), + default: () => ({data: {}, metadata: mockTodoMetadata}), })); jest.mock('@components/Search/SearchContext', () => ({ @@ -48,6 +50,7 @@ describe('SearchResultsProvider live to-do loading flag', () => { beforeEach(() => { mockUseOnyx.mockReset(); mockIsTodoSearch = true; + mockTodoMetadata = EMPTY_TODO_METADATA; }); it('forces isLoading off for a live to-do search even when the snapshot left it stuck true', () => { @@ -66,4 +69,15 @@ describe('SearchResultsProvider live to-do loading flag', () => { expect(result.current.currentSearchResults?.search.hasResults).toBe(false); expect(result.current.currentSearchResults?.data).toEqual({}); }); + + it('shows the figures the to-do list worked out, including the report count, over the snapshot', () => { + // Given the to-do list corrected the server's figures for reports that left it on this device + mockTodoMetadata = {count: 125, total: 125000, currency: 'USD', reportCount: 119}; + + // When the provider builds the live results over a snapshot that still holds the server's old figures + const {result} = renderProvider({isLoading: false, offset: 0, hash: 1, count: 126, total: 126000, currency: 'USD', reportCount: 120}); + + // Then the search shows the corrected figures, which "Select all" and the footer read + expect(result.current.currentSearchResults?.search).toMatchObject({count: 125, total: 125000, currency: 'USD', reportCount: 119}); + }); }); diff --git a/tests/unit/Search/SearchResultsProviderLiveReportCountTest.tsx b/tests/unit/Search/SearchResultsProviderLiveReportCountTest.tsx new file mode 100644 index 000000000000..e730f2e2183c --- /dev/null +++ b/tests/unit/Search/SearchResultsProviderLiveReportCountTest.tsx @@ -0,0 +1,293 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import {SearchResultsContext} from '@components/Search/SearchContextDefinitions'; +import SearchResultsProvider from '@components/Search/SearchResultsProvider'; + +import {getSuggestedSearches} from '@libs/SearchSuggestionUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, SearchResults, Transaction} from '@src/types/onyx'; + +import React, {use} from 'react'; +import Onyx from 'react-native-onyx'; + +import createMock from '../../utils/createMock'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const CURRENT_USER_ACCOUNT_ID = 1; +const CURRENT_USER_EMAIL = 'tester@mail.com'; +const APPROVER_ACCOUNT_ID = 2; +const POLICY_ID = 'policy123'; +const DRAFT_REPORT_IDS = ['draft_1', 'draft_2', 'draft_3']; + +// The server matched more drafts than this device holds, as on an account whose to-dos aren't all loaded. Each draft +// has one 10.00 expense. +const SERVER_REPORT_COUNT = 120; +const EXPENSE_AMOUNT = 1000; + +const suggestedSearches = getSuggestedSearches(CURRENT_USER_ACCOUNT_ID); +const draftsQueryJSON = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.SUBMIT].searchQueryJSON; + +const mockQueryContext = { + currentSearchHash: draftsQueryJSON?.hash ?? CONST.DEFAULT_NUMBER_ID, + currentSearchKey: CONST.SEARCH.SEARCH_KEYS.SUBMIT, + currentSearchQueryJSON: draftsQueryJSON, + suggestedSearches, +}; +jest.mock('@components/Search/SearchContext', () => ({ + useSearchQueryContext: () => mockQueryContext, +})); + +const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${mockQueryContext.currentSearchHash}` as const; +const firstDraftReportID = DRAFT_REPORT_IDS.at(0) ?? ''; + +const draftReport = (reportID: string): Report => + createMock({ + reportID, + chatReportID: `chat_${reportID}`, + policyID: POLICY_ID, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + managerID: APPROVER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + type: CONST.REPORT.TYPE.EXPENSE, + currency: 'USD', + }); + +const draftTransaction = (reportID: string): Transaction => + createMock({ + transactionID: `transaction_${reportID}`, + reportID, + amount: -1000, + currency: 'USD', + merchant: 'Merchant', + created: '2026-10-13', + reimbursable: true, + groupAmount: -1000, + groupCurrency: 'USD', + }); + +// The first page the server returned when Drafts loaded: the three drafts this device holds, out of SERVER_REPORT_COUNT. +const draftsSnapshotData = createMock({}); +for (const reportID of DRAFT_REPORT_IDS) { + Object.assign(draftsSnapshotData, { + [`${ONYXKEYS.COLLECTION.REPORT}${reportID}` as const]: draftReport(reportID), + [`${ONYXKEYS.COLLECTION.TRANSACTION}${draftTransaction(reportID).transactionID}` as const]: draftTransaction(reportID), + }); +} + +const draftsSnapshot = createMock({ + search: { + hash: mockQueryContext.currentSearchHash, + offset: 0, + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + hasMoreResults: true, + hasResults: true, + isLoading: false, + count: SERVER_REPORT_COUNT, + reportCount: SERVER_REPORT_COUNT, + total: SERVER_REPORT_COUNT * EXPENSE_AMOUNT, + currency: 'USD', + footerTotal: CONST.SEARCH.FOOTER_TOTAL.TOTAL, + countedReportIDs: Object.fromEntries(DRAFT_REPORT_IDS.map((reportID) => [reportID, true])), + }, + data: draftsSnapshotData, +}); + +function renderProvider() { + return renderHook(() => use(SearchResultsContext), { + wrapper: ({children}: {children: React.ReactNode}) => {children}, + }); +} + +async function seedDraftsAndSnapshot() { + const policy = createMock({ + id: POLICY_ID, + name: 'Test Policy', + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + owner: CURRENT_USER_EMAIL, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + outputCurrency: 'USD', + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + }); + + await Onyx.set(ONYXKEYS.SESSION, {email: CURRENT_USER_EMAIL, accountID: CURRENT_USER_ACCOUNT_ID}); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await Promise.all(DRAFT_REPORT_IDS.map((reportID) => Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, draftReport(reportID)))); + await Promise.all(DRAFT_REPORT_IDS.map((reportID) => Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${draftTransaction(reportID).transactionID}`, draftTransaction(reportID)))); + await Onyx.set(snapshotKey, draftsSnapshot); + await waitForBatchedUpdates(); +} + +async function renderDrafts() { + await seedDraftsAndSnapshot(); + const rendered = renderProvider(); + await act(async () => { + await waitForBatchedUpdates(); + }); + return rendered; +} + +async function moveFirstDraft(stateNum: Report['stateNum'], statusNum: Report['statusNum']) { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${firstDraftReportID}`, {stateNum, statusNum}); + await waitForBatchedUpdates(); + }); +} + +// The last draft stands for a report from a later page, which a first-page response leaves out of its record. +const laterPageDraftReportID = DRAFT_REPORT_IDS.at(-1) ?? ''; +const firstPageDraftReportIDs = DRAFT_REPORT_IDS.filter((reportID) => reportID !== laterPageDraftReportID); + +async function submitDraft(reportID: string) { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED}); + await waitForBatchedUpdates(); + }); +} + +/** A first-page response, which replaces the snapshot with the server's fresh count and a record of page 1 */ +async function answerFirstPage(reportCount: number) { + await act(async () => { + await Onyx.set(snapshotKey, { + ...draftsSnapshot, + search: {...draftsSnapshot.search, reportCount, countedReportIDs: Object.fromEntries(firstPageDraftReportIDs.map((reportID) => [reportID, true]))}, + }); + await waitForBatchedUpdates(); + }); +} + +function getReportCount(result: Awaited>['result']) { + return result.current.currentSearchResults?.search?.reportCount; +} + +function expectFigures(result: Awaited>['result'], reportCount: number, deviceDraftCount: number) { + const search = result.current.currentSearchResults?.search; + expect(search?.reportCount).toBe(reportCount); + expect(search?.count).toBe(deviceDraftCount); + expect(search?.total).toBe(deviceDraftCount * EXPENSE_AMOUNT); + expect(search?.currency).toBe('USD'); +} + +describe('SearchResultsProvider live to-do report count', () => { + beforeAll(() => { + // As in the app, Onyx.update copies pending markers into the snapshots that hold the updated keys. + Onyx.init({keys: ONYXKEYS, snapshotMergeKeys: ['pendingAction', 'pendingFields']}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it("keeps the server's report count when the device holds only some of the matching reports", async () => { + // Given Drafts on a device that holds three of the 120 drafts the server counted + // When the provider builds the live results + const {result} = await renderDrafts(); + + // Then "Select all" counts every matching report, not only the three on the device + expect(result.current.shouldUseLiveData).toBe(true); + expectFigures(result, SERVER_REPORT_COUNT, DRAFT_REPORT_IDS.length); + }); + + it('keeps a counted report this device never lists on the count, such as one its own rules hide', async () => { + // Given Drafts whose server responses also held a draft this device doesn't list + const {result} = await renderDrafts(); + await act(async () => { + await Onyx.merge(snapshotKey, {search: {countedReportIDs: {hiddenDraft: true}}}); + await waitForBatchedUpdates(); + }); + + // When the provider builds the live results + // Then the server's 120 stand, since "Select all" still acts on that draft + expect(getReportCount(result)).toBe(SERVER_REPORT_COUNT); + }); + + it('lowers the report count as soon as a report leaves Drafts, without a new search', async () => { + // Given Drafts with the server's 120 reports + const {result} = await renderDrafts(); + + // When one draft is submitted from its row, which moves it out of Drafts on this device + await moveFirstDraft(CONST.REPORT.STATE_NUM.SUBMITTED, CONST.REPORT.STATUS_NUM.SUBMITTED); + + // Then the report count drops with it, like the Drafts badge does + expectFigures(result, SERVER_REPORT_COUNT - 1, DRAFT_REPORT_IDS.length - 1); + }); + + it('keeps it lowered after the submit succeeds and removes the report from the snapshot', async () => { + // Given a draft that was submitted from its row + const {result} = await renderDrafts(); + await moveFirstDraft(CONST.REPORT.STATE_NUM.SUBMITTED, CONST.REPORT.STATUS_NUM.SUBMITTED); + + // When the submit succeeds and its success data removes the report's row from the snapshot + await act(async () => { + await Onyx.merge(snapshotKey, {data: {[`${ONYXKEYS.COLLECTION.REPORT}${firstDraftReportID}`]: null}}); + await waitForBatchedUpdates(); + }); + + // Then the report count stays lowered instead of jumping back to the server's old one + expectFigures(result, SERVER_REPORT_COUNT - 1, DRAFT_REPORT_IDS.length - 1); + }); + + it('puts the report back in the report count when a failed submit returns it to Drafts', async () => { + // Given a draft that was submitted from its row + const {result} = await renderDrafts(); + await moveFirstDraft(CONST.REPORT.STATE_NUM.SUBMITTED, CONST.REPORT.STATUS_NUM.SUBMITTED); + + // When the submit fails and its failure data returns the report to Drafts + await moveFirstDraft(CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN); + + // Then the report count is the server's again, without anything having to restore it + expectFigures(result, SERVER_REPORT_COUNT, DRAFT_REPORT_IDS.length); + }); + + it('takes nothing off for a draft created on this device and then submitted, even once its creation succeeded', async () => { + // Given Drafts with the server's 120 reports, and a draft created on this device, which the app writes into the snapshot with its pending markers + const {result} = await renderDrafts(); + const createdReportKey = `${ONYXKEYS.COLLECTION.REPORT}created` as const; + const createdTransaction = {...draftTransaction('created'), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}; + const createdTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${createdTransaction.transactionID}` as const; + const createdReport = {...draftReport('created'), pendingFields: {createReport: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}}; + const createdSnapshotData = createMock({}); + Object.assign(createdSnapshotData, {[createdReportKey]: createdReport, [createdTransactionKey]: createdTransaction}); + await act(async () => { + await Onyx.set(createdReportKey, createdReport); + await Onyx.set(createdTransactionKey, createdTransaction); + await Onyx.merge(snapshotKey, {data: createdSnapshotData}); + await waitForBatchedUpdates(); + }); + + // When the creation succeeds, its success data clearing the pending markers through Onyx.update, which also clears them in the snapshot + await act(async () => { + await Onyx.update([ + {onyxMethod: Onyx.METHOD.MERGE, key: createdReportKey, value: {pendingFields: {createReport: null}}}, + {onyxMethod: Onyx.METHOD.MERGE, key: createdTransactionKey, value: {pendingAction: null}}, + ]); + await waitForBatchedUpdates(); + }); + + // And the draft is then submitted + await act(async () => { + await Onyx.merge(createdReportKey, {stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED}); + await waitForBatchedUpdates(); + }); + + // Then the server's 120 stand, since the draft isn't among the reports its responses held + expect(result.current.currentSearchResults?.search?.reportCount).toBe(SERVER_REPORT_COUNT); + }); + + it("doesn't take a report off twice when a first-page response lands after it left", async () => { + // Given a later-page draft submitted, so the count drops to 119 + const {result} = await renderDrafts(); + await submitDraft(laterPageDraftReportID); + expect(getReportCount(result)).toBe(SERVER_REPORT_COUNT - 1); + + // When a first-page response brings the server's fresh count, which no longer covers it, and a record of page 1 + await answerFirstPage(SERVER_REPORT_COUNT - 1); + + // Then the count stays at 119, since the fresh count already left it out + expect(getReportCount(result)).toBe(SERVER_REPORT_COUNT - 1); + }); +}); diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index 623ee2fbdf76..51e43a08e390 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -4,7 +4,7 @@ import SearchSelectionFooter, {resetAnsweredFooterTotalsForTesting} from '@compo import type {SearchFooterCount, SearchFooterTotal, SearchQueryJSON, SelectedReports, SelectedTransactionInfo, SelectedTransactions} from '@components/Search/types'; import {getFooterConvertedAmounts} from '@libs/actions/Search'; -import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {buildSearchQueryJSON, isSearchQuerySavable} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -13,6 +13,7 @@ import type {SearchResults} from '@src/types/onyx'; import Onyx from 'react-native-onyx'; import createRandomTransaction from '../../utils/collections/transaction'; +import createMock from '../../utils/createMock'; import {makeSettlementGroup} from '../../utils/ExpensifyCardStatementTestUtils'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; @@ -1046,6 +1047,67 @@ describe('SearchSelectionFooter', () => { expect(mockSetParams.mock.calls.at(0)?.at(0)?.q).toContain('footerCurrency:EUR'); }); + it('leaves the default currency out of the query, so a Reset no longer offers to save the search', async () => { + // Given a search whose footer was switched to EUR + setSearchQuery(`type:expense footerCurrency:${CONST.CURRENCY.EUR}`); + mockSelectedTransactions.current = {}; + render(); + await waitForBatchedUpdates(); + const defaultCurrency = mockCapturedFooterProps.current?.defaultCurrency ?? ''; + + // When the currency is reset, which picks the default currency explicitly + await act(async () => { + mockCapturedFooterProps.current?.onCurrencyChange?.(defaultCurrency); + await waitForBatchedUpdates(); + }); + + // Then the query carries no currency, so it matches the default search and the Save button goes away + const nextQuery = mockSetParams.mock.calls.at(-1)?.at(0)?.q ?? ''; + expect(nextQuery).not.toContain('footerCurrency'); + expect(isSearchQuerySavable(buildSearchQueryJSON(nextQuery), buildSearchQueryJSON('type:expense'))).toBe(false); + }); + + it('keeps converting to the reset currency once the query lands without one', async () => { + // Given a search on EUR whose snapshot is still in another currency than the default it resets to + setSearchQuery(`type:expense footerCurrency:${CONST.CURRENCY.EUR}`); + mockSelectedTransactions.current = {}; + const {rerender} = render(); + await waitForBatchedUpdates(); + const defaultCurrency = mockCapturedFooterProps.current?.defaultCurrency ?? ''; + expect(defaultCurrency).not.toBe(CONST.CURRENCY.USD); + + // When the currency is reset and the query, now carrying no currency, lands + await act(async () => { + mockCapturedFooterProps.current?.onCurrencyChange?.(defaultCurrency); + await waitForBatchedUpdates(); + }); + jest.mocked(getFooterConvertedAmounts).mockClear(); + setSearchQuery(mockSetParams.mock.calls.at(-1)?.at(0)?.q ?? ''); + rerender(); + await waitForBatchedUpdates(); + + // Then the footer still converts to the reset currency instead of falling back to the snapshot's + expect(getFooterConvertedAmounts).toHaveBeenCalledWith(expect.objectContaining({targetCurrency: defaultCurrency})); + }); + + it('still writes a currency other than the default into the query', async () => { + // Given a search with no footer currency + setSearchQuery('type:expense'); + mockSelectedTransactions.current = {}; + render(); + await waitForBatchedUpdates(); + + // When a currency other than the default is picked + const otherCurrency = mockCapturedFooterProps.current?.defaultCurrency === CONST.CURRENCY.EUR ? CONST.CURRENCY.GBP : CONST.CURRENCY.EUR; + await act(async () => { + mockCapturedFooterProps.current?.onCurrencyChange?.(otherCurrency); + await waitForBatchedUpdates(); + }); + + // Then it is kept in the query, so it survives a reload and a saved search + expect(mockSetParams.mock.calls.at(-1)?.at(0)?.q).toContain(`footerCurrency:${otherCurrency}`); + }); + it('lets the query currency take over once it moves off the one the pick was made on', async () => { // Given EUR picked on a query that carried no currency setSearchQuery('type:expense'); @@ -1141,6 +1203,50 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 2})); }); + it('counts no report for a selected expense on an IOU report, which the server count leaves out too', async () => { + // Given one expense on an expense report and one on an IOU report, both in the loaded data + setSearchQuery('type:expense footerCount:reports'); + mockSelectedTransactions.current = { + transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'expenseReport'}, + transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'iouReport'}, + }; + const searchResults = buildSearchResults(CONST.CURRENCY.USD, 1204, 36000, CONST.SEARCH.DATA_TYPES.EXPENSE, 87); + mockCurrentSearchResults.current = createMock({ + ...searchResults, + data: { + [`${ONYXKEYS.COLLECTION.REPORT}expenseReport` as const]: {reportID: 'expenseReport', type: CONST.REPORT.TYPE.EXPENSE}, + [`${ONYXKEYS.COLLECTION.REPORT}iouReport` as const]: {reportID: 'iouReport', type: CONST.REPORT.TYPE.IOU}, + }, + }); + + // When the footer counts the reports the selection covers + render(); + await waitForBatchedUpdates(); + + // Then only the expense report counts, so the figure holds once the server's count takes over + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 1})); + }); + + it('counts no selected IOU report on a Reports search either', async () => { + // Given a selected expense report and a selected IOU report on a Reports search + setSearchQuery('type:expense-report footerCount:reports'); + mockSelectedTransactions.current = { + transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'expenseReport'}, + transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'iouReport'}, + }; + mockSelectedReports.current = [ + {...buildSelectedReport('expenseReport', -100), type: CONST.REPORT.TYPE.EXPENSE}, + {...buildSelectedReport('iouReport', -100), type: CONST.REPORT.TYPE.IOU}, + ]; + + // When the footer counts the selected reports + render(); + await waitForBatchedUpdates(); + + // Then the IOU report is left out + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 1})); + }); + it('counts no report for a selected unreported expense, which sits on none', async () => { setSearchQuery('type:expense footerCount:reports'); // Two expenses from one report, one unreported: three expenses, one report. diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 605892308cc2..0b8e3f8abefd 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -18,7 +18,7 @@ import type { } from '@components/Search/SearchList/ListItem/types'; import {GROUP_ITEM_TYPES} from '@components/Search/SearchList/ListItem/types'; import {getExpenseHeaders} from '@components/Search/SearchTableHeader'; -import type {SearchColumnType, SearchFilterKey, SelectedTransactionInfo, SortOrder} from '@components/Search/types'; +import type {ASTNode, SearchColumnType, SearchFilterKey, SearchQueryJSON, SelectedTransactionInfo, SortOrder} from '@components/Search/types'; import type {ListItem} from '@components/SelectionList/types'; import Navigation from '@navigation/Navigation'; @@ -33,6 +33,7 @@ import IntlStore from '@src/languages/IntlStore'; import type {CardFeedForDisplay} from '@src/libs/CardFeedUtils'; import {getCardDescriptionForSearchTable} from '@src/libs/CardUtils'; import DateUtils from '@src/libs/DateUtils'; +import getTransactionFromTransactionListItem, {SEARCH_TRANSACTION_ROW_ONLY_FIELD_NAMES} from '@src/libs/getTransactionFromTransactionListItem'; import {savedSearchIDToSearchKey} from '@src/libs/SearchKeyUtils'; import {buildSearchQueryJSON, getDateRangeForPreset, getQueryHashes, getUserFriendlyValue} from '@src/libs/SearchQueryUtils'; import * as SearchQueryUtils from '@src/libs/SearchQueryUtils'; @@ -2912,6 +2913,57 @@ describe('SearchUIUtils', () => { expect(allReportActionsLength).toBe(6); }); + it('should hide a MARKED_REIMBURSED action that has a sibling PAY action in the Search snapshot when the report is not loaded into Onyx', () => { + // Given a CHAT search result whose report only exists in the snapshot, with a stale MARKED_REIMBURSED action and its sibling PAY action + const snapshotReportID = 'snapshotOnlyReportID'; + const markedReimbursedAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + reportActionID: 'markedReimbursedActionID', + reportID: snapshotReportID, + created: '2025-01-01 00:00:00', + message: [{type: 'TEXT', style: 'normal', text: 'Marked as reimbursed'}], + originalMessage: {}, + } as OnyxTypes.ReportAction; + const payAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + reportActionID: 'payActionID', + reportID: snapshotReportID, + created: '2025-01-01 00:00:01', + message: [{type: 'TEXT', style: 'normal', text: 'paid'}], + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUReportID: snapshotReportID, amount: 100, currency: CONST.CURRENCY.USD}, + } as OnyxTypes.ReportAction; + const data: OnyxTypes.SearchResults['data'] = { + ...searchResults.data, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${snapshotReportID}`]: { + [markedReimbursedAction.reportActionID]: markedReimbursedAction, + [payAction.reportActionID]: payAction, + }, + }; + + // When the CHAT sections are built from the snapshot + const [sections] = SearchUIUtils.getSections({ + dateFnsLocale: undefined, + type: CONST.SEARCH.DATA_TYPES.CHAT, + data, + currentAccountID: 2074551, + currentUserEmail: '', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + rules: undefined, + conciergeReportID: undefined, + convertToDisplayString, + reportAttributesDerivedValue: {}, + }); + + // Then only the PAY action is listed, because the snapshot is used to find the sibling of the MARKED_REIMBURSED action + const snapshotReportActionIDs = sections + .filter(SearchUIUtils.isReportActionListItemType) + .filter((item) => item.reportID === snapshotReportID) + .map((item) => item.reportActionID); + expect(snapshotReportActionIDs).toStrictEqual([payAction.reportActionID]); + }); + it('should return getTransactionsSections result when groupBy is undefined', () => { expect( SearchUIUtils.getSections({ @@ -4015,6 +4067,7 @@ describe('SearchUIUtils', () => { groupedBy: CONST.SEARCH.GROUP_BY.DAY, formattedDay: 'September 15, 2026', shortFormattedDay: 'Sep 15, ’26', + lineChartFormattedDay: 'Sep 15', transactions: [], transactionsQueryJSON: undefined, keyForList: 'group_2026-09-15', @@ -4276,6 +4329,7 @@ describe('SearchUIUtils', () => { groupedBy: CONST.SEARCH.GROUP_BY.WEEK, formattedWeek: 'Jan 25 - Jan 31, 2026', shortFormattedWeek: 'Jan 25 - 31, ’26', + lineChartFormattedWeek: DateUtils.getShortFormattedDayForSearch('2026-01-25', undefined, new Date().getFullYear() !== 2026), transactions: [], transactionsQueryJSON: undefined, keyForList: '2026-01-25-01-25', @@ -4876,6 +4930,7 @@ describe('SearchUIUtils', () => { groupedBy: CONST.SEARCH.GROUP_BY.WEEK, formattedWeek: 'Jan 25 - Jan 31, 2026', shortFormattedWeek: 'Jan 25 - 31, ’26', + lineChartFormattedWeek: DateUtils.getShortFormattedDayForSearch('2026-01-25', undefined, new Date().getFullYear() !== 2026), transactions: [], transactionsQueryJSON: undefined, keyForList: 'group_2026-01-25', @@ -4888,6 +4943,7 @@ describe('SearchUIUtils', () => { groupedBy: CONST.SEARCH.GROUP_BY.WEEK, formattedWeek: 'Dec 21 - Dec 27, 2025', shortFormattedWeek: 'Dec 21 - 27, ’25', + lineChartFormattedWeek: DateUtils.getShortFormattedDayForSearch('2025-12-21', undefined, new Date().getFullYear() !== 2025), transactions: [], transactionsQueryJSON: undefined, keyForList: 'group_2025-12-21', @@ -5311,7 +5367,7 @@ describe('SearchUIUtils', () => { expect(emptyMerchantItem?.transactionsQueryJSON).toBeDefined(); // The query should use 'none' (MERCHANT_EMPTY_VALUE) instead of empty string expect(emptyMerchantItem?.transactionsQueryJSON?.inputQuery).toContain(CONST.SEARCH.MERCHANT_EMPTY_VALUE); - expect(emptyMerchantItem?.transactionsQueryJSON?.exactMatchFilterKeys).toEqual([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); + expect(emptyMerchantItem?.transactionsQueryJSON?.inputQuery).toContain(`merchant=${CONST.SEARCH.MERCHANT_EMPTY_VALUE}`); }); it('should treat DEFAULT_MERCHANT "Expense" as empty merchant and display "No merchant"', () => { @@ -8183,6 +8239,96 @@ describe('SearchUIUtils', () => { }); }); + describe('Merchant group exact match', () => { + function findFilterNode(node: ASTNode | null | undefined, key: string): ASTNode | undefined { + if (!node) { + return undefined; + } + if (node.left === key) { + return node; + } + const leftMatch = typeof node.left === 'object' ? findFilterNode(node.left, key) : undefined; + if (leftMatch) { + return leftMatch; + } + return typeof node.right === 'object' && !Array.isArray(node.right) ? findFilterNode(node.right, key) : undefined; + } + + function getMerchantNode(queryJSON: SearchQueryJSON): ASTNode | undefined { + return findFilterNode(queryJSON.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT); + } + + function getMerchantDrillDownQuery(merchant: string): SearchQueryJSON | undefined { + const [result] = getSectionsByType( + SearchUIUtils.getSections({ + dateFnsLocale: undefined, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: { + personalDetailsList: {}, + [`${CONST.SEARCH.GROUP_PREFIX}777777777` as const]: {merchant, count: 1, currency: 'USD', total: 10}, + }, + currentAccountID: 2074551, + currentUserEmail: '', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + rules: undefined, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + queryJSON: buildSearchQueryJSON('type:expense groupBy:merchant'), + conciergeReportID: undefined, + convertToDisplayString, + reportAttributesDerivedValue: {}, + }), + SearchUIUtils.isTransactionMerchantGroupListItemType, + ); + return result.at(0)?.transactionsQueryJSON; + } + + it('should send an exact merchant match when drilling down into a merchant group', () => { + // Given a merchant group "Amazon", where a contains match would also load "Amazon Marketplace" expenses + // When the sections build the drill-down query for that group + const drillDownQueryJSON = getMerchantDrillDownQuery('Amazon'); + if (!drillDownQueryJSON) { + throw new Error('Expected the merchant drill-down query to be defined'); + } + + // Then the query uses the exact `merchant=` syntax and the backend receives it unchanged as `eq`, + // so only the group's own expenses load + expect(drillDownQueryJSON.inputQuery).toContain('merchant=Amazon'); + expect(SearchQueryUtils.serializeQueryJSONForBackend(drillDownQueryJSON)).toBe(JSON.stringify(drillDownQueryJSON)); + const merchantNode = getMerchantNode(drillDownQueryJSON); + expect(merchantNode?.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + expect(merchantNode?.right).toBe('Amazon'); + }); + + it.each(['merchant:Amazon', 'merchant*:Amazon'])('should give the merchant drill-down query a different hash than the same search with %s', (containsFilter) => { + // Given the drill-down query of the "Amazon" group and the same search with a contains merchant filter + const drillDownQueryJSON = getMerchantDrillDownQuery('Amazon'); + const containsQueryJSON = buildSearchQueryJSON(drillDownQueryJSON?.inputQuery.replace('merchant=Amazon', containsFilter) ?? ''); + + // When the two queries are compared + // Then their hashes differ, because `merchant=` already makes the query string differ, so the snapshots never share results + expect(containsQueryJSON?.inputQuery).toContain(containsFilter); + expect(drillDownQueryJSON?.hash).not.toBe(containsQueryJSON?.hash); + expect(drillDownQueryJSON?.recentSearchHash).not.toBe(containsQueryJSON?.recentSearchHash); + }); + + it('should give each selected merchant group a merchant filter entry with its own value', () => { + // Given the "Amazon" group and the "No merchant" group of a merchant-grouped search + const groups = [{merchant: 'Amazon'}, {merchant: ''}]; + + // When the export builds the filter entry for each selected group + const selectedGroupEntries = groups.map((group) => SearchUIUtils.getSelectedGroupFilterEntry(CONST.SEARCH.GROUP_BY.MERCHANT, group)); + + // Then each entry filters on merchant with the group's own value, and the empty merchant maps to `none`, + // so the export can match each group exactly. The hook test checks that the backend gets `eq`. + expect(selectedGroupEntries).toEqual([ + {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, value: 'Amazon'}, + {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, value: CONST.SEARCH.MERCHANT_EMPTY_VALUE}, + ]); + }); + }); + describe('Test getSortedSections', () => { it('should return getSortedReportActionData result when type is CHAT', () => { const sortedActions = getSortedSectionsByType( @@ -8365,6 +8511,7 @@ describe('SearchUIUtils', () => { it.each([ {columnName: 'Reimbursable', sortBy: CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE, boolOverride: {reimbursable: true}}, {columnName: 'Billable', sortBy: CONST.SEARCH.TABLE_COLUMNS.BILLABLE, boolOverride: {billable: true}}, + {columnName: 'Held', sortBy: CONST.SEARCH.TABLE_COLUMNS.HELD, boolOverride: {comment: {hold: '123'}}}, ])('should still tie-break tied $columnName rows by inserted honoring sortOrder', ({sortBy, boolOverride}) => { // Guards against this fix over-reaching: boolean columns resolve to a non-empty "yes"/"no" value, so every row // ties on the primary comparison but is NOT empty — the inserted/transactionID tie breaker must still run and @@ -8402,6 +8549,43 @@ describe('SearchUIUtils', () => { expect(descendingResult.map((item) => ('transactionID' in item ? item.transactionID : undefined))).toEqual(['bool-mar', 'bool-feb', 'bool-jan']); }); + it('should sort the held column by whether the expense is on hold, with expenses not on hold first when ascending', () => { + // Given one expense on hold, one that was unheld (its hold is an empty string), and one that was never held + const baseTransaction = transactionsListItems.at(0); + if (!baseTransaction) { + throw new Error('Missing base transaction fixture'); + } + const transactions: TransactionListItemType[] = [ + {...baseTransaction, transactionID: 'held', keyForList: 'held', inserted: '2024-01-01', comment: {hold: '123'}}, + {...baseTransaction, transactionID: 'unheld', keyForList: 'unheld', inserted: '2024-02-02', comment: {hold: ''}}, + {...baseTransaction, transactionID: 'never-held', keyForList: 'never-held', inserted: '2024-03-03', comment: {}}, + ]; + + // When the expenses are sorted by the held column in both directions + const ascendingResult = SearchUIUtils.getSortedSections( + CONST.SEARCH.DATA_TYPES.EXPENSE, + [...transactions], + localeCompare, + translateLocal, + CONST.SEARCH.TABLE_COLUMNS.HELD, + CONST.SEARCH.SORT_ORDER.ASC, + undefined, + ); + const descendingResult = SearchUIUtils.getSortedSections( + CONST.SEARCH.DATA_TYPES.EXPENSE, + [...transactions], + localeCompare, + translateLocal, + CONST.SEARCH.TABLE_COLUMNS.HELD, + CONST.SEARCH.SORT_ORDER.DESC, + undefined, + ); + + // Then the expense on hold comes last ascending and first descending, and the unheld expense counts as not held + expect(ascendingResult.map((item) => ('transactionID' in item ? item.transactionID : undefined))).toEqual(['unheld', 'never-held', 'held']); + expect(descendingResult.map((item) => ('transactionID' in item ? item.transactionID : undefined))).toEqual(['held', 'never-held', 'unheld']); + }); + it('should sort the exported-to column by export name, with never exported rows first when ascending', () => { // The backend sorts this column by the name of the export, so the local sort has to do the same instead of // only sorting on whether the report was exported. Rows that were never exported have no name, so they end @@ -10763,6 +10947,32 @@ describe('SearchUIUtils', () => { expect(withReport.visibility.approve).toBe(true); }); + test('Should not throw when employeeList contains a null entry', () => { + // Given an approval-enabled policy whose employeeList holds a null entry, which Onyx can leave behind when + // several policy updates are applied in one batch + const policies: OnyxCollection = { + [`policy_${policyID}`]: { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + id: policyID, + role: CONST.POLICY.ROLE.USER, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + approver: 'someoneelse@policy.com', + employeeList: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- This entry deliberately exercises the runtime guard for a null employee. + 'removed@policy.com': null as unknown as OnyxTypes.PolicyEmployee, + 'employee@policy.com': {email: 'employee@policy.com', submitsTo: adminEmail}, + }, + }, + }; + + // When the suggested searches visibility is computed + const getVisibility = () => SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, policies, undefined); + + // Then it skips the null entry instead of crashing, and still finds the member who submits to the user + expect(getVisibility).not.toThrow(); + expect(getVisibility().visibility.approve).toBe(true); + }); + test('Should show Top Categories when areCategoriesEnabled is true', () => { const policyKey = `policy_${policyID}`; @@ -13351,6 +13561,22 @@ describe('SearchUIUtils', () => { expect(transaction).toBeTruthy(); }); + it('Should not save Search row display fields on the optimistic transaction', async () => { + // Given a Search row whose transaction isn't in Onyx yet + const transactionListItem = getTransactionListItem(0); + + // When the transaction thread preview data is set optimistically + setOptimisticDataForTransactionThreadPreview(transactionListItem, {...transactionPreviewData, hasTransaction: false}, getCurrencyDecimalsLocal, undefined); + await waitForBatchedUpdates(); + + // Then only the real transaction fields are saved, so display values can't shadow later edits + const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionListItem.transactionID}`); + expect(transaction?.transactionID).toBe(transactionListItem.transactionID); + for (const fieldName of SEARCH_TRANSACTION_ROW_ONLY_FIELD_NAMES) { + expect(transaction).not.toHaveProperty(fieldName); + } + }); + it('Should create an optimistic transaction thread if the hasTransactionThreadReport is false', async () => { const transactionListItem = getTransactionListItem(0); setOptimisticDataForTransactionThreadPreview(transactionListItem, {...transactionPreviewData, hasTransactionThreadReport: false}, getCurrencyDecimalsLocal, undefined, '456'); @@ -13363,6 +13589,25 @@ describe('SearchUIUtils', () => { }); }); + describe('getTransactionFromTransactionListItem', () => { + it('removes the display-only fields of a Search row and keeps the transaction fields', () => { + // Given a Search row, which carries display-only fields on top of the transaction + const transactionListItem = getTransactionListItem(0); + + // When it is turned back into a transaction + const transaction = getTransactionFromTransactionListItem(transactionListItem); + + // Then the row-only fields are gone and the real transaction fields are untouched + for (const fieldName of SEARCH_TRANSACTION_ROW_ONLY_FIELD_NAMES) { + expect(transaction).not.toHaveProperty(fieldName); + } + expect(transaction.transactionID).toBe(transactionListItem.transactionID); + expect(transaction.reportID).toBe(transactionListItem.reportID); + expect(transaction.amount).toBe(transactionListItem.amount); + expect(transaction.merchant).toBe(transactionListItem.merchant); + }); + }); + describe('getSearchBulkEditPolicyID', () => { it('returns the active policy when there are no selected transactions', () => { const result = SearchUIUtils.getSearchBulkEditPolicyID([], 'policy-1', undefined, undefined); @@ -13561,7 +13806,7 @@ describe('SearchUIUtils', () => { await Onyx.merge(ONYXKEYS.SESSION, {accountID: TEST_ACCOUNT_ID}); - expect(SearchUIUtils.shouldShowDeleteOption(selectedTransactions, currentSearchResults, TEST_ACCOUNT_ID, undefined, undefined)).toBe(true); + expect(SearchUIUtils.shouldShowDeleteOption(selectedTransactions, currentSearchResults, TEST_ACCOUNT_ID, undefined, undefined, undefined)).toBe(true); }); it('should show delete option for unreported expense which can be deleted', async () => { @@ -13747,7 +13992,7 @@ describe('SearchUIUtils', () => { await Onyx.merge(ONYXKEYS.SESSION, {accountID: TEST_ACCOUNT_ID}); - expect(SearchUIUtils.shouldShowDeleteOption(selectedTransactions, currentSearchResults, TEST_ACCOUNT_ID, undefined, undefined)).toBe(true); + expect(SearchUIUtils.shouldShowDeleteOption(selectedTransactions, currentSearchResults, TEST_ACCOUNT_ID, undefined, undefined, undefined)).toBe(true); }); }); describe('getToFieldValueForTransaction', () => { @@ -14672,6 +14917,32 @@ describe('SearchUIUtils', () => { expect(result).toBe(translateLocal('common.read')); }); + + test.each([ + [CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, 'search.filters.merchant.equalTo'], + [CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, 'search.filters.merchant.contains'], + [undefined, 'search.filters.merchant.contains'], + ] as const)('includes the %s match type in a Merchant filter label', (merchantOperator, translationKey) => { + // Given a Merchant filter with an exact, contains, or missing operator + const form = {[FILTER_KEYS.MERCHANT]: 'I', [FILTER_KEYS.MERCHANT_OPERATOR]: merchantOperator}; + + // When the filter label is built + const result = SearchUIUtils.getDisplayValue(FILTER_KEYS.MERCHANT, form, CONST.SEARCH.DATA_TYPES.EXPENSE, translateLocal, localeCompare); + + // Then the match type is part of the translated label, so a missing operator reads as contains + expect(result).toBe(translateLocal(translationKey, 'I')); + }); + + test.each([FILTER_KEYS.BILLABLE_NOT, FILTER_KEYS.REIMBURSABLE_NOT, FILTER_KEYS.DECLINED_NOT] as const)('translates the yes/no value of a negated %s filter', (key) => { + // Given a negated yes/no filter, as stored when the query is "-billable:yes", "-reimbursable:yes" or "-declined:yes" + const form = {[key]: CONST.SEARCH.BOOLEAN.YES}; + + // When the filter label is built + const result = SearchUIUtils.getDisplayValue(key, form, CONST.SEARCH.DATA_TYPES.EXPENSE, translateLocal, localeCompare); + + // Then the label uses the localized value instead of the raw query token, matching the non-negated filter + expect(result).toBe(translateLocal('common.yes')); + }); }); describe('filterValidHasValues', () => { @@ -15280,6 +15551,14 @@ describe('hasFilterContentValuesChanged', () => { // A text content offers no options, so the search type is nothing to it. ignored: [{[SYNTAX_FILTER_KEYS.MERCHANT]: 'a'}, {[SYNTAX_FILTER_KEYS.MERCHANT]: 'a', type: CONST.SEARCH.DATA_TYPES.INVOICE}], }, + { + kind: 'merchant, reading its match type', + filterKey: SYNTAX_FILTER_KEYS.MERCHANT, + read: [ + {[SYNTAX_FILTER_KEYS.MERCHANT]: 'a', [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}, + {[SYNTAX_FILTER_KEYS.MERCHANT]: 'a', [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}, + ], + }, { kind: 'amount', filterKey: SYNTAX_FILTER_KEYS.AMOUNT, @@ -15324,3 +15603,20 @@ describe('hasFilterContentValuesChanged', () => { } }); }); + +describe('getDatePresets', () => { + it('does not offer never for anyApproval', () => { + // Given anyApproval, which always matches an approval action + // When presets are requested + const presets = SearchUIUtils.getDatePresets(CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, false); + + // Then only the common date presets are returned, without never + expect(presets).toEqual([ + CONST.SEARCH.DATE_PRESETS.THIS_MONTH, + CONST.SEARCH.DATE_PRESETS.LAST_MONTH, + CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE, + CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS, + ]); + expect(presets).not.toContain(CONST.SEARCH.DATE_PRESETS.NEVER); + }); +}); diff --git a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap index c85a7d23b544..d4443f54c67c 100644 --- a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap +++ b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap @@ -19,6 +19,7 @@ exports[`Column availability single source of truth the derived picker lists mat "originalamount", "reimbursable", "billable", + "held", "mcc", "taxCode", "taxrate", @@ -61,6 +62,7 @@ exports[`Column availability single source of truth the derived picker lists mat "base62ReportID", "reimbursable", "billable", + "held", "mcc", "taxCode", "taxrate", diff --git a/tests/unit/Search/buildCardFilterDataTest.ts b/tests/unit/Search/buildCardFilterDataTest.ts index eae1e74355c5..2e2e7516ce45 100644 --- a/tests/unit/Search/buildCardFilterDataTest.ts +++ b/tests/unit/Search/buildCardFilterDataTest.ts @@ -247,7 +247,7 @@ const workspaceCardFeedsClosed: Record = isVirtual: false, }, lastFourPAN: '1234', - state: 6, // CLOSED + state: 5, // STATE_DEACTIVATED }), '21539012': createMock({ accountID: 1, @@ -259,7 +259,7 @@ const workspaceCardFeedsClosed: Record = isVirtual: false, }, lastFourPAN: '3211', - state: 6, // CLOSED + state: 5, // STATE_DEACTIVATED }), '21539027': createMock({ accountID: 1, @@ -287,11 +287,11 @@ const cardListClosed: CardList = { isVirtual: false, }, lastFourPAN: '', - state: 6, // CLOSED + state: 5, // STATE_DEACTIVATED }), '21534525': createMock({ accountID: 1, - bank: 'Expensify Card', + bank: 'vcf', cardID: 21534525, domainName: 'expensify-policy1.exfy', nameValuePairs: { @@ -388,6 +388,15 @@ describe('buildCardsData closed cards', () => { isSelected: false, }); }); + + it('Keeps deactivated and closed cards out of the individual cards section', () => { + // Given deactivated Expensify Cards and a closed company card alongside open cards + // When building the individual (non-closed) cards section + const result = buildCardsData(workspaceCardFeedsClosed, cardListClosed, {}, [], illustrationsMock, companyCardIconsMock); + + // Then only the open cards are listed, because removed Expensify Cards are deactivated rather than closed and must still be treated as closed + expect(result.map((card) => card.keyForList).sort()).toEqual(['21534526', '21539027']); + }); }); describe('buildCardsData with empty argument objects', () => { diff --git a/tests/unit/Search/chartGroupByConfigTest.ts b/tests/unit/Search/chartGroupByConfigTest.ts new file mode 100644 index 000000000000..26a5be3d6dfe --- /dev/null +++ b/tests/unit/Search/chartGroupByConfigTest.ts @@ -0,0 +1,14 @@ +import CHART_GROUP_BY_CONFIG from '@components/Search/chartGroupByConfig'; +import type {TransactionMerchantGroupListItemType} from '@components/Search/SearchList/ListItem/types'; + +import CONST from '@src/CONST'; + +import createMock from '../../utils/createMock'; + +describe('chartGroupByConfig', () => { + it('uses the explicit exact operator for merchant drill-downs', () => { + const item = createMock({merchant: 'I'}); + + expect(CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.MERCHANT].getFilterQuery(item)).toBe('merchant="I"'); + }); +}); diff --git a/tests/unit/Search/handleActionButtonPressTest.ts b/tests/unit/Search/handleActionButtonPressTest.ts index 4ea58f09d49b..14e9d6bb2d53 100644 --- a/tests/unit/Search/handleActionButtonPressTest.ts +++ b/tests/unit/Search/handleActionButtonPressTest.ts @@ -362,6 +362,7 @@ describe('handleActionButtonPress', () => { isTrackIntentUser: false, allViolations: undefined, rules: undefined, + bankAccountList: undefined, confirmSubmitReportViolations: jest.fn(), personalDetailsByLogins: {}, }); @@ -393,6 +394,7 @@ describe('handleActionButtonPress', () => { isTrackIntentUser: false, allViolations: undefined, rules: undefined, + bankAccountList: undefined, confirmSubmitReportViolations: jest.fn(), personalDetailsByLogins: {}, }); @@ -424,6 +426,7 @@ describe('handleActionButtonPress', () => { isTrackIntentUser: false, allViolations: undefined, rules: undefined, + bankAccountList: undefined, confirmSubmitReportViolations: jest.fn(), personalDetailsByLogins: {}, }); @@ -464,6 +467,7 @@ describe('handleActionButtonPress', () => { personalDetailsByLogins: {}, conciergeChat: undefined, getCurrencyDecimals: getCurrencyDecimalsLocal, + bankAccountList: undefined, confirmSubmitReportViolations: jest.fn(), }); diff --git a/tests/unit/Search/searchCountedReportIDsTest.ts b/tests/unit/Search/searchCountedReportIDsTest.ts new file mode 100644 index 000000000000..5141efbb3e0b --- /dev/null +++ b/tests/unit/Search/searchCountedReportIDsTest.ts @@ -0,0 +1,197 @@ +import {search} from '@libs/actions/Search'; +import {makeRequestWithSideEffects, waitForWrites} from '@libs/API'; +import {getExpenseReportIDs, getSuggestedSearches} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, SearchResults} from '@src/types/onyx'; + +import type {OnyxUpdate} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +import createMock from '../../utils/createMock'; +import getOnyxValue from '../../utils/getOnyxValue'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +jest.mock('@libs/API', () => ({ + makeRequestWithSideEffects: jest.fn(), + waitForWrites: jest.fn(), + write: jest.fn(), + read: jest.fn(), +})); + +const draftsQueryJSON = getSuggestedSearches(1)[CONST.SEARCH.SEARCH_KEYS.SUBMIT].searchQueryJSON; +if (!draftsQueryJSON) { + throw new Error('The Drafts query should build'); +} +const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${draftsQueryJSON.hash}` as const; + +function buildSearchData(reports: Array<{reportID: string; type: string}>): SearchResults['data'] { + const data = createMock({}); + for (const report of reports) { + data[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`] = createMock(report); + } + return data; +} + +/** + * Answers the next request the way the API layer does: it applies the response's onyxData before search() sees the + * result. Like a real to-do response, it lists another collection first and the snapshot last. + */ +function answerWith(onyxMethod: typeof Onyx.METHOD.SET | typeof Onyx.METHOD.MERGE, data: SearchResults['data'], jsonCode: number = CONST.JSON_CODE.SUCCESS) { + const onyxData: Array> = [ + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: {}}, + {onyxMethod, key: snapshotKey, value: {search: {reportCount: 120}, data}}, + ]; + jest.mocked(makeRequestWithSideEffects).mockImplementationOnce(async () => { + await Onyx.update(onyxData); + return {jsonCode, onyxData}; + }); +} + +async function getCountedReportIDs() { + await waitForBatchedUpdates(); + const snapshot = await getOnyxValue(snapshotKey); + return snapshot?.search?.countedReportIDs; +} + +describe('getExpenseReportIDs', () => { + it('keeps only the expense reports, which are the only reports a live to-do list holds', () => { + // Given search data with expense, IOU, invoice and chat reports + const data = buildSearchData([ + {reportID: 'expense1', type: CONST.REPORT.TYPE.EXPENSE}, + {reportID: 'iou', type: CONST.REPORT.TYPE.IOU}, + {reportID: 'invoice', type: CONST.REPORT.TYPE.INVOICE}, + {reportID: 'chat', type: CONST.REPORT.TYPE.CHAT}, + {reportID: 'expense2', type: CONST.REPORT.TYPE.EXPENSE}, + ]); + + // When their IDs are collected + const reportIDs = getExpenseReportIDs(data); + + // Then only the expense reports are kept + expect(reportIDs).toEqual({expense1: true, expense2: true}); + }); + + it("takes each report's ID from its key, so an entry without a reportID is still recorded", () => { + // Given an expense report entry whose value carries no reportID + const data = createMock({}); + data[`${ONYXKEYS.COLLECTION.REPORT}expense1`] = createMock({type: CONST.REPORT.TYPE.EXPENSE}); + + // When its ID is collected + const reportIDs = getExpenseReportIDs(data); + + // Then it is recorded under its key's ID, never as "undefined", which would count as a report that left + expect(reportIDs).toEqual({expense1: true}); + }); + + it('returns nothing for data without reports', () => { + // Given search data holding no reports + // When the IDs are collected + // Then there are none + expect(getExpenseReportIDs(createMock({}))).toEqual({}); + }); +}); + +describe('search() records the reports a to-do search counted', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + jest.mocked(waitForWrites).mockResolvedValue(undefined); + await Onyx.clear(); + }); + + it("records the expense reports a to-do search's first page held", async () => { + // Given Drafts answers its first page with two expense reports and a chat report + answerWith( + Onyx.METHOD.SET, + buildSearchData([ + {reportID: 'A', type: CONST.REPORT.TYPE.EXPENSE}, + {reportID: 'B', type: CONST.REPORT.TYPE.EXPENSE}, + {reportID: 'chat', type: CONST.REPORT.TYPE.CHAT}, + ]), + ); + + // When the first page is searched + await search({queryJSON: draftsQueryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.SUBMIT, offset: 0, shouldCalculateTotals: true, isLoading: false}); + + // Then the two expense reports are recorded as the ones its count covers + expect(await getCountedReportIDs()).toEqual({A: true, B: true}); + }); + + it("adds a later page's reports to the record", async () => { + // Given Drafts already recorded report A from its first page + answerWith(Onyx.METHOD.SET, buildSearchData([{reportID: 'A', type: CONST.REPORT.TYPE.EXPENSE}])); + await search({queryJSON: draftsQueryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.SUBMIT, offset: 0, shouldCalculateTotals: true, isLoading: false}); + + // When the next page answers with report C, merged into the snapshot + answerWith(Onyx.METHOD.MERGE, buildSearchData([{reportID: 'C', type: CONST.REPORT.TYPE.EXPENSE}])); + await search({queryJSON: draftsQueryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.SUBMIT, offset: CONST.SEARCH.RESULTS_PAGE_SIZE, shouldCalculateTotals: false, isLoading: false}); + + // Then both are recorded + expect(await getCountedReportIDs()).toEqual({A: true, C: true}); + }); + + it('starts the record over with a first-page response, which replaces the snapshot', async () => { + // Given a record from an earlier response holding report OLD + await Onyx.set(snapshotKey, createMock({search: {countedReportIDs: {OLD: true}}, data: {}})); + + // When a new first page answers with report A + answerWith(Onyx.METHOD.SET, buildSearchData([{reportID: 'A', type: CONST.REPORT.TYPE.EXPENSE}])); + await search({queryJSON: draftsQueryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.SUBMIT, offset: 0, shouldCalculateTotals: true, isLoading: false}); + + // Then only report A is recorded, since the old record went with the replaced snapshot + expect(await getCountedReportIDs()).toEqual({A: true}); + }); + + it('starts the record over with a response to a request for totals, even when it merges into the snapshot', async () => { + // Given a record from an earlier response holding report OLD + await Onyx.set(snapshotKey, createMock({search: {countedReportIDs: {OLD: true}}, data: {}})); + + // When a first page asked for totals answers with report A, merged into the snapshot instead of replacing it + answerWith(Onyx.METHOD.MERGE, buildSearchData([{reportID: 'A', type: CONST.REPORT.TYPE.EXPENSE}])); + await search({queryJSON: draftsQueryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.SUBMIT, offset: 0, shouldCalculateTotals: true, isLoading: false}); + + // Then only report A is recorded, so the record matches the fresh count the response brought + expect(await getCountedReportIDs()).toEqual({A: true}); + }); + + it('starts the record over with a later page asked for totals, as when the report count was missing', async () => { + // Given Drafts recorded report A from its first page + answerWith(Onyx.METHOD.SET, buildSearchData([{reportID: 'A', type: CONST.REPORT.TYPE.EXPENSE}])); + await search({queryJSON: draftsQueryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.SUBMIT, offset: 0, shouldCalculateTotals: true, isLoading: false}); + + // When a later page asked for totals answers with report C + answerWith(Onyx.METHOD.MERGE, buildSearchData([{reportID: 'C', type: CONST.REPORT.TYPE.EXPENSE}])); + await search({queryJSON: draftsQueryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.SUBMIT, offset: CONST.SEARCH.RESULTS_PAGE_SIZE, shouldCalculateTotals: true, isLoading: false}); + + // Then only C is recorded, so a page-1 report that left before the fresh count can't come off twice + expect(await getCountedReportIDs()).toEqual({C: true}); + }); + + it('records nothing for a search that is not a to-do', async () => { + // Given the same response answers a search that isn't a to-do + answerWith(Onyx.METHOD.SET, buildSearchData([{reportID: 'A', type: CONST.REPORT.TYPE.EXPENSE}])); + + // When it is searched + await search({queryJSON: draftsQueryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, offset: 0, shouldCalculateTotals: true, isLoading: false}); + + // Then no record is kept, since only a live to-do search corrects its count + expect(await getCountedReportIDs()).toBeUndefined(); + }); + + it('records nothing when the response failed', async () => { + // Given Drafts' request fails + answerWith(Onyx.METHOD.MERGE, {}, 500); + + // When the first page is searched + await search({queryJSON: draftsQueryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.SUBMIT, offset: 0, shouldCalculateTotals: true, isLoading: false}); + + // Then no record is kept + expect(await getCountedReportIDs()).toBeUndefined(); + }); +}); diff --git a/tests/unit/SearchAutocompleteListTest.tsx b/tests/unit/SearchAutocompleteListTest.tsx index ea6da8fd654c..b068422ede12 100644 --- a/tests/unit/SearchAutocompleteListTest.tsx +++ b/tests/unit/SearchAutocompleteListTest.tsx @@ -39,6 +39,7 @@ import createCollection from '../utils/collections/createCollection'; import createPersonalDetails from '../utils/collections/personalDetails'; import {createRandomReport} from '../utils/collections/reports'; import * as TestHelper from '../utils/TestHelper'; +import {formatPhoneNumber} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; @@ -191,6 +192,7 @@ const mockedOptions = createFilteredOptionList( convertToDisplayString: TestHelper.convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ); @@ -487,6 +489,7 @@ describe('SearchAutocompleteList', () => { convertToDisplayString: TestHelper.convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ), @@ -582,6 +585,7 @@ describe('SearchAutocompleteList', () => { convertToDisplayString: TestHelper.convertToDisplayString, conciergeReportID: undefined, isSearching: true, + formatPhoneNumber, }, undefined, ), diff --git a/tests/unit/SearchAutocompleteParserTest.ts b/tests/unit/SearchAutocompleteParserTest.ts index 2d3a6b8bae56..1f2cc48e3f51 100644 --- a/tests/unit/SearchAutocompleteParserTest.ts +++ b/tests/unit/SearchAutocompleteParserTest.ts @@ -342,6 +342,21 @@ const tests: Array<{query: string; expected: ExpectedAutocompleteParserResult}> ], }, }, + { + query: 'type:expense -merchant*:Uber', + expected: { + autocomplete: null, + ranges: [{key: 'type', value: 'expense', negated: false, start: 5, length: 7}], + }, + }, + { + // The "contains" operator is only supported for the merchant field + query: 'type:expense category*:travel', + expected: { + autocomplete: null, + ranges: [{key: 'type', value: 'expense', negated: false, start: 5, length: 7}], + }, + }, ]; const limitAutocompleteTests: Array<{query: string; expected: ExpectedAutocompleteParserResult; description: string}> = [ @@ -772,6 +787,48 @@ const nameFieldContinuationTests: Array<{query: string; expected: ExpectedAutoco }, description: 'Negates multiple from and has values', }, + { + query: 'anyApproval:last-month', + expected: { + autocomplete: { + key: 'anyApproval', + value: 'last-month', + negated: false, + start: 12, + length: 10, + }, + ranges: [{key: 'anyApproval', value: 'last-month', negated: false, start: 12, length: 10}], + }, + description: 'anyApproval date filter provides autocomplete', + }, + { + query: 'anyApproval:', + expected: { + autocomplete: { + key: 'anyApproval', + value: '', + negated: false, + start: 12, + length: 0, + }, + ranges: [], + }, + description: 'anyApproval with an empty value still identifies the filter key', + }, + { + query: 'any-approval:last-month', + expected: { + autocomplete: { + key: 'anyApproval', + value: 'last-month', + negated: false, + start: 13, + length: 10, + }, + ranges: [{key: 'anyApproval', value: 'last-month', negated: false, start: 13, length: 10}], + }, + description: 'any-approval user-friendly alias maps to anyApproval', + }, ]; describe('autocomplete parser', () => { @@ -784,8 +841,12 @@ describe('autocomplete parser', () => { describe('autocomplete parser - name field continuation detection', () => { test.each(nameFieldContinuationTests)(`$description: $query`, ({query, expected}) => { + // Given a partially typed search query, possibly using a user-friendly alias or an empty filter value + + // When the autocomplete parser processes it const result: unknown = parse(query); + // Then the active filter key/value and highlighted ranges are identified so the right suggestions can be shown expect(result).toEqual(expected); }); }); @@ -829,3 +890,46 @@ describe('autocomplete parser - escaped values', () => { expect(query.slice(range?.start, (range?.start ?? 0) + (range?.length ?? 0))).toBe('"Acme \\"US\\",Inc"'); }); }); + +describe('autocomplete parser ordinary consumers', () => { + test.each([ + ['createdBy:alice', 'createdBy', 'alice'], + ['ReportID:123', 'ReportID', '123'], + ['TAXRATE:20', 'TAXRATE', '20'], + ['report-field-LongSuffix:value', 'reportField-LongSuffix', 'value'], + ['merchant:""', 'merchant', ''], + ['merchant:"Acme\\,Inc"', 'merchant', 'Acme,Inc'], + ])('preserves the generated ordinary result for %s', (query, key, value) => { + // Given ordinary query syntax that includes keys outside the closed filter union. + const ordinary: unknown = parse(query); + const explicitQuery: unknown = parse(query, {startRule: 'query'}); + const library: unknown = parse(query, {peg$library: true}); + // When the typed consumer narrows only fields it actually reads. + const result = parseForAutocomplete(query); + // Then it preserves all generated fields, offsets and the library's distinct wrapper shape. + expect(result).toBeDefined(); + expect(result).toEqual(ordinary); + expect(explicitQuery).toEqual(ordinary); + expect(result?.ranges).toHaveLength(1); + expect(result?.ranges[0]).toMatchObject({key, value, start: query.indexOf(':') + 1, length: query.length - query.indexOf(':') - 1}); + // @ts-expect-error Raw library output cannot satisfy the ordinary consumed-field view. + expect(library satisfies NonNullable>).toBe(library); + // @ts-expect-error The ordinary consumer intentionally exposes no parser-options parameter. + const unsupportedOptions = parseForAutocomplete(query, {peg$library: true}); + expect(unsupportedOptions).toEqual(ordinary); + expect(library).not.toHaveProperty('ranges'); + expect(library).toHaveProperty('peg$result', ordinary); + }); + + it('keeps empty input and an unfinished filter distinct', () => { + // Given no ranges yet, both ordinary inputs remain valid parser results. + const empty = ''; + const unfinished = 'from:'; + // When the ordinary consumers parse them without library options. + const emptyResult = parseForAutocomplete(empty); + const unfinishedResult = parseForAutocomplete(unfinished); + // Then nullable autocomplete and an empty value retain their producer shapes. + expect(emptyResult).toEqual({autocomplete: null, ranges: []}); + expect(unfinishedResult).toEqual({autocomplete: {key: 'from', negated: false, value: '', start: 5, length: 0}, ranges: []}); + }); +}); diff --git a/tests/unit/SearchAutocompleteUtilsTest.ts b/tests/unit/SearchAutocompleteUtilsTest.ts index c1ca2c0e4de6..f23f1679d084 100644 --- a/tests/unit/SearchAutocompleteUtilsTest.ts +++ b/tests/unit/SearchAutocompleteUtilsTest.ts @@ -1,7 +1,8 @@ import type {SubstitutionMap} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; import {getExportLabelForConnection, getStandardExportTemplateDisplayName, isStandardExportTemplate, isStandardExportTemplateLabel} from '@libs/AccountingUtils'; -import {getTrimmedUserSearchQueryPreservingComma, parseForLiveMarkdown} from '@libs/SearchAutocompleteUtils'; +import {getTrimmedUserSearchQueryPreservingComma, parseForAutocomplete, parseForLiveMarkdown} from '@libs/SearchAutocompleteUtils'; +import {parse} from '@libs/SearchParser/autocompleteParser'; import CONST from '@src/CONST'; @@ -615,3 +616,74 @@ describe('SearchAutocompleteUtils', () => { }); }); }); + +describe('ordinary autocomplete parser boundaries', () => { + afterEach(() => jest.restoreAllMocks()); + + it('returns the original generated object with extra properties and escaped offsets', () => { + // Given the real parser result, with an unused property at the generated-result boundary. + const query = 'merchant:"Acme\\,Inc"'; + const parsed: unknown = parse(query); + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('Expected the ordinary generated parser object'); + } + Object.defineProperty(parsed, 'extra', {value: 'Unused parser metadata', enumerable: true}); + const parser = jest.spyOn(jest.requireActual<{parse: typeof parse}>('@libs/SearchParser/autocompleteParser'), 'parse').mockReturnValueOnce(parsed); + // When the existing ordinary consumer handles it. + const result = parseForAutocomplete(query); + // Then narrowing preserves identity, extras and source widths instead of projecting fields. + expect(result).toBe(parsed); + expect(result).toHaveProperty('extra', 'Unused parser metadata'); + expect(result?.ranges).toEqual([{key: 'merchant', negated: false, value: 'Acme,Inc', start: 9, length: 11}]); + expect(parser).toHaveBeenCalledWith(query); + }); + + const unknownLibraryResult: unknown = parse('from:alice', {peg$library: true}); + test.each([ + {description: 'library wrapper', result: unknownLibraryResult}, + {description: 'absent result', result: null}, + {description: 'invalid range value', result: {autocomplete: null, ranges: [{key: 'from', value: null, start: 5, length: 0}]}}, + {description: 'invalid autocomplete offset', result: {autocomplete: {key: 'from', value: '', start: '5', length: 0}, ranges: []}}, + ])('returns no highlights for $description', ({result}) => { + // Given an erased result that cannot prove the consumed ordinary fields. + const parser = jest.spyOn(jest.requireActual<{parse: typeof parse}>('@libs/SearchParser/autocompleteParser'), 'parse').mockReturnValue(result); + const log = jest.spyOn(console, 'error').mockImplementation(() => {}); + const emptyList = createSharedValueMock([]); + // When each existing consumer handles the same boundary result. + expect(parseForAutocomplete('from:alice')).toBeUndefined(); + // Then autocomplete logs once while live markdown safely skips highlighting. + expect(log).toHaveBeenCalledTimes(1); + expect(parseForLiveMarkdown('from:alice', '', {}, emptyList, emptyList, emptyList, emptyList, emptyList)).toEqual([]); + expect(parser).toHaveBeenCalledTimes(2); + }); + + it('keeps real parser exceptions caught for autocomplete and propagated for live markdown', () => { + // Given the real parser rejecting an unsupported input type at its public boundary. + const log = jest.spyOn(console, 'error').mockImplementation(() => {}); + const emptyList = createSharedValueMock([]); + // When a deliberately malformed input reaches the existing consumers. + // @ts-expect-error This malformed runtime input tests the parser's existing exception handling. + expect(parseForAutocomplete(null)).toBeUndefined(); + // Then the same production parser exception propagates through live markdown. + expect(() => { + // @ts-expect-error Keep the exception limited to the invalid parser input. + parseForLiveMarkdown(null, '', {}, emptyList, emptyList, emptyList, emptyList, emptyList); + }).toThrow(); + expect(log).toHaveBeenCalledTimes(1); + }); + + test.each([ + ['columns:date', [{start: 8, type: 'mention-user', length: 4}]], + ['columns:unknown-column', []], + ['report-field-Custom:value', [{start: 20, type: 'mention-user', length: 5}]], + ['createdBy:alice ReportID:123 TAXRATE:20', []], + ['merchant:""', []], + ])('highlights the real parser result for %s', (query, expected) => { + // Given actual generated ranges and empty lookup data, without substituting the parser. + const emptyList = createSharedValueMock([]); + // When live markdown applies its existing highlighting rules. + const result = parseForLiveMarkdown(query, '', {}, emptyList, emptyList, emptyList, emptyList, emptyList); + // Then column equality, dynamic keys and unsupported key spelling retain their outcomes. + expect(result).toEqual(expected); + }); +}); diff --git a/tests/unit/SearchParserTest.ts b/tests/unit/SearchParserTest.ts index 6602508e4cdd..f95119159ee6 100644 --- a/tests/unit/SearchParserTest.ts +++ b/tests/unit/SearchParserTest.ts @@ -231,7 +231,7 @@ const tests = [ }, }, right: { - operator: 'eq', + operator: 'contains', left: 'merchant', right: 'marriott', }, @@ -261,7 +261,7 @@ const tests = [ }, }, right: { - operator: 'eq', + operator: 'contains', left: 'merchant', right: "McDonald's", }, @@ -596,7 +596,7 @@ const tests = [ }, }, right: { - operator: 'eq', + operator: 'contains', left: 'merchant', right: 'Expensify, Inc.', }, @@ -727,6 +727,98 @@ const tests = [ }, }, }, + { + query: 'type:expense anyApproval:last-month', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + sortBy: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: 'table', + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, + right: CONST.SEARCH.DATE_PRESETS.LAST_MONTH, + }, + }, + }, + { + query: 'type:expense any-approval:last-month', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + sortBy: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: 'table', + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, + right: CONST.SEARCH.DATE_PRESETS.LAST_MONTH, + }, + }, + }, + { + query: 'type:expense anyApproval>2026-04-01', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + sortBy: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: 'table', + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN, + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, + right: '2026-04-01', + }, + }, + }, + { + query: 'type:expense anyApproval<2026-04-30', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + sortBy: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: 'table', + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, + right: '2026-04-30', + }, + }, + }, + { + query: 'type:expense anyApproval>=2026-04-01 anyApproval<=2026-04-30', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + sortBy: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: 'table', + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, + right: '2026-04-01', + }, + right: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, + right: '2026-04-30', + }, + }, + }, + }, + { + query: 'type:expense -anyApproval:last-month', + expected: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + sortBy: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: 'table', + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO, + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, + right: CONST.SEARCH.DATE_PRESETS.LAST_MONTH, + }, + }, + }, { query: 'type:expense group-by:from', expected: { @@ -1037,6 +1129,50 @@ const tests = [ */ const keywordTests = [ + { + query: '-merchant*:Uber', + expected: { + type: 'expense', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + filters: { + operator: 'eq', + left: 'keyword', + right: ['-merchant*:Uber'], + }, + }, + }, + { + // The "contains" operator is only supported for the merchant field + query: 'category*:travel', + expected: { + type: 'expense', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + filters: { + operator: 'eq', + left: 'keyword', + right: ['category*:travel'], + }, + }, + }, + { + // The "contains" operator is not supported for default keys + query: 'type*:expense', + expected: { + type: 'expense', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + filters: { + operator: 'eq', + left: 'keyword', + right: ['type*:expense'], + }, + }, + }, { query: '" " " "', // Multiple whitespaces wrapped in quotes expected: { @@ -1229,6 +1365,62 @@ const keywordTests = [ }, }, }, + { + query: 'declined:yes', + expected: { + type: 'expense', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + filters: { + operator: 'eq', + left: 'declined', + right: 'yes', + }, + }, + }, + { + query: 'type:expense-report -declined:no', + expected: { + type: 'expense-report', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + filters: { + operator: 'neq', + left: 'declined', + right: 'no', + }, + }, + }, + { + query: 'held:yes', + expected: { + type: 'expense', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + filters: { + operator: 'eq', + left: 'held', + right: 'yes', + }, + }, + }, + { + query: '-held:no', + expected: { + type: 'expense', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + filters: { + operator: 'neq', + left: 'held', + right: 'no', + }, + }, + }, { query: 'columns:per-diem,drafts,draft,tax-rate,policy-name,withdrawal-id,bank-account', expected: { @@ -1329,6 +1521,20 @@ const keywordTests = [ }, { query: 'merchant:tax', + expected: { + type: 'expense', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + filters: { + operator: 'contains', + left: 'merchant', + right: 'tax', + }, + }, + }, + { + query: 'merchant=tax', expected: { type: 'expense', sortBy: 'date', @@ -1576,7 +1782,7 @@ const limitTests = [ view: 'table', limit: '50', filters: { - operator: 'eq', + operator: 'contains', left: 'merchant', right: 'Amazon', }, @@ -1653,7 +1859,7 @@ const footerSelectionTests = [ right: 'USD', }, right: { - operator: 'eq', + operator: 'contains', left: 'merchant', right: 'Amazon', }, @@ -1716,7 +1922,7 @@ const compareTests = [ groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, compare: CONST.SEARCH.COMPARE.PREVIOUS_PERIOD, filters: { - operator: 'eq', + operator: 'contains', left: 'merchant', right: 'Amazon', }, @@ -1777,7 +1983,13 @@ function parseSearchQueryWithoutRawFilters(query: string): Record { test.each(tests)(`parsing: $query`, ({query, expected}) => { - expect(parseSearchQueryWithoutRawFilters(query)).toEqual(expected); + // Given a raw search query string typed by the user (including filter aliases such as any-approval) + + // When the query is parsed into its structured representation + const result = parseSearchQueryWithoutRawFilters(query); + + // Then the parsed type, sort, view and filter tree match what the search API expects for that query + expect(result).toEqual(expected); }); }); diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 27f1b64ea78c..84e3c26eac38 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -710,10 +710,12 @@ describe('Workspace Search Router navigation source', () => { policies: Object.fromEntries(policies.map((policy) => [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy])), policyCategories: undefined, currentUserLogin: workspaceCurrentUserLogin, + currentUserPersonalDetails: {accountID: 1}, icons: workspaceIcons, isOffline, isVendorMatchingBetaEnabled: false, isRecruitingBetaEnabled: false, + isCompanyAgentsBetaEnabled: false, isUnifiedConnectionsBetaEnabled, shouldUseNarrowLayout: false, convertToDisplayString: () => '$0.00', diff --git a/tests/unit/SelectionListEnterConfirmGateTest.tsx b/tests/unit/SelectionListEnterConfirmGateTest.tsx index d96521425426..b5954dbb0a3b 100644 --- a/tests/unit/SelectionListEnterConfirmGateTest.tsx +++ b/tests/unit/SelectionListEnterConfirmGateTest.tsx @@ -262,3 +262,54 @@ describe.each([ expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); }); }); + +describe('BaseSelectionListWithSections search input press', () => { + beforeEach(() => { + mockUseSelectionListShortcuts.mockClear(); + jest.mocked(NativeNavigation.useIsFocused).mockReturnValue(true); + }); + + function renderInviteLikeList(shouldAllowArrowUpToSearchInput: boolean) { + return render( + + + , + ); + } + + it('clears the focused row when the search input is pressed, so plain Enter reaches the confirm button again', () => { + // Given the invite list after keyboard navigation, where Enter would toggle the focused row + renderInviteLikeList(true); + fireEvent(screen.getByTestId('selection-list-text-input'), 'keyPress', {nativeEvent: {key: 'Tab'}}); + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + + // When the user presses the search input with the mouse + fireEvent.press(screen.getByTestId('selection-list-text-input')); + + // Then no row is focused, so Enter in the search input goes to the confirm button + expect(getGatedFocusedIndex()).toBe(-1); + }); + + it('keeps the focused row when the search input is pressed and the list does not opt in', () => { + // Given a list that has not opted in, after keyboard navigation + renderInviteLikeList(false); + fireEvent(screen.getByTestId('selection-list-text-input'), 'keyPress', {nativeEvent: {key: 'Tab'}}); + + // When the user presses the search input + fireEvent.press(screen.getByTestId('selection-list-text-input')); + + // Then the focused row still owns Enter, preserving existing behavior for other lists + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/tests/unit/SelectionScraperTest.ts b/tests/unit/SelectionScraperTest.ts index e612595a488d..d9f7d094357d 100644 --- a/tests/unit/SelectionScraperTest.ts +++ b/tests/unit/SelectionScraperTest.ts @@ -1,3 +1,4 @@ +import type * as ClipboardTextWebModule from '@libs/Clipboard/getClipboardText/index'; import type * as SelectionScraperWebModule from '@libs/SelectionScraper/index'; import installTransformedChildren from '@libs/SelectionScraper/installTransformedChildren'; @@ -8,15 +9,23 @@ import {Element, Text} from 'domhandler'; // cspell:ignore mtext // Selection scraping only exists in the web implementation. The native variant always returns an empty string. const {default: SelectionScraper} = jest.requireActual('@libs/SelectionScraper/index.ts'); +const {default: getClipboardText} = jest.requireActual('@libs/Clipboard/getClipboardText/index.ts'); +const copyableRowAttribute = `data-${CONST.COPYABLE_ROW_ELEMENT}`; +const copyableTextAttribute = `data-${CONST.COPYABLE_TEXT_ELEMENT}`; +const hiddenElementAttribute = `data-${CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT}`; const fixtures: HTMLElement[] = []; -const selectFixture = (html: string) => { +function createFixture(html: string): HTMLElement { const fixture = document.createElement('div'); fixture.innerHTML = html; document.body.append(fixture); fixtures.push(fixture); + return fixture; +} +function selectFixture(html: string) { + const fixture = createFixture(html); const range = document.createRange(); range.selectNodeContents(fixture); const selection = window.getSelection(); @@ -25,7 +34,26 @@ const selectFixture = (html: string) => { } selection.removeAllRanges(); selection.addRange(range); -}; +} + +function getTextNode(id: string): ChildNode { + const element = document.getElementById(id); + if (!element?.firstChild) { + throw new Error(`Missing text node for ${id}`); + } + + return element.firstChild; +} + +function selectText(startNode: Node, startOffset: number, endNode: Node, endOffset: number) { + const selection = window.getSelection(); + const range = document.createRange(); + range.setStart(startNode, startOffset); + range.setEnd(endNode, endOffset); + + selection?.removeAllRanges(); + selection?.addRange(range); +} describe('SelectionScraper', () => { afterEach(() => { @@ -36,6 +64,157 @@ describe('SelectionScraper', () => { fixtures.length = 0; }); + it('formats selected copyable cells as one line per row', () => { + createFixture(` +
+
+ Aug 26 + Paid + T +
+
+ Aug 27 + Draft +
+
+ `); + + selectText(getTextNode('date1'), 0, getTextNode('status2'), 'Draft'.length); + + expect(SelectionScraper.getCurrentSelection()).toBe('Aug 26 Paid
Aug 27 Draft'); + }); + + it('preserves semantic formatting without layout wrappers in a multi-field selection', () => { + createFixture(` +
+ TJS 10.00 + +
+
+ Test is + good +
+
+
+ Merchant +
+ `); + + selectText(getTextNode('amount'), 0, getTextNode('merchant'), 'Merchant'.length); + + const selectionHTML = SelectionScraper.getCurrentSelection(); + expect(selectionHTML).toBe('TJS 10.00 Test is good Merchant'); + expect(getClipboardText(selectionHTML)).toBe('TJS 10.00 Test is good Merchant'); + + selectText(getTextNode('formatted-description'), 'Test '.length, getTextNode('merchant'), 'Merchant'.length); + expect(SelectionScraper.getCurrentSelection()).toBe('is good Merchant'); + }); + + it('joins a continuation row to the next row with a space', () => { + // Given report fields followed by expense details in separate copyable rows + createFixture(` +
+
+ Test is good +
+
+ 2026-10-03 + 10.00 +
+
+ `); + + // When the selection crosses from the report field into the expense details + selectText(getTextNode('report-field'), 0, getTextNode('amount'), '10.00'.length); + + // Then all selected values are separated by spaces without introducing a line break + expect(SelectionScraper.getCurrentSelection()).toBe('Test is good 2026-10-03 10.00'); + }); + + it('keeps browser selection behavior for a single selected copyable cell', () => { + createFixture(` +
+ $123.45 +
+ `); + + selectText(getTextNode('amount'), 1, getTextNode('amount'), 4); + + expect(SelectionScraper.getCurrentSelection()).toBe('123'); + }); + + it.each([ + {startOffset: 0, endOffset: 11, expectedText: 'Ann Manager'}, + {startOffset: 0, endOffset: 3, expectedText: 'Ann'}, + ])('preserves exactly "$expectedText" in the approver clipboard text', ({startOffset, endOffset, expectedText}) => { + // Given an approver cell whose avatar must not become part of the copied name. + createFixture(`
AAnn Manager
`); + + // When the user selects either the whole name or just its first word. + selectText(getTextNode('approver'), startOffset, getTextNode('approver'), endOffset); + const selectionHTML = SelectionScraper.getCurrentSelection(); + + // Then the clipboard payload preserves the exact selection, not the full field or avatar initial. + expect(selectionHTML).toBe(expectedText); + expect(getClipboardText(selectionHTML)).toBe(expectedText); + }); + + it('excludes approver avatar text when the selection range crosses the avatar', () => { + // Given an excluded avatar next to a copyable approver name in an interactive row. + createFixture( + `
AAnn Manager
`, + ); + + // When selection handles extend across the avatar as well as the name. + selectText(getTextNode('approver-avatar'), 0, getTextNode('approver'), 'Ann Manager'.length); + const selectionHTML = SelectionScraper.getCurrentSelection(); + + // Then the copied plain text and HTML exclude the avatar initial. + expect(selectionHTML).toBe('Ann Manager'); + expect(getClipboardText(selectionHTML)).toBe('Ann Manager'); + }); + + it('preserves partial first and last cell boundaries in multi-row selections', () => { + createFixture(` +
+
+ Aug 26 + Paid + Expense Report +
+
+ Aug 27 + Draft + Expense Report +
+
+ `); + + selectText(getTextNode('date1'), 'Aug '.length, getTextNode('title2'), 'Expense'.length); + + expect(SelectionScraper.getCurrentSelection()).toBe('26 Paid Expense Report
Aug 27 Draft Expense'); + }); + + it('falls back to regular scraping when selected text extends outside copyable rows', () => { + createFixture(` +
+
+ $40.00 + APPLE TEST +
+

submitted a comment

+
+ `); + + selectText(getTextNode('amount'), 0, getTextNode('comment'), 'submitted a comment'.length); + + const selectionHTML = SelectionScraper.getCurrentSelection(); + expect(selectionHTML).toContain('$40.00'); + expect(selectionHTML).toContain('APPLE TEST'); + expect(selectionHTML).toContain('submitted a comment'); + expect(selectionHTML).not.toBe('$40.00 APPLE TEST'); + }); + it('serializes HTML children in SVG foreignObject with paired tags', () => { selectFixture('
selected'); diff --git a/tests/unit/SentryHtmlTreeAsStringTest.ts b/tests/unit/SentryHtmlTreeAsStringTest.ts new file mode 100644 index 000000000000..a66694073e0f --- /dev/null +++ b/tests/unit/SentryHtmlTreeAsStringTest.ts @@ -0,0 +1,98 @@ +import {htmlTreeAsString} from '@sentry/browser-utils'; + +/** + * `@sentry/browser-utils` builds the element selectors that web INP, CLS and LCP spans, click breadcrumbs and Session Replay report. + * We patch it (patches/sentry-browser-utils) to use `data-sentry-label`, so these tests fail if a Sentry bump drops or bypasses the patch. + */ +function getElement(id: string): Element { + const element = document.getElementById(id); + if (!element) { + throw new Error(`Element with id "${id}" not found`); + } + return element; +} + +describe('Sentry htmlTreeAsString', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('returns only the label when the labelled element is the pressable', () => { + // Given a labelled pressable with nested content, like the DOM react-native-web renders for PressableWithFeedback + document.body.innerHTML = ` +
+
+ Save +
+
`; + + // When Sentry describes an interaction with the inner text + const selector = htmlTreeAsString(getElement('target')); + + // Then the span is attributed to the pressable's label alone, so the same button groups under one selector no matter which child was hit + expect(selector).toBe('[data-sentry-label="SaveButton"]'); + }); + + it('keeps the CSS path when the label is on an ancestor that is not the pressable', () => { + // Given a label on a wrapper and an unlabelled pressable inside it + document.body.innerHTML = ` +
+
+ Title +
+
`; + + // When Sentry describes an interaction with the inner text + const selector = htmlTreeAsString(getElement('target')); + + // Then the selector carries the label and still points at the element that was hit + expect(selector).toBe('body > [data-sentry-label="ReportHeader"] > div > span#target'); + }); + + it('prefixes the label when it is too far up to be part of the CSS path', () => { + // Given a label more levels above the target than Sentry walks when building the CSS path + document.body.innerHTML = ` +
+
+ Result +
+
`; + + // When Sentry describes an interaction with the deeply nested text + const selector = htmlTreeAsString(getElement('target')); + + // Then the label is prepended to the CSS path so the span is still attributable to a screen area + expect(selector).toBe('[data-sentry-label="SearchPage"] div > div > div > div > span#target'); + }); + + it('skips labels on disabled elements', () => { + // Given a disabled labelled pressable nested inside an enabled labelled pressable + document.body.innerHTML = ` +
+
+ Disabled +
+
`; + + // When Sentry describes an interaction with the disabled button's text + const selector = htmlTreeAsString(getElement('target')); + + // Then the disabled button's label is ignored, because a disabled element did not handle the interaction + expect(selector).not.toContain('DisabledButton'); + expect(selector).toContain('[data-sentry-label="Row"]'); + }); + + it('falls back to the CSS path when no element is labelled', () => { + // Given an unlabelled pressable + document.body.innerHTML = ` +
+ Unlabelled +
`; + + // When Sentry describes an interaction with the inner text + const selector = htmlTreeAsString(getElement('target')); + + // Then Sentry's default CSS selector is kept unchanged + expect(selector).toBe('body > div.button > span#target'); + }); +}); diff --git a/tests/unit/ShareCodePageTranslateTest.tsx b/tests/unit/ShareCodePageTranslateTest.tsx index 94d9f446909c..07299510fa6c 100644 --- a/tests/unit/ShareCodePageTranslateTest.tsx +++ b/tests/unit/ShareCodePageTranslateTest.tsx @@ -1,5 +1,6 @@ import {render} from '@testing-library/react-native'; +import ContextMenuItem from '@components/ContextMenuItem'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import QRShareWithDownload from '@components/QRShare/QRShareWithDownload'; @@ -54,6 +55,7 @@ jest.mock('@libs/ReportUtils', () => { const mockGetDisplayNameForParticipant = jest.mocked(getDisplayNameForParticipant); const mockFindLocalAvatarForURL = jest.mocked(findLocalAvatarForURL); const mockQRShareWithDownload = jest.mocked(QRShareWithDownload); +const mockContextMenuItem = jest.mocked(ContextMenuItem); const PARTICIPANT_A = 717001; const PARTICIPANT_B = 717002; @@ -90,6 +92,34 @@ describe('ShareCodePage', () => { expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({hiddenTranslation: 'common.hidden'})); }); + it('hides the rows under the QR code when only the QR code is asked for', async () => { + // Given a report, whose share code page normally lists Copy URL under the QR code + const report: Report = {reportID: '717200', type: CONST.REPORT.TYPE.EXPENSE}; + mockContextMenuItem.mockClear(); + render( + + + , + ); + await waitForBatchedUpdates(); + expect(mockContextMenuItem).toHaveBeenCalled(); + + // When the page opens from the Share menu, which already has Copy URL and Refer a friend + mockContextMenuItem.mockClear(); + render( + + + , + ); + await waitForBatchedUpdates(); + + // Then only the QR code shows + expect(mockContextMenuItem).not.toHaveBeenCalled(); + }); + describe('profile QR code avatar logo', () => { it('wraps catalog-backed avatars in an SVG logo that only forwards an explicitly set fill', async () => { const localAvatar: React.FC = jest.fn(() => null); diff --git a/tests/unit/SidebarLinksTest.tsx b/tests/unit/SidebarLinksTest.tsx index 07fde6da432d..388928a7070c 100644 --- a/tests/unit/SidebarLinksTest.tsx +++ b/tests/unit/SidebarLinksTest.tsx @@ -12,13 +12,13 @@ import React from 'react'; // Expose the row press handler (showReportPage) without rendering the full LHN option list. jest.mock('@components/LHNOptionsList/LHNOptionsList', () => { const ReactModule = jest.requireActual('react'); - return function MockLHNOptionsList({data, onSelectRow}: {data: Report[]; onSelectRow: (option: Report) => void}) { - const [firstOption] = data; - if (!firstOption) { + return function MockLHNOptionsList({data, onSelectRow}: {data: string[]; onSelectRow: (option: Report) => void}) { + const [firstReportID] = data; + if (!firstReportID) { return null; } - return ReactModule.createElement('button', {testID: 'lhn-row', onPress: () => onSelectRow(firstOption)}, 'row'); + return ReactModule.createElement('button', {testID: 'lhn-row', onPress: () => onSelectRow({reportID: firstReportID} as Report)}, 'row'); }; }); @@ -28,6 +28,7 @@ jest.mock('@hooks/useResponsiveLayout', () => ({ default: () => ({shouldUseNarrowLayout: mockShouldUseNarrowLayout, isInLandscapeMode: false}), })); +jest.mock('@components/Navigation/TabBarBottomContent/useTabRootScrollProps', () => () => ({})); jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: () => ({})})); jest.mock('@hooks/useStyleUtils', () => ({__esModule: true, default: () => ({getSafeAreaMargins: () => ({marginBottom: 0})})})); jest.mock('@hooks/useSidebarOrderedReports', () => ({useSidebarOrderedReportsActions: () => ({setStickyReportID: jest.fn()})})); @@ -53,7 +54,7 @@ function renderSidebarLinks() { render( , ); diff --git a/tests/unit/SidebarOrderTest.ts b/tests/unit/SidebarOrderTest.ts index 0b1399f36ce2..c21d5f59384f 100644 --- a/tests/unit/SidebarOrderTest.ts +++ b/tests/unit/SidebarOrderTest.ts @@ -60,6 +60,20 @@ jest.mock('@src/components/ReportActionAvatars', () => { }; }); +// FlashList keeps a row's rendered children when only its index changes, so the index-based testID can go stale after a reorder. The cell's own index prop is always current. +type RenderedElement = ReturnType[number]; + +function getDisplayNamesInListOrder() { + const getCellIndex = (element: RenderedElement) => { + let current: RenderedElement | null = element; + while (current && typeof current.props.index !== 'number') { + current = current.parent; + } + return typeof current?.props.index === 'number' ? current.props.index : Number.MAX_SAFE_INTEGER; + }; + return screen.queryAllByLabelText('Chat member display names').sort((first, second) => getCellIndex(first) - getCellIndex(second)); +} + describe('Sidebar', () => { beforeAll(() => { Onyx.init({ @@ -361,13 +375,12 @@ describe('Sidebar', () => { // Then the order of the reports should be 1 > 3 > 2 // ^--- (1 goes to the front and pushes other two down) .then(() => { - const firstElement = screen.queryByTestId('DisplayNames-0'); - const secondElement = screen.queryByTestId('DisplayNames-1'); - const thirdElement = screen.queryByTestId('DisplayNames-2'); + const displayNames = getDisplayNamesInListOrder(); - expect(firstElement).toHaveTextContent('Email Two'); - expect(secondElement).toHaveTextContent('Email Four'); - expect(thirdElement).toHaveTextContent('Email Three'); + expect(displayNames).toHaveLength(3); + expect(displayNames.at(0)).toHaveTextContent('Email Two'); + expect(displayNames.at(1)).toHaveTextContent('Email Four'); + expect(displayNames.at(2)).toHaveTextContent('Email Three'); }) ); }); diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index fd08a996227c..7f6021815089 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -51,6 +51,9 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct' // Mock PolicyUtils jest.mock('@libs/PolicyUtils', () => ({ ...jest.requireActual('@libs/PolicyUtils'), + // This factory runs while PolicyUtils is still loading inside an import cycle, so the barrel's `export *` helpers + // aren't on it yet. Spread them from their own modules, which load fully. + ...jest.requireActual>('@libs/PolicyUtils/members'), getConnectedIntegration: jest.fn(() => true), isPolicyAdmin: jest.fn(() => true), })); @@ -764,6 +767,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, transactionViolations: undefined, + reportMetadata: undefined, }); expect(requiresAttention?.reason).toBe(CONST.REQUIRES_ATTENTION_REASONS.HAS_CHILD_REPORT_AWAITING_ACTION); @@ -1523,6 +1527,173 @@ describe('SidebarUtils', () => { }); describe('getOptionData', () => { + it('returns isUnread for a never-read chat whose copy carries no lastActorAccountID', async () => { + // Given a 1:1 chat the current user has never opened, where the only visible action is a report + // preview posted by the other participant, so the received copy has a lastVisibleActionCreated + // but neither a lastReadTime nor a lastActorAccountID + const report: Report = { + ...createRandomReport(7101, undefined), + type: CONST.REPORT.TYPE.CHAT, + lastMessageText: '', + lastReadTime: undefined, + lastActorAccountID: undefined, + lastVisibleActionCreated: '2025-01-20 12:30:03.784', + participants: { + [CURRENT_USER_ACCOUNT_ID]: {notificationPreference: 'always'}, + 18921695: {notificationPreference: 'always'}, + }, + }; + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + }); + + // When the LHN option data is built and the derived attributes report the chat as not empty + const result = SidebarUtils.getOptionData({ + dateFnsLocale: undefined, + report, + reportAttributes: { + reportName: '', + isEmpty: false, + brickRoadStatus: undefined, + requiresAttention: false, + reportErrors: {}, + }, + reportNameValuePairs: {}, + personalDetails: {}, + policy: undefined, + invoiceReceiverPolicy: undefined, + parentReportAction: undefined, + conciergeReportID: '', + oneTransactionThreadReport: undefined, + card: undefined, + translate: translateLocal, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + localeCompare, + lastAction: undefined, + lastActionReport: undefined, + isReportArchived: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_LOGIN, + reportAttributesDerived: undefined, + formatPhoneNumber, + rules: undefined, + }); + + // Then the row is unread, because the emptiness check already ruled out the deleted-message case + expect(result?.isUnread).toBe(true); + }); + + /** + * Builds a chat whose only comment sits after lastReadTime. The server does not reset + * lastVisibleActionCreated when a message is deleted, so the deleted variant is the case the removed + * lastActorAccountID guard used to catch. Returns the LHN option data for it. + * + * lastActorAccountID defaults to a real account on purpose: with it populated the removed guard would have + * been a no-op, so a `false` result can only come from the empty-report check inside isUnread. Pass 0 to + * cover the shape the server actually writes on delete. + * + * createRandomReportAction assigns random shouldShow and originalMessage.whisperedTo values, either of which + * hides the comment from the last-visible-message scan, so both are pinned here. + */ + const getOptionDataForSoleComment = async (reportSeed: number, deleted: string, overrides: Partial = {}) => { + const report: Report = { + ...createRandomReport(reportSeed, undefined), + type: CONST.REPORT.TYPE.CHAT, + lastMessageText: '', + lastReadTime: '2025-01-20 12:30:00.000', + lastActorAccountID: 18921695, + lastVisibleActionCreated: '2025-01-20 12:30:03.784', + participants: { + [CURRENT_USER_ACCOUNT_ID]: {notificationPreference: 'always'}, + 18921695: {notificationPreference: 'always'}, + }, + ...overrides, + }; + const createdAction: ReportAction = { + ...createRandomReportAction(1), + reportID: report.reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: '2025-01-20 12:29:00.000', + shouldShow: true, + originalMessage: {html: '', lastModified: '2025-01-20 12:29:00.000'}, + }; + const comment: ReportAction = { + ...createRandomReportAction(2), + reportID: report.reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: 18921695, + created: '2025-01-20 12:30:03.784', + shouldShow: true, + originalMessage: {html: '

hi

', lastModified: '2025-01-20 12:30:03.784'}, + message: [{type: 'COMMENT', html: '

hi

', text: 'hi', isDeletedParentAction: false, deleted}], + }; + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { + [createdAction.reportActionID]: createdAction, + [comment.reportActionID]: comment, + }); + }); + + // reportAttributes is left undefined so the emptiness check has to resolve the last visible message + // from the report actions itself instead of reading a value the test handed it. + return SidebarUtils.getOptionData({ + dateFnsLocale: undefined, + report, + reportAttributes: undefined, + reportNameValuePairs: {}, + personalDetails: {}, + policy: undefined, + invoiceReceiverPolicy: undefined, + parentReportAction: undefined, + conciergeReportID: '', + oneTransactionThreadReport: undefined, + card: undefined, + translate: translateLocal, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + localeCompare, + lastAction: undefined, + lastActionReport: undefined, + isReportArchived: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_LOGIN, + reportAttributesDerived: undefined, + formatPhoneNumber, + rules: undefined, + }); + }; + + it('does not return isUnread for a chat whose only message was deleted', async () => { + // Given a chat whose sole comment has been deleted + // When the LHN option data is built + const result = await getOptionDataForSoleComment(7102, '2025-01-20 12:31:00.000'); + + // Then the row is not bold, because the chat reads as empty once the deleted comment is skipped + expect(result?.isUnread).toBe(false); + }); + + it('returns isUnread for the same chat while its only message is still there', async () => { + // Given the same chat with the comment not deleted. This is the control for the case above, which + // passes vacuously if the fixture's only action is hidden from the last-visible-message scan. + // When the LHN option data is built + const result = await getOptionDataForSoleComment(7103, ''); + + // Then the row is bold, so the deleted case above is driven by the delete and nothing else + expect(result?.isUnread).toBe(true); + }); + + it('does not return isUnread for a never-read chat whose only message was deleted', async () => { + // Given the shape the server actually writes on delete: lastActorAccountID zeroed and the chat never + // read, so the timestamp comparison alone would call it unread + // When the LHN option data is built + const result = await getOptionDataForSoleComment(7104, '2025-01-20 12:31:00.000', {lastReadTime: undefined, lastActorAccountID: 0}); + + // Then the row is not bold + expect(result?.isUnread).toBe(false); + }); + it('returns the last action message as an alternate text if the action is POLICY_CHANGE_LOG.LEAVE_ROOM type', async () => { // When a report has last action of POLICY_CHANGE_LOG.LEAVE_ROOM type const report: Report = { @@ -4630,6 +4801,120 @@ describe('SidebarUtils', () => { expect(result).toEqual({}); }); + it('should flag a never-read chat with no lastActorAccountID as unread for the Unread tab', () => { + // Given the same chat shape: never read, not empty, and no lastActorAccountID on the report + const report: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.CHAT, + lastMessageText: '', + lastReadTime: undefined, + lastActorAccountID: undefined, + lastVisibleActionCreated: '2025-01-20 12:30:03.784', + participants: { + [CURRENT_USER_ACCOUNT_ID]: {notificationPreference: 'always'}, + 18921695: {notificationPreference: 'always'}, + }, + }; + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: report, + }; + + // When the LHN report set is built + const result = SidebarUtils.getReportsToDisplayInLHN({ + currentReportId: '1', + reports, + isDefaultRoomsBetaEnabled: false, + priorityMode: CONST.PRIORITY_MODE.DEFAULT, + draftComments: {}, + transactionViolations: {}, + transactions: {}, + isOffline: false, + currentUserLogin: CURRENT_USER_LOGIN, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + reportNameValuePairs: {}, + reportAttributes: { + '1': { + reportName: '', + isEmpty: false, + brickRoadStatus: undefined, + requiresAttention: false, + reportErrors: {}, + }, + }, + guideAccountIDs: [], + conciergeReportID: undefined, + }); + + // Then it belongs in the Unread tab + expect(result[`${ONYXKEYS.COLLECTION.REPORT}1`]?.isUnreadReport).toBe(true); + }); + + it('should keep a never-read chat whose only message was deleted out of the Unread tab', async () => { + // Given a never-read chat whose sole comment was deleted, with lastActorAccountID zeroed the way the + // server writes it on delete, so the timestamp comparison alone would call it unread + const report: Report = { + ...createRandomReport(2, undefined), + type: CONST.REPORT.TYPE.CHAT, + lastMessageText: '', + lastReadTime: undefined, + lastActorAccountID: 0, + lastVisibleActionCreated: '2025-01-20 12:30:03.784', + participants: { + [CURRENT_USER_ACCOUNT_ID]: {notificationPreference: 'always'}, + 18921695: {notificationPreference: 'always'}, + }, + }; + const createdAction: ReportAction = { + ...createRandomReportAction(1), + reportID: report.reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: '2025-01-20 12:29:00.000', + shouldShow: true, + originalMessage: {html: '', lastModified: '2025-01-20 12:29:00.000'}, + }; + const deletedComment: ReportAction = { + ...createRandomReportAction(2), + reportID: report.reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: 18921695, + created: '2025-01-20 12:30:03.784', + shouldShow: true, + originalMessage: {html: '

hi

', lastModified: '2025-01-20 12:30:03.784'}, + message: [{type: 'COMMENT', html: '

hi

', text: 'hi', isDeletedParentAction: false, deleted: '2025-01-20 12:31:00.000'}], + }; + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { + [createdAction.reportActionID]: createdAction, + [deletedComment.reportActionID]: deletedComment, + }); + }); + + // When the LHN report set is built without derived attributes, so the emptiness check resolves the + // last visible message from the report actions itself + const result = SidebarUtils.getReportsToDisplayInLHN({ + currentReportId: report.reportID, + reports: {[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report}, + isDefaultRoomsBetaEnabled: false, + priorityMode: CONST.PRIORITY_MODE.DEFAULT, + draftComments: {}, + transactionViolations: {}, + transactions: {}, + isOffline: false, + currentUserLogin: CURRENT_USER_LOGIN, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + reportNameValuePairs: {}, + reportAttributes: undefined, + guideAccountIDs: [], + conciergeReportID: undefined, + }); + + // Then it is still displayed, but not flagged for the Unread tab. Asserting it is displayed matters: + // without it a report missing from the set entirely would satisfy the isUnreadReport check. + expect(result[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]).toBeDefined(); + expect(result[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]?.isUnreadReport).toBeFalsy(); + }); + it('should pass isOffline=false to shouldDisplayReportInLHN', () => { const report = { ...createRandomReport(1, undefined), diff --git a/tests/unit/SplitRouterTest.ts b/tests/unit/SplitRouterTest.ts new file mode 100644 index 000000000000..024c1bb67095 --- /dev/null +++ b/tests/unit/SplitRouterTest.ts @@ -0,0 +1,34 @@ +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import SplitRouter from '@libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter'; + +import SCREENS from '@src/SCREENS'; + +import type {ParamListBase, StackNavigationState} from '@react-navigation/native'; + +import {CommonActions} from '@react-navigation/native'; + +jest.mock('@libs/getIsNarrowLayout', () => jest.fn()); + +describe('SplitRouter', () => { + it('handles navigating to the sidebar on a narrow layout when the sidebar is already the only screen', () => { + // Given a narrow layout split navigator that shows only its sidebar, as the Inbox tab does at its chat list + jest.mocked(getIsNarrowLayout).mockReturnValue(true); + const router = SplitRouter({sidebarScreen: SCREENS.INBOX, defaultCentralScreen: SCREENS.REPORT, parentRoute: {key: 'split-key', name: 'ReportsSplitNavigator'}}); + const routeNames = [SCREENS.INBOX, SCREENS.REPORT]; + const state: StackNavigationState = { + stale: false, + type: 'stack', + key: 'stack-key', + index: 0, + routeNames, + preloadedRoutes: [], + routes: [{key: 'inbox-key', name: SCREENS.INBOX}], + }; + + // When the sidebar is navigated to again, which happens when the tab's navigation params are applied to an already mounted navigator + const result = router.getStateForAction(state, CommonActions.navigate(SCREENS.INBOX, undefined, {pop: true}), {routeNames, routeParamList: {}, routeGetIdList: {}}); + + // Then the action is handled without changes, because an unhandled (null) result makes React Navigation log an error and ignore the navigation + expect(result).toBe(state); + }); +}); diff --git a/tests/unit/TagsOptionsListUtilsTest.ts b/tests/unit/TagsOptionsListUtilsTest.ts index ec389a01b206..b8e68100ab1c 100644 --- a/tests/unit/TagsOptionsListUtilsTest.ts +++ b/tests/unit/TagsOptionsListUtilsTest.ts @@ -1101,6 +1101,23 @@ describe('TagsOptionsListUtils', () => { }); }); + it('groups tags into Recent and All sections once the count reaches a custom searchInputThreshold', () => { + // Given fewer enabled tags than the default limit, but as many as the custom threshold + const tags = { + Alpha: {name: 'Alpha', enabled: true}, + Beta: {name: 'Beta', enabled: true}, + Gamma: {name: 'Gamma', enabled: true}, + }; + + // When building sections with the default threshold and with a threshold matching the tag count + const defaultSections = getTagListSections({tags, recentlyUsedTags: ['Alpha'], localeCompare, translate: translateLocal}); + const customSections = getTagListSections({tags, recentlyUsedTags: ['Alpha'], localeCompare, translate: translateLocal, searchInputThreshold: 3}); + + // Then the default keeps a single untitled list, while the custom threshold splits it so the list matches the visible search input + expect(defaultSections.map((section) => section.title)).toEqual(['']); + expect(customSections.map((section) => section.title)).toEqual([translateLocal('common.recent'), translateLocal('common.all')]); + }); + describe('getUpdatedTransactionTag', () => { const dependentPolicyTags: PolicyTagLists = { company: { diff --git a/tests/unit/TelemetrySynchronizerTest.ts b/tests/unit/TelemetrySynchronizerTest.ts index 725e0683323a..4454e796ef65 100644 --- a/tests/unit/TelemetrySynchronizerTest.ts +++ b/tests/unit/TelemetrySynchronizerTest.ts @@ -23,6 +23,7 @@ jest.mock('@sentry/react-native', () => ({ jest.mock('@libs/PolicyUtils', () => ({ getActivePolicies: jest.fn(), + getHighestPolicyRole: jest.requireActual>('@libs/PolicyUtils/permissions').getHighestPolicyRole, })); jest.mock('@libs/telemetry/sendMemoryContext', () => ({ diff --git a/tests/unit/TransactionTest.ts b/tests/unit/TransactionTest.ts index 9f0f65450351..038e06291144 100644 --- a/tests/unit/TransactionTest.ts +++ b/tests/unit/TransactionTest.ts @@ -2920,6 +2920,33 @@ describe('Transaction', () => { // And the new expense is sent with the trip's route alternative expect(TransactionUtils.getSelectedRouteDistance(transaction)).toBe(200); }); + + it('converts routeDistanceMeters into the destination distanceUnit', async () => { + // Given a prior trip of 22.8 km (22800 meters) + const transactionID = 'txn-select-reusable-route-converted'; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, generateTransaction({transactionID, reportID: '1'})); + const route: ReusableDistanceRoute = { + transactionID: 'source-transaction', + inserted: '2026-10-01 12:00:00', + distance: 22.8, + routeDistanceMeters: 22800, + waypoints: { + waypoint0: {address: 'A', lat: 1, lng: 1}, + waypoint1: {address: 'B', lat: 2, lng: 2}, + }, + }; + + // When the trip is reused in a workspace using miles + await selectReusableRoute(transactionID, route, undefined, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + await waitForBatchedUpdates(); + + // Then the draft converts the trip's route distance in meters to the destination unit + const transaction = await OnyxUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + expect(transaction?.isReusedRoute).toBe(true); + expect(transaction?.comment?.customUnit?.quantity).toBe(14.17); + expect(transaction?.comment?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + expect(transaction?.comment?.customUnit?.routeDistanceMeters).toBe(22800); + }); }); describe('setSelectedRoute', () => { diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 63cbaa1d2546..a549560380ac 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -23,7 +23,7 @@ import createRandomPolicy, {createCategoryTaxExpenseRules} from '../utils/collec import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; import createMock from '../utils/createMock'; -import {getCurrencyDecimalsLocal, getCurrencySymbolLocal} from '../utils/TestHelper'; +import {formatPhoneNumber, getCurrencyDecimalsLocal, getCurrencySymbolLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; function generateTransaction(values: Partial = {}): Transaction { @@ -3536,7 +3536,7 @@ describe('TransactionUtils', () => { {email: 'b@x.com', displayName: 'banana', avatarUrl: ''}, {email: 'a@x.com', displayName: 'apple', avatarUrl: ''}, ]; - expect(TransactionUtils.getAttendeesListDisplayString(attendees)).toBe('banana, apple'); + expect(TransactionUtils.getAttendeesListDisplayString(attendees, formatPhoneNumber)).toBe('banana, apple'); }); it('returns attendees alphabetically regardless of insertion order (deploy blocker #89130)', () => { @@ -3544,7 +3544,7 @@ describe('TransactionUtils', () => { {email: 'b@x.com', displayName: 'banana', avatarUrl: ''}, {email: 'a@x.com', displayName: 'apple', avatarUrl: ''}, ]; - expect(TransactionUtils.getAttendeesListDisplayString(attendees, localeCompare)).toBe('apple, banana'); + expect(TransactionUtils.getAttendeesListDisplayString(attendees, formatPhoneNumber, localeCompare)).toBe('apple, banana'); }); it('uses numeric-aware sort so "User 9" comes before "User 10"', () => { @@ -3552,7 +3552,7 @@ describe('TransactionUtils', () => { {email: '10@x.com', displayName: 'User 10', avatarUrl: ''}, {email: '9@x.com', displayName: 'User 9', avatarUrl: ''}, ]; - expect(TransactionUtils.getAttendeesListDisplayString(attendees, localeCompare)).toBe('User 9, User 10'); + expect(TransactionUtils.getAttendeesListDisplayString(attendees, formatPhoneNumber, localeCompare)).toBe('User 9, User 10'); }); it('compares case-insensitively so the joined string matches pill order', () => { @@ -3560,19 +3560,19 @@ describe('TransactionUtils', () => { {email: 'b@x.com', displayName: 'Bob', avatarUrl: ''}, {email: 'a@x.com', displayName: 'alice', avatarUrl: ''}, ]; - expect(TransactionUtils.getAttendeesListDisplayString(attendees, localeCompare)).toBe('alice, Bob'); + expect(TransactionUtils.getAttendeesListDisplayString(attendees, formatPhoneNumber, localeCompare)).toBe('alice, Bob'); }); - it('strips the @expensify.sms domain so phone-login attendees render the same as in pills', () => { + it('formats phone-login attendees so they render the same as in pills', () => { const attendees: Attendee[] = [ {displayName: '+15551234567@expensify.sms', avatarUrl: ''}, {displayName: 'Alice', avatarUrl: ''}, ]; - expect(TransactionUtils.getAttendeesListDisplayString(attendees, localeCompare)).toBe('+15551234567, Alice'); + expect(TransactionUtils.getAttendeesListDisplayString(attendees, formatPhoneNumber, localeCompare)).toBe('+1 555-123-4567, Alice'); }); it('returns empty string for empty array', () => { - expect(TransactionUtils.getAttendeesListDisplayString([], localeCompare)).toBe(''); + expect(TransactionUtils.getAttendeesListDisplayString([], formatPhoneNumber, localeCompare)).toBe(''); }); it('does not mutate the input array', () => { @@ -3581,7 +3581,7 @@ describe('TransactionUtils', () => { {email: 'a@x.com', displayName: 'apple', avatarUrl: ''}, ]; const snapshot = [...attendees]; - TransactionUtils.getAttendeesListDisplayString(attendees, localeCompare); + TransactionUtils.getAttendeesListDisplayString(attendees, formatPhoneNumber, localeCompare); expect(attendees).toEqual(snapshot); }); }); diff --git a/tests/unit/UserAvatarUtilsTest.ts b/tests/unit/UserAvatarUtilsTest.ts index 83325e7cac52..17a199ba990a 100644 --- a/tests/unit/UserAvatarUtilsTest.ts +++ b/tests/unit/UserAvatarUtilsTest.ts @@ -592,6 +592,24 @@ describe('UserAvatarUtils', () => { expect(UserAvatarUtils.isGeneratedLetterAvatarURL(icon.source)).toBe(true); }); + it('should seed a generated letter-avatar URL from invitedEmail when the invitee has no personal details yet', () => { + // Given an invitee that only exists in the invite flow, with no personal details and no accountEmail + const {result: avatars} = renderHook(() => useDefaultAvatars()); + const invitedEmail = 'invited@example.com'; + + // When the icon is built from the invited email alone + const icon = UserAvatarUtils.buildUserIcon({ + accountID: ACCOUNT_ID, + personalDetails: {}, + defaultAvatars: avatars.current, + invitedEmail, + }); + + // Then it shows the same letter avatar that is stored once the invite is submitted, instead of the generic fallback + expect(icon.source).toBe(UserAvatarUtils.getDefaultAvatarURL({accountID: ACCOUNT_ID, accountEmail: invitedEmail})); + expect(UserAvatarUtils.isGeneratedLetterAvatarURL(icon.source)).toBe(true); + }); + it('should fall through to the catalog default for a phone login without personal details', () => { const {result: avatars} = renderHook(() => useDefaultAvatars()); const phoneLogin = '+15551234567'; diff --git a/tests/unit/UserSelectionListItemTest.tsx b/tests/unit/UserSelectionListItemTest.tsx index 01c8bdc54cb8..904e86c7bc90 100644 --- a/tests/unit/UserSelectionListItemTest.tsx +++ b/tests/unit/UserSelectionListItemTest.tsx @@ -14,8 +14,6 @@ const mockFormatPhoneNumber = jest.fn((value: string) => value); jest.mock('@hooks/useLocalize', () => () => ({translate: mockTranslate, formatPhoneNumber: mockFormatPhoneNumber})); -jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({accountID: 1, login: 'current@test.com'})); - jest.mock('@components/SelectionList/ListItem/SelectableListItem', () => jest.fn(() => null)); jest.mock('@libs/ReportUtils', () => { @@ -55,22 +53,47 @@ describe('UserSelectionListItem', () => { expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({accountID: ITEM_ACCOUNT_ID, hiddenTranslation: 'common.hidden'})); }); - describe('user handle', () => { - // Render the row content so the handle text is reachable; the mock from the module factory renders nothing by default. + describe('primary login', () => { + // Render the row content so the login text is reachable; the mock from the module factory renders nothing by default. beforeEach(() => { mockSelectableListItem.mockImplementation(({children}) => {children}); + mockGetDisplayNameForParticipant.mockReturnValue('SPY_NAME'); + }); + + it.each(['someone@test.com', 'someone@gmail.com'])('shows the full login %s below the display name', (login) => { + // Given a named user, whatever the login domain + const item: ListItem = { + keyForList: 'user-item', + accountID: ITEM_ACCOUNT_ID, + login, + }; + + // When the row renders + render( + , + ); + + // Then the full login is shown under the name, so users can be told apart by login, not a domain-stripped handle + expect(screen.getByText('SPY_NAME')).toBeOnTheScreen(); + expect(screen.getByText(login)).toBeOnTheScreen(); }); - it.each([ - ['strips the domain when the login shares the current user private domain', 'someone@test.com', '@someone'], - ['shows the full login when the domains differ', 'someone@gmail.com', '@someone@gmail.com'], - ])('%s', (_, login, expectedHandle) => { + it('hides the login when it matches the display name', () => { + // Given a user without a name, whose display name falls back to their login + const login = 'someone@test.com'; + mockGetDisplayNameForParticipant.mockReturnValue(login); const item: ListItem = { keyForList: 'user-item', accountID: ITEM_ACCOUNT_ID, login, }; + // When the row renders render( { />, ); - expect(screen.getByText(expectedHandle)).toBeOnTheScreen(); + // Then the login is shown once, since a second line would only repeat the name + expect(screen.getAllByText(login)).toHaveLength(1); }); }); }); diff --git a/tests/unit/VendorMatchingMerchantRulesTest.ts b/tests/unit/VendorMatchingMerchantRulesTest.ts index 21f5338dd9dd..b7b910f53a67 100644 --- a/tests/unit/VendorMatchingMerchantRulesTest.ts +++ b/tests/unit/VendorMatchingMerchantRulesTest.ts @@ -75,7 +75,7 @@ const buildIntacctPolicy = (vendors: Array<{id: string; name: string; value: str }), }); -/** Xero policy whose supplier list scopes vendor matching to Xero (label flips vendor -> supplier). */ +/** Xero policy whose supplier list scopes vendor matching to Xero. */ const buildXeroPolicy = (contacts: Record | undefined): Policy => createMock({ ...createRandomPolicy(0), @@ -184,13 +184,19 @@ describe('Vendor matching on merchant rules', () => { expect(buildTableData(policy, 'v-1').at(0)?.ruleDescription).toContain('Update vendor to "Acme Co"'); }); - it('uses "supplier" wording and "Supplier unavailable" on Xero workspaces', () => { + it('uses "vendor" wording and "Vendor unavailable" on Xero workspaces', () => { + // Given Xero workspaces where the rule's vendor is synced, missing from a loaded list, or not loaded yet const resolved = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); - expect(buildTableData(resolved, 'xc1').at(0)?.ruleDescription).toContain('Update supplier to "Acme Xero"'); - expect(buildTableData(buildXeroPolicy({}), 'xc1').at(0)?.ruleDescription).toContain('Update supplier to "Supplier unavailable"'); + // When the merchant rule summaries are built + const resolvedDescription = buildTableData(resolved, 'xc1').at(0)?.ruleDescription; + const unavailableDescription = buildTableData(buildXeroPolicy({}), 'xc1').at(0)?.ruleDescription; + const unsyncedDescription = buildTableData(buildXeroPolicy(undefined), 'xc1').at(0)?.ruleDescription; - expect(buildTableData(buildXeroPolicy(undefined), 'xc1').at(0)?.ruleDescription).toContain('Update supplier to "xc1"'); + // Then they say "vendor" like every other integration, so the wording matches the rest of the app + expect(resolvedDescription).toContain('Update vendor to "Acme Xero"'); + expect(unavailableDescription).toContain('Update vendor to "Vendor unavailable"'); + expect(unsyncedDescription).toContain('Update vendor to "xc1"'); }); }); diff --git a/tests/unit/VideoPlayerUtilsTest.ts b/tests/unit/VideoPlayerUtilsTest.ts new file mode 100644 index 000000000000..93c665b7c21a --- /dev/null +++ b/tests/unit/VideoPlayerUtilsTest.ts @@ -0,0 +1,51 @@ +import {buildVideoSourceURL} from '@components/VideoPlayer/utils'; + +import CONFIG from '@src/CONFIG'; + +const ENCRYPTED_TOKEN = 'testEncryptedToken123'; +const SKIP_TIME_TAG = '#t=0.001'; + +describe('buildVideoSourceURL', () => { + it('adds the token and the skip time tag to a video on an Expensify server', () => { + // Given a chat attachment video on an Expensify server + const url = `${CONFIG.EXPENSIFY.EXPENSIFY_URL}chat-attachments/123/w_video.mp4`; + + // When building the playable source + const result = buildVideoSourceURL(url, ENCRYPTED_TOKEN); + + // Then the server gets the token it needs, and playback skips the first millisecond + expect(result).toBe(`${url}?encryptedAuthToken=${ENCRYPTED_TOKEN}${SKIP_TIME_TAG}`); + }); + + it('adds only the skip time tag to a video outside Expensify', () => { + // Given a video on a host that is not an Expensify server + const url = 'https://attacker.example/video.mp4'; + + // When building the playable source + const result = buildVideoSourceURL(url, ENCRYPTED_TOKEN); + + // Then the token stays out of the URL, so that host never sees it + expect(result).toBe(`${url}${SKIP_TIME_TAG}`); + }); + + it.each([ + ['a blob video', 'blob:https://new.expensify.com/1234-5678'], + ['a file video', 'file:///data/user/0/video.mp4'], + ])('adds only the skip time tag to %s on the device', (_, url) => { + // Given a video the user picked but has not uploaded yet + // When building the playable source + const result = buildVideoSourceURL(url, ENCRYPTED_TOKEN); + + // Then the token stays out of the URL, because a local file needs no auth + expect(result).toBe(`${url}${SKIP_TIME_TAG}`); + }); + + it('returns an empty URL unchanged', () => { + // Given a video with no URL yet + // When building the playable source + const result = buildVideoSourceURL('', ENCRYPTED_TOKEN); + + // Then nothing is added + expect(result).toBe(''); + }); +}); diff --git a/tests/unit/VideoRendererTest.tsx b/tests/unit/VideoRendererTest.tsx index 484bef36aa18..01efe0564c98 100644 --- a/tests/unit/VideoRendererTest.tsx +++ b/tests/unit/VideoRendererTest.tsx @@ -87,4 +87,40 @@ describe('VideoRenderer', () => { const navigateArgs = firstCall?.at(0); expect(navigateArgs).toContain('isAuthTokenRequired=true'); }); + + it('should use the inner text as the file name when data-name is missing', () => { + // Given a server-rendered video tag that carries the original file name only as its inner text + render( + + + + {/* @ts-expect-error - Ignoring type errors for testing purposes */} + + + + , + ); + + // When the video preview renders + // Then it should receive the original file name instead of the storage key from the URL + expect(screen.getByLabelText('clip.mp4')).toBeTruthy(); + }); + + it('should fall back to the URL file name when data-name and inner text are missing', () => { + // Given a video tag with neither data-name nor inner text + render( + + + + {/* @ts-expect-error - Ignoring type errors for testing purposes */} + + + + , + ); + + // When the video preview renders + // Then it should use the last part of the source URL, since there is no other name to use + expect(screen.getByLabelText('test.mp4')).toBeTruthy(); + }); }); diff --git a/tests/unit/ViolationFixParityTest.ts b/tests/unit/ViolationFixParityTest.ts new file mode 100644 index 000000000000..ecc7dcd735ba --- /dev/null +++ b/tests/unit/ViolationFixParityTest.ts @@ -0,0 +1,259 @@ +import {getViolatingReportIDForRBRInLHN, isReportEligibleForViolationFix} from '@libs/ReportUtils'; + +import {getFlaggedExpenses} from '@pages/home/ForYouSection/useReviewFlaggedExpenses'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, ReportAction, Session, Transaction, TransactionViolation} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxKey} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +import {createExpenseReport, createPolicyExpenseChat} from '../utils/collections/reports'; +import createRandomTransaction from '../utils/collections/transaction'; +import createMock from '../utils/createMock'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const currentUserAccountID = 5; +const currentUserEmail = 'submitter@parity.test'; +const otherUserAccountID = 42; + +type Cell = { + name: string; + reportState: 'open' | 'processing' | 'approved'; + instantSubmit: boolean; + ownedByCurrentUser: boolean; + violations: TransactionViolation[]; + // An approver already forwarded this report, but it is still submitted. + forwardedSinceSubmit?: boolean; +}; + +const stateToNums = { + open: {stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}, + processing: {stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED}, + approved: {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED}, +} as const; + +const RESOLVABLE: TransactionViolation = {name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}; + +describe('Violation Fix parity between Inbox and Home', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + async function buildCell(cell: Cell) { + await Onyx.clear(); + const key = cell.name.replaceAll(/\s+/g, '-'); + const policyID = `policy-${key}`; + const chatReportID = `chat-${key}`; + const expenseReportID = `expense-${key}`; + const transactionID = `transaction-${key}`; + + const policy: Policy = { + id: policyID, + name: 'Parity Workspace', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + outputCurrency: CONST.CURRENCY.USD, + owner: currentUserEmail, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + ...(cell.instantSubmit ? {autoReporting: true, autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT} : {}), + } as Policy; + + const ownerAccountID = cell.ownedByCurrentUser ? currentUserAccountID : otherUserAccountID; + + const chatReport: Report = { + ...createPolicyExpenseChat(830), + reportID: chatReportID, + ownerAccountID, + policyID, + iouReportID: expenseReportID, + }; + + const expenseReport: Report = { + ...createExpenseReport(831), + reportID: expenseReportID, + chatReportID, + ownerAccountID, + managerID: otherUserAccountID, + policyID, + type: CONST.REPORT.TYPE.EXPENSE, + currency: CONST.CURRENCY.USD, + total: 2500, + ...stateToNums[cell.reportState], + }; + + const transaction: Transaction = { + ...createRandomTransaction(830), + transactionID, + reportID: expenseReportID, + amount: 2500, + currency: CONST.CURRENCY.USD, + status: CONST.TRANSACTION.STATUS.POSTED, + reimbursable: true, + }; + + const violationsKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}` as OnyxKey; + const violationsCollection: OnyxCollection = {[violationsKey]: cell.violations}; + + const submittedAction = createMock({ + reportActionID: `${expenseReportID}-submitted`, + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2026-01-02 10:00:00.000', + }); + const forwardedAction = createMock({ + reportActionID: `${expenseReportID}-forwarded`, + actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, + created: '2026-01-03 10:00:00.000', + }); + + await Onyx.merge(ONYXKEYS.SESSION, {accountID: currentUserAccountID, email: currentUserEmail}); + await Promise.all([ + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy), + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport), + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, expenseReport), + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction), + Onyx.merge(violationsKey, cell.violations), + ...(cell.forwardedSinceSubmit + ? [ + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`, { + [submittedAction.reportActionID]: submittedAction, + [forwardedAction.reportActionID]: forwardedAction, + }), + ] + : []), + ]); + await waitForBatchedUpdates(); + + const allReports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`]: chatReport, + [`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`]: expenseReport, + }; + const allTransactions: OnyxCollection = {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction}; + const allPolicies: OnyxCollection = {[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]: policy}; + const session: Session = {accountID: currentUserAccountID, email: currentUserEmail}; + + return {chatReport, expenseReport, policy, violationsCollection, allReports, allTransactions, allPolicies, session}; + } + + function evaluateSurfaces(fixture: Awaited>) { + const inboxShows = getViolatingReportIDForRBRInLHN(fixture.chatReport, fixture.violationsCollection, currentUserEmail) === fixture.expenseReport.reportID; + const homeShows = getFlaggedExpenses(fixture.allReports, fixture.allTransactions, fixture.violationsCollection, fixture.allPolicies, fixture.session).some( + (flagged) => flagged.reportID === fixture.expenseReport.reportID, + ); + return {inboxShows, homeShows, expenseReport: fixture.expenseReport, policy: fixture.policy}; + } + + const violationVariants: Array<{label: string; violations: TransactionViolation[]}> = [ + {label: 'resolvable', violations: [RESOLVABLE]}, + {label: 'companyCardRequired-only', violations: [{name: CONST.VIOLATIONS.COMPANY_CARD_REQUIRED, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}]}, + {label: 'companyCardRequired-plus-resolvable', violations: [{name: CONST.VIOLATIONS.COMPANY_CARD_REQUIRED, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, RESOLVABLE]}, + {label: 'modifiedAmount-notice', violations: [{name: CONST.VIOLATIONS.MODIFIED_AMOUNT, type: CONST.VIOLATION_TYPES.NOTICE, showInReview: true}]}, + {label: 'notice-hidden-from-review', violations: [{name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.NOTICE, showInReview: false}]}, + ]; + + const cells: Cell[] = []; + for (const reportState of ['open', 'processing', 'approved'] as const) { + for (const instantSubmit of [false, true]) { + for (const ownedByCurrentUser of [true, false]) { + for (const variant of violationVariants) { + cells.push({ + name: `${reportState}-${instantSubmit ? 'instant' : 'standard'}-${ownedByCurrentUser ? 'owner' : 'other'}-${variant.label}`, + reportState, + instantSubmit, + ownedByCurrentUser, + violations: variant.violations, + }); + } + } + } + } + + it.each(cells)('Inbox and Home agree for $name', async (cell) => { + // Given both surfaces see the same report + const fixture = await buildCell(cell); + + // When each surface decides whether to show Fix + const {inboxShows, homeShows, expenseReport, policy} = evaluateSurfaces(fixture); + + // Then they agree + expect(inboxShows).toBe(homeShows); + + // Then a fixable violation shows only while the report can still be fixed + if (cell.violations.length === 1 && cell.violations.at(0)?.name === CONST.VIOLATIONS.MISSING_CATEGORY && cell.violations.at(0)?.type === CONST.VIOLATION_TYPES.VIOLATION) { + const expected = cell.ownedByCurrentUser && isReportEligibleForViolationFix(expenseReport, policy); + expect(inboxShows).toBe(expected); + } + }); + + it('hides Fix on both surfaces after the first approver forwards an instant-submit report', async () => { + // Given an instant-submit report that was forwarded and is still submitted + const fixture = await buildCell({ + name: 'processing-instant-owner-forwarded-resolvable', + reportState: 'processing', + instantSubmit: true, + ownedByCurrentUser: true, + violations: [RESOLVABLE], + forwardedSinceSubmit: true, + }); + + // When both surfaces decide whether to show Fix + const {inboxShows, homeShows} = evaluateSurfaces(fixture); + + // Then neither shows it, because an approver already acted + expect(inboxShows).toBe(false); + expect(homeShows).toBe(false); + }); + + it('Home honors a forward passed via the forwardedSinceLastSubmitReportIDs param, independent of any Onyx REPORT_ACTIONS write', async () => { + // Given an instant-submit report with a resolvable violation and no forward recorded in Onyx + const fixture = await buildCell({ + name: 'processing-instant-owner-explicit-forward-resolvable', + reportState: 'processing', + instantSubmit: true, + ownedByCurrentUser: true, + violations: [RESOLVABLE], + }); + expect(evaluateSurfaces(fixture).homeShows).toBe(true); + + // When Home is told the report was forwarded only through the forwardedSinceLastSubmitReportIDs param + // (what the COLLECTION.REPORT_ACTIONS selector in useReviewFlaggedExpenses supplies), with Onyx's own + // REPORT_ACTIONS key left untouched + const expenseReportID = fixture.expenseReport.reportID; + const forwardedSinceLastSubmitReportIDs: Record = {[expenseReportID]: true}; + + // Then it no longer shows the report as fixable + const homeShows = getFlaggedExpenses( + fixture.allReports, + fixture.allTransactions, + fixture.violationsCollection, + fixture.allPolicies, + fixture.session, + forwardedSinceLastSubmitReportIDs, + ).some((flagged) => flagged.reportID === expenseReportID); + expect(homeShows).toBe(false); + }); + + it('counts a warning-typed modifiedAmount on both surfaces', async () => { + // Given a submitted instant-submit report with only a warning modifiedAmount + const fixture = await buildCell({ + name: 'processing-instant-owner-modifiedAmount-warning', + reportState: 'processing', + instantSubmit: true, + ownedByCurrentUser: true, + violations: [{name: CONST.VIOLATIONS.MODIFIED_AMOUNT, type: CONST.VIOLATION_TYPES.WARNING, showInReview: true}], + }); + + // When both surfaces decide whether to show Fix + const {inboxShows, homeShows} = evaluateSurfaces(fixture); + + // Then both show it, because only a notice is skipped + expect(inboxShows).toBe(true); + expect(homeShows).toBe(true); + }); + + afterAll(async () => { + await Onyx.clear(); + }); +}); diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 1059502aaf94..6f11cd81e60b 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -24,6 +24,10 @@ jest.mock('@libs/actions/Report', () => ({ getCurrentUserEmail: jest.fn(() => MOCK_CURRENT_USER_EMAIL), })); +jest.mock('@expensify/react-native-hybrid-app', () => ({ + isHybridApp: jest.fn(() => false), +})); + const categoryOutOfPolicyViolation = { name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION, @@ -117,13 +121,6 @@ const inactiveVendorViolation = { showInReview: true, }; -const inactiveSupplierViolation = { - name: CONST.VIOLATIONS.INACTIVE_VENDOR, - type: CONST.VIOLATION_TYPES.VIOLATION, - showInReview: true, - data: {isSupplierViolation: true}, -}; - const smartScanFailedViolation = { name: CONST.VIOLATIONS.SMARTSCAN_FAILED, type: CONST.VIOLATION_TYPES.WARNING, @@ -3180,7 +3177,7 @@ describe('getViolationsOnyxData', () => { // Pass a `vendors` array to control the synced list, or `null` to simulate the list still // hydrating (`data.vendors` absent, so `isMatchingVendorListLoaded` returns false). - const policyWithQBOVendorFeature = (vendors: Array<{id: string; name: string; currency: string}> | null = [{id: 'v-active', name: 'Acme Co', currency: 'USD'}]) => + const policyWithQBOVendorFeature = (vendors: Array<{id: string; name: string; currency: string}> | null = [{id: 'vActive', name: 'Acme Co', currency: 'USD'}]) => createMock({ requiresTag: false, requiresCategory: false, @@ -3247,7 +3244,108 @@ describe('getViolationsOnyxData', () => { it('removes an existing violation when the vendor is restored in the policy list', () => { policy = policyWithQBOVendorFeature(); - transaction.comment = {...transaction.comment, vendor: {externalID: 'v-active', wasManuallySet: true}}; + transaction.comment = {...transaction.comment, vendor: {externalID: 'vActive', wasManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData({ + isVendorMatchingBetaEnabled: true, + ownerLogin: undefined, + updatedTransaction: transaction, + transactionViolations: [inactiveVendorViolation], + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + policyVendors: { + vActive: { + externalID: 'vActive', + name: 'Acme Co', + enabled: true, + }, + }, + }); + expect(result.value).not.toContainEqual(inactiveVendorViolation); + }); + + it('preserves an existing violation when policyVendors is not provided even if the vendor is in the raw integration list', () => { + policy = policyWithQBOVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: 'vActive', wasManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData({ + isVendorMatchingBetaEnabled: true, + ownerLogin: undefined, + updatedTransaction: transaction, + transactionViolations: [inactiveVendorViolation], + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).toContainEqual(inactiveVendorViolation); + }); + + it('adds the violation when the vendor remains in the raw integration list but is disabled in policyVendors', () => { + const activeVendorID = 'vActive'; + policy = policyWithQBOVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: activeVendorID, wasManuallySet: true}}; + const policyVendors: PolicyVendors = { + [activeVendorID]: { + externalID: activeVendorID, + name: 'Acme Co', + enabled: false, + }, + }; + const result = ViolationsUtils.getViolationsOnyxData({ + isVendorMatchingBetaEnabled: true, + ownerLogin: undefined, + updatedTransaction: transaction, + transactionViolations: [], + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + policyVendors, + }); + expect(result.value).toEqual(expect.arrayContaining([inactiveVendorViolation])); + }); + + it('retains an existing violation through an optimistic update while the vendor remains disabled in policyVendors', () => { + const activeVendorID = 'vActive'; + policy = policyWithQBOVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: activeVendorID, wasManuallySet: true}}; + const policyVendors: PolicyVendors = { + [activeVendorID]: { + externalID: activeVendorID, + name: 'Acme Co', + enabled: false, + }, + }; + const result = ViolationsUtils.getViolationsOnyxData({ + isVendorMatchingBetaEnabled: true, + ownerLogin: undefined, + updatedTransaction: transaction, + transactionViolations: [inactiveVendorViolation], + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + policyVendors, + }); + expect(result.value).toContainEqual(inactiveVendorViolation); + }); + + it('removes an existing violation when the vendor is re-enabled in policyVendors', () => { + const activeVendorID = 'vActive'; + policy = policyWithQBOVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: activeVendorID, wasManuallySet: true}}; + const policyVendors: PolicyVendors = { + [activeVendorID]: { + externalID: activeVendorID, + name: 'Acme Co', + enabled: true, + }, + }; const result = ViolationsUtils.getViolationsOnyxData({ isVendorMatchingBetaEnabled: true, ownerLogin: undefined, @@ -3258,10 +3356,37 @@ describe('getViolationsOnyxData', () => { policyCategories, hasDependentTags: false, isInvoiceTransaction: false, + policyVendors, }); expect(result.value).not.toContainEqual(inactiveVendorViolation); }); + it('leaves the violation untouched when the active integration vendor list is not loaded yet even if policyVendors is present', () => { + const activeVendorID = 'vActive'; + policy = policyWithQBOVendorFeature(null); + transaction.comment = {...transaction.comment, vendor: {externalID: activeVendorID, wasManuallySet: true}}; + const policyVendors: PolicyVendors = { + [activeVendorID]: { + externalID: activeVendorID, + name: 'Acme Co', + enabled: false, + }, + }; + const result = ViolationsUtils.getViolationsOnyxData({ + isVendorMatchingBetaEnabled: true, + ownerLogin: undefined, + updatedTransaction: transaction, + transactionViolations: [inactiveVendorViolation], + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + policyVendors, + }); + expect(result.value).toContainEqual(inactiveVendorViolation); + }); + it('removes an existing violation when the user clears the vendor while the feature is still active', () => { policy = policyWithQBOVendorFeature(); // transaction.comment has no vendor key — represents a cleared selection @@ -3286,11 +3411,11 @@ describe('getViolationsOnyxData', () => { connections: { [CONST.POLICY.CONNECTIONS.NAME.QBO]: { config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL}, - data: {vendors: [{id: 'v-active', name: 'Acme Co', currency: 'USD'}]}, + data: {vendors: [{id: 'vActive', name: 'Acme Co', currency: 'USD'}]}, }, }, }); - transaction.comment = {...transaction.comment, vendor: {externalID: 'v-active', wasManuallySet: true}}; + transaction.comment = {...transaction.comment, vendor: {externalID: 'vActive', wasManuallySet: true}}; const result = ViolationsUtils.getViolationsOnyxData({ isVendorMatchingBetaEnabled: true, ownerLogin: undefined, @@ -3364,7 +3489,7 @@ describe('getViolationsOnyxData', () => { it('preserves an existing violation while the QBO vendor list is still hydrating', () => { // Given the vendor list is still hydrating but an inactive-vendor violation already exists from a prior real check policy = policyWithQBOVendorFeature(null); - transaction.comment = {...transaction.comment, vendor: {externalID: 'v-active', wasManuallySet: true}}; + transaction.comment = {...transaction.comment, vendor: {externalID: 'vActive', wasManuallySet: true}}; // When violations are recomputed const result = ViolationsUtils.getViolationsOnyxData({ @@ -3388,7 +3513,7 @@ describe('getViolationsOnyxData', () => { // hydrating. Intacct vendors carry the display name in `value`. const policyWithIntacctVendorFeature = ( nonReimbursable: SageIntacctExportConfig['nonReimbursable'] = CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE, - vendors: Array<{id: string; name: string; value: string}> | null = [{id: 'iv-active', name: 'V001', value: 'Acme Intacct'}], + vendors: Array<{id: string; name: string; value: string}> | null = [{id: 'ivActive', name: 'V001', value: 'Acme Intacct'}], ) => createMock({ requiresTag: false, @@ -3416,14 +3541,13 @@ describe('getViolationsOnyxData', () => { hasDependentTags: false, isInvoiceTransaction: false, }); - // Intacct is not a supplier source, so the plain vendor violation (no isSupplierViolation flag) is expected. expect(result.value).toEqual(expect.arrayContaining([inactiveVendorViolation])); }); it('does not add the violation when the Intacct vendor is in the synced list', () => { isBetaEnabledSpy.mockImplementation(() => false); policy = policyWithIntacctVendorFeature(); - transaction.comment = {...transaction.comment, vendor: {externalID: 'iv-active', wasManuallySet: true}}; + transaction.comment = {...transaction.comment, vendor: {externalID: 'ivActive', wasManuallySet: true}}; const result = ViolationsUtils.getViolationsOnyxData({ ownerLogin: undefined, isVendorMatchingBetaEnabled: false, @@ -3495,84 +3619,26 @@ describe('getViolationsOnyxData', () => { }, }); - it('adds the violation with isSupplierViolation flag when the Xero supplier ID is not in the synced contacts list', () => { - // Xero is the active matching source — the violation must carry the isSupplierViolation - // flag so the render layer uses the "Supplier no longer valid" copy that matches the - // rest of the Xero UI (picker, default-supplier row). + it('adds the violation when the Xero supplier ID is not in the synced contacts list', () => { + // Given a Xero workspace and a transaction whose vendor is missing from the synced contacts policy = policyWithXeroVendorFeature(); transaction.comment = {...transaction.comment, vendor: {externalID: 'xcMissing', wasManuallySet: true}}; - const result = ViolationsUtils.getViolationsOnyxData({ - isVendorMatchingBetaEnabled: true, - ownerLogin: undefined, - updatedTransaction: transaction, - transactionViolations, - policy, - policyTagList: policyTags, - policyCategories, - hasDependentTags: false, - isInvoiceTransaction: false, - }); - expect(result.value).toEqual(expect.arrayContaining([inactiveSupplierViolation])); - }); - it('backfills isSupplierViolation on an existing server-fired violation when Xero is the active matching source', () => { - // The backend can fire `inactiveVendor` directly (e.g. after a supplier deletion + - // sync) — that violation has no `data.isSupplierViolation` flag, so the render - // layer would default to the "Vendor" wording even though the rest of the Xero UI - // shows "Supplier". The reconciliation pass must stamp the flag on existing - // violations so the copy matches. - policy = policyWithXeroVendorFeature(); - transaction.comment = {...transaction.comment, vendor: {externalID: 'xcMissing', wasManuallySet: true}}; + // When violations are recomputed const result = ViolationsUtils.getViolationsOnyxData({ isVendorMatchingBetaEnabled: true, ownerLogin: undefined, updatedTransaction: transaction, - transactionViolations: [inactiveVendorViolation], + transactionViolations, policy, policyTagList: policyTags, policyCategories, hasDependentTags: false, isInvoiceTransaction: false, }); - expect(result.value).toEqual(expect.arrayContaining([inactiveSupplierViolation])); - expect(result.value).not.toContainEqual(inactiveVendorViolation); - }); - it('strips a stale isSupplierViolation flag when the active matching source switched back from Xero to QBO', () => { - // Dual-connected: QBO is in CC export mode (now the active matching source), Xero - // is also connected with supplier data. An existing violation still carries the - // supplier flag from when Xero was previously active. The reconciliation must - // strip the stale flag so the render layer renders "Vendor" copy that matches the - // QBO picker, not the stale "Supplier" wording. - const xeroPolicyContacts = {xcActive: {id: 'xcActive', name: 'Acme Xero', email: 'acme@example.com'}}; - policy = createMock({ - requiresTag: false, - requiresCategory: false, - connections: { - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, - data: {vendors: [{id: 'v-active', name: 'Acme QBO', currency: 'USD'}]}, - }, - [CONST.POLICY.CONNECTIONS.NAME.XERO]: { - config: {isConfigured: true}, - data: {contacts: xeroPolicyContacts}, - }, - }, - }); - transaction.comment = {...transaction.comment, vendor: {externalID: 'v-missing', wasManuallySet: true}}; - const result = ViolationsUtils.getViolationsOnyxData({ - isVendorMatchingBetaEnabled: true, - ownerLogin: undefined, - updatedTransaction: transaction, - transactionViolations: [inactiveSupplierViolation], - policy, - policyTagList: policyTags, - policyCategories, - hasDependentTags: false, - isInvoiceTransaction: false, - }); - expect(result.value).toContainEqual(inactiveVendorViolation); - expect(result.value).not.toContainEqual(inactiveSupplierViolation); + // Then the plain inactive vendor violation is added, the same as for every other integration + expect(result.value).toEqual(expect.arrayContaining([inactiveVendorViolation])); }); it('removes an existing violation when the Xero supplier is restored in the contacts list', () => { @@ -3588,6 +3654,13 @@ describe('getViolationsOnyxData', () => { policyCategories, hasDependentTags: false, isInvoiceTransaction: false, + policyVendors: { + xcActive: { + externalID: 'xcActive', + name: 'Acme Xero', + enabled: true, + }, + }, }); expect(result.value).not.toContainEqual(inactiveVendorViolation); }); @@ -3652,8 +3725,8 @@ describe('getViolationsOnyxData', () => { isInvoiceTransaction: false, }); - // Then the violation is added with the supplier wording because Xero does not depend on the beta - expect(result.value).toEqual(expect.arrayContaining([inactiveSupplierViolation])); + // Then the violation is added because Xero does not depend on the beta + expect(result.value).toEqual(expect.arrayContaining([inactiveVendorViolation])); }); it('removes an existing violation when the Xero connection is not configured with the beta disabled', () => { @@ -3676,7 +3749,7 @@ describe('getViolationsOnyxData', () => { isVendorMatchingBetaEnabled: false, ownerLogin: undefined, updatedTransaction: transaction, - transactionViolations: [inactiveSupplierViolation], + transactionViolations: [inactiveVendorViolation], policy, policyTagList: policyTags, policyCategories, @@ -3700,7 +3773,7 @@ describe('getViolationsOnyxData', () => { connections: { [CONST.POLICY.CONNECTIONS.NAME.QBO]: { config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, - data: {vendors: [{id: 'v-active', name: 'Acme QBO', currency: 'USD'}]}, + data: {vendors: [{id: 'vActive', name: 'Acme QBO', currency: 'USD'}]}, }, [CONST.POLICY.CONNECTIONS.NAME.XERO]: { config: {}, diff --git a/tests/unit/WorkArrangementUtilsTest.ts b/tests/unit/WorkArrangementUtilsTest.ts index 460e91425b3b..35b11934c74a 100644 --- a/tests/unit/WorkArrangementUtilsTest.ts +++ b/tests/unit/WorkArrangementUtilsTest.ts @@ -5,17 +5,17 @@ import {translateLocal} from '../utils/TestHelper'; describe('WorkArrangementUtils', () => { describe('getEffectiveWorkArrangement', () => { it.each([ - {memberArrangement: true, workspaceArrangement: false, expected: true}, - {memberArrangement: false, workspaceArrangement: true, expected: false}, - {memberArrangement: undefined, workspaceArrangement: true, expected: true}, + {memberArrangement: true, workspaceArrangement: false, fallbackArrangement: false, expected: true}, + {memberArrangement: false, workspaceArrangement: true, fallbackArrangement: false, expected: false}, + {memberArrangement: undefined, workspaceArrangement: true, fallbackArrangement: false, expected: true}, {memberArrangement: undefined, workspaceArrangement: false, expected: false}, {memberArrangement: undefined, workspaceArrangement: undefined, expected: true}, - ] as const)('uses member arrangement when set and otherwise falls back to the workspace default (%#)', ({memberArrangement, workspaceArrangement, expected}) => { + {memberArrangement: undefined, workspaceArrangement: undefined, fallbackArrangement: false, expected: false}, + ] as const)('uses member arrangement when set, then workspace default, then fallback (%#)', ({memberArrangement, workspaceArrangement, fallbackArrangement, expected}) => { // Given a member arrangement and an optional workspace default, // When resolving the member's effective work arrangement, - // Then the member value takes precedence, and a workspace that stored neither reads as office-based - // the way the server reads it. - expect(getEffectiveWorkArrangement(memberArrangement, workspaceArrangement)).toBe(expected); + // Then explicit member and workspace values take precedence, with an office-based server default unless an explicit fallback is provided. + expect(getEffectiveWorkArrangement(memberArrangement, workspaceArrangement, fallbackArrangement)).toBe(expected); }); }); diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index ff75a29fddef..7a4a3fd7cf63 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -1268,6 +1268,16 @@ describe('WorkflowUtils', () => { // Then it doesn't have everyone, so no workflow is made the default by mistake expect(includesEveryWorkspaceMember(['1@example.com'], {})).toBe(false); }); + + it('skips a null employeeList entry instead of crashing', () => { + // Given a workspace whose employeeList holds a null entry, which the type rules out but happens at runtime + const employeeListWithNull: PolicyEmployeeList = {...employeeList}; + Reflect.set(employeeListWithNull, 'stale@example.com', null); + + // When checking a workflow with every member, as creating or saving a workflow does + // Then the null entry is skipped rather than crashing, and the workflow still has everyone + expect(includesEveryWorkspaceMember(['1@example.com', '2@example.com'], employeeListWithNull)).toBe(true); + }); }); describe('convertApprovalWorkflowToPolicyEmployees', () => { @@ -3462,6 +3472,21 @@ describe('WorkflowUtils', () => { expect(hasApprovalWorkflowWithNonMemberApprover({policy: approvalsOffPolicy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(false); expect(hasApprovalWorkflowWithNonMemberApprover({policy: lockedPolicy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(false); }); + + it.each([false, true])('skips a null employeeList entry instead of crashing when the multiple approvers beta is %s', (isMultipleApproversBetaEnabled) => { + // Given a workspace whose employeeList holds a null entry, which the type rules out but happens at runtime, + // next to member 1 who still submits to someone no longer on the workspace + // The type rules out a null entry, so set it without a cast. It goes in first so every pass over employeeList reaches it before member 1 + const employeeList: PolicyEmployeeList = {}; + Reflect.set(employeeList, 'stale@example.com', null); + employeeList[ownerEmail] = {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail}; + employeeList['1@example.com'] = {email: '1@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: removedEmail}; + const policy = buildPolicy({employeeList}); + + // When the owner checks whether a workflow needs fixing, as the Workspaces tab does on every page + // Then the null entry is skipped rather than crashing the tab bar, and member 1's workflow is still flagged + expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: ownerEmail, rules: {}, isMultipleApproversBetaEnabled})).toBe(true); + }); }); describe('getNonMemberApproverError', () => { diff --git a/tests/unit/WorkspacesSettingsUtilsTest.ts b/tests/unit/WorkspacesSettingsUtilsTest.ts index d25fa472ddd9..9a554fa328d4 100644 --- a/tests/unit/WorkspacesSettingsUtilsTest.ts +++ b/tests/unit/WorkspacesSettingsUtilsTest.ts @@ -1,4 +1,5 @@ -import {getBrickRoadForPolicy, getChatTabBrickRoad, getChatTabBrickRoadReportID, getLeaveWorkspaceConfirmationPrompt, getWorkspaceAddressStreetLines} from '@libs/WorkspacesSettingsUtils'; +import {getWorkspaceAddressStreetLines} from '@libs/WorkspaceDisplayUtils'; +import {getBrickRoadForPolicy, getChatTabBrickRoad, getChatTabBrickRoadReportID, getLeaveWorkspaceConfirmationPrompt} from '@libs/WorkspacesSettingsUtils'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; diff --git a/tests/unit/addEncryptedAuthTokenToURLTest.ts b/tests/unit/addEncryptedAuthTokenToURLTest.ts index 4b6ae08061af..b019bc45b291 100644 --- a/tests/unit/addEncryptedAuthTokenToURLTest.ts +++ b/tests/unit/addEncryptedAuthTokenToURLTest.ts @@ -1,15 +1,57 @@ +import CONFIG from '@src/CONFIG'; import addEncryptedAuthTokenToURL from '@src/libs/addEncryptedAuthTokenToURL'; -const FILE_URL = 'https://example.com/file.jpg'; +const FILE_URL = `${CONFIG.EXPENSIFY.EXPENSIFY_URL}chat-attachments/123/file.jpg`; +const ATTACKER_FILE_URL = 'https://attacker.example/file.jpg'; const ENCRYPTED_TOKEN = 'testEncryptedToken123'; + describe('addEncryptedAuthTokenToURL', () => { it('should add encryptedAuthToken with ? when URL has no parameters', () => { + // Given an Expensify file URL with no query parameters + // When adding the token const result = addEncryptedAuthTokenToURL(FILE_URL, ENCRYPTED_TOKEN); + + // Then the token starts the query string expect(result).toBe(`${FILE_URL}?encryptedAuthToken=${ENCRYPTED_TOKEN}`); }); it('should add encryptedAuthToken with & when URL has existing parameters', () => { + // Given an Expensify file URL that the caller says already has query parameters + // When adding the token const result = addEncryptedAuthTokenToURL(FILE_URL, ENCRYPTED_TOKEN, true); + + // Then the token joins the existing query string expect(result).toBe(`${FILE_URL}&encryptedAuthToken=${ENCRYPTED_TOKEN}`); }); + + it('should encode the token so it stays a single query parameter', () => { + // Given a token with characters that have a meaning inside a query string + const token = 'a+b/c=d&e'; + + // When adding the token to an Expensify file URL + const result = addEncryptedAuthTokenToURL(FILE_URL, token); + + // Then the token is encoded, so the server reads it back exactly + expect(result).toBe(`${FILE_URL}?encryptedAuthToken=a%2Bb%2Fc%3Dd%26e`); + }); + + it.each([false, true])('should return a URL outside Expensify unchanged when hasOtherParameters is %s', (hasOtherParameters) => { + // Given a file URL on a host that is not an Expensify server + // When adding the token + const result = addEncryptedAuthTokenToURL(ATTACKER_FILE_URL, ENCRYPTED_TOKEN, hasOtherParameters); + + // Then the URL comes back unchanged, so the token never reaches that host + expect(result).toBe(ATTACKER_FILE_URL); + }); + + it('should return a protocol relative URL unchanged', () => { + // Given a protocol relative URL, which loads from whatever host follows the two slashes + const url = '//attacker.example/file.jpg'; + + // When adding the token + const result = addEncryptedAuthTokenToURL(url, ENCRYPTED_TOKEN); + + // Then the URL comes back unchanged + expect(result).toBe(url); + }); }); diff --git a/tests/unit/applyColumnWidthOverridesTest.ts b/tests/unit/applyColumnWidthOverridesTest.ts deleted file mode 100644 index 1aad17700536..000000000000 --- a/tests/unit/applyColumnWidthOverridesTest.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type {ColumnWidthOverrideColumn} from '@components/Table/columnResize/applyColumnWidthOverrides'; -import applyColumnWidthOverrides from '@components/Table/columnResize/applyColumnWidthOverrides'; - -/** A headed column sized from its content, which is the only kind whose edge drags. */ -function contentColumn(key: string): ColumnWidthOverrideColumn { - return {key, label: key, hasDeclaredWidth: false}; -} - -/** A headed column that declared a width of its own, e.g. a status or a switch. */ -function fixedColumn(key: string): ColumnWidthOverrideColumn { - return {key, label: key, hasDeclaredWidth: true}; -} - -/** A column with no heading, e.g. a trailing arrow. */ -function headlessColumn(key: string): ColumnWidthOverrideColumn { - return {key, label: '', hasDeclaredWidth: false}; -} - -describe('applyColumnWidthOverrides', () => { - it('applies a stored width to its column and paints it before any drag', () => { - // Given three columns where the user widened the first by 60px - const columns = [contentColumn('name'), contentColumn('email'), contentColumn('role')]; - const baseColumnWidths = {name: 200, email: 200, role: 200}; - - // When the stored width is applied - const {columnWidths, columnWidthValues} = applyColumnWidthOverrides({columns, baseColumnWidths, columnWidthOverrides: {name: 260}}); - - // Then only that column changes, and its CSS value falls back to the stored width so it paints at 260px with no drag running - expect(columnWidths).toEqual({name: 260, email: 200, role: 200}); - expect(columnWidthValues.at(0)).toContain('260px'); - }); - - it('rounds a stored width to whole px', () => { - // Given a stored width with a fraction - const columns = [contentColumn('name'), contentColumn('role')]; - - // When it is applied - const {columnWidths} = applyColumnWidthOverrides({columns, baseColumnWidths: {name: 200, role: 120}, columnWidthOverrides: {name: 240.6}}); - - // Then the column gets whole px, so its grid track never lands on a sub-pixel - expect(columnWidths).toEqual({name: 241, role: 120}); - }); - - it('ignores a width stored for a column that declared its own', () => { - // Given a stored width for a column that has since declared a fixed width - const columns = [fixedColumn('status')]; - - // When it is applied - const {columnWidths} = applyColumnWidthOverrides({columns, baseColumnWidths: {status: 80}, columnWidthOverrides: {status: 300}}); - - // Then the declared width wins, since that column can no longer be dragged and the stored width is stale - expect(columnWidths).toEqual({status: 80}); - }); - - it('gives an edge only to headed columns sized from their content', () => { - // Given a table mixing content-sized, fixed and headless columns - const columns = [contentColumn('name'), fixedColumn('status'), contentColumn('email'), headlessColumn('arrow')]; - const baseColumnWidths = {name: 200, status: 80, email: 200, arrow: 40}; - - // When the resizable columns are worked out - const {resizableColumnKeys} = applyColumnWidthOverrides({columns, baseColumnWidths, columnWidthOverrides: undefined}); - - // Then only the content-sized ones get an edge, since fixed and headless columns hold fixed-size content - expect(resizableColumnKeys).toEqual(['name', 'email']); - }); -}); diff --git a/tests/unit/buildChartSeriesTest.ts b/tests/unit/buildChartSeriesTest.ts index edf6418753d2..c3d5644aadc4 100644 --- a/tests/unit/buildChartSeriesTest.ts +++ b/tests/unit/buildChartSeriesTest.ts @@ -1,10 +1,23 @@ import type {ChartDataPoint} from '@components/Charts/types'; import VictoryTheme from '@components/Charts/VictoryTheme'; import {buildChartSeries, CHART_SERIES_KEY, getSliceColorsByDataIndex} from '@components/Search/buildChartSeries'; -import type {TransactionMerchantGroupListItemType, TransactionQuarterGroupListItemType} from '@components/Search/SearchList/ListItem/types'; -import type {GroupedItem} from '@components/Search/types'; +import CHART_GROUP_BY_CONFIG from '@components/Search/chartGroupByConfig'; +import type {ChartComparisonPeriods} from '@components/Search/comparisonBuckets'; +import type { + TransactionDayGroupListItemType, + TransactionMerchantGroupListItemType, + TransactionMonthGroupListItemType, + TransactionQuarterGroupListItemType, + TransactionWeekGroupListItemType, +} from '@components/Search/SearchList/ListItem/types'; +import type {GroupedItem, SearchGroupBy} from '@components/Search/types'; + +import themeColors from '@styles/theme/colors'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; + +import {translateLocal} from '../utils/TestHelper'; /** A grouped merchant result, carrying the fields the series is built from. */ function merchantGroup(merchant: string, total: number, count = 1, percentOfTotal?: number): TransactionMerchantGroupListItemType { @@ -22,15 +35,15 @@ function merchantGroup(merchant: string, total: number, count = 1, percentOfTota } /** A grouped quarter result, the shape of group the search still returns without a share of the total. */ -function quarterGroup(quarter: number, total: number): TransactionQuarterGroupListItemType { +function quarterGroup(quarter: number, total: number, year = 2026): TransactionQuarterGroupListItemType { return { groupedBy: CONST.SEARCH.GROUP_BY.QUARTER, - year: 2026, + year, quarter, - formattedQuarter: `Q${quarter} 2026`, + formattedQuarter: `Q${quarter} ${year}`, shortFormattedQuarter: `Q${quarter}`, sortKey: quarter, - keyForList: `group_2026_Q${quarter}`, + keyForList: `group_${year}_Q${quarter}`, count: 1, total, currency: CONST.CURRENCY.USD, @@ -52,7 +65,16 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Person', 480000, 12), merchantGroup('Target', 190000, 41)]; // When the series is built for a bar chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.BAR, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.BAR, + getLabel, + getCurrencyDecimals, + }); // Then every value is scaled down to the currency unit, because the chart axis reads in dollars, not cents, // and the rows stay in the order the search returned so the chart and the inline table line up row for row @@ -65,7 +87,16 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Person', 480000), merchantGroup('Target', 190000)]; // When the series is built for a bar chart - const {series, rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.BAR, getLabel, getCurrencyDecimals}); + const {series, rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.BAR, + getLabel, + getCurrencyDecimals, + }); // Then a single primary series comes back and every point holds its amount under that series' key, // because the charts read each point's amount by the key of the series they draw @@ -78,7 +109,16 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Person', 480000), merchantGroup('Target', 190000)]; // When the series is built for a line chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.LINE, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.LINE, + getLabel, + getCurrencyDecimals, + }); // Then each row still references its own group object, because pressing a row has to open that group's // transactions and a copied object would lose the identity the rest of Search matches on @@ -92,6 +132,10 @@ describe('buildChartSeries', () => { // When the series is built with a group-by that offers a shortened label const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, data, view: CONST.SEARCH.VIEW.BAR, getLabel, @@ -109,7 +153,16 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Person', 480000), merchantGroup('Target', 190000)]; // When the series is built for a line chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.LINE, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.LINE, + getLabel, + getCurrencyDecimals, + }); // Then no row carries a color, because a line chart draws one continuous stroke and the inline table // must not show color swatches that nothing on the canvas corresponds to @@ -121,7 +174,16 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Person', 190000), merchantGroup('Target', 480000)]; // When the series is built for a bar chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.BAR, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.BAR, + getLabel, + getCurrencyDecimals, + }); // Then colors follow the array order rather than the totals, so each bar keeps its color when the totals change expect(rows.map((row) => row.point.color)).toEqual([VictoryTheme.colors.getColor(0), VictoryTheme.colors.getColor(1)]); @@ -132,7 +194,16 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Small', 10000), merchantGroup('Large', 900000), merchantGroup('Medium', 50000)]; // When the series is built for a pie chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.PIE, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.PIE, + getLabel, + getCurrencyDecimals, + }); // Then the palette follows the slices by size, so `Large` gets the first color regardless of sitting second in the array expect(rows.at(1)?.point.color).toBe(VictoryTheme.colors.getColor(0)); @@ -145,7 +216,16 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Refund', -900000), merchantGroup('Target', 190000)]; // When the series is built for a pie chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.PIE, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.PIE, + getLabel, + getCurrencyDecimals, + }); // Then the refund is ranked first, because the pie draws slices sized by absolute value and ranking on the // signed total would push the biggest slice to the end of the palette @@ -158,7 +238,16 @@ describe('buildChartSeries', () => { const data = [merchantGroup('Person', 480000, 12, 71.6), merchantGroup('Target', 190000, 41, 28.4)]; // When the series is built for a pie chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.PIE, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.PIE, + getLabel, + getCurrencyDecimals, + }); // Then the point carries the backend share untouched, because the chart tooltip and the inline table both // read it from here and recomputing it in either place is what let them disagree @@ -170,7 +259,16 @@ describe('buildChartSeries', () => { const data = [quarterGroup(1, 75000), quarterGroup(2, 25000)]; // When the series is built for a line chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.LINE, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.QUARTER, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.LINE, + getLabel, + getCurrencyDecimals, + }); // Then the point carries no share, because the only share we quote is the one the search measured against // its own total, and a share derived from the groups on hand would disagree with it as soon as the @@ -183,13 +281,137 @@ describe('buildChartSeries', () => { const data: GroupedItem[] = []; // When the series is built for a pie chart - const {rows} = buildChartSeries({data, view: CONST.SEARCH.VIEW.PIE, getLabel, getCurrencyDecimals}); + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.PIE, + getLabel, + getCurrencyDecimals, + }); // Then there is nothing to plot, because the empty state is the view's job and the chart must not be handed // placeholder rows to draw expect(rows).toEqual([]); }); + it('plots each compared row against the group stored under the same key', () => { + // Given three quarters and the average the search returned for each of them, as cents, out of order + const data = [quarterGroup(1, 30000), quarterGroup(2, 60000), quarterGroup(3, 0)]; + const averages = [quarterGroup(3, 30000), quarterGroup(1, 30000), quarterGroup(2, 30000)]; + + // When the series is built for a line chart compared against the average + const {series, rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.QUARTER, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + comparison: {data: averages, label: 'Average', color: themeColors.green200}, + view: CONST.SEARCH.VIEW.LINE, + getLabel, + getCurrencyDecimals, + }); + + // Then each point carries its own quarter's average in dollars, and the compared series is named for the tooltip + expect(rows.map((row) => row.point.values[CHART_SERIES_KEY.COMPARISON])).toEqual([300, 300, 300]); + expect(series).toEqual([{key: CHART_SERIES_KEY.PRIMARY}, {key: CHART_SERIES_KEY.COMPARISON, color: themeColors.green200, label: 'Average'}]); + }); + + it('leaves the compared value off a group the compared rows have nothing for', () => { + // Given two quarters, where the compared rows hold only the first + const data = [quarterGroup(1, 30000), quarterGroup(2, 60000)]; + + // When the series is built for a line chart compared against those rows + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.QUARTER, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + comparison: {data: [quarterGroup(1, 45000)], label: 'Average', color: themeColors.green200}, + view: CONST.SEARCH.VIEW.LINE, + getLabel, + getCurrencyDecimals, + }); + + // Then the second quarter has no compared value, so the tooltip leaves its row out + expect(rows.map((row) => row.point.values[CHART_SERIES_KEY.COMPARISON])).toEqual([450, undefined]); + }); + + it('compares a ranked group the compared rows leave out against nothing spent', () => { + // Given this month's top merchants, one of which had no spend last month, so last month's rows leave it out + const data = [merchantGroup('Person', 480000), merchantGroup('Target', 190000)]; + const previousMonth = [merchantGroup('Person', 240000)]; + + // When the series is built for a bar chart compared against last month + const {rows} = buildChartSeries({ + data, + comparison: {data: previousMonth, color: themeColors.green200}, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + view: CONST.SEARCH.VIEW.BAR, + getLabel, + getCurrencyDecimals, + }); + + // Then the new merchant compares against $0, since a ranking only lists groups that spent, rather than drawing no bar + expect(rows.map((row) => row.point.values[CHART_SERIES_KEY.COMPARISON])).toEqual([2400, 0]); + }); + + it("shades each compared bar with its own bar's color", () => { + // Given this month's top two merchants compared against the average amounts + const data = [merchantGroup('Person', 480000), merchantGroup('Target', 190000)]; + + // When the series is built for a bar chart + const {rows} = buildChartSeries({ + data, + comparison: {data: [merchantGroup('Person', 240000), merchantGroup('Target', 90000)], label: 'Average', color: themeColors.green200}, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + view: CONST.SEARCH.VIEW.BAR, + getLabel, + getCurrencyDecimals, + }); + + // Then each pair shares its rank's hue, the compared bar lighter, rather than every compared bar taking the series' green + expect(rows.map((row) => row.point.seriesColors)).toEqual([ + {[CHART_SERIES_KEY.PRIMARY]: themeColors.yellow400, [CHART_SERIES_KEY.COMPARISON]: themeColors.yellow200}, + {[CHART_SERIES_KEY.PRIMARY]: themeColors.tangerine400, [CHART_SERIES_KEY.COMPARISON]: themeColors.tangerine200}, + ]); + }); + + it('names each series by the period it covers when comparing periods', () => { + // Given this month's merchants compared against last month + const data = [merchantGroup('Person', 480000)]; + + // When the series is built with the two periods + const {series} = buildChartSeries({ + data, + comparison: { + data: [merchantGroup('Person', 240000)], + color: themeColors.green200, + periods: {primary: {start: '2026-10-01', end: '2026-10-31'}, comparison: {start: '2026-09-01', end: '2026-09-30'}}, + }, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + view: CONST.SEARCH.VIEW.BAR, + getLabel, + getCurrencyDecimals, + }); + + // Then the tooltip can tell the two bars apart by their months, since neither period has a name of its own + expect(series.map((seriesItem) => seriesItem.label)).toEqual(['October 2026', 'September 2026']); + }); + it('relabels the group still collecting expenses, keeping its axis label', () => { // Given two quarter groups, the last of which is still in progress const data = [quarterGroup(3, 100000), quarterGroup(4, 20000)]; @@ -197,6 +419,10 @@ describe('buildChartSeries', () => { // When the series is built with a label for the in-progress group const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.QUARTER, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, data, view: CONST.SEARCH.VIEW.LINE, getLabel: quarterLabel, @@ -205,10 +431,34 @@ describe('buildChartSeries', () => { }); // Then only that group is marked and relabeled, and its axis still names the whole quarter - expect(rows.map((row) => row.point.isInProgress)).toEqual([undefined, true]); + expect(rows.map((row) => row.point.isInProgress)).toEqual([false, true]); expect(rows.at(1)?.point.label).toBe('Q4 so far'); expect(rows.at(1)?.point.shortLabel).toBe('Q4 2026'); }); + + it('marks groups after the current period as not ended, keeping their own labels', () => { + // Given a finished quarter, the quarter in progress, and a later quarter holding future-dated expenses + const data = [quarterGroup(2, 100000), quarterGroup(3, 20000), quarterGroup(4, 5000)]; + const quarterLabel = (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER ? item.formattedQuarter : ''); + + // When the series is built knowing which group is in progress and which come later + const {rows} = buildChartSeries({ + groupBy: CONST.SEARCH.GROUP_BY.QUARTER, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + data, + view: CONST.SEARCH.VIEW.LINE, + getLabel: quarterLabel, + getCurrencyDecimals, + getInProgressLabel: (item) => (item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER && item.quarter === 3 ? 'Q3 so far' : undefined), + isLaterPeriod: (item) => item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER && item.quarter === 4, + }); + + // Then both periods that haven't ended are marked, so the line chart draws them dashed, and only the current one says "so far" + expect(rows.map((row) => row.point.isInProgress)).toEqual([false, true, true]); + expect(rows.map((row) => row.point.label)).toEqual(['Q2 2026', 'Q3 so far', 'Q4 2026']); + }); }); describe('getSliceColorsByDataIndex', () => { @@ -249,3 +499,304 @@ describe('getSliceColorsByDataIndex', () => { expect(colors).toEqual([]); }); }); + +describe('buildChartSeries with a compared period', () => { + const PERIODS = {primary: {start: '2026-01-01', end: '2026-12-31'}, comparison: {start: '2025-01-01', end: '2025-12-31'}}; + + /** Builds the model of a chart plotting one period against the one before it. */ + function buildComparison(rows: GroupedItem[], previousRows: GroupedItem[], groupBy: SearchGroupBy) { + return buildChartSeries({ + data: rows, + comparison: {data: previousRows, color: themeColors.green200, periods: PERIODS}, + view: CONST.SEARCH.VIEW.BAR, + groupBy, + getLabel: CHART_GROUP_BY_CONFIG[groupBy].getLabel, + getCurrencyDecimals, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + }); + } + + it('pairs time buckets by their position in their own period, so a gap cannot shift the rest', () => { + // Given a period with no row for its second quarter + const rows = [quarterGroup(1, 100000), quarterGroup(3, 300000)]; + const previousRows = [quarterGroup(1, 10000, 2025), quarterGroup(2, 20000, 2025), quarterGroup(3, 30000, 2025)]; + + // When the two periods are plotted against each other + const model = buildComparison(rows, previousRows, CONST.SEARCH.GROUP_BY.QUARTER); + + // Then Q1 meets Q1 and Q3 meets Q3, rather than Q3 sliding onto Q2, and last year's Q2 still shows between them + expect(model.rows.map((row) => row.point.values)).toEqual([ + {[CHART_SERIES_KEY.PRIMARY]: 1000, [CHART_SERIES_KEY.COMPARISON]: 100}, + {[CHART_SERIES_KEY.COMPARISON]: 200}, + {[CHART_SERIES_KEY.PRIMARY]: 3000, [CHART_SERIES_KEY.COMPARISON]: 300}, + ]); + }); +}); + +describe('buildChartSeries labels for compared time buckets', () => { + beforeAll(() => { + IntlStore.load(CONST.LOCALES.EN); + }); + + /** Fields every grouped bucket carries besides its own date */ + const BUCKET_BASE = {count: 1, total: 100000, currency: CONST.CURRENCY.USD, transactions: []}; + + function monthGroup(year: number, month: number): TransactionMonthGroupListItemType { + return { + ...BUCKET_BASE, + groupedBy: CONST.SEARCH.GROUP_BY.MONTH, + year, + month, + formattedMonth: `${month}/${year}`, + shortFormattedMonth: `${month}`, + sortKey: month, + keyForList: `month_${year}_${month}`, + }; + } + + function weekGroup(week: string): TransactionWeekGroupListItemType { + return {...BUCKET_BASE, groupedBy: CONST.SEARCH.GROUP_BY.WEEK, week, formattedWeek: week, shortFormattedWeek: week, lineChartFormattedWeek: week, keyForList: `week_${week}`}; + } + + function dayGroup(day: string): TransactionDayGroupListItemType { + return {...BUCKET_BASE, groupedBy: CONST.SEARCH.GROUP_BY.DAY, day, formattedDay: day, shortFormattedDay: day, lineChartFormattedDay: day, keyForList: `day_${day}`}; + } + + /** Builds the labels of a chart plotting the window on screen against the one before it. */ + function buildLabels(rows: GroupedItem[], groupBy: SearchGroupBy, current: {start: string; end: string}, previous: {start: string; end: string}) { + return buildChartSeries({ + data: rows, + comparison: {data: [], color: themeColors.green200, periods: {primary: current, comparison: previous}}, + view: CONST.SEARCH.VIEW.BAR, + groupBy, + getLabel: CHART_GROUP_BY_CONFIG[groupBy].getLabel, + getShortLabel: CHART_GROUP_BY_CONFIG[groupBy].getShortLabel, + getCurrencyDecimals, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + }).rows.map((row) => [row.point.label, row.point.shortLabel]); + } + + it('names months without their year, since the same month of each year is compared', () => { + // Given year to date plotted against the year before + const labels = buildLabels( + [monthGroup(2026, 1), monthGroup(2026, 2)], + CONST.SEARCH.GROUP_BY.MONTH, + {start: '2026-01-01', end: '2026-10-01'}, + {start: '2025-01-01', end: '2025-10-01'}, + ); + + // Then each month is named on its own, which fits both of the bars drawn for it, and shortened on the axis like a lone series + expect(labels).toEqual([ + ['January', 'Jan'], + ['February', 'Feb'], + ]); + }); + + it('numbers weeks from the start of the window, since their dates differ from month to month', () => { + // Given September 2026, whose first week starts on Sunday August 30 and second on September 6 + const labels = buildLabels( + [weekGroup('2026-08-30'), weekGroup('2026-09-06')], + CONST.SEARCH.GROUP_BY.WEEK, + {start: '2026-09-01', end: '2026-09-30'}, + {start: '2026-08-01', end: '2026-08-31'}, + ); + + // Then the weeks are numbered by their position, which August's weeks share although their dates differ + expect(labels).toEqual([ + ['Week 1', 'Week 1'], + ['Week 2', 'Week 2'], + ]); + }); + + it('names a lone bucket by its unit alone', () => { + // Given September plotted against August, so a month name would only fit one of the two bars + const labels = buildLabels([monthGroup(2026, 9)], CONST.SEARCH.GROUP_BY.MONTH, {start: '2026-09-01', end: '2026-09-30'}, {start: '2026-08-01', end: '2026-08-31'}); + + // Then it is just "Month", since with nothing beside it a position tells the user nothing + expect(labels).toEqual([['Month', 'Month']]); + }); + + /** Builds the plotted values of a chart comparing the window on screen against the one before it, which had no expenses. */ + function buildValues(rows: GroupedItem[], groupBy: SearchGroupBy, periods: ChartComparisonPeriods) { + return buildChartSeries({ + data: rows, + comparison: {data: [], color: themeColors.green200, periods}, + view: CONST.SEARCH.VIEW.LINE, + groupBy, + getLabel: CHART_GROUP_BY_CONFIG[groupBy].getLabel, + getCurrencyDecimals, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + }).rows.map((row) => row.point.values); + } + + it('plots nothing for the compared period where it has no matching day, unlike a day with no expenses', () => { + // Given October plotted by day against September, which has no 31st + const values = buildValues([dayGroup('2026-10-30'), dayGroup('2026-10-31')], CONST.SEARCH.GROUP_BY.DAY, { + primary: {start: '2026-10-01', end: '2026-10-31'}, + comparison: {start: '2026-09-01', end: '2026-09-30'}, + monthsApart: 1, + }); + + // Then October 30 is compared with an empty September 30, and October 31 with nothing + expect(values).toEqual([{[CHART_SERIES_KEY.PRIMARY]: 1000, [CHART_SERIES_KEY.COMPARISON]: 0}, {[CHART_SERIES_KEY.PRIMARY]: 1000}]); + }); + + it('pairs the days of a custom range by their position, since its compared range is the same number of days before it', () => { + // Given October as a custom range, compared with the 31 days before it, which spent only on their first day, August 31 + const {rows} = buildChartSeries({ + data: [dayGroup('2026-10-01')], + comparison: { + data: [{...dayGroup('2026-08-31'), total: 50000}], + color: themeColors.green200, + periods: {primary: {start: '2026-10-01', end: '2026-10-31'}, comparison: {start: '2026-08-31', end: '2026-09-30'}}, + }, + view: CONST.SEARCH.VIEW.LINE, + groupBy: CONST.SEARCH.GROUP_BY.DAY, + getLabel: CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.DAY].getLabel, + getCurrencyDecimals, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + }); + + // Then October 1 is compared with August 31, the first day of each range, rather than with September 1 + expect(rows.at(0)?.point.values).toEqual({[CHART_SERIES_KEY.PRIMARY]: 1000, [CHART_SERIES_KEY.COMPARISON]: 500}); + }); + + /** Builds a chart plotting the window on screen by day against the one before it. */ + function buildDays(days: string[], comparedDays: string[], periods: ChartComparisonPeriods) { + return buildChartSeries({ + data: days.map(dayGroup), + comparison: {data: comparedDays.map(dayGroup), color: themeColors.green200, periods}, + view: CONST.SEARCH.VIEW.LINE, + groupBy: CONST.SEARCH.GROUP_BY.DAY, + getLabel: CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.DAY].getLabel, + getCurrencyDecimals, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + }).rows; + } + + it('adds the compared days the current period has no day for after its own days', () => { + // Given February plotted by day against January, whose 29th to 31st have no February day to pair with + const rows = buildDays(['2026-02-27', '2026-02-28'], ['2026-01-27', '2026-01-28', '2026-01-29', '2026-01-30', '2026-01-31'], { + primary: {start: '2026-02-01', end: '2026-02-28'}, + comparison: {start: '2026-01-01', end: '2026-01-31'}, + monthsApart: 1, + }); + + // Then January's last three days follow February's, carrying only January's spend, named like the days beside them, and with no group to open + expect(rows.map((row) => row.point.label)).toEqual(['27th', '28th', '29th', '30th', '31st']); + expect(rows.slice(2).map((row) => row.point.values)).toEqual([{[CHART_SERIES_KEY.COMPARISON]: 1000}, {[CHART_SERIES_KEY.COMPARISON]: 1000}, {[CHART_SERIES_KEY.COMPARISON]: 1000}]); + expect(rows.at(2)?.point.seriesLabels).toEqual({[CHART_SERIES_KEY.COMPARISON]: 'January 2026'}); + expect(rows.at(2)?.item).toBeUndefined(); + }); + + it('places a compared day the current period has no day for between the days around it', () => { + // Given 2029 so far plotted by day against 2028, whose February 29 has no 2029 day to pair with + const rows = buildDays(['2029-02-28', '2029-03-01'], ['2028-02-28', '2028-02-29', '2028-03-01'], { + primary: {start: '2029-01-01', end: '2029-03-01'}, + comparison: {start: '2028-01-01', end: '2028-03-01'}, + monthsApart: 12, + }); + + // Then February 29 sits between February 28 and March 1, so the compared line keeps its date order + expect(rows.map((row) => row.point.label)).toEqual(['Feb 28', 'Feb 29', 'Mar 1']); + expect(rows.at(1)?.point.values).toEqual({[CHART_SERIES_KEY.COMPARISON]: 1000}); + }); + + it('numbers a compared month the current period has no month for when the months beside it are numbered', () => { + // Given March and April as a custom range, compared by position with the 61 days before it, which touch December, January and February + const {rows} = buildChartSeries({ + data: [monthGroup(2026, 3), monthGroup(2026, 4)], + comparison: { + data: [monthGroup(2025, 12), monthGroup(2026, 1), monthGroup(2026, 2)], + color: themeColors.green200, + periods: {primary: {start: '2026-03-01', end: '2026-04-30'}, comparison: {start: '2025-12-30', end: '2026-02-28'}}, + }, + view: CONST.SEARCH.VIEW.LINE, + groupBy: CONST.SEARCH.GROUP_BY.MONTH, + getLabel: CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.MONTH].getLabel, + getCurrencyDecimals, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + }); + + // Then February, which no current month pairs with, is numbered like the months before it rather than named "February" + expect(rows.map((row) => row.point.label)).toEqual(['Month 1', 'Month 2', 'Month 3']); + }); + + /** Builds the dates the tooltip shows for each period at every point. */ + function buildSeriesLabels(rows: GroupedItem[], groupBy: SearchGroupBy, current: {start: string; end: string}, previous: {start: string; end: string}) { + return buildChartSeries({ + data: rows, + comparison: {data: [], color: themeColors.green200, periods: {primary: current, comparison: previous}}, + view: CONST.SEARCH.VIEW.LINE, + groupBy, + getLabel: CHART_GROUP_BY_CONFIG[groupBy].getLabel, + getCurrencyDecimals, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + }).rows.map((row) => row.point.seriesLabels); + } + + it('gives each period the dates of its own bucket, cut to the period', () => { + // Given September plotted by week against August, whose first weeks both start before the month + const labels = buildSeriesLabels([weekGroup('2026-08-30')], CONST.SEARCH.GROUP_BY.WEEK, {start: '2026-09-01', end: '2026-09-30'}, {start: '2026-08-01', end: '2026-08-31'}); + + // Then the tooltip names the days each period actually adds up, rather than the period names + expect(labels).toEqual([{[CHART_SERIES_KEY.PRIMARY]: 'Sep 1 - Sep 5, 2026', [CHART_SERIES_KEY.COMPARISON]: 'Aug 1, 2026'}]); + }); + + it("names each period's row by its year when the same month of two years is compared", () => { + // Given April of this year to date plotted against April of the year before + const labels = buildSeriesLabels([monthGroup(2026, 4)], CONST.SEARCH.GROUP_BY.MONTH, {start: '2026-01-01', end: '2026-10-20'}, {start: '2025-01-01', end: '2025-10-20'}); + + // Then each row is named by the year, since the month they share already titles the tooltip + expect(labels).toEqual([{[CHART_SERIES_KEY.PRIMARY]: '2026', [CHART_SERIES_KEY.COMPARISON]: '2025'}]); + }); + + it("names each period's row by its own date when paired days share no name", () => { + // Given October as a custom range, whose first day is paired with August 31, the first of the 31 days before it + const labels = buildSeriesLabels([dayGroup('2026-10-01')], CONST.SEARCH.GROUP_BY.DAY, {start: '2026-10-01', end: '2026-10-31'}, {start: '2026-08-31', end: '2026-09-30'}); + + // Then each row is named by its full date, since "1st" and "31st" name only one of the two days + expect(labels).toEqual([{[CHART_SERIES_KEY.PRIMARY]: 'Oct 1, 2026', [CHART_SERIES_KEY.COMPARISON]: 'Aug 31, 2026'}]); + }); + + it('says "so far" on the current period of a bucket still collecting expenses, keeping the bucket name', () => { + // Given October plotted by day against September, while October 8 is still in progress + const model = buildChartSeries({ + data: [dayGroup('2026-10-08')], + comparison: { + data: [], + color: themeColors.green200, + periods: {primary: {start: '2026-10-01', end: '2026-10-31'}, comparison: {start: '2026-09-01', end: '2026-09-30'}}, + }, + view: CONST.SEARCH.VIEW.LINE, + groupBy: CONST.SEARCH.GROUP_BY.DAY, + getLabel: CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.DAY].getLabel, + getCurrencyDecimals, + translate: translateLocal, + dateFnsLocale: undefined, + colorScheme: CONST.COLOR_SCHEME.LIGHT, + getInProgressLabel: () => 'Oct 8 so far', + }); + const october8 = model.rows.at(0)?.point; + + // Then only October's row is marked, since September 8 is over, and the axis keeps naming the day while each + // row names only the month the day belongs to + expect(october8?.label).toBe('8th'); + expect(october8?.seriesLabels).toEqual({[CHART_SERIES_KEY.PRIMARY]: 'October 2026 so far', [CHART_SERIES_KEY.COMPARISON]: 'September 2026'}); + expect(october8?.isInProgress).toBe(true); + }); +}); diff --git a/tests/unit/buildInsightsChartCSVTest.ts b/tests/unit/buildInsightsChartCSVTest.ts new file mode 100644 index 000000000000..27013818e109 --- /dev/null +++ b/tests/unit/buildInsightsChartCSVTest.ts @@ -0,0 +1,122 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {GroupedItem} from '@components/Search/types'; + +import {translate as translatePhrase} from '@libs/Localize'; + +import buildInsightsChartCSV from '@pages/Insights/buildInsightsChartCSV'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; + +const translate: LocalizedTranslate = (key, ...parameters) => translatePhrase(CONST.LOCALES.EN, key, ...parameters); +const getCurrencyDecimals = (currency: string) => (currency === 'JPY' ? 0 : 2); +const GROUP_FIELDS = {keyForList: 'group_test', count: 2, total: 12345, currency: CONST.CURRENCY.USD, transactions: []}; + +const TIME_GROUPS: Array<{item: GroupedItem; start: string; end: string; dates: string[]}> = [ + { + item: {...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.DAY, day: '2026-01-02', formattedDay: 'Jan 2', shortFormattedDay: '2', lineChartFormattedDay: 'Jan 2'}, + start: '2026-01-01', + end: '2026-01-03', + dates: ['2026-01-01', '2026-01-02', '2026-01-03'], + }, + { + item: {...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.WEEK, week: '2026-01-04', formattedWeek: 'Jan 4–10', shortFormattedWeek: 'Jan 4–10', lineChartFormattedWeek: 'Jan 4'}, + start: '2026-01-01', + end: '2026-01-11', + dates: ['2025-12-28', '2026-01-04', '2026-01-11'], + }, + { + item: {...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.MONTH, year: 2026, month: 2, formattedMonth: 'February 2026', shortFormattedMonth: 'Feb', sortKey: 202602}, + start: '2026-01-15', + end: '2026-03-15', + dates: ['2026-01-01', '2026-02-01', '2026-03-01'], + }, + { + item: {...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.QUARTER, year: 2026, quarter: 2, formattedQuarter: 'Q2 2026', shortFormattedQuarter: 'Q2', sortKey: 20262}, + start: '2026-02-01', + end: '2026-08-01', + dates: ['2026-01-01', '2026-04-01', '2026-07-01'], + }, + { + item: {...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.YEAR, year: 2026, formattedYear: '2026', sortKey: 2026}, + start: '2025-06-01', + end: '2027-06-01', + dates: ['2025-01-01', '2026-01-01', '2027-01-01'], + }, +]; + +describe('buildInsightsChartCSV', () => { + beforeAll(async () => { + await IntlStore.load(CONST.LOCALES.EN); + await IntlStore.load(CONST.LOCALES.FR); + }); + + it.each(TIME_GROUPS)('includes empty $item.groupedBy periods across the selected range', ({item, start, end, dates}) => { + // Given a filtered time range with spend only in its middle period + const data = [item]; + + // When the loaded groups are exported without fetching their expenses + const csv = buildInsightsChartCSV({data, groupBy: item.groupedBy, dateRange: {start, end}, currency: CONST.CURRENCY.USD, getCurrencyDecimals, translate}); + + // Then partial boundary periods and empty periods remain in chronological order + expect(csv).toBe(`\uFEFFDate,Expenses,Total,Currency\r\n${dates.at(0)},0,0.00,USD\r\n${dates.at(1)},2,123.45,USD\r\n${dates.at(2)},0,0.00,USD`); + }); + + it('exports only the supplied ranking groups in chart order', () => { + // Given the ranking rows the chart plots, rather than their individual expenses + const data: GroupedItem[] = [ + {...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, merchant: 'Café', formattedMerchant: 'Café'}, + {...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, merchant: 'Refund', formattedMerchant: 'Refund', total: -150, count: 1}, + ]; + + // When the ranking is exported for a date range that includes empty days + const csv = buildInsightsChartCSV({ + data, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateRange: {start: '2026-01-01', end: '2026-01-03'}, + currency: CONST.CURRENCY.USD, + getCurrencyDecimals, + translate, + }); + + // Then no empty ranking groups or expense rows are added, and refunds stay numeric + expect(csv).toBe('\uFEFFMerchant,Expenses,Total,Currency\r\nCafé,2,123.45,USD\r\nRefund,1,-1.50,USD'); + }); + + it('escapes labels and prevents spreadsheet formulas without changing numeric totals', () => { + // Given merchant names containing CSV delimiters, line breaks and formula prefixes + const names = ['Shop, "Central"\nBranch', '=1+1', ' @SUM(A1:A2)']; + const data: GroupedItem[] = names.map((merchant) => ({...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, merchant, formattedMerchant: merchant})); + + // When user-controlled names are written into the spreadsheet + const csv = buildInsightsChartCSV({data, groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, dateRange: {}, currency: CONST.CURRENCY.USD, getCurrencyDecimals, translate}); + + // Then each name stays in one cell and cannot execute as a formula + expect(csv).toBe('\uFEFFMerchant,Expenses,Total,Currency\r\n"Shop, ""Central""\nBranch",2,123.45,USD\r\n\'=1+1,2,123.45,USD\r\n\' @SUM(A1:A2),2,123.45,USD'); + }); + + it('uses the group currency precision and localized category header', () => { + // Given a category chart converted to a currency without decimal places + const data: GroupedItem[] = [{...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.CATEGORY, category: 'Travel', formattedCategory: 'Travel', currency: 'JPY'}]; + const translateFrench: LocalizedTranslate = (key, ...parameters) => translatePhrase(CONST.LOCALES.FR, key, ...parameters); + + // When its grouped totals are exported + const csv = buildInsightsChartCSV({data, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, dateRange: {}, currency: 'JPY', getCurrencyDecimals, translate: translateFrench}); + + // Then amounts use currency precision, without symbols or thousands separators + expect(csv).toBe( + `\uFEFF${translateFrench('common.category')},${translateFrench('common.expenses')},${translateFrench('common.total')},${translateFrench('common.currency')}\r\nTravel,2,123,JPY`, + ); + }); + + it('exports the same full submitter names shown in the graph', () => { + // Given a submitter group with a resolved display name + const data: GroupedItem[] = [{...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.FROM, accountID: 1, formattedFrom: 'Alex Smith'}]; + + // When the chart is exported + const csv = buildInsightsChartCSV({data, groupBy: CONST.SEARCH.GROUP_BY.FROM, dateRange: {}, currency: CONST.CURRENCY.USD, getCurrencyDecimals, translate}); + + // Then the CSV names the person rather than exposing only their internal account ID + expect(csv).toBe('\uFEFFFrom,Expenses,Total,Currency\r\nAlex Smith,2,123.45,USD'); + }); +}); diff --git a/tests/unit/canEditFieldOfMoneyRequestTest.ts b/tests/unit/canEditFieldOfMoneyRequestTest.ts index eb187627db16..38f0114012e4 100644 --- a/tests/unit/canEditFieldOfMoneyRequestTest.ts +++ b/tests/unit/canEditFieldOfMoneyRequestTest.ts @@ -8,6 +8,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report, ReportAction} from '@src/types/onyx'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; +import type {ValueOf} from 'type-fest'; + import Onyx from 'react-native-onyx'; import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; @@ -654,6 +656,126 @@ describe('canEditFieldOfMoneyRequest', () => { }); }); + describe('receipt on a distance expense', () => { + const DISTANCE_POLICY_ID = '33'; + const DISTANCE_REPORT_ID = '44'; + const DISTANCE_TRANSACTION_ID = '45'; + + const distanceReportAction = { + ...createRandomReportAction(8), + reportID: DISTANCE_REPORT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: currentUserAccountID, + originalMessage: { + IOUTransactionID: DISTANCE_TRANSACTION_ID, + type: CONST.IOU.ACTION.CREATE, + amount: 100, + currency: CONST.CURRENCY.USD, + }, + }; + + const distancePolicy: Policy = { + ...createRandomPolicy(Number(DISTANCE_POLICY_ID), CONST.POLICY.TYPE.CORPORATE), + id: DISTANCE_POLICY_ID, + role: CONST.POLICY.ROLE.USER, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + }; + + const openReport = { + ...createExpenseReport(Number(DISTANCE_REPORT_ID)), + policyID: DISTANCE_POLICY_ID, + ownerAccountID: currentUserAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const createDistanceTransaction = (iouRequestType: ValueOf) => ({ + ...createRandomTransaction(Number(DISTANCE_TRANSACTION_ID)), + transactionID: DISTANCE_TRANSACTION_ID, + reportID: DISTANCE_REPORT_ID, + iouRequestType, + }); + + const setUpOnyx = async (transaction: ReturnType) => { + const policyCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.POLICY, [distancePolicy], (p) => p.id); + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {email: currentUserEmail, accountID: currentUserAccountID}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${DISTANCE_TRANSACTION_ID}`]: transaction, + [`${ONYXKEYS.COLLECTION.REPORT}${DISTANCE_REPORT_ID}`]: openReport, + ...policyCollectionDataSet, + }); + await waitForBatchedUpdates(); + }; + + afterEach(() => { + Onyx.clear(); + return waitForBatchedUpdates(); + }); + + it('should let the submitter add, replace, and delete the receipt on an odometer expense', async () => { + // Given an odometer expense the current user submitted on an open report + const odometerTransaction = createDistanceTransaction(CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER); + await setUpOnyx(odometerTransaction); + + // When checking whether the receipt can be edited or deleted + const canEditReceipt = canEditFieldOfMoneyRequest({ + reportAction: distanceReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, + transaction: odometerTransaction, + rules: undefined, + reportNameValuePairs: undefined, + }); + const canDeleteReceipt = canEditFieldOfMoneyRequest({ + reportAction: distanceReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, + isDeleteAction: true, + transaction: odometerTransaction, + rules: undefined, + reportNameValuePairs: undefined, + }); + + // Then both are allowed, the same as for a manual distance expense, so the receipt can be attached by click or drop + expect(canEditReceipt).toBe(true); + expect(canDeleteReceipt).toBe(true); + }); + + it('should let the submitter edit the receipt on a manual distance expense', async () => { + // Given a manual distance expense the current user submitted on an open report + const manualTransaction = createDistanceTransaction(CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL); + await setUpOnyx(manualTransaction); + + // When checking whether the receipt can be edited + const canEditReceipt = canEditFieldOfMoneyRequest({ + reportAction: distanceReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, + transaction: manualTransaction, + rules: undefined, + reportNameValuePairs: undefined, + }); + + // Then it is allowed + expect(canEditReceipt).toBe(true); + }); + + it('should not let the submitter edit the receipt on a map distance expense', async () => { + // Given a map distance expense, whose receipt is generated from the route + const mapTransaction = createDistanceTransaction(CONST.IOU.REQUEST_TYPE.DISTANCE_MAP); + await setUpOnyx(mapTransaction); + + // When checking whether the receipt can be edited + const canEditReceipt = canEditFieldOfMoneyRequest({ + reportAction: distanceReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, + transaction: mapTransaction, + rules: undefined, + reportNameValuePairs: undefined, + }); + + // Then it is not allowed, because the map receipt must stay in sync with the route + expect(canEditReceipt).toBe(false); + }); + }); + describe('receipt on an approved expense', () => { const APPROVED_POLICY_ID = '55'; const APPROVED_REPORT_ID = '66'; diff --git a/tests/unit/canSendAuthTokenToURLTest.ts b/tests/unit/canSendAuthTokenToURLTest.ts new file mode 100644 index 000000000000..c55707720571 --- /dev/null +++ b/tests/unit/canSendAuthTokenToURLTest.ts @@ -0,0 +1,164 @@ +import canSendAuthTokenToURL from '@libs/canSendAuthTokenToURL'; + +import CONFIG from '@src/CONFIG'; + +type ExpensifyConfig = typeof CONFIG.EXPENSIFY; + +const FILE_PATH = 'chat-attachments/123/w_abc.pdf'; + +// Jest loads `.env.example`, so these are the dev roots. Reading them from CONFIG keeps the tests valid if that file changes. +const EXPENSIFY_ROOT = CONFIG.EXPENSIFY.EXPENSIFY_URL; +const EXPENSIFY_HOST = new URL(EXPENSIFY_ROOT).host; + +const ATTACKER_FILE_URL = 'https://attacker.example/file.pdf'; + +/** + * The trusted roots are read once when the module loads, so each config change needs a fresh copy of the module. + */ +function loadWithExpensifyConfig(overrides: Partial): typeof canSendAuthTokenToURL { + let loadedFunction: typeof canSendAuthTokenToURL | undefined; + jest.isolateModules(() => { + jest.doMock('@src/CONFIG', () => ({ + __esModule: true, + default: {...CONFIG, EXPENSIFY: {...CONFIG.EXPENSIFY, ...overrides}}, + })); + loadedFunction = jest.requireActual<{ + default: typeof canSendAuthTokenToURL; + }>('@libs/canSendAuthTokenToURL').default; + }); + jest.dontMock('@src/CONFIG'); + + if (!loadedFunction) { + throw new Error('Failed to load canSendAuthTokenToURL'); + } + return loadedFunction; +} + +describe('canSendAuthTokenToURL', () => { + describe('URLs on Expensify servers', () => { + it.each([ + ['main Expensify server', CONFIG.EXPENSIFY.EXPENSIFY_URL], + ['secure Expensify server', CONFIG.EXPENSIFY.SECURE_EXPENSIFY_URL], + ['staging server', CONFIG.EXPENSIFY.STAGING_API_ROOT], + ['secure staging server', CONFIG.EXPENSIFY.STAGING_SECURE_API_ROOT], + ])('returns true for a file on the %s', (_, root) => { + // Given a file URL on one of the configured Expensify servers + const url = `${root}${FILE_PATH}`; + + // When checking whether the token can go to that URL + const result = canSendAuthTokenToURL(url); + + // Then it can, because the Expensify servers need the token to serve private files + expect(result).toBe(true); + }); + + it('returns true for an API command URL that already has query parameters', () => { + // Given an API command URL like the one used to stream a workspace rules document + const url = `${EXPENSIFY_ROOT}api/GetPolicyRulesDocument?policyID=ABC123&cacheBuster=${encodeURIComponent('https://example.com/rules.pdf')}`; + + // When checking whether the token can go to that URL + const result = canSendAuthTokenToURL(url); + + // Then it can, because a URL in the query does not change the host the request goes to + expect(result).toBe(true); + }); + + it('returns true for a path on the current origin', () => { + // Given a path with one leading slash, which the browser loads from the origin that serves the App + const url = `/${FILE_PATH}`; + + // When checking whether the token can go to that URL + const result = canSendAuthTokenToURL(url); + + // Then it can, because the request stays on the App's own origin + expect(result).toBe(true); + }); + }); + + describe('URLs on other hosts', () => { + it.each([ + ['an unrelated host', ATTACKER_FILE_URL], + ['a host that starts with the Expensify host', `https://${EXPENSIFY_HOST}.attacker.example/${FILE_PATH}`], + ['a host that ends with the Expensify host', `https://attacker${EXPENSIFY_HOST}/${FILE_PATH}`], + ['user info that looks like the Expensify host', `https://${EXPENSIFY_HOST}@attacker.example/${FILE_PATH}`], + ['a backslash in place of the slash after the Expensify host', `https://${EXPENSIFY_HOST}\\@attacker.example/${FILE_PATH}`], + ['the Expensify host on another port', `https://${EXPENSIFY_HOST}:8443/${FILE_PATH}`], + ['the Expensify host over plain HTTP', `http://${EXPENSIFY_HOST}/${FILE_PATH}`], + ['an Expensify URL inside the path', `https://attacker.example/${EXPENSIFY_ROOT}${FILE_PATH}`], + ['an Expensify URL inside the query', `https://attacker.example/file.pdf?next=${EXPENSIFY_ROOT}`], + ['a protocol relative URL', '//attacker.example/file.pdf'], + ['a backslash after the leading slash', '/\\attacker.example/file.pdf'], + ['a tab between the leading slashes', '/\t/attacker.example/file.pdf'], + ['a line break between the leading slashes', '/\n/attacker.example/file.pdf'], + ['a leading space before a protocol relative URL', ' //attacker.example/file.pdf'], + ])('returns false for %s', (_, url) => { + // Given a URL that a browser or native networking stack sends to a host other than Expensify + // When checking whether the token can go to that URL + const result = canSendAuthTokenToURL(url); + + // Then it cannot, because whoever runs that host could reuse the token + expect(result).toBe(false); + }); + }); + + describe('URLs that do not point at a server', () => { + it.each([ + ['an empty string', ''], + ['a blob URL', 'blob:https://new.expensify.com/1234-5678'], + ['a file URL', 'file:///data/user/0/receipt.jpg'], + ['a data URL', 'data:text/plain,receipt'], + // eslint-disable-next-line no-script-url -- Verify that a script URL never receives the token. + ['a javascript URL', 'javascript:alert(1)'], + ['a relative path without a leading slash', FILE_PATH], + ])('returns false for %s', (_, url) => { + // Given a URL that is not an Expensify server URL + // When checking whether the token can go to that URL + const result = canSendAuthTokenToURL(url); + + // Then it cannot, because only Expensify server URLs need the token + expect(result).toBe(false); + }); + }); + + describe('configuration', () => { + it('allows paths on the current origin but not other hosts when the web proxy makes the API root a relative path', () => { + // Given the web proxy config, where the API root is the relative path `/` + const canSendWithWebProxy = loadWithExpensifyConfig({ + DEFAULT_API_ROOT: '/', + DEFAULT_SECURE_API_ROOT: '/', + }); + + // When checking a path on the current origin, a protocol relative URL and another host + // Then only the path on the current origin can receive the token + expect(canSendWithWebProxy(`/${FILE_PATH}`)).toBe(true); + expect(canSendWithWebProxy('//attacker.example/file.pdf')).toBe(false); + expect(canSendWithWebProxy(ATTACKER_FILE_URL)).toBe(false); + }); + + it('ignores server roots that are not configured', () => { + // Given a config where the QA roots are empty strings, which is the default when no QA server is set up + const canSendWithoutQA = loadWithExpensifyConfig({ + QA_API_ROOT: '', + QA_SECURE_API_ROOT: '', + }); + + // When checking a URL on another host + const result = canSendWithoutQA(ATTACKER_FILE_URL); + + // Then it cannot receive the token, because an empty root must not match every URL + expect(result).toBe(false); + }); + + it('allows the QA server when it is configured', () => { + // Given a config that points the App at a QA server + const qaRoot = 'https://qa.expensify.example/'; + const canSendWithQA = loadWithExpensifyConfig({QA_API_ROOT: qaRoot}); + + // When checking a file URL on that QA server + const result = canSendWithQA(`${qaRoot}${FILE_PATH}`); + + // Then it can receive the token, because the QA server serves the same private files + expect(result).toBe(true); + }); + }); +}); diff --git a/tests/unit/components/AddToWalletButton.test.tsx b/tests/unit/components/AddToWalletButton.test.tsx new file mode 100644 index 000000000000..2148d3e42213 --- /dev/null +++ b/tests/unit/components/AddToWalletButton.test.tsx @@ -0,0 +1,162 @@ +/** Explicit native entrypoint: the web component intentionally renders nothing. */ +import {fireEvent, render, screen, waitFor} from '@testing-library/react-native'; + +import AddToWalletButton from '@components/AddToWalletButton/index.native'; +import useIsCardInWallet from '@components/AddToWalletButton/useIsCardInWallet'; +import {useMultifactorAuthentication} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext'; + +import {getPaymentMethods} from '@libs/actions/PaymentMethods'; +import Navigation from '@libs/Navigation/Navigation'; +import {handleAddCardToWallet} from '@libs/Wallet/index'; + +import CONST from '@src/CONST'; +import type {Card} from '@src/types/onyx'; + +import type ReactNative from 'react-native'; + +import React from 'react'; +import {Alert} from 'react-native'; + +import createMock from '../../utils/createMock'; + +jest.mock('@expensify/react-native-wallet', () => { + const {Pressable, Text} = jest.requireActual('react-native'); + return { + AddToWalletButton: ({onPress}: {onPress: () => void}) => ( + + Add to wallet + + ), + }; +}); +jest.mock('@components/ActivityIndicator', () => { + const {Text} = jest.requireActual('react-native'); + return () => Loading wallet; +}); +jest.mock('@components/AddToWalletButton/useIsCardInWallet'); +jest.mock('@components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext'); +jest.mock('@libs/Wallet/index', () => ({handleAddCardToWallet: jest.fn()})); +jest.mock('@libs/actions/PaymentMethods', () => ({getPaymentMethods: jest.fn()})); +jest.mock('@libs/Navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn()}})); +jest.mock('@libs/getPlatform', () => ({__esModule: true, default: () => 'ios'})); +jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: () => ({addToWalletButtonStyles: {}})})); + +const card = createMock({cardID: 123, bank: CONST.EXPENSIFY_CARD.BANK, nameValuePairs: {feedCountry: CONST.COUNTRY.GB}}); +const mockExecuteScenario = jest.fn().mockResolvedValue(undefined); +const available = {isInWallet: false, isLoading: false, isCardAvailable: true, isWalletAvailable: true}; + +describe('Native AddToWalletButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useIsCardInWallet).mockReturnValue(available); + jest.mocked(useMultifactorAuthentication).mockReturnValue(createMock>({executeScenario: mockExecuteScenario})); + }); + + it('starts MFA for the actual UK/EU program card without latching a button spinner', () => { + // Given a GB-feed Expensify card with display metadata. + render( + , + ); + + // When its native badge is pressed (initialization is not authorization completion). + fireEvent.press(screen.getByText('Add to wallet')); + + // Then MFA owns native launch and success, while the badge stays usable after initialization. + expect(mockExecuteScenario).toHaveBeenCalledWith(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET, {card, cardHolderName: 'Jane', cardDescription: 'Virtual card'}); + expect(handleAddCardToWallet).not.toHaveBeenCalled(); + expect(getPaymentMethods).not.toHaveBeenCalled(); + expect(screen.queryByText('Loading wallet')).toBeNull(); + }); + + it.each([createMock({...card, nameValuePairs: {feedCountry: CONST.COUNTRY.US}}), createMock({...card, bank: CONST.PERSONAL_CARDS.BANK_NAME.CHASE})])( + 'keeps unsigned provisioning for cards outside the UK/EU program', + async (legacyCard) => { + // Given a US-feed or non-Expensify card, irrespective of user location/currency. + jest.mocked(handleAddCardToWallet).mockResolvedValue('success'); + render( + , + ); + + // When its badge is pressed. + fireEvent.press(screen.getByText('Add to wallet')); + + // Then the legacy helper and success side effects stay unchanged. + await waitFor(() => expect(getPaymentMethods).toHaveBeenCalledTimes(1)); + expect(handleAddCardToWallet).toHaveBeenCalledWith(legacyCard, 'Jane', 'Card'); + expect(mockExecuteScenario).not.toHaveBeenCalled(); + expect(Navigation.navigate).toHaveBeenCalled(); + }, + ); + + it.each(['canceled', 'error'] as const)('restores the legacy badge after native %s', async (status) => { + // Given native legacy tokenization did not succeed. + jest.mocked(handleAddCardToWallet).mockResolvedValue(status); + render( + ({...card, nameValuePairs: {feedCountry: CONST.COUNTRY.US}})} + cardHolderName="Jane" + cardDescription="Card" + />, + ); + + // When that attempt finishes. + fireEvent.press(screen.getByText('Add to wallet')); + + // Then retry is available without refresh or added-card navigation. + expect(await screen.findByText('Add to wallet')).toBeTruthy(); + expect(getPaymentMethods).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('retains the legacy rejection alert and retry', async () => { + // Given the unsigned native flow rejects. + const alert = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); + jest.mocked(handleAddCardToWallet).mockRejectedValue(new Error('failed')); + render( + ({...card, nameValuePairs: {feedCountry: CONST.COUNTRY.US}})} + cardHolderName="Jane" + cardDescription="Card" + />, + ); + + // When the attempt ends. + fireEvent.press(screen.getByText('Add to wallet')); + + // Then the existing alert remains and the badge is restored. + await waitFor(() => expect(alert).toHaveBeenCalled()); + expect(screen.getByText('Add to wallet')).toBeTruthy(); + alert.mockRestore(); + }); + + it.each([ + {...available, isWalletAvailable: false}, + {...available, isInWallet: null}, + {...available, isInWallet: true}, + {...available, isCardAvailable: false}, + ])('retains wallet/card visibility rules', (status) => { + // Given a card or wallet that is not eligible to show a badge. + jest.mocked(useIsCardInWallet).mockReturnValue(status); + // When the native component renders. + render( + , + ); + // Then neither a new MFA entry nor a legacy badge is exposed. + expect(screen.queryByText('Add to wallet')).toBeNull(); + }); +}); diff --git a/tests/unit/components/Avatar/layouts/SingleAvatarTest.tsx b/tests/unit/components/Avatar/layouts/SingleAvatarTest.tsx new file mode 100644 index 000000000000..c4f9b6bd5a9f --- /dev/null +++ b/tests/unit/components/Avatar/layouts/SingleAvatarTest.tsx @@ -0,0 +1,60 @@ +import {render, screen} from '@testing-library/react-native'; + +import SingleAvatar from '@components/Avatar/layouts/SingleAvatar'; +import type {AvatarIcon} from '@components/Avatar/types'; + +import CONST from '@src/CONST'; + +import type {PropsWithChildren} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; + +const AVATAR_CONTAINER_STYLE = {borderRadius: 20}; +const WRAPPER_STYLE = {marginRight: 12}; + +const AVATAR: AvatarIcon = { + source: 'https://example.com/avatar.png', + type: CONST.ICON_TYPE_AVATAR, + id: 123, + name: 'Alice', +}; + +// Render the tooltip as a passthrough so the host tree starts at the `AvatarTooltip` wrapper `View`, which is the tooltip's hover and measure target. +jest.mock('@components/UserDetailsTooltip', () => ({ + __esModule: true, + default: ({children}: PropsWithChildren) => children, +})); + +jest.mock('@components/Avatar/AvatarFromIcon', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- require() returns an untyped module object + const {View} = require('react-native'); + return ({containerStyles, testID}: {containerStyles: StyleProp; testID: string}) => ( + + ); +}); + +describe('SingleAvatar', () => { + it('should apply `style` to the tooltip wrapper and `containerStyles` to the avatar', () => { + // Given an avatar with outer spacing passed through `style`, as the LHN row does + // When it renders + render( + , + ); + + // Then the margin sits on the tooltip wrapper, so the tooltip target is only as wide as the avatar and the tooltip centers on it + const avatar = screen.getByTestId('SingleAvatar'); + expect(screen.root).toContainElement(avatar); + expect(screen.root).toHaveStyle(WRAPPER_STYLE); + expect(avatar).toHaveStyle(AVATAR_CONTAINER_STYLE); + expect(avatar).not.toHaveStyle(WRAPPER_STYLE); + }); +}); diff --git a/tests/unit/components/CategoryPickerModalTest.tsx b/tests/unit/components/CategoryPickerModalTest.tsx index d38810854507..2ff123e11d04 100644 --- a/tests/unit/components/CategoryPickerModalTest.tsx +++ b/tests/unit/components/CategoryPickerModalTest.tsx @@ -1,20 +1,25 @@ -import {render} from '@testing-library/react-native'; +import {fireEvent, render, renderHook, screen} from '@testing-library/react-native'; -import CategoryPickerModal from '@components/CategoryPicker/CategoryPickerModal'; +import CategoryPickerModal, {CATEGORY_PICKER_HEADER_HEIGHT} from '@components/CategoryPicker/CategoryPickerModal'; import type PopoverWithMeasuredContentProps from '@components/PopoverWithMeasuredContent/types'; +import useThemeStyles from '@hooks/useThemeStyles'; + import type * as Browser from '@libs/Browser'; -import getBottomSheetHeight from '@libs/getBottomSheetHeight'; +import getBottomSheetHeight, {BOTTOM_SHEET_TOP_PADDING} from '@libs/getBottomSheetHeight'; import getSelectionListPopoverContentHeight from '@libs/getSelectionListPopoverContentHeight'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; +import {Dimensions} from 'react-native'; import Onyx from 'react-native-onyx'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); + // Only mobile browsers dock the sheet behind the keyboard, so the tests flip this to cover both. let mockIsMobileBrowser = true; jest.mock('@libs/Browser', () => ({ @@ -24,10 +29,14 @@ jest.mock('@libs/Browser', () => ({ // A phone-width window by default, where the pop-over is a bottom sheet. A test can widen it to get the pop-over. let mockIsSmallScreenWidth = true; -jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({isSmallScreenWidth: mockIsSmallScreenWidth, shouldUseNarrowLayout: mockIsSmallScreenWidth}))); +let mockIsInLandscapeMode = false; +jest.mock('@hooks/useResponsiveLayout', () => + jest.fn(() => ({isSmallScreenWidth: mockIsSmallScreenWidth, shouldUseNarrowLayout: mockIsSmallScreenWidth, isInLandscapeMode: mockIsInLandscapeMode})), +); // The window as a mobile browser reports it with the keyboard open: shorter, and panned down past the page top. -const mockWindowHeight = 500; +const WINDOW_HEIGHT = 500; +let mockWindowHeight = WINDOW_HEIGHT; const mockViewportOffsetTop = 40; jest.mock('@hooks/useWindowDimensions', () => jest.fn(() => ({windowWidth: 390, windowHeight: mockWindowHeight}))); jest.mock('@hooks/useViewportOffsetTop', () => jest.fn(() => mockViewportOffsetTop)); @@ -36,17 +45,22 @@ jest.mock('@hooks/useViewportOffsetTop', () => jest.fn(() => mockViewportOffsetT let mockKeyboardHeight = 0; jest.mock('@hooks/useKeyboardState', () => jest.fn(() => ({isKeyboardActive: mockKeyboardHeight > 0, keyboardActiveHeight: mockKeyboardHeight}))); +// A phone by default. A test can make it a tablet, which keeps the regular sheet. +let mockIsTablet = false; +jest.mock('@libs/isTablet', () => jest.fn(() => mockIsTablet)); + // The home indicator area the sheet pads itself above, and no top inset, as the test harness reports it. const SAFE_AREA_PADDING_BOTTOM = 34; jest.mock('@hooks/useSafeAreaPaddings', () => jest.fn(() => ({paddingTop: 0, paddingBottom: SAFE_AREA_PADDING_BOTTOM}))); -// The list is covered by CategoryPickerTest; here only the sheet around it matters. +// The list is covered by CategoryPickerTest; here only the sheet around it and its header matter. jest.mock('@components/CategoryPicker', () => () => null); +// Records what the sheet is handed and renders its content, so both the container props and the header can be checked. let mockPopoverProps: PopoverWithMeasuredContentProps | undefined; jest.mock('@components/PopoverWithMeasuredContent', () => (props: PopoverWithMeasuredContentProps) => { mockPopoverProps = props; - return null; + return props.children; }); const POLICY_ID = 'policyID'; @@ -56,19 +70,81 @@ describe('CategoryPickerModal', () => { mockPopoverProps = undefined; mockIsMobileBrowser = true; mockIsSmallScreenWidth = true; + mockIsInLandscapeMode = false; mockKeyboardHeight = 0; + mockWindowHeight = WINDOW_HEIGHT; + mockIsTablet = false; + }); + + type SheetProps = {onAddCategory?: () => void; onClose?: () => void; shouldFitContentHeight?: boolean}; + + const getSheet = ({onAddCategory, onClose = jest.fn(), shouldFitContentHeight = false}: SheetProps) => ( + + ); + + // `rerenderSheet` renders the same props again, e.g. after a test changes the keyboard or window it reads. + const renderSheet = (props: SheetProps = {}) => { + const result = render(getSheet(props)); + return {...result, rerenderSheet: () => result.rerender(getSheet(props))}; + }; + + it('shows an add button that adds a category for users who can create one', () => { + // Given a user who can add categories to the workspace, as the full-page selector's "+" allows + const onAddCategory = jest.fn(); + renderSheet({onAddCategory}); + expect(screen.getByText('common.category')).toBeOnTheScreen(); + + // When they press the add button in the pop-over's header + fireEvent.press(screen.getByLabelText('workspace.categories.addCategory')); + + // Then the pop-over hands over to the add-category flow, so the pop-over offers the same action the full page does + expect(onAddCategory).toHaveBeenCalledTimes(1); + }); + + it('closes the pop-over from the back button in its header', () => { + // Given the pop-over showing its header, for a user who can add categories + const onClose = jest.fn(); + const onAddCategory = jest.fn(); + renderSheet({onAddCategory, onClose}); + + // When they press the back button on the top left of the header + fireEvent.press(screen.getByLabelText('common.back')); + + // Then the pop-over closes without starting the add-category flow, like the full page's back button leaves the step + expect(onClose).toHaveBeenCalledTimes(1); + expect(onAddCategory).not.toHaveBeenCalled(); }); - const renderSheet = (shouldFitContentHeight = false) => - render( - , - ); + it('leaves the back button out of the wide pop-over', () => { + // Given a wide layout, where the list opens as a pop-over that a click outside it already closes + mockIsSmallScreenWidth = false; + + // When the pop-over opens with its header, for a user who can add categories + renderSheet({onAddCategory: jest.fn()}); + + // Then the header keeps its title and add button, but has no back button + expect(screen.queryByLabelText('common.back')).toBeNull(); + expect(screen.getByText('common.category')).toBeOnTheScreen(); + expect(screen.getByLabelText('workspace.categories.addCategory')).toBeOnTheScreen(); + }); + + it('shows no header for users who cannot create a category', () => { + // Given a user who cannot add categories, e.g. a member or a workspace synced with an accounting integration + // When the pop-over opens + renderSheet(); + + // Then there is no back button, add button or header, matching the full page that hides its "+" for them + expect(screen.queryByLabelText('workspace.categories.addCategory')).toBeNull(); + expect(screen.queryByLabelText('common.back')).toBeNull(); + expect(screen.queryByText('common.category')).toBeNull(); + }); it('docks the sheet to the visual viewport on a mobile browser', () => { // Given a mobile browser whose keyboard shrank and panned the visual viewport, which the bottom-docked modal @@ -111,6 +187,32 @@ describe('CategoryPickerModal', () => { expect(mockPopoverProps?.avoidKeyboard).toBe(true); }); + it('fills the screen with the sheet on a phone in landscape instead of handing over to a page', () => { + // Given a phone turned to landscape, where a sheet over the form would leave too little room to scroll the list + mockIsInLandscapeMode = true; + + // When the list opens, or is already open as the phone turns + renderSheet(); + + // Then the sheet stays up and fills the window up to the status bar, which the test harness reports as zero, less the + // sheet's own top padding, and the modal neither caps nor wraps it, so the list scrolls all the way down on its own + expect(mockPopoverProps?.popoverDimensions?.height).toBe(mockWindowHeight - BOTTOM_SHEET_TOP_PADDING); + expect(mockPopoverProps?.shouldLimitHeightIfBottomDockedInLandscapeMode).toBe(false); + expect(mockPopoverProps?.shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode).toBe(false); + }); + + it('fits the full-screen sheet above the keyboard on a phone in landscape', () => { + // Given a phone in landscape with the keyboard open, e.g. while searching the list + mockIsInLandscapeMode = true; + mockKeyboardHeight = 200; + + // When the list opens + renderSheet(); + + // Then the sheet fills only the room between the keyboard and the status bar, so the search input and the rows stay visible + expect(mockPopoverProps?.popoverDimensions?.height).toBe(mockWindowHeight - mockKeyboardHeight - BOTTOM_SHEET_TOP_PADDING); + }); + describe('with a short list', () => { const ONE_ROW_HEIGHT = getSelectionListPopoverContentHeight({optionCount: 1, isSearchable: false}); @@ -124,22 +226,57 @@ describe('CategoryPickerModal', () => { await Onyx.clear(); }); + it('still fills the screen on a phone in landscape', async () => { + // Given a phone in landscape and a workspace with a single category, whose sheet would otherwise fit that one row + mockIsInLandscapeMode = true; + + // When the expense form's category list opens + renderSheet({shouldFitContentHeight: true}); + await waitForBatchedUpdates(); + + // Then the sheet fills the screen anyway, so turning the phone never leaves a sliver of a sheet over the form + expect(mockPopoverProps?.popoverDimensions?.height).toBe(mockWindowHeight - BOTTOM_SHEET_TOP_PADDING); + }); + it('fits the bottom sheet to the list instead of opening at the window height', async () => { // Given a phone, where the list opens as a bottom sheet, and a workspace with a single category // When the expense form's category list opens - renderSheet(true); + renderSheet({shouldFitContentHeight: true}); await waitForBatchedUpdates(); // Then the sheet is one row tall plus the bottom safe area it pads itself with, leaving no empty space below the row expect(mockPopoverProps?.popoverDimensions?.height).toBe(ONE_ROW_HEIGHT + SAFE_AREA_PADDING_BOTTOM); }); + it('keeps the header 20px from the top of the sheet, which already pads its own top', async () => { + // Given a phone, and a user who can add categories, so the sheet shows a header above the single category + // When the expense form's category list opens + renderSheet({onAddCategory: jest.fn(), shouldFitContentHeight: true}); + await waitForBatchedUpdates(); + + // Then the list drops its own 16px top padding under the header, since the sheet's padding plus the header's 4px already make 20px + const {result: styles} = renderHook(() => useThemeStyles()); + expect(mockPopoverProps?.popoverDimensions?.height).toBe(ONE_ROW_HEIGHT + CATEGORY_PICKER_HEADER_HEIGHT - styles.current.pt4.paddingTop + SAFE_AREA_PADDING_BOTTOM); + }); + + it('keeps the list top padding above the header in the wide pop-over', async () => { + // Given a wide layout, where the pop-over has no top padding of its own, and a user who can add categories + mockIsSmallScreenWidth = false; + + // When the expense form's category list opens + renderSheet({onAddCategory: jest.fn(), shouldFitContentHeight: true}); + await waitForBatchedUpdates(); + + // Then the pop-over is one row plus the header, whose 20px from the top comes from the list's padding + expect(mockPopoverProps?.popoverDimensions?.height).toBe(ONE_ROW_HEIGHT + CATEGORY_PICKER_HEADER_HEIGHT); + }); + it('fits the pop-over to the list without any bottom safe area', async () => { // Given a wide layout, where the list opens as a pop-over that never reaches the bottom safe area mockIsSmallScreenWidth = false; // When the expense form's category list opens - renderSheet(true); + renderSheet({shouldFitContentHeight: true}); await waitForBatchedUpdates(); // Then the pop-over is exactly one row tall @@ -151,7 +288,7 @@ describe('CategoryPickerModal', () => { mockKeyboardHeight = 200; // When the expense form's category list opens - renderSheet(true); + renderSheet({shouldFitContentHeight: true}); await waitForBatchedUpdates(); // Then the sheet is exactly one row tall, with no padding for a home indicator the keyboard already hides @@ -187,11 +324,108 @@ describe('CategoryPickerModal', () => { expect(getSelectionListPopoverContentHeight({optionCount: CATEGORY_COUNT, isSearchable: false}) + SAFE_AREA_PADDING_BOTTOM).toBeGreaterThan(windowCap); // When the expense form's category list opens - renderSheet(true); + renderSheet({shouldFitContentHeight: true}); await waitForBatchedUpdates(); // Then the sheet stops at the window cap instead of growing to fit every row and running off the screen expect(mockPopoverProps?.popoverDimensions?.height).toBe(windowCap); }); }); + + describe('with a searchable list', () => { + beforeEach(async () => { + await Onyx.clear(); + await Onyx.merge( + `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${POLICY_ID}`, + Object.fromEntries(Array.from({length: CONST.STANDARD_LIST_ITEM_LIMIT}, (_, index) => [`Category${index}`, {name: `Category${index}`, enabled: true}])), + ); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('opens as tall as a sheet may be on a phone, so opening the keyboard does not push the list up', async () => { + // Given a phone in portrait and enough categories that the list shows its search input + // When the expense form's category list opens, and then the search input brings up the keyboard + const {rerenderSheet} = renderSheet({shouldFitContentHeight: true}); + await waitForBatchedUpdates(); + const closedHeight = mockPopoverProps?.popoverDimensions?.height; + mockKeyboardHeight = 200; + rerenderSheet(); + + // Then the sheet opens tall, leaving a gap above it rather than filling the window, and neither shrinks nor is lifted + // when the keyboard opens, so its top edge and the list under it stay where they were. The room for the keyboard is + // made inside it, in step with the keyboard's own animation. + const topGap = Dimensions.get('screen').height * (1 - CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO); + expect(closedHeight).toBe(mockWindowHeight - topGap - BOTTOM_SHEET_TOP_PADDING); + expect(mockPopoverProps?.popoverDimensions?.height).toBe(closedHeight); + expect(mockPopoverProps?.avoidKeyboard).toBe(false); + }); + + it('loses only the keyboard height on mobile web, where the keyboard shrinks the window, so its top does not move', async () => { + // Given a mobile browser in portrait, with enough categories that the list shows its search input + const {rerenderSheet} = renderSheet({shouldFitContentHeight: true}); + await waitForBatchedUpdates(); + const closedHeight = mockPopoverProps?.popoverDimensions?.height ?? 0; + + // When the search input brings up the keyboard, which on mobile web shrinks the window by its height + const keyboardHeight = 200; + mockWindowHeight = WINDOW_HEIGHT - keyboardHeight; + rerenderSheet(); + + // Then the sheet, docked to the bottom of that window, is shorter by exactly the keyboard, so its top edge stays where it was + expect(mockPopoverProps?.popoverDimensions?.height).toBe(closedHeight - keyboardHeight); + }); + + it('keeps the same keyboard handling when the phone turns, so the sheet is not remounted', async () => { + // Given a phone in portrait with the searchable list open and the keyboard up for its search input + mockKeyboardHeight = 200; + const {rerenderSheet} = renderSheet({shouldFitContentHeight: true}); + await waitForBatchedUpdates(); + expect(mockPopoverProps?.avoidKeyboard).toBe(false); + + // When the phone turns to landscape + mockIsInLandscapeMode = true; + rerenderSheet(); + + // Then `avoidKeyboard` stays off, as flipping it swaps the modal's wrapper, which replays the sheet's entry animation and + // drops the input's focus. The sheet fills the window and still makes the room for the keyboard inside itself + expect(mockPopoverProps?.avoidKeyboard).toBe(false); + expect(mockPopoverProps?.popoverDimensions?.height).toBe(mockWindowHeight - BOTTOM_SHEET_TOP_PADDING); + }); + + it('keeps the regular sheet on a tablet, which never needs the phone keyboard handling', async () => { + // Given a tablet, which shows the list as a bottom sheet natively but never turns into landscape mode here + mockIsTablet = true; + + // When the expense form's category list opens + renderSheet({shouldFitContentHeight: true}); + await waitForBatchedUpdates(); + + // Then the sheet keeps the dropdown cap rather than taking most of the screen, and rises above the keyboard like other sheets + const regularSheetHeight = getBottomSheetHeight({ + preferredHeight: CONST.POPOVER_DROPDOWN_MAX_HEIGHT, + windowHeight: mockWindowHeight, + keyboardHeight: 0, + topSafeAreaInset: 0, + minHeight: getSelectionListPopoverContentHeight({optionCount: 1}), + }); + expect(mockPopoverProps?.popoverDimensions?.height).toBe(regularSheetHeight); + expect(mockPopoverProps?.avoidKeyboard).toBe(true); + }); + + it('keeps the wide pop-over at its own height', async () => { + // Given a wide layout, where the list opens as a pop-over and no keyboard pushes it around + mockIsSmallScreenWidth = false; + + // When the expense form's category list opens + renderSheet({shouldFitContentHeight: true}); + await waitForBatchedUpdates(); + + // Then the pop-over keeps the height it was given rather than filling the window + expect(mockPopoverProps?.popoverDimensions?.height).toBe(CONST.POPOVER_DROPDOWN_MAX_HEIGHT); + }); + }); }); diff --git a/tests/unit/components/Charts/VictoryTheme.test.ts b/tests/unit/components/Charts/VictoryTheme.test.ts index 772c7bf4462b..d89d266be32a 100644 --- a/tests/unit/components/Charts/VictoryTheme.test.ts +++ b/tests/unit/components/Charts/VictoryTheme.test.ts @@ -3,6 +3,8 @@ import type * as VictoryThemeModule from '@components/Charts/VictoryTheme'; import colors from '@styles/theme/colors'; +import CONST from '@src/CONST'; + /** * Loads VictoryTheme after each module reset so individual tests can mock * `@styles/theme/colors` before the module's IIFE runs. @@ -17,6 +19,37 @@ describe('VictoryTheme', () => { jest.resetModules(); }); + describe('colors.getComparisonColor', () => { + it('lightens each color to the 200 shade of the same hue', () => { + // Given palette colors items and series are drawn in + const VictoryTheme = loadVictoryTheme(); + + // When getting the colors their compared periods are drawn in, in light mode + const comparisonColors = [colors.yellow400, colors.blue400, colors.pink600].map((color) => VictoryTheme.colors.getComparisonColor(color, CONST.COLOR_SCHEME.LIGHT)); + + // Then each keeps its hue, so the two bars or lines of a comparison read as one item + expect(comparisonColors).toEqual([colors.yellow200, colors.blue200, colors.pink200]); + }); + + it('lightens the default line color to green200', () => { + // Given the default chart color, green400 + const VictoryTheme = loadVictoryTheme(); + + // When getting its comparison color + // Then it is green200 + expect(VictoryTheme.colors.getComparisonColor(VictoryTheme.colors.default, CONST.COLOR_SCHEME.LIGHT)).toBe(colors.green200); + }); + + it('darkens to the 700 shade in dark mode', () => { + // Given green400, drawn on a dark background + const VictoryTheme = loadVictoryTheme(); + + // When getting its comparison color in dark mode + // Then it is the 700 shade + expect(VictoryTheme.colors.getComparisonColor(colors.green400, CONST.COLOR_SCHEME.DARK)).toBe(colors.green700); + }); + }); + describe('colors.palette', () => { it('contains 30 entries (5 shades × 6 hues)', () => { const VictoryTheme = loadVictoryTheme(); diff --git a/tests/unit/components/Charts/chartWidthHandoff.test.tsx b/tests/unit/components/Charts/chartWidthHandoff.test.tsx new file mode 100644 index 000000000000..b34aefe53c3e --- /dev/null +++ b/tests/unit/components/Charts/chartWidthHandoff.test.tsx @@ -0,0 +1,121 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import ActivityIndicator from '@components/ActivityIndicator'; +import BarChart from '@components/Charts/BarChart'; +import LineChart from '@components/Charts/LineChart'; +import PieChart from '@components/Charts/PieChart'; +import type {ChartDataPoint, ChartProps} from '@components/Charts/types'; +import {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/VictoryTheme'; +import {CHART_SERIES_KEY} from '@components/Search/buildChartSeries'; + +import React from 'react'; +import {PolarChart} from 'victory-native'; + +const CONTAINER_WIDTH = 320; + +const data: ChartDataPoint[] = [ + {label: 'Amazon', values: {[CHART_SERIES_KEY.PRIMARY]: 120}}, + {label: 'Uber', values: {[CHART_SERIES_KEY.PRIMARY]: 80}}, +]; + +const series: ChartProps['series'] = [{key: CHART_SERIES_KEY.PRIMARY}]; + +function getMeasuredBox() { + let node = screen.UNSAFE_getByType(ActivityIndicator).parent; + + while (node) { + if (typeof node.props.onLayout === 'function') { + return node; + } + node = node.parent; + } + + throw new Error('the loading spinner has no ancestor reporting its layout'); +} + +describe('chart width handoff', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + ['bar', BarChart], + ['line', LineChart], + ])('should measure the %s chart width while the chart is still loading', (_name, Chart) => { + // Given a chart whose data has not arrived yet + const loadingChart = ( + + ); + + // When it renders its loading state + render(loadingChart); + + // Then the spinner already sits inside the box that measures the width, + // so the width is known by the time the data arrives and the chart can be sized in the pass it mounts in + expect(getMeasuredBox()).toBeTruthy(); + }); + + it('should hand the pie chart the width measured while it was loading', () => { + // Given a loading pie chart whose box has been measured + const {rerender} = render( + , + ); + fireEvent(getMeasuredBox(), 'layout', {nativeEvent: {layout: {width: CONTAINER_WIDTH, height: CHART_CONTENT_MIN_HEIGHT}}}); + + // When the data arrives + rerender( + , + ); + + // Then the chart mounts with that size instead of measuring itself, which would cost it a render with no content + expect(PolarChart).toHaveBeenCalledWith(expect.objectContaining({explicitSize: {width: CONTAINER_WIDTH, height: CHART_CONTENT_MIN_HEIGHT}}), undefined); + }); + + it('should keep the pie chart under the spinner until a width has been measured', () => { + // Given a pie chart whose box has not reported a layout yet + // When it renders with its data + render( + , + ); + + // Then the spinner stays and the chart does not mount, because it can only be sized once the width is known + expect(PolarChart).not.toHaveBeenCalled(); + expect(screen.UNSAFE_queryByType(ActivityIndicator)).not.toBeNull(); + }); + + it('should keep the pie chart mounted when its box reports a width of 0', () => { + // Given a pie chart that has mounted at its measured width + render( + , + ); + const measuredBox = getMeasuredBox(); + fireEvent(measuredBox, 'layout', {nativeEvent: {layout: {width: CONTAINER_WIDTH, height: CHART_CONTENT_MIN_HEIGHT}}}); + + // When its card is hidden and the box reports a width of 0 + fireEvent(measuredBox, 'layout', {nativeEvent: {layout: {width: 0, height: 0}}}); + + // Then the chart stays mounted at its last width instead of falling back to the spinner, which would drop its hover state and rebuild its canvas + expect(screen.UNSAFE_queryByType(ActivityIndicator)).toBeNull(); + expect(PolarChart).toHaveBeenLastCalledWith(expect.objectContaining({explicitSize: {width: CONTAINER_WIDTH, height: CHART_CONTENT_MIN_HEIGHT}}), undefined); + }); +}); diff --git a/tests/unit/components/Charts/formatTooltipComparison.test.ts b/tests/unit/components/Charts/formatTooltipComparison.test.ts new file mode 100644 index 000000000000..c879d0b729ed --- /dev/null +++ b/tests/unit/components/Charts/formatTooltipComparison.test.ts @@ -0,0 +1,158 @@ +import type {ChartDataPoint, ChartSeries} from '@components/Charts/types'; +import formatTooltipComparison from '@components/Charts/utils/formatTooltipComparison'; +import {CHART_SERIES_KEY} from '@components/Search/buildChartSeries'; + +import colors from '@styles/theme/colors'; + +import type {NonEmptyTuple} from 'type-fest'; + +const formatAmount = (value: number) => `$${value}`; + +const COMPARED_SERIES: NonEmptyTuple = [ + {key: CHART_SERIES_KEY.PRIMARY, color: colors.green400}, + {key: CHART_SERIES_KEY.COMPARISON, color: colors.green200, label: 'Average'}, +]; + +describe('formatTooltipComparison', () => { + it('gives each compared series a row in its color, named by the series', () => { + // Given April's spend, with its share of the period's spend, plotted against the average + const point: ChartDataPoint = { + label: 'April', + values: {[CHART_SERIES_KEY.PRIMARY]: 300, [CHART_SERIES_KEY.COMPARISON]: 100}, + seriesPercentOfTotal: {[CHART_SERIES_KEY.PRIMARY]: 34.2}, + }; + + // When the tooltip comparison for that point is built + const comparison = formatTooltipComparison(point, COMPARED_SERIES, formatAmount, 'en'); + + // Then each series gets a row whose dot matches its line, and only April's own amount carries a share, since the average has none + expect(comparison?.rows).toEqual([ + {key: CHART_SERIES_KEY.PRIMARY, color: colors.green400, label: undefined, amount: '$300', percentage: '34.2%'}, + {key: CHART_SERIES_KEY.COMPARISON, color: colors.green200, label: 'Average', amount: '$100', percentage: undefined}, + ]); + }); + + it("names each period's row by its dates and gives it its own period's share", () => { + // Given April plotted for two years, each with its share of that year's spend + const point: ChartDataPoint = { + label: 'April', + values: {[CHART_SERIES_KEY.PRIMARY]: 300, [CHART_SERIES_KEY.COMPARISON]: 100}, + seriesLabels: {[CHART_SERIES_KEY.PRIMARY]: '2026', [CHART_SERIES_KEY.COMPARISON]: '2025'}, + seriesPercentOfTotal: {[CHART_SERIES_KEY.PRIMARY]: 34.2, [CHART_SERIES_KEY.COMPARISON]: 16.1}, + }; + const series: NonEmptyTuple = [ + {key: CHART_SERIES_KEY.PRIMARY, color: colors.green400}, + {key: CHART_SERIES_KEY.COMPARISON, color: colors.green200}, + ]; + + // When the tooltip comparison for that point is built + const comparison = formatTooltipComparison(point, series, formatAmount, 'en'); + + // Then each row is named by the point's own label for that series, its year, and carries that year's share + expect(comparison?.rows).toEqual([ + {key: CHART_SERIES_KEY.PRIMARY, color: colors.green400, label: '2026', amount: '$300', percentage: '34.2%'}, + {key: CHART_SERIES_KEY.COMPARISON, color: colors.green200, label: '2025', amount: '$100', percentage: '16.1%'}, + ]); + }); + + it("colors a bar's rows like its own bars rather than like the series", () => { + // Given a blue bar and its lighter average bar, where the compared series keeps the line's color + const point: ChartDataPoint = { + label: 'Amazon', + values: {[CHART_SERIES_KEY.PRIMARY]: 300, [CHART_SERIES_KEY.COMPARISON]: 100}, + seriesColors: {[CHART_SERIES_KEY.PRIMARY]: colors.blue400, [CHART_SERIES_KEY.COMPARISON]: colors.blue200}, + }; + const series: NonEmptyTuple = [{key: CHART_SERIES_KEY.PRIMARY}, {key: CHART_SERIES_KEY.COMPARISON, color: colors.green200, label: 'Average'}]; + + // When the tooltip comparison for that point is built + const comparison = formatTooltipComparison(point, series, formatAmount, 'en'); + + // Then the dots match the bars drawn: the bar's own blue, and a lighter blue for the average amount + expect(comparison?.rows.map((row) => row.color)).toEqual([colors.blue400, colors.blue200]); + }); + + it('leaves the tooltip as it is when a single series is plotted', () => { + // Given a point of a chart that compares nothing + const point: ChartDataPoint = {label: 'April', values: {[CHART_SERIES_KEY.PRIMARY]: 300}}; + + // When the tooltip for that point is built + const comparison = formatTooltipComparison(point, [{key: CHART_SERIES_KEY.PRIMARY}], formatAmount, 'en'); + + // Then there is no comparison, so the tooltip keeps showing the amount and expense count + expect(comparison).toBeUndefined(); + }); + + it('leaves the tooltip as it is when the average has nothing at the point', () => { + // Given April's spend, compared against an average that has no value for April + const point: ChartDataPoint = {label: 'April', values: {[CHART_SERIES_KEY.PRIMARY]: 300}}; + + // When the tooltip for that point is built + const comparison = formatTooltipComparison(point, COMPARED_SERIES, formatAmount, 'en'); + + // Then there is no comparison, so the tooltip shows April's amount and expense count rather than one unnamed row + expect(comparison).toBeUndefined(); + }); + + it('drops the row of a previous period that has nothing at the point', () => { + // Given October 31, which the shorter previous month has no day for + const point: ChartDataPoint = { + label: 'Oct 31', + values: {[CHART_SERIES_KEY.PRIMARY]: 300}, + seriesLabels: {[CHART_SERIES_KEY.PRIMARY]: 'Oct 31, 2026'}, + }; + + // When the tooltip comparison for that point is built + const comparison = formatTooltipComparison(point, COMPARED_SERIES, formatAmount, 'en'); + + // Then only October 31's amount is listed, named by its date, with no change to show + expect(comparison).toEqual({rows: [{key: CHART_SERIES_KEY.PRIMARY, color: colors.green400, label: 'Oct 31, 2026', amount: '$300', percentage: undefined}], change: undefined}); + }); + + it('lists only the previous period where the current period has no bucket', () => { + // Given January 31 plotted against February, which has no 31st + const point: ChartDataPoint = { + label: '31st', + values: {[CHART_SERIES_KEY.COMPARISON]: 100}, + seriesLabels: {[CHART_SERIES_KEY.COMPARISON]: 'January 2026'}, + }; + + // When the tooltip comparison for that point is built + const comparison = formatTooltipComparison(point, COMPARED_SERIES, formatAmount, 'en'); + + // Then only January's amount is listed, named by its month, with no change to show + expect(comparison).toEqual({rows: [{key: CHART_SERIES_KEY.COMPARISON, color: colors.green200, label: 'January 2026', amount: '$100', percentage: undefined}], change: undefined}); + }); + + it('shows how much the primary series grew against the compared one', () => { + // Given April spend three times the average + const point: ChartDataPoint = {label: 'April', values: {[CHART_SERIES_KEY.PRIMARY]: 300, [CHART_SERIES_KEY.COMPARISON]: 100}}; + + // When the tooltip comparison for that point is built + const comparison = formatTooltipComparison(point, COMPARED_SERIES, formatAmount, 'en'); + + // Then the change reads as an increase of the difference, relative to the average + expect(comparison?.change).toEqual({amount: '$200', percentage: '200%', isIncrease: true}); + }); + + it('shows a drop below the compared series as a decrease', () => { + // Given April spend at half the average + const point: ChartDataPoint = {label: 'April', values: {[CHART_SERIES_KEY.PRIMARY]: 50, [CHART_SERIES_KEY.COMPARISON]: 100}}; + + // When the tooltip comparison for that point is built + const comparison = formatTooltipComparison(point, COMPARED_SERIES, formatAmount, 'en'); + + // Then the change is the unsigned difference, marked as a decrease + expect(comparison?.change).toEqual({amount: '$50', percentage: '50%', isIncrease: false}); + }); + + it('shows a dash for the percentage of spend that is new against nothing', () => { + // Given April spend for a group with no spend in the compared period + const point: ChartDataPoint = {label: 'April', values: {[CHART_SERIES_KEY.PRIMARY]: 300, [CHART_SERIES_KEY.COMPARISON]: 0}}; + + // When the tooltip comparison for that point is built + const comparison = formatTooltipComparison(point, COMPARED_SERIES, formatAmount, 'en'); + + // Then the change has no meaningful share of nothing, so a dash stands in for its percentage + expect(comparison?.change).toEqual({amount: '$300', percentage: '—', isIncrease: true}); + }); +}); diff --git a/tests/unit/components/Charts/utils.test.ts b/tests/unit/components/Charts/utils.test.ts index f645e8484962..a7e0ff3c83d5 100644 --- a/tests/unit/components/Charts/utils.test.ts +++ b/tests/unit/components/Charts/utils.test.ts @@ -6,10 +6,12 @@ import { effectiveHeight, effectiveWidth, findSliceAtPosition, + getAxisLabelStep, getBarLayout, getAdditionalOffset, getDomainPaddingForEdgeSpace, getNiceYAxisTicks, + getCartesianPlotBounds, isAngleInSlice, isCursorInSkewedLabel, isCursorOverChartLabel, @@ -753,6 +755,29 @@ describe('getBarLayout', () => { }); }); +describe('getCartesianPlotBounds', () => { + const PADDING_LEFT = VictoryTheme.axis.padding.left; + + it('reserves the right gutter for labels and the left base padding', () => { + // Given a 300px container with a 30px right gutter + // When computing the plot bounds + // Then the plot spans from the left padding to the gutter, where victory-native puts it, + // so labels laid out before the chart mounts line up with the plot it draws + expect(getCartesianPlotBounds(300, 30)).toEqual({left: PADDING_LEFT, right: 300 - 30, width: 300 - 30 - PADDING_LEFT}); + }); + + it('clamps to a zero-width plot when the container is too small for the gutters', () => { + // Given a container narrower than the right gutter itself + // When computing the plot bounds + // Then the plot collapses to zero width at the left padding, because a negative width would break the bar and + // label layouts that are sized from it + const bounds = getCartesianPlotBounds(10, 30); + expect(bounds.left).toBe(PADDING_LEFT); + expect(bounds.right).toBe(PADDING_LEFT); + expect(bounds.width).toBe(0); + }); +}); + describe('getDomainPaddingForEdgeSpace', () => { it('returns the padding victory-native shrinks back to the requested edge space', () => { // Given a 200px plot and the space wanted before the first and after the last point @@ -775,3 +800,95 @@ describe('getDomainPaddingForEdgeSpace', () => { expect(getDomainPaddingForEdgeSpace({left: 30, right: 0}, 0)).toEqual({left: 30, right: 0}); }); }); + +describe('getAxisLabelStep', () => { + const MIN_GAP = 24; + + const getEvenPointXs = (count: number, spacing: number) => Array.from({length: count}, (_, index) => index * spacing); + + it('labels every point when neighboring labels keep the minimum gap', () => { + // Given 20px labels on points 50px apart, which leaves a 30px gap between neighbors + const pointXs = getEvenPointXs(5, 50); + + // When picking the label step + const step = getAxisLabelStep({labelWidths: Array(5).fill(20), pointXs, steps: [1, 2, 7, 14], minGap: MIN_GAP}); + + // Then the smallest step is used, so no label is hidden needlessly + expect(step).toBe(1); + }); + + it('moves to the next listed step when neighboring labels are too close', () => { + // Given 30px labels on points 50px apart, which leaves only a 20px gap between neighbors + const pointXs = getEvenPointXs(5, 50); + + // When picking the label step + const step = getAxisLabelStep({labelWidths: Array(5).fill(30), pointXs, steps: [1, 2, 7, 14], minGap: MIN_GAP}); + + // Then every other point is labeled, which widens the gap to 70px + expect(step).toBe(2); + }); + + it('accepts a gap exactly equal to the minimum gap', () => { + // Given 26px labels on points 50px apart, which leaves exactly a 24px gap between neighbors + const pointXs = getEvenPointXs(5, 50); + + // When picking the label step + const step = getAxisLabelStep({labelWidths: Array(5).fill(26), pointXs, steps: [1, 2], minGap: MIN_GAP}); + + // Then the minimum gap counts as enough room, so every point stays labeled + expect(step).toBe(1); + }); + + it('tries multiples of the last listed step when no listed step fits', () => { + // Given 30px labels on points 10px apart, too dense for steps 1 and 2 + const pointXs = getEvenPointXs(10, 10); + + // When picking the label step + const step = getAxisLabelStep({labelWidths: Array(10).fill(30), pointXs, steps: [1, 2], minGap: MIN_GAP}); + + // Then 4 is still too tight (10px gap), and 6 is the first multiple of 2 that leaves a 30px gap + expect(step).toBe(6); + }); + + it('labels only the first point when no step fits', () => { + // Given 3 points 10px apart with 30px labels, where even the first and last labels overlap + const pointXs = getEvenPointXs(3, 10); + + // When picking the label step + const step = getAxisLabelStep({labelWidths: Array(3).fill(30), pointXs, steps: [1], minGap: MIN_GAP}); + + // Then the step reaches past the last point, so only the first label is drawn instead of overlapping ones + expect(step).toBe(3); + }); + + it('labels only the first and last points when the only step spans the whole axis', () => { + // Given 5 points and the single step the narrow layout passes, points.length - 1 + const pointXs = getEvenPointXs(5, 50); + + // When picking the label step + const step = getAxisLabelStep({labelWidths: Array(5).fill(30), pointXs, steps: [4], minGap: MIN_GAP}); + + // Then that step is used, so the first and last points are labeled + expect(step).toBe(4); + }); + + it("checks each label's own width", () => { + // Given a wide first label that reaches the second label but clears the third + const pointXs = [0, 50, 100]; + + // When picking the label step + const step = getAxisLabelStep({labelWidths: [80, 20, 20], pointXs, steps: [1, 2], minGap: MIN_GAP}); + + // Then step 1 is rejected because of the first label's width, even though the narrow labels would fit + expect(step).toBe(2); + }); + + it('labels a single point with the smallest step', () => { + // Given one point, which has no neighbor its label could overlap + // When picking the label step + const step = getAxisLabelStep({labelWidths: [30], pointXs: [0], steps: [1, 2], minGap: MIN_GAP}); + + // Then the smallest step is used and the point stays labeled + expect(step).toBe(1); + }); +}); diff --git a/tests/unit/components/DynamicForm/DynamicFormFieldsPageTest.tsx b/tests/unit/components/DynamicForm/DynamicFormFieldsPageTest.tsx new file mode 100644 index 000000000000..22ef5a621c0c --- /dev/null +++ b/tests/unit/components/DynamicForm/DynamicFormFieldsPageTest.tsx @@ -0,0 +1,57 @@ +import {render} from '@testing-library/react-native'; + +import DynamicFormFieldsPage from '@components/DynamicForm/components/DynamicFormFieldsPage'; +import FormProvider from '@components/Form/FormProvider'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {DynamicFormPage} from '@src/types/onyx'; + +import React from 'react'; + +jest.mock('@components/Form/FormProvider', () => jest.fn(() => null)); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); + +const page: DynamicFormPage = {key: 'identity', title: 'Identity', content: [{key: 'ssn', type: 'text', required: true, sensitive: true}]}; + +function renderPage(isLastPage: boolean) { + render( + , + ); + return jest.mocked(FormProvider).mock.lastCall?.[0]; +} + +describe('DynamicFormFieldsPage', () => { + it('lets the user move on to the next page while offline', () => { + // Given a page that a later page or a confirmation page follows + // When it renders + const formProps = renderPage(false); + + // Then its button works offline, since moving between pages sends nothing + expect(formProps?.enabledWhenOffline).toBe(true); + }); + + it('submits the last page only while online', () => { + // Given the last page of a flow without a confirmation page, so its button submits the form + // When it renders + const formProps = renderPage(true); + + // Then its button is disabled offline, since an offline request is saved to disk with the sensitive answers it carries + expect(formProps?.enabledWhenOffline).toBe(false); + }); +}); diff --git a/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx b/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx index eb27f45c4136..8b0438eedf0f 100644 --- a/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx +++ b/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx @@ -9,11 +9,14 @@ import DatePicker from '@components/DatePicker'; import CurrencyInlineListAdapter from '@components/DynamicForm/adapters/CurrencyInlineListAdapter'; import FileUploadAdapter from '@components/DynamicForm/adapters/FileUploadAdapter'; import InlineSelectionListAdapter from '@components/DynamicForm/adapters/InlineSelectionListAdapter'; +import ListFieldAdapter from '@components/DynamicForm/adapters/ListFieldAdapter'; import TabsAdapter from '@components/DynamicForm/adapters/TabsAdapter'; import YesNoAdapter from '@components/DynamicForm/adapters/YesNoAdapter'; import DynamicFormFields from '@components/DynamicForm/components/DynamicFormFields'; import type {DynamicFormValues} from '@components/DynamicForm/types'; import getAddressInputKeys from '@components/DynamicForm/utils/getAddressInputKeys'; +import type {FormValue} from '@components/Form/types'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import PercentageForm from '@components/PercentageForm'; import PushRowWithModal from '@components/PushRowWithModal'; import RadioButtons from '@components/RadioButtons'; @@ -44,7 +47,7 @@ type CapturedInputProps = { maxFileSize?: number; accessibilityLabel?: string; currency?: string; - defaultValue?: string; + defaultValue?: unknown; onValueChange?: (value: string, key: string) => void; onBlur?: () => void; }; @@ -69,13 +72,28 @@ jest.mock('@hooks/useLocalize', () => jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); +type TestInstance = ReturnType; + +/** Whether a view around the element masks it in session recordings */ +function isMasked(element: TestInstance): boolean { + let ancestor: TestInstance | null = element; + while (ancestor) { + if (ancestor.props.fsClass === CONST.FULLSTORY.CLASS.MASK) { + return true; + } + ancestor = ancestor.parent; + } + return false; +} + type RenderFieldsOptions = { currency?: string; maxFileSize?: number; onRefreshRequirements?: (inputID: string, value: unknown) => void; + isLoneFieldInHeadline?: boolean; }; -function renderFields(content: DynamicFormContentItem[], values: DynamicFormValues = {}, {currency, maxFileSize, onRefreshRequirements}: RenderFieldsOptions = {}) { +function renderFields(content: DynamicFormContentItem[], values: DynamicFormValues = {}, {currency, maxFileSize, onRefreshRequirements, isLoneFieldInHeadline}: RenderFieldsOptions = {}) { mockInputWrapper.mockClear(); render( , ); return new Map(mockInputWrapper.mock.calls.map(([props]) => [props.inputID, props])); @@ -98,7 +117,7 @@ const EXPECTED_INPUT_BY_TYPE: Record> select: ValuePicker, radio: RadioButtons, date: DatePicker, - boolean: CheckboxWithLabel, + boolean: YesNoAdapter, country: CountryPicker, currency: CurrencyPicker, address: AddressSearch, @@ -107,21 +126,41 @@ const EXPECTED_INPUT_BY_TYPE: Record> file: FileUploadAdapter, amount: AmountForm, percent: PercentageForm, + list: ListFieldAdapter, }; const FIELD_TYPES = Object.keys(EXPECTED_INPUT_BY_TYPE).filter((type): type is DynamicFormFieldType => Object.hasOwn(EXPECTED_INPUT_BY_TYPE, type)); +const SENSITIVE_ANSWER_BY_TYPE: Record = { + text: 'Jane', + number: '123456789', + select: 'SAVINGS', + radio: 'SAVINGS', + multiselect: ['RETAIL'], + date: '1990-05-01', + address: '1 Main St', + file: [{name: 'passport.pdf', uri: 'file://passport.pdf'}], + amount: '1000', + country: 'PL', + countryMultiselect: ['PL', 'DE'], + boolean: true, + currency: 'EUR', + percent: '25', + list: [{id: 'a'}], +}; + describe('DynamicFormFields', () => { it.each(FIELD_TYPES)('renders a %s field with its input', (type) => { // Given a field of one type with nothing but the required properties, next to another question so it is drawn as a row - const field: DynamicFormField = {key: 'answer', type, required: false}; + const field: DynamicFormField = type === 'list' ? {key: 'answer', type, required: false, itemFields: []} : {key: 'answer', type, required: false}; // When it renders const rendered = renderFields([field, otherQuestion]); - // Then it is drawn by the input its type maps to, which is what a schema without layout hints gets, and its answer is masked in session recordings + // Then it is drawn by the input its type maps to, which is what a schema without layout hints gets, and its answer is masked in session recordings, including by inputs that do not pass the FullStory class on expect(rendered.get('answer')?.InputComponent).toBe(EXPECTED_INPUT_BY_TYPE[type]); expect(rendered.get('answer')?.forwardedFSClass).toBe(CONST.FULLSTORY.CLASS.MASK); + expect(isMasked(screen.UNSAFE_getByProps({inputID: 'answer'}))).toBe(true); }); it('keeps sensitive answers out of the draft', () => { @@ -142,6 +181,58 @@ describe('DynamicFormFields', () => { expect(rendered.get('nationality')?.shouldSaveDraft).toBe(true); }); + it.each(FIELD_TYPES)('refills a sensitive %s field from the answer kept in memory', (type) => { + // Given a sensitive field of one type, whose answer is kept in memory instead of in the draft + const field: DynamicFormField = type === 'list' ? {key: 'answer', type, required: true, sensitive: true, itemFields: []} : {key: 'answer', type, required: true, sensitive: true}; + const answer = SENSITIVE_ANSWER_BY_TYPE[type]; + + // When the user comes back to its page + const rendered = renderFields([field, otherQuestion], {answer}); + + // Then the input starts from that answer, so the user does not have to give it again + expect(rendered.get('answer')?.defaultValue).toEqual(answer); + }); + + it('leaves a regular field to its draft when the user comes back to its page', () => { + // Given a regular field with an answer, which FormProvider refills from the draft + const nationality: DynamicFormField = {key: 'nationality', type: 'country', required: true}; + + // When its page renders + const rendered = renderFields([nationality, otherQuestion], {nationality: 'PL'}); + + // Then the input gets no default of its own, so the draft stays the one source of its answer + expect(rendered.get('nationality')?.defaultValue).toBeUndefined(); + }); + + it('refills every part of a sensitive address from memory', () => { + // Given a sensitive address whose parts are kept in memory + const homeAddress: DynamicFormField = {key: 'homeAddress', type: 'address', required: true, sensitive: true}; + const keys = getAddressInputKeys('homeAddress'); + const values = {homeAddress: '1 Main St', [keys.city]: 'Austin', [keys.state]: 'TX', [keys.zipCode]: '78701', [keys.country]: CONST.COUNTRY.US}; + + // When the user comes back to its page + const rendered = renderFields([homeAddress], values); + + // Then every part starts from its answer, so none of them is asked again + expect(rendered.get(keys.street)?.defaultValue).toBe('1 Main St'); + expect(rendered.get(keys.city)?.defaultValue).toBe('Austin'); + expect(rendered.get(keys.state)?.defaultValue).toBe('TX'); + expect(rendered.get(keys.zipCode)?.defaultValue).toBe('78701'); + expect(rendered.get(keys.country)?.defaultValue).toBe(CONST.COUNTRY.US); + }); + + it('refills the currency of a sensitive amount from memory', () => { + // Given a sensitive amount whose currency the user picked, both kept in memory + const expectedVolume: DynamicFormField = {key: 'expectedVolume', type: 'amount', required: true, sensitive: true, currencyKey: 'expectedVolumeCurrency'}; + + // When the user comes back to its page + const rendered = renderFields([expectedVolume], {expectedVolume: '1000', expectedVolumeCurrency: CONST.CURRENCY.EUR}, {currency: CONST.CURRENCY.GBP}); + + // Then the picker starts from the picked currency rather than the screen currency + expect(rendered.get('expectedVolumeCurrency')?.defaultValue).toBe(CONST.CURRENCY.EUR); + expect(rendered.get('expectedVolume')?.defaultValue).toBe('1000'); + }); + it('offers the options picked by the answer a select depends on', () => { // Given an account type whose options depend on the recipient type const accountType: DynamicFormField = { @@ -177,6 +268,18 @@ describe('DynamicFormFields', () => { expect(Object.keys(rendered.get('industry')?.optionsList ?? {})).toHaveLength(options.length); }); + it('keeps a select as a picker up to the number of options a shared page takes', () => { + // Given a select with as many options as pagination still lets share a page, next to another question + const options = Array.from({length: CONST.DYNAMIC_FORM.LARGE_SELECT_MIN_OPTIONS}, (_, index) => ({key: `option${index}`, label: `Option ${index}`})); + const accountType: DynamicFormField = {key: 'accountType', label: 'Account type', type: 'select', required: true, values: options}; + + // When the fields render + const rendered = renderFields([accountType, otherQuestion]); + + // Then it stays a picker, since a searchable list is kept for selects that pagination puts on their own page + expect(rendered.get('accountType')?.InputComponent).toBe(ValuePicker); + }); + it('hints a text field with its example when it has no description', () => { // Given a text field with an example and no description const accountNumber: DynamicFormField = {key: 'accountNumber', type: 'text', required: true, example: '12345678'}; @@ -232,9 +335,10 @@ describe('DynamicFormFields', () => { // When the fields render const rendered = renderFields([readonlyCountry], {nationality: 'GB'}); - // Then no input is drawn, and the row shows the translated country + // Then no input is drawn, and the row shows the translated country, masked in session recordings since a prefilled answer is still personal expect(rendered.size).toBe(0); expect(screen.getByText('allCountries.GB')).toBeOnTheScreen(); + expect(isMasked(screen.UNSAFE_getByType(MenuItemField))).toBe(true); }); it('draws a heading with its description above the fields that follow it', () => { @@ -476,6 +580,33 @@ describe('DynamicFormFields', () => { expect(screen.getByText('25%')).toBeOnTheScreen(); }); + it('shows a readonly list as the names of its entries', () => { + // Given a readonly list of owners holding two entries + const owners: DynamicFormField = { + key: 'owners', + type: 'list', + required: true, + readonly: true, + itemFields: [ + {key: 'firstName', type: 'text', required: true}, + {key: 'lastName', type: 'text', required: true}, + ], + }; + const values = { + owners: [ + {id: 'jane', firstName: 'Jane', lastName: 'Doe'}, + {id: 'john', firstName: 'John', lastName: 'Roe'}, + ], + } as DynamicFormValues; + + // When it renders + const rendered = renderFields([owners], values); + + // Then no input is drawn, and the row names every owner, since a readonly list cannot be opened to see them + expect(rendered.size).toBe(0); + expect(screen.getByText('Jane Doe, John Roe')).toBeOnTheScreen(); + }); + it.each([ ['select', InlineSelectionListAdapter, {valueType: 'string'}], ['multiselect', InlineSelectionListAdapter, {canSelectMultiple: true, valueType: 'stringList'}], @@ -483,11 +614,11 @@ describe('DynamicFormFields', () => { ['countryMultiselect', InlineSelectionListAdapter, {canSelectMultiple: true, valueType: 'stringList', isSearchable: true}], ['currency', CurrencyInlineListAdapter, {}], ] as const)('draws a %s field alone on its page as the page itself', (type, ExpectedInput, expectedProps) => { - // Given the only question on the page + // Given the only question on a page without its own title, so the page is titled after the question const field: DynamicFormField = {key: 'answer', label: 'The question', type, required: true}; // When it renders - const rendered = renderFields([field]); + const rendered = renderFields([field], {}, {isLoneFieldInHeadline: true}); // Then the choice is drawn as the page itself, keeping its kind of answer, and the label is left to the page title instead of repeating it expect(rendered.get('answer')?.InputComponent).toBe(ExpectedInput); @@ -495,6 +626,18 @@ describe('DynamicFormFields', () => { expect(screen.queryByText('The question')).not.toBeOnTheScreen(); }); + it.each(['select', 'multiselect', 'country', 'currency', 'boolean', 'radio', 'list'] as const)('shows the question above a lone %s field on a page with its own title', (type) => { + // Given the only question on a page whose own title, such as "Ownership", does not ask it + const field: DynamicFormField = + type === 'list' ? {key: 'answer', label: 'The question', type, required: true, itemFields: []} : {key: 'answer', label: 'The question', type, required: true}; + + // When it renders + renderFields([field]); + + // Then the question is drawn as a heading above the answers, since nothing else on the page says what they answer + expect(screen.getByRole(CONST.ROLE.HEADING, {name: 'The question'})).toBeOnTheScreen(); + }); + it('keeps a lone list as the page when it reveals a follow-up field', () => { // Given a source of wealth list whose "Other" answer reveals a description field const sourceOfWealth: DynamicFormField = {key: 'sourceOfWealth', type: 'select', required: true, values: [{key: 'SAVINGS'}, {key: 'OTHER'}]}; @@ -539,8 +682,8 @@ describe('DynamicFormFields', () => { }; const companyName: DynamicFormField = {key: 'companyName', type: 'text', required: true, showWhen: {key: 'legalType', equals: ['BUSINESS']}}; - // When it renders for a business recipient - const rendered = renderFields([legalType, companyName], {legalType: 'BUSINESS'}); + // When it renders for a business recipient on a page without its own title + const rendered = renderFields([legalType, companyName], {legalType: 'BUSINESS'}, {isLoneFieldInHeadline: true}); // Then it is still a tab row rather than an inline list, since the schema asked for tabs, and the question is left to the page title expect(rendered.get('legalType')?.InputComponent).toBe(TabsAdapter); @@ -563,18 +706,20 @@ describe('DynamicFormFields', () => { expect(lonePage.get('hasOtherOwners')?.accessibilityLabel).toBe('Does anyone else own 25% or more?'); }); - it('asks a lone boolean as Yes/No, and a boolean among other fields as a checkbox', () => { - // Given a consent boolean next to another question, and a boolean alone on its page - const acceptTerms: DynamicFormField = {key: 'acceptTerms', type: 'boolean', required: true}; - const isUSCitizen: DynamicFormField = {key: 'isUSCitizen', type: 'boolean', required: true}; + it('asks a boolean as Yes/No wherever it sits, and as a checkbox only when the schema asks for one', () => { + // Given a question next to another field, the same question alone on its page, and a consent checkbox + const ownsQuarter: DynamicFormField = {key: 'ownsQuarter', label: 'Do you own 25% or more?', type: 'boolean', required: true}; + const acceptTerms: DynamicFormField = {key: 'acceptTerms', type: 'boolean', required: true, presentation: 'checkbox'}; // When each renders + const sharedPage = renderFields([ownsQuarter, otherQuestion]); + const lonePage = renderFields([ownsQuarter]); const consent = renderFields([acceptTerms, otherQuestion]); - const question = renderFields([isUSCitizen]); - // Then the consent is a checkbox the user must tick, and the lone question offers No as an answer + // Then the question offers No as an answer on both pages, since how the fields are grouped into pages must not change what it asks, while the consent is a checkbox to tick + expect(sharedPage.get('ownsQuarter')?.InputComponent).toBe(YesNoAdapter); + expect(lonePage.get('ownsQuarter')?.InputComponent).toBe(YesNoAdapter); expect(consent.get('acceptTerms')?.InputComponent).toBe(CheckboxWithLabel); - expect(question.get('isUSCitizen')?.InputComponent).toBe(YesNoAdapter); }); it('asks for the state only in countries the state picker covers', () => { diff --git a/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx b/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx new file mode 100644 index 000000000000..6bf47853c506 --- /dev/null +++ b/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx @@ -0,0 +1,691 @@ +import {render} from '@testing-library/react-native'; + +import ActivityIndicator from '@components/ActivityIndicator'; +import DynamicFormFlow from '@components/DynamicForm'; +import type {DynamicFormLayout, DynamicFormSubPageProps} from '@components/DynamicForm/types'; +import {getListItems, getListItemSensitiveKey} from '@components/DynamicForm/utils/listItems'; +import type {FormValue} from '@components/Form/types'; +import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; + +import useSubPage from '@hooks/useSubPage'; +import type {SubPageProps} from '@hooks/useSubPage/types'; + +import Navigation from '@libs/Navigation/Navigation'; + +import {clearSensitiveAnswers, forgetSensitiveAnswers, saveDraftAnswers, saveSensitiveAnswers} from '@userActions/DynamicForm'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {DynamicFormPage, DynamicFormSchema} from '@src/types/onyx'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../../../utils/waitForBatchedUpdatesWithAct'; + +/** useSubPage types its page as taking SubPageProps alone, so the flow's props are checked for at run time */ +function hasDynamicFormProps(props: SubPageProps | undefined): props is DynamicFormSubPageProps { + return !!props && 'onPageSubmit' in props; +} + +const mockCurrentPage = jest.fn(() => null); +const mockPrevPage = jest.fn(); +const mockResetToPage = jest.fn(); +const mockMoveTo = jest.fn(); +let mockOnBackButtonPress: (() => void) | undefined; +const mockStepWrapper = jest.fn(() => null); + +jest.mock('@hooks/useSubPage', () => jest.fn()); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@userActions/DynamicForm', () => ({ + saveDraftAnswers: jest.fn(), + saveSensitiveAnswers: jest.fn(), + clearSensitiveAnswers: jest.fn(), + forgetSensitiveAnswers: jest.fn(), +})); +jest.mock('@libs/Navigation/Navigation', () => ({navigate: jest.fn(), goBack: jest.fn()})); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ActivityIndicator', () => jest.fn(() => null)); +jest.mock('@components/FullscreenLoadingIndicator', () => jest.fn(() => null)); +jest.mock('@components/InteractiveStepWrapper', () => jest.fn((props: {stepNames: string[]; startStepIndex: number}) => mockStepWrapper(props))); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => + jest.fn(({onBackButtonPress}: {onBackButtonPress: () => void}) => { + mockOnBackButtonPress = onBackButtonPress; + return null; + }), +); + +// Any form key works, since the draft is read as plain values. This one types its draft as any answers, list entries included. +const FORM_ID = ONYXKEYS.FORMS.DYNAMIC_FORM_LIST_ITEM_FORM; + +const personalDetails: DynamicFormPage = { + key: 'personal-details', + title: 'Personal details', + content: [ + {key: 'firstName', type: 'text', required: true}, + {key: 'ssn', type: 'text', required: true, sensitive: true}, + ], +}; +const recipient: DynamicFormPage = { + key: 'recipient', + title: 'Recipient', + content: [{key: 'legalType', type: 'radio', required: true, values: [{key: 'PRIVATE'}, {key: 'BUSINESS'}]}], +}; +const company: DynamicFormPage = { + key: 'company', + title: 'Company', + content: [{key: 'companyNumber', type: 'text', required: true, showWhen: {key: 'legalType', equals: ['BUSINESS']}}], +}; + +const schema: DynamicFormSchema = {pages: [personalDetails, recipient, company]}; +const schemaWithoutSsn: DynamicFormSchema = {pages: [{...personalDetails, content: personalDetails.content.slice(0, 1)}, recipient, company]}; + +const buildRoute = (pageName: string, action?: 'edit') => ROUTES.BANK_ACCOUNT_PERSONAL.getRoute(pageName, action); + +type RenderFlowOptions = { + flowSchema?: DynamicFormSchema; + draft?: Record; + sensitiveAnswers?: Record; + pageIndex?: number; + currentPageName?: string; + isRedirecting?: boolean; + /** `default` leaves the prop out, so the flow's own default applies. Tests that do not care about the confirmation page turn it off. */ + hasConfirmation?: boolean | 'default'; + layout?: DynamicFormLayout; + isSubmitting?: boolean; + shouldReplaceRoute?: boolean; + maxFileSize?: number; + onRefreshRequirements?: (inputID: string, value: FormValue) => void; +}; + +async function renderFlow({ + flowSchema = schema, + draft = {}, + sensitiveAnswers = {}, + pageIndex = 0, + currentPageName = 'personal-details', + isRedirecting = false, + hasConfirmation = false, + layout, + isSubmitting, + shouldReplaceRoute, + maxFileSize, + onRefreshRequirements, +}: RenderFlowOptions = {}) { + await Onyx.set(`${FORM_ID}Draft`, draft); + await Onyx.set(ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS, {[FORM_ID]: sensitiveAnswers}); + jest.mocked(useSubPage).mockReturnValue({ + CurrentPage: mockCurrentPage, + isEditing: false, + currentPageName, + pageIndex, + lastPageIndex: 3, + nextPage: jest.fn(), + prevPage: mockPrevPage, + moveTo: mockMoveTo, + resetToPage: mockResetToPage, + isRedirecting, + }); + const onSubmit = jest.fn(); + const onBack = jest.fn(); + const onPageSubmit = jest.fn(); + const renderWith = (submitting?: boolean) => ( + + ); + const {rerender, unmount} = render(renderWith(isSubmitting)); + const setSubmitting = async (submitting?: boolean) => { + rerender(renderWith(submitting)); + await waitForBatchedUpdatesWithAct(); + }; + await waitForBatchedUpdatesWithAct(); + const subPageOptions = jest.mocked(useSubPage).mock.lastCall?.[0]; + if (!subPageOptions) { + throw new Error('useSubPage was not called'); + } + const pageProps = mockCurrentPage.mock.lastCall?.[0]; + return {subPageOptions, setSubmitting, unmount, onSubmit, onBack, onPageSubmit, pageProps: hasDynamicFormProps(pageProps) ? pageProps : undefined}; +} + +describe('DynamicFormFlow', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + }); + + it('makes one page per schema page plus a confirmation page, and skips pages with nothing to ask', async () => { + // Given a private recipient, so the company page has no visible field + // When the flow renders with a confirmation page + const {subPageOptions} = await renderFlow({draft: {legalType: 'PRIVATE'}, hasConfirmation: true}); + + // Then every page is a route before the confirmation page, and the company page is skipped + expect(subPageOptions.pages.map((page) => page.pageName)).toEqual(['personal-details', 'recipient', 'company', 'confirm']); + expect(subPageOptions.skipPages).toEqual(['company']); + }); + + it('asks for a confirmation page unless the screen turns it off', async () => { + // Given the same schema, once with the default and once with the confirmation page turned off + // When each flow renders + const defaultFlow = await renderFlow({hasConfirmation: 'default'}); + const withoutConfirmation = await renderFlow({hasConfirmation: false}); + + // Then only the first ends on a confirmation page, since a flow of any length has answers worth reviewing + expect(defaultFlow.subPageOptions.pages.at(-1)?.pageName).toBe('confirm'); + expect(withoutConfirmation.subPageOptions.pages.map((page) => page.pageName)).toEqual(['personal-details', 'recipient', 'company']); + }); + + it('still lands on a confirmation page when the schema has no page and none was asked for', async () => { + // Given a schema with no page, and a screen that turned the confirmation page off + // When the flow renders + const {subPageOptions} = await renderFlow({flowSchema: {pages: []}, hasConfirmation: false}); + + // Then the confirmation page is the only page, since useSubPage throws when every page is skipped + expect(subPageOptions.pages.map((page) => page.pageName)).toEqual(['confirm']); + }); + + it('draws the confirmation page when the schema has no page', async () => { + // Given a schema with no page and a confirmation page + // When the flow renders + await renderFlow({flowSchema: {pages: []}, hasConfirmation: true, currentPageName: 'confirm'}); + + // Then the confirmation page is drawn instead of a loading indicator the user could never leave + expect(mockCurrentPage).toHaveBeenCalled(); + }); + + it('keeps the sensitive answers when a page of the flow unmounts', async () => { + // Given a flow where every page is its own screen, so moving between pages unmounts one instance + const {unmount} = await renderFlow(); + jest.mocked(clearSensitiveAnswers).mockClear(); + + // When a page instance unmounts while the user is still in the flow + unmount(); + + // Then the sensitive answers stay, so Back and Next do not wipe what the user already entered + expect(clearSensitiveAnswers).not.toHaveBeenCalled(); + }); + + it('paginates a flat list of fields', async () => { + // Given a schema of fields with no pages, one of them an address that needs a page of its own + const flowSchema: DynamicFormSchema = { + fields: [ + {key: 'firstName', type: 'text', required: true}, + {key: 'lastName', type: 'text', required: true}, + {key: 'home', type: 'address', required: true}, + ], + }; + + // When the flow renders + const {subPageOptions} = await renderFlow({flowSchema, currentPageName: 'page-1'}); + + // Then the pages get stable names + expect(subPageOptions.pages.map((page) => page.pageName)).toEqual(['page-1', 'page-2']); + }); + + it('submits from the last page, with its answers, when no confirmation page follows', async () => { + // Given the recipient page, the last one shown while no business recipient is chosen, with the first page answered + const {pageProps, subPageOptions, onSubmit} = await renderFlow({draft: {firstName: 'Jane'}, sensitiveAnswers: {ssn: '123456789'}, pageIndex: 1, currentPageName: 'recipient'}); + + // When the user picks a private recipient, and useSubPage finishes the flow with the page's answers + subPageOptions.onFinished({legalType: 'PRIVATE'}); + + // Then the page is marked last, so it shows Confirm, and the submission holds its answer, which the draft does not have yet + expect(pageProps?.isLastPage).toBe(true); + expect(onSubmit).toHaveBeenCalledWith({firstName: 'Jane', legalType: 'PRIVATE', ssn: '123456789'}); + }); + + it('shows a step indicator from three pages on, unless the layout turns it off', async () => { + // Given a business recipient, so all three pages are shown + const draft = {legalType: 'BUSINESS'}; + + // When the flow renders with the default layout, and then with plain pages + await renderFlow({draft, pageIndex: 1, currentPageName: 'recipient'}); + const autoLayout = mockStepWrapper.mock.lastCall?.[0]; + mockStepWrapper.mockClear(); + await renderFlow({draft, layout: 'pages'}); + + // Then the default layout has a step per page and highlights the current one, and plain pages have no indicator + expect(autoLayout).toEqual(expect.objectContaining({stepNames: ['personal-details', 'recipient', 'company'], startStepIndex: 1})); + expect(mockStepWrapper).not.toHaveBeenCalled(); + }); + + it('keys the steps of pages with no title by their page keys', async () => { + // Given pages built from a flat list, which carry no title, where two pages ask several questions and so have no headline either + const flowSchema: DynamicFormSchema = { + fields: [ + {key: 'home', type: 'address', required: true, label: 'Home address'}, + {key: 'firstName', type: 'text', required: true, label: 'First name'}, + {key: 'lastName', type: 'text', required: true, label: 'Last name'}, + {key: 'work', type: 'address', required: true, label: 'Work address'}, + {key: 'company', type: 'text', required: true, label: 'Company'}, + {key: 'role', type: 'text', required: true, label: 'Role'}, + ], + }; + + // When the flow renders with the stepper + await renderFlow({flowSchema, layout: 'stepper', currentPageName: 'page-1'}); + + // Then every step has a distinct name, since the indicator keys its steps by them + expect(mockStepWrapper.mock.lastCall?.[0].stepNames).toEqual(['page-1', 'page-2', 'page-3', 'page-4']); + }); + + it('starts a new form on its first page that has something to ask', async () => { + // Given an empty draft, and a form whose first page only asks a follow-up to a later answer + const followUpFirst: DynamicFormSchema = {pages: [company, personalDetails, recipient]}; + + // When the flow renders each + const newForm = (await renderFlow()).subPageOptions; + const skippedFirstPage = (await renderFlow({flowSchema: followUpFirst})).subPageOptions; + + // Then the first form starts on page one, and the other skips its empty first page instead of showing it + expect(newForm.startFrom).toBe(0); + expect(skippedFirstPage.startFrom).toBe(1); + }); + + it('resumes a started form on the first page its draft leaves incomplete', async () => { + // Given a form without sensitive fields whose first page is answered and whose recipient page is not + // When the flow renders + const {subPageOptions} = await renderFlow({flowSchema: schemaWithoutSsn, draft: {firstName: 'Jane'}}); + + // Then it resumes on the recipient page, so the user does not walk through answered pages again + expect(subPageOptions.startFrom).toBe(1); + }); + + it('resumes a complete form without a confirmation page on its last page', async () => { + // Given a complete form without sensitive fields or a confirmation page + // When the flow renders + const {subPageOptions} = await renderFlow({flowSchema: schemaWithoutSsn, draft: {firstName: 'Jane', legalType: 'PRIVATE'}}); + + // Then it opens on the recipient page, the last one this recipient sees + expect(subPageOptions.startFrom).toBe(1); + }); + + it('opens the confirmation page when the draft completes every page', async () => { + // Given a complete form without sensitive fields + // When the flow renders with a confirmation page + const {subPageOptions} = await renderFlow({flowSchema: schemaWithoutSsn, draft: {firstName: 'Jane', legalType: 'PRIVATE'}, hasConfirmation: true}); + + // Then it opens on the confirmation page, where the user only has to confirm + expect(subPageOptions.startFrom).toBe(3); + }); + + it('skips a page whose own showWhen does not match, and resume passes over it', async () => { + // Given a page shown only for business recipients, holding a field the draft never answered + const businessPage: DynamicFormPage = {key: 'business', title: 'Business', showWhen: {key: 'legalType', equals: ['BUSINESS']}, content: [{key: 'ein', type: 'text', required: true}]}; + const flowSchema: DynamicFormSchema = {pages: [{...personalDetails, content: personalDetails.content.slice(0, 1)}, recipient, businessPage]}; + + // When the flow renders for a private recipient with a confirmation page + const {subPageOptions} = await renderFlow({flowSchema, draft: {firstName: 'Jane', legalType: 'PRIVATE'}, hasConfirmation: true}); + + // Then the page is skipped and its missing answer does not stop resume from opening the confirmation page + expect(subPageOptions.skipPages).toEqual(['business']); + expect(subPageOptions.startFrom).toBe(3); + }); + + it('drops sensitive answers of an earlier visit and asks for them again on a new visit', async () => { + // Given a draft that completes the form, and an SSN kept in memory from an earlier visit + // When the flow opens with no page in the URL + const {subPageOptions} = await renderFlow({draft: {firstName: 'Jane', legalType: 'PRIVATE'}, sensitiveAnswers: {ssn: '123456789'}, isRedirecting: true}); + + // Then the old SSN is cleared and the visit starts on the SSN's page, since sensitive answers last one visit only + expect(clearSensitiveAnswers).toHaveBeenCalledWith(FORM_ID); + expect(subPageOptions.startFrom).toBe(0); + }); + + it('replaces a URL naming a skipped page with the next shown page', async () => { + // Given a private recipient, so the company page has nothing to ask, and a confirmation page after it + // When the URL names the company page + await renderFlow({draft: {legalType: 'PRIVATE'}, currentPageName: 'company', pageIndex: 2, hasConfirmation: true}); + + // Then the confirmation page takes the company page's place in the stack, so Back does not return to the empty page + expect(Navigation.navigate).toHaveBeenCalledWith(buildRoute('confirm'), {forceReplace: true}); + expect(mockResetToPage).not.toHaveBeenCalled(); + }); + + it('replaces a URL naming an unknown page with the page a new visit starts on', async () => { + // Given a new form + // When the URL names a page the form does not have, as an old link would + await renderFlow({currentPageName: 'removed-page', pageIndex: 0}); + + // Then the first page takes its place in the stack + expect(Navigation.navigate).toHaveBeenCalledWith(buildRoute('personal-details'), {forceReplace: true}); + }); + + it('drafts the page answers, keeps sensitive ones in memory, and hands the screen draft answers only', async () => { + // Given the first page + const {pageProps, onPageSubmit} = await renderFlow(); + const page = pageProps?.pages.at(0); + if (!pageProps || !page) { + throw new Error('No first page'); + } + + // When the user leaves it with Next, with FormProvider's values also holding another page's answer + pageProps.onPageSubmit(page, {firstName: 'Jane', ssn: '123456789', legalType: 'PRIVATE'}); + + // Then only this page's name is drafted, the SSN goes to memory only, and the screen never sees the SSN, since a page save would queue it + expect(saveDraftAnswers).toHaveBeenCalledWith(FORM_ID, {firstName: 'Jane'}); + expect(saveSensitiveAnswers).toHaveBeenCalledWith(FORM_ID, {ssn: '123456789'}); + expect(onPageSubmit).toHaveBeenCalledWith(page, {firstName: 'Jane'}); + }); + + it('submits the visible answers, sensitive ones included, from the confirmation page', async () => { + // Given a complete form with a stale company number left from a business recipient + const {subPageOptions, onSubmit} = await renderFlow({draft: {firstName: 'Jane', legalType: 'PRIVATE', companyNumber: '123'}, sensitiveAnswers: {ssn: '123456789'}}); + + // When the user confirms + subPageOptions.onFinished(); + + // Then the answers include the SSN from memory and leave out the hidden company number + expect(onSubmit).toHaveBeenCalledWith({firstName: 'Jane', legalType: 'PRIVATE', ssn: '123456789'}); + }); + + it('drops sensitive answers once they are submitted', async () => { + // Given a complete form with an SSN in memory, once submitted by a screen that reports no progress and once by one that does + const draft = {firstName: 'Jane', legalType: 'PRIVATE'}; + const sensitiveAnswers = {ssn: '123456789'}; + const untracked = await renderFlow({draft, sensitiveAnswers}); + + // When the user confirms on each + untracked.subPageOptions.onFinished(); + const clearedWithoutTracking = jest.mocked(clearSensitiveAnswers).mock.calls.length; + jest.mocked(clearSensitiveAnswers).mockClear(); + const tracked = await renderFlow({draft, sensitiveAnswers, isSubmitting: false}); + tracked.subPageOptions.onFinished(); + const clearedBeforeTheRequestEnds = jest.mocked(clearSensitiveAnswers).mock.calls.length; + await tracked.setSubmitting(true); + await tracked.setSubmitting(false); + + // Then the first drops the SSN right away, and the second only once its request ends without an error, so a retry still has it + expect(clearedWithoutTracking).toBe(1); + expect(clearedBeforeTheRequestEnds).toBe(0); + expect(clearSensitiveAnswers).toHaveBeenCalledWith(FORM_ID); + }); + + it('shows no page and submits nothing while every field is hidden', async () => { + // Given a form whose only field is shown for business recipients, and a private recipient + const flowSchema: DynamicFormSchema = {pages: [company]}; + + // When the flow renders + const {subPageOptions} = await renderFlow({flowSchema, draft: {legalType: 'PRIVATE'}, currentPageName: 'company'}); + + // Then no page is drawn and the hidden page is not skipped, since useSubPage needs a page to stay on + expect(mockCurrentPage).not.toHaveBeenCalled(); + expect(subPageOptions.skipPages).toEqual([]); + }); + + it('shows a spinner below the header while it loads, so no second Go Back button covers the header', async () => { + // Given a flow that is redirecting to another page + // When the flow renders + await renderFlow({isRedirecting: true}); + + // Then the header is drawn with a plain spinner under it instead of a page + expect(HeaderWithBackButtonAndTitle).toHaveBeenCalled(); + expect(ActivityIndicator).toHaveBeenCalled(); + expect(FullscreenLoadingIndicator).not.toHaveBeenCalled(); + expect(mockCurrentPage).not.toHaveBeenCalled(); + }); + + it('sends the user back to the incomplete page without pushing a second copy of it', async () => { + // Given a form whose SSN was lost when the app restarted + const {subPageOptions, onSubmit} = await renderFlow({draft: {firstName: 'Jane', legalType: 'PRIVATE'}}); + + // When the user confirms from the confirmation page + subPageOptions.onFinished(); + + // Then the stack goes back to the page instead of growing, and nothing is submitted + expect(Navigation.goBack).toHaveBeenCalledWith(buildRoute('personal-details')); + expect(mockResetToPage).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('resets to the incomplete page when routes are replaced, since there is no stack to go back in', async () => { + // Given the same lost SSN on a flow that replaces its routes + const {subPageOptions} = await renderFlow({draft: {firstName: 'Jane', legalType: 'PRIVATE'}, shouldReplaceRoute: true}); + + // When the user confirms + subPageOptions.onFinished(); + + // Then the page replaces the current route + expect(mockResetToPage).toHaveBeenCalledWith('personal-details'); + expect(Navigation.goBack).not.toHaveBeenCalled(); + }); + + it('forgets sensitive answers when the user leaves from the first page', async () => { + // Given the first page + const {onBack} = await renderFlow({pageIndex: 0}); + + // When the user presses back + mockOnBackButtonPress?.(); + + // Then the sensitive answers are cleared and the screen is left + expect(clearSensitiveAnswers).toHaveBeenCalledWith(FORM_ID); + expect(onBack).toHaveBeenCalled(); + expect(mockPrevPage).not.toHaveBeenCalled(); + }); + + it('goes back one page from a later page', async () => { + // Given the second page + await renderFlow({pageIndex: 1}); + + // When the user presses back + mockOnBackButtonPress?.(); + + // Then the previous page opens and the sensitive answers are kept + expect(mockPrevPage).toHaveBeenCalled(); + expect(clearSensitiveAnswers).not.toHaveBeenCalled(); + }); + + it('passes the file size limit on to the page', async () => { + // Given a screen that sets a file size limit + // When the flow renders + const {pageProps} = await renderFlow({maxFileSize: 2097152}); + + // Then the page gets it, so file fields can enforce it + expect(pageProps?.maxFileSize).toBe(2097152); + }); + + describe('with a list field', () => { + const directorsSchema: DynamicFormSchema = { + pages: [ + { + key: 'directors', + title: 'Directors', + content: [ + { + key: 'directors', + type: 'list', + required: true, + itemLabel: 'director', + itemFields: [ + {key: 'firstName', type: 'text', required: true}, + {key: 'ssn', type: 'text', required: false, sensitive: true}, + ], + }, + ], + }, + ], + }; + const jane = {id: 'jane', firstName: 'Jane'}; + + it('keeps the entry editors out of Next and Back', async () => { + // Given a list with one entry + // When the flow renders + const {subPageOptions} = await renderFlow({flowSchema: directorsSchema, draft: {directors: [jane]}, currentPageName: 'directors'}); + + // Then the editors of a new and of the existing entry are pages that only Add, Edit or a row can open + expect(subPageOptions.skipPages).toEqual(expect.arrayContaining(['directors~new', 'directors~jane'])); + }); + + it('opens the entry editor page when the user edits an entry', async () => { + // Given the list page with one entry + const {pageProps, subPageOptions} = await renderFlow({flowSchema: directorsSchema, draft: {directors: [jane]}, currentPageName: 'directors'}); + + // When the user edits the entry + pageProps?.onOpenListItemEditor('directors', 'jane'); + + // Then the entry's editor page opens, outside edit mode since the list page was not opened from the confirmation page + expect(mockMoveTo).toHaveBeenCalledWith( + subPageOptions.pages.findIndex((page) => page.pageName === 'directors~jane'), + false, + ); + }); + + it('drafts a new entry, keeps its SSN in memory, and returns to the list page', async () => { + // Given the editor of a new director + const {pageProps} = await renderFlow({flowSchema: directorsSchema, currentPageName: 'directors~new'}); + + // When the user saves it + pageProps?.onListItemSave('directors', undefined, {firstName: 'John', ssn: '123456789'}); + + // Then the entry is added to the draft without its SSN, the SSN is kept in memory under the entry, and the list page opens again + const draftedItems = getListItems(jest.mocked(saveDraftAnswers).mock.lastCall?.[1].directors); + expect(draftedItems.map((item) => item.firstName)).toEqual(['John']); + expect(draftedItems.at(0)?.ssn).toBeUndefined(); + expect(jest.mocked(saveSensitiveAnswers).mock.lastCall).toEqual([ + FORM_ID, + Object.fromEntries(draftedItems.map((item) => [getListItemSensitiveKey('directors', item.id, 'ssn'), '123456789'])), + ]); + expect(Navigation.goBack).toHaveBeenCalledWith(buildRoute('directors')); + }); + + it('forgets the SSN of a removed entry when the user leaves the list page', async () => { + // Given a director whose SSN is kept in memory, removed from the list on its page + const {pageProps} = await renderFlow({ + flowSchema: directorsSchema, + draft: {directors: [jane]}, + sensitiveAnswers: {[getListItemSensitiveKey('directors', 'jane', 'ssn')]: '123456789'}, + currentPageName: 'directors', + }); + const page = pageProps?.pages.at(0); + if (!pageProps || !page) { + throw new Error('No list page'); + } + + // When the user leaves the page with Next + pageProps.onPageSubmit(page, {directors: []}); + + // Then the removed director's SSN is dropped from memory instead of lasting the whole visit + expect(forgetSensitiveAnswers).toHaveBeenCalledWith(FORM_ID, [getListItemSensitiveKey('directors', 'jane', 'ssn')]); + }); + + it('gives the screen the list entries when the user leaves the list page, without their SSNs', async () => { + // Given a director whose SSN is kept in memory, on the list page + const {pageProps, onPageSubmit} = await renderFlow({ + flowSchema: directorsSchema, + draft: {directors: [jane]}, + sensitiveAnswers: {[getListItemSensitiveKey('directors', 'jane', 'ssn')]: '123456789'}, + currentPageName: 'directors', + }); + const page = pageProps?.pages.at(0); + if (!pageProps || !page) { + throw new Error('No list page'); + } + + // When the user leaves the page with Next + pageProps.onPageSubmit(page, {directors: [jane]}); + + // Then a screen saving page by page gets the director, while the SSN stays in memory, out of the request queue + expect(onPageSubmit).toHaveBeenCalledWith(page, {directors: [jane]}); + }); + + it('keeps a sensitive list in memory instead of the draft', async () => { + // Given the editor of a new director, in a list marked sensitive as a whole + const sensitiveSchema: DynamicFormSchema = { + pages: [{key: 'directors', content: [{key: 'directors', type: 'list', required: true, sensitive: true, itemFields: [{key: 'firstName', type: 'text', required: true}]}]}], + }; + const {pageProps} = await renderFlow({flowSchema: sensitiveSchema, currentPageName: 'directors~new'}); + jest.mocked(saveDraftAnswers).mockClear(); + + // When the user saves the director + pageProps?.onListItemSave('directors', undefined, {firstName: 'John'}); + + // Then the list goes to memory only, so no director reaches the device's storage + expect(saveDraftAnswers).not.toHaveBeenCalled(); + expect(getListItems(jest.mocked(saveSensitiveAnswers).mock.lastCall?.[1].directors).map((item) => item.firstName)).toEqual(['John']); + }); + + it('asks for fresh requirements when the user saves an entry of a list that refreshes them on change', async () => { + // Given the editor of a new director, in a list whose entries decide which questions come next + const refreshingSchema: DynamicFormSchema = { + pages: [ + {key: 'directors', content: [{key: 'directors', type: 'list', required: true, refreshOnChange: true, itemFields: [{key: 'firstName', type: 'text', required: true}]}]}, + ], + }; + const onRefreshRequirements = jest.fn(); + const {pageProps} = await renderFlow({flowSchema: refreshingSchema, draft: {directors: [jane]}, currentPageName: 'directors~new', onRefreshRequirements}); + + // When the user saves the director + pageProps?.onListItemSave('directors', undefined, {firstName: 'John'}); + + // Then the screen gets the updated list, as it does when an entry is removed, so the requirements match the entries + expect(onRefreshRequirements).toHaveBeenCalledTimes(1); + const refreshedItems = getListItems(onRefreshRequirements.mock.lastCall?.[1]); + expect(onRefreshRequirements.mock.lastCall?.[0]).toBe('directors'); + expect(refreshedItems.map((item) => item.firstName)).toEqual(['Jane', 'John']); + }); + + it('gives a readonly list no entry editors', async () => { + // Given a readonly list with one entry + const readonlySchema: DynamicFormSchema = { + pages: [{key: 'directors', content: [{key: 'directors', type: 'list', required: true, readonly: true, itemFields: [{key: 'firstName', type: 'text', required: true}]}]}], + }; + + // When the flow renders + const {subPageOptions} = await renderFlow({flowSchema: readonlySchema, draft: {directors: [jane]}, currentPageName: 'directors'}); + + // Then no editor page exists, so a link to one cannot edit an answer the schema locks + const pageNames = subPageOptions.pages.map((page) => page.pageName); + expect(pageNames).not.toContain('directors~new'); + expect(pageNames).not.toContain('directors~jane'); + }); + + it('returns from an entry editor to its list page on back', async () => { + // Given the editor of an existing entry + const {onBack} = await renderFlow({flowSchema: directorsSchema, draft: {directors: [jane]}, currentPageName: 'directors~jane', pageIndex: 2}); + + // When the user presses back + mockOnBackButtonPress?.(); + + // Then the list page opens, rather than the page before the editor or the screen before the flow + expect(Navigation.goBack).toHaveBeenCalledWith(buildRoute('directors')); + expect(onBack).not.toHaveBeenCalled(); + }); + + it('submits each entry with its sensitive answers', async () => { + // Given a director whose SSN is kept in memory + const {subPageOptions, onSubmit} = await renderFlow({ + flowSchema: directorsSchema, + draft: {directors: [jane]}, + sensitiveAnswers: {[getListItemSensitiveKey('directors', 'jane', 'ssn')]: '123456789'}, + currentPageName: CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY, + hasConfirmation: true, + }); + + // When the user confirms + subPageOptions.onFinished(); + + // Then the entry is submitted with its SSN back in place + expect(onSubmit).toHaveBeenCalledWith({directors: [{id: 'jane', firstName: 'Jane', ssn: '123456789'}]}); + }); + }); +}); diff --git a/tests/unit/components/DynamicForm/adapters/ListFieldAdapterTest.tsx b/tests/unit/components/DynamicForm/adapters/ListFieldAdapterTest.tsx new file mode 100644 index 000000000000..16c1da5a211c --- /dev/null +++ b/tests/unit/components/DynamicForm/adapters/ListFieldAdapterTest.tsx @@ -0,0 +1,177 @@ +import {act, render} from '@testing-library/react-native'; + +import ListFieldAdapter from '@components/DynamicForm/adapters/ListFieldAdapter'; +import type {ListFieldRow} from '@components/ListField'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; + +import {clearListItemEdit, startListItemEdit} from '@userActions/DynamicForm'; + +import type {DynamicFormField, DynamicFormListItem} from '@src/types/onyx'; + +import React from 'react'; + +type CapturedListFieldProps = { + rows: ListFieldRow[]; + canAddMore?: boolean; + onAdd: () => void; + onEdit: (id: string) => void; + onRemove: (id: string) => void; +}; + +type CapturedModalProps = { + isVisible: boolean; + isSensitive: boolean; + keptAnswers: Record; + onSave: (answers: Record) => void; + onClose: () => void; +}; + +type ConfirmModalOptions = { + title: string; +}; + +const mockShowConfirmModal = jest.fn, [ConfirmModalOptions]>(() => Promise.resolve({action: ModalActions.CONFIRM})); + +const mockListField = jest.fn(() => null); +const mockModal = jest.fn(() => null); + +jest.mock('@components/ListField', () => ({ + __esModule: true, + default: (props: CapturedListFieldProps) => mockListField(props), +})); +jest.mock('@components/DynamicForm/components/DynamicFormListItemModal', () => ({ + __esModule: true, + default: (props: CapturedModalProps) => mockModal(props), +})); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string, params?: unknown) => (params === undefined ? key : `${key} ${JSON.stringify(params)}`)}))); +jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: mockShowConfirmModal}))); +jest.mock('@userActions/DynamicForm', () => ({startListItemEdit: jest.fn(() => Promise.resolve()), clearListItemEdit: jest.fn()})); + +const itemFields: DynamicFormField[] = [{key: 'fullName', type: 'text', required: true}]; +const jane: DynamicFormListItem = {id: 'jane', fullName: 'Jane Doe'}; +const john: DynamicFormListItem = {id: 'john', fullName: 'John Roe'}; + +function renderAdapter(value: DynamicFormListItem[], onInputChange: (items: DynamicFormListItem[]) => void, maxItems?: number, isInFlow = true) { + render( + , + ); + const props = mockListField.mock.lastCall?.[0]; + if (!props) { + throw new Error('ListField did not render'); + } + return props; +} + +describe('ListFieldAdapter', () => { + it('shows each entry as a row named by its answers', () => { + // Given two directors + // When the list renders + const {rows} = renderAdapter([jane, john], jest.fn()); + + // Then each director is a row titled with their name, so the user can tell them apart + expect(rows.map((row) => [row.id, row.title])).toEqual([ + ['jane', 'Jane Doe'], + ['john', 'John Roe'], + ]); + }); + + it('gives FormProvider the list without the removed entry once the user confirms', async () => { + // Given two directors + const onInputChange = jest.fn(); + const {onRemove} = renderAdapter([jane, john], onInputChange); + + // When the user removes one and confirms + await act(async () => onRemove('jane')); + + // Then the form value is the whole list minus that director, and the unmasked modal title names no one, since the name is personal + expect(onInputChange).toHaveBeenCalledWith([john]); + expect(mockShowConfirmModal.mock.lastCall?.[0].title).not.toContain('Jane'); + }); + + it('keeps the entry when the user cancels the removal', async () => { + // Given two directors, and a user who will cancel the confirmation + const onInputChange = jest.fn(); + const {onRemove} = renderAdapter([jane, john], onInputChange); + mockShowConfirmModal.mockResolvedValueOnce({action: ModalActions.CLOSE}); + + // When the user taps remove on one + await act(async () => onRemove('jane')); + + // Then the list is left as it was + expect(onInputChange).not.toHaveBeenCalled(); + }); + + it('edits an entry in a modal outside the flow, filling a stored sensitive answer from the entry instead of the draft', async () => { + // Given a director with a stored SSN, in a list that no flow opens editor pages for + const onInputChange = jest.fn(); + const {onEdit} = renderAdapter([{...jane, ssn: '123456789'}], onInputChange, undefined, false); + + // When the user edits the director, renames them and saves with the SSN they were shown + await act(async () => onEdit('jane')); + const modal = mockModal.mock.lastCall?.[0]; + act(() => modal?.onSave({...modal.keptAnswers, fullName: 'Jane Smith'})); + + // Then the modal opened over the page with the SSN kept out of the editor draft, and the list holds the renamed director with the SSN still in place + expect(modal?.isVisible).toBe(true); + expect(startListItemEdit).toHaveBeenLastCalledWith({fullName: 'Jane Doe'}); + expect(modal?.keptAnswers).toEqual({ssn: '123456789'}); + expect(onInputChange).toHaveBeenCalledWith([{id: 'jane', fullName: 'Jane Smith', ssn: '123456789'}]); + }); + + it('drafts none of the answers of an entry of a sensitive list edited in the modal', async () => { + // Given a director in a list marked sensitive, which no flow opens editor pages for + render( + , + ); + + // When the user opens the director in the modal + await act(async () => mockListField.mock.lastCall?.[0].onEdit('jane')); + const modal = mockModal.mock.lastCall?.[0]; + + // Then the editor draft starts empty and the director's answers fill the inputs from the entry, so nothing of a sensitive list reaches the device + expect(startListItemEdit).toHaveBeenLastCalledWith({}); + expect(modal?.keptAnswers).toEqual({fullName: 'Jane Doe'}); + expect(modal?.isSensitive).toBe(true); + }); + + it('empties the editor draft when the modal closes', async () => { + // Given a director open in the modal editor outside the flow + const {onEdit} = renderAdapter([jane], jest.fn(), undefined, false); + await act(async () => onEdit('jane')); + jest.mocked(clearListItemEdit).mockClear(); + + // When the user closes the modal without saving + act(() => mockModal.mock.lastCall?.[0].onClose()); + + // Then the editor draft is emptied, so the director's answers do not stay on the device + expect(clearListItemEdit).toHaveBeenCalledTimes(1); + }); + + it('stops offering to add entries once the list is full', () => { + // Given a list allowing two directors, with two + // When it renders + const {canAddMore} = renderAdapter([jane, john], jest.fn(), 2); + + // Then the add row is hidden, as a third director could not be submitted + expect(canAddMore).toBe(false); + }); +}); diff --git a/tests/unit/components/DynamicForm/getConfirmationItemsTest.ts b/tests/unit/components/DynamicForm/getConfirmationItemsTest.ts new file mode 100644 index 000000000000..8005d857d06f --- /dev/null +++ b/tests/unit/components/DynamicForm/getConfirmationItemsTest.ts @@ -0,0 +1,131 @@ +import getConfirmationItems from '@components/DynamicForm/utils/getConfirmationItems'; +import {getAllFields} from '@components/DynamicForm/utils/paginate'; + +import type {DynamicFormPage} from '@src/types/onyx'; + +import {translateLocal} from '../../../utils/TestHelper'; + +const pages: DynamicFormPage[] = [ + { + key: 'identity', + title: 'Identity', + content: [ + {key: 'name', type: 'text', required: true, label: 'Name'}, + {key: 'ssn', type: 'text', required: true, label: 'SSN', sensitive: true}, + {key: 'country', type: 'text', required: false, label: 'Country', readonly: true}, + ], + }, + {key: 'business', title: 'Business', showWhen: {key: 'type', equals: ['BUSINESS']}, content: [{key: 'ein', type: 'text', required: true, label: 'EIN'}]}, +]; + +describe('getConfirmationItems', () => { + it('masks sensitive answers and opens the page of an editable row', () => { + // Given answers with an SSN + const onEditPage = jest.fn(); + const [group] = getConfirmationItems(pages, getAllFields(pages), {name: 'Ada', ssn: '123451234', country: 'US'}, translateLocal, onEditPage, jest.fn()); + + // When the user taps the SSN row + const ssnRow = group.rows.find((row) => row.id === 'ssn'); + ssnRow?.onPress(); + + // Then the SSN is masked to its last four digits and the page is opened + expect(ssnRow?.title).toBe('•••-••-1234'); + expect(onEditPage).toHaveBeenCalledWith(0); + }); + + it('shows a readonly row with no chevron and no action', () => { + // Given a readonly field + const onEditPage = jest.fn(); + const [group] = getConfirmationItems(pages, getAllFields(pages), {name: 'Ada', ssn: '123451234', country: 'US'}, translateLocal, onEditPage, jest.fn()); + + // When its row is tapped + const row = group.rows.find((candidate) => candidate.id === 'country'); + row?.onPress(); + + // Then it is not editable + expect(row).toMatchObject({shouldShowRightIcon: false}); + expect(onEditPage).not.toHaveBeenCalled(); + }); + + it('has no group for a page that is not shown', () => { + // Given a private recipient, so the business page is skipped + const values = {type: 'PRIVATE', name: 'Ada', ein: '1'}; + + // When the confirmation page lists the answers + const groups = getConfirmationItems(pages, getAllFields(pages), values, translateLocal, jest.fn(), jest.fn()); + + // Then only the identity page is summarized + expect(groups.map((group) => group.id)).toEqual(['identity']); + }); + + it('leaves the section of an untitled page with several questions unnamed', () => { + // Given a page the form built from a flat list, so it has no title, asking an account number and a currency + const untitledPages: DynamicFormPage[] = [ + { + key: 'page-1', + content: [ + {key: 'accountNumber', type: 'text', required: true, label: 'Account number'}, + {key: 'currency', type: 'currency', required: true, label: 'Settlement currency'}, + ], + }, + ]; + + // When the confirmation sections are built + const [group] = getConfirmationItems(untitledPages, getAllFields(untitledPages), {accountNumber: '12345678', currency: 'GBP'}, translateLocal, jest.fn(), jest.fn()); + + // Then the section has no name, since naming it after its first answer would mislabel the others + expect(group.name).toBe(''); + expect(group.rows.map((row) => row.id)).toEqual(['accountNumber', 'currency']); + }); + + it('shows each list entry as its own row that opens the entry', () => { + // Given a page with a list of directors holding one entry + const directorsPages: DynamicFormPage[] = [ + { + key: 'directors', + content: [ + { + key: 'directors', + type: 'list', + required: true, + itemFields: [ + {key: 'firstName', type: 'text', required: true}, + {key: 'lastName', type: 'text', required: true}, + ], + }, + ], + }, + ]; + const values = {directors: [{id: 'jane', firstName: 'Jane', lastName: 'Doe'}]}; + const onEditListItem = jest.fn(); + + // When the confirmation sections are built and the entry row is tapped + const row = getConfirmationItems(directorsPages, getAllFields(directorsPages), values, translateLocal, jest.fn(), onEditListItem).at(0)?.rows.at(0); + row?.onPress(); + + // Then the entry is an avatar row named by its leading text answers, and tapping it opens that entry rather than the page + expect(row).toMatchObject({kind: 'avatar', title: 'Jane Doe'}); + expect(onEditListItem).toHaveBeenCalledWith('directors', 'jane'); + }); + + it('shows an empty list as a row that opens its page', () => { + // Given a page with an optional list of directors that holds no entry + const directorsPages: DynamicFormPage[] = [ + { + key: 'directors', + content: [{key: 'directors', label: 'Directors', type: 'list', required: false, itemFields: [{key: 'firstName', type: 'text', required: true}]}], + }, + ]; + const onEditPage = jest.fn(); + const onEditListItem = jest.fn(); + + // When the confirmation sections are built and the list row is tapped + const row = getConfirmationItems(directorsPages, getAllFields(directorsPages), {directors: []}, translateLocal, onEditPage, onEditListItem).at(0)?.rows.at(0); + row?.onPress(); + + // Then the list still has a row, so the user can go back and add a director from the confirmation page + expect(row).toMatchObject({description: 'Directors', title: ''}); + expect(onEditPage).toHaveBeenCalledWith(0); + expect(onEditListItem).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/components/DynamicForm/getDynamicFieldErrorsTest.ts b/tests/unit/components/DynamicForm/getDynamicFieldErrorsTest.ts index 979dbd31801a..e7c3219ff6f3 100644 --- a/tests/unit/components/DynamicForm/getDynamicFieldErrorsTest.ts +++ b/tests/unit/components/DynamicForm/getDynamicFieldErrorsTest.ts @@ -1,5 +1,6 @@ import getAddressInputKeys from '@components/DynamicForm/utils/getAddressInputKeys'; import getDynamicFieldErrors from '@components/DynamicForm/utils/getDynamicFieldErrors'; +import {getListItemSensitiveKey} from '@components/DynamicForm/utils/listItems'; import Log from '@libs/Log'; import {getCountryZipRegexDetails} from '@libs/ValidationUtils'; @@ -48,7 +49,7 @@ describe('getDynamicFieldErrors', () => { it('treats an unchecked checkbox as unanswered', () => { // Given a required consent checkbox left unchecked, next to another question - const acceptTerms: DynamicFormField = {key: 'acceptTerms', type: 'boolean', required: true}; + const acceptTerms: DynamicFormField = {key: 'acceptTerms', type: 'boolean', required: true, presentation: 'checkbox'}; const companyName: DynamicFormField = {key: 'companyName', type: 'text', required: false}; // When the form is validated @@ -353,20 +354,68 @@ describe('getDynamicFieldErrors', () => { expect(incompleteErrors).toEqual({phone: translateLocal('common.error.phoneNumber')}); }); - it('accepts No as the answer to a lone boolean, but not to a checkbox among other fields', () => { - // Given a required boolean alone on its page, asked as Yes/No, and a required checkbox next to another question - const hasOtherOwners: DynamicFormField = {key: 'hasOtherOwners', type: 'boolean', required: true}; - const acceptTerms: DynamicFormField = {key: 'acceptTerms', type: 'boolean', required: true}; + it('accepts No as the answer to a Yes/No question wherever it sits, but not to a consent checkbox', () => { + // Given a required Yes/No question and a required consent checkbox, sharing a page with another question + const ownsQuarter: DynamicFormField = {key: 'ownsQuarter', type: 'boolean', required: true}; + const acceptTerms: DynamicFormField = {key: 'acceptTerms', type: 'boolean', required: true, presentation: 'checkbox'}; const fullName: DynamicFormField = {key: 'fullName', type: 'text', required: false}; - // When the question is answered No or not at all, and the checkbox is left empty - const noErrors = getDynamicFieldErrors([hasOtherOwners], {hasOtherOwners: false}, translateLocal); - const unansweredErrors = getDynamicFieldErrors([hasOtherOwners], {hasOtherOwners: ''}, translateLocal); + // When the question is answered No, alone and among other fields, or not at all, and the checkbox is left empty + const loneNoErrors = getDynamicFieldErrors([ownsQuarter], {ownsQuarter: false}, translateLocal); + const sharedNoErrors = getDynamicFieldErrors([ownsQuarter, fullName], {ownsQuarter: false}, translateLocal); + const unansweredErrors = getDynamicFieldErrors([ownsQuarter, fullName], {ownsQuarter: ''}, translateLocal); const consentErrors = getDynamicFieldErrors([acceptTerms, fullName], {acceptTerms: false}, translateLocal); - // Then No answers the question, while an unanswered question and an empty checkbox are flagged - expect(noErrors).toEqual({}); - expect(unansweredErrors).toEqual({hasOtherOwners: translateLocal('common.error.fieldRequired')}); + // Then No answers the question however the page is grouped, while an unanswered question and an empty checkbox are flagged + expect(loneNoErrors).toEqual({}); + expect(sharedNoErrors).toEqual({}); + expect(unansweredErrors).toEqual({ownsQuarter: translateLocal('common.error.fieldRequired')}); expect(consentErrors).toEqual({acceptTerms: translateLocal('common.error.fieldRequired')}); }); + + it('checks a list for its size and for the first entry with a problem', () => { + // Given a list of at least two directors, whose entries need a first name and may hold a sensitive SSN + const directors: DynamicFormField = { + key: 'directors', + type: 'list', + required: true, + itemLabel: 'director', + minItems: 2, + itemFields: [ + {key: 'firstName', type: 'text', required: true}, + {key: 'ssn', type: 'text', required: true, sensitive: true}, + ], + }; + + // When it holds no entry, and when it holds one entry without a first name + const emptyErrors = getDynamicFieldErrors([directors], {directors: []}, translateLocal); + const shortErrors = getDynamicFieldErrors([directors], {directors: [{id: 'jane', firstName: ''}]}, translateLocal); + + // Then the empty list is required, and the short list shows both the size and the entry's first problem, its missing name, under the item noun since the entry has no name to show + const missingNameError = translateLocal('dynamicForm.error.itemError', {item: 'director', error: translateLocal('common.error.fieldRequired')}); + expect(emptyErrors).toEqual({directors: translateLocal('common.error.fieldRequired')}); + expect(shortErrors).toEqual({directors: `${translateLocal('dynamicForm.error.tooFewItems', {min: 2})}\n${missingNameError}`}); + }); + + it('asks again for a sensitive entry answer the memory no longer holds', () => { + // Given a director whose required SSN is kept in memory apart from the drafted entry + const directors: DynamicFormField = { + key: 'directors', + type: 'list', + required: true, + itemFields: [ + {key: 'firstName', type: 'text', required: true}, + {key: 'ssn', type: 'text', required: true, sensitive: true}, + ], + }; + const draftValues = {directors: [{id: 'jane', firstName: 'Jane'}]}; + + // When the SSN is in memory, and when the memory was lost, as after the app restarts + const keptErrors = getDynamicFieldErrors([directors], {...draftValues, [getListItemSensitiveKey('directors', 'jane', 'ssn')]: '123456789'}, translateLocal); + const lostErrors = getDynamicFieldErrors([directors], draftValues, translateLocal); + + // Then the list passes while the SSN is kept, and asks for it once it is lost, naming Jane so the user knows which entry to open, so a director is never submitted without one + expect(keptErrors).toEqual({}); + expect(lostErrors).toEqual({directors: translateLocal('dynamicForm.error.itemError', {item: 'Jane', error: translateLocal('common.error.fieldRequired')})}); + }); }); diff --git a/tests/unit/components/DynamicForm/getSubmittedAnswersTest.ts b/tests/unit/components/DynamicForm/getSubmittedAnswersTest.ts new file mode 100644 index 000000000000..f8f25c655a7c --- /dev/null +++ b/tests/unit/components/DynamicForm/getSubmittedAnswersTest.ts @@ -0,0 +1,81 @@ +import getAddressInputKeys from '@components/DynamicForm/utils/getAddressInputKeys'; +import getSubmittedAnswers, {getAnswerKeys} from '@components/DynamicForm/utils/getSubmittedAnswers'; +import {getListItemSensitiveKey} from '@components/DynamicForm/utils/listItems'; + +import type {DynamicFormField} from '@src/types/onyx'; + +const cityKey = getAddressInputKeys('home').city; + +describe('getSubmittedAnswers', () => { + it('leaves out the answer of a field hidden by its controller', () => { + // Given a text field shown only for business, with an answer left over from an earlier choice + const fields: DynamicFormField[] = [ + {key: 'type', type: 'text', required: true}, + {key: 'ein', type: 'text', required: true, showWhen: {key: 'type', equals: ['BUSINESS']}}, + ]; + + // When the answers are collected for a private recipient + const answers = getSubmittedAnswers(fields, {type: 'PRIVATE', ein: '123'}); + + // Then the stale answer is not submitted + expect(answers).toEqual({type: 'PRIVATE'}); + }); + + it('uses the whole form to find a controller on another page', () => { + // Given a field whose controller sits on another page + const controller: DynamicFormField = {key: 'type', type: 'text', required: true}; + const dependent: DynamicFormField = {key: 'ein', type: 'text', required: true, showWhen: {key: 'type', equals: ['BUSINESS']}}; + + // When one page is collected with the whole form + const answers = getSubmittedAnswers([dependent], {type: 'BUSINESS', ein: '123'}, [controller, dependent]); + + // Then the field counts as visible + expect(answers).toEqual({ein: '123'}); + }); + + it('submits the address parts and the chosen currency along with the field', () => { + // Given an address and an amount that lets the user pick a currency + const fields: DynamicFormField[] = [ + {key: 'home', type: 'address', required: true}, + {key: 'fee', type: 'amount', required: true, currencyKey: 'feeCurrency'}, + ]; + + // When the answers are collected + const answers = getSubmittedAnswers(fields, {home: '1 Main St', [cityKey]: 'Austin', fee: '100', feeCurrency: 'EUR'}); + + // Then the currency is part of the answer, since the amount means nothing without it + expect(answers).toMatchObject({home: '1 Main St', [cityKey]: 'Austin', fee: '100', feeCurrency: 'EUR'}); + }); + + it('lists every answer key of a compound field', () => { + // Given an address field, whose answer is spread over several keys + const field: DynamicFormField = {key: 'home', type: 'address', required: true, sensitive: true}; + + // When the answer keys are listed + const keys = getAnswerKeys(field); + + // Then the address parts are included, so marking the field sensitive covers all of them + expect(keys).toContain('home'); + expect(keys).toContain(cityKey); + }); + + it('submits each list entry with its sensitive answers kept in memory', () => { + // Given a list of directors whose SSN is sensitive, so it is kept in memory under the entry instead of in the drafted entry + const directors: DynamicFormField = { + key: 'directors', + type: 'list', + required: true, + itemFields: [ + {key: 'firstName', type: 'text', required: true}, + {key: 'ssn', type: 'text', required: true, sensitive: true}, + ], + }; + const values = {directors: [{id: 'jane', firstName: 'Jane'}], [getListItemSensitiveKey('directors', 'jane', 'ssn')]: '123456789'}; + + // When the answers are collected + const answers = getSubmittedAnswers([directors], values); + + // Then the entry is sent whole, SSN included, without the memory key it was kept under + expect(answers).toEqual({directors: [{id: 'jane', firstName: 'Jane', ssn: '123456789'}]}); + }); +}); diff --git a/tests/unit/components/DynamicForm/maskSensitiveValueTest.ts b/tests/unit/components/DynamicForm/maskSensitiveValueTest.ts new file mode 100644 index 000000000000..2688332b3d05 --- /dev/null +++ b/tests/unit/components/DynamicForm/maskSensitiveValueTest.ts @@ -0,0 +1,26 @@ +import maskSensitiveValue from '@components/DynamicForm/utils/maskSensitiveValue'; + +describe('maskSensitiveValue', () => { + it('shows an SSN as •••-••-1234', () => { + // Given a full nine digit SSN + // When it is masked + // Then only the last four digits stay, in the usual SSN grouping, so the user can tell which number they entered + expect(maskSensitiveValue('123451234')).toBe('•••-••-1234'); + }); + + it('keeps the last four characters of any other value', () => { + // Given an account number that is not an SSN + // When it is masked + // Then everything but the last four characters is hidden + expect(maskSensitiveValue('00112233445')).toBe('•••••••3445'); + }); + + it('hides half of a short value', () => { + // Given values of four characters or fewer, such as a PIN + // When they are masked + // Then at most half of each stays visible, so a short answer is never shown in full + expect(maskSensitiveValue('1234')).toBe('••34'); + expect(maskSensitiveValue('12')).toBe('•2'); + expect(maskSensitiveValue('1')).toBe('•'); + }); +}); diff --git a/tests/unit/components/DynamicForm/paginateTest.ts b/tests/unit/components/DynamicForm/paginateTest.ts new file mode 100644 index 000000000000..a64f765a43b4 --- /dev/null +++ b/tests/unit/components/DynamicForm/paginateTest.ts @@ -0,0 +1,233 @@ +import getPageTitle, {getPageSectionName, isPageShown} from '@components/DynamicForm/utils/getPageTitle'; +import paginate, {getAllFields, getPages} from '@components/DynamicForm/utils/paginate'; + +import type {DynamicFormField, DynamicFormPage, DynamicFormSchemaField, DynamicFormTextField} from '@src/types/onyx'; + +import {translateLocal} from '../../../utils/TestHelper'; + +function text(key: string, extra: Partial = {}): DynamicFormTextField { + return {key, type: 'text', required: true, label: key, ...extra}; +} + +function options(count: number) { + return Array.from({length: count}, (_, index) => ({key: `o${index}`, label: `o${index}`})); +} + +function keysOf(pages: DynamicFormPage[]): string[][] { + return pages.map((page) => page.content.map((item) => item.key)); +} + +describe('paginate', () => { + it('puts each own-page type on a page of its own', () => { + // Given one field of every type that needs room + const fields: DynamicFormField[] = [ + {key: 'address', type: 'address', required: true}, + {key: 'file', type: 'file', required: true}, + {key: 'multi', type: 'multiselect', required: true, values: options(2)}, + {key: 'countries', type: 'countryMultiselect', required: true}, + {key: 'dependent', type: 'select', required: true, dependsOn: {key: 'country', valuesBy: {}}}, + ]; + + // When the fields are paginated + const pages = paginate(fields); + + // Then none of them shares a page, since each needs the whole screen + expect(keysOf(pages)).toEqual([['address'], ['file'], ['multi'], ['countries'], ['dependent']]); + }); + + it('keeps a small select with the other short questions but moves a large one out', () => { + // Given a small select and a select with more options than the threshold + const small: DynamicFormField = {key: 'small', type: 'select', required: true, values: options(3)}; + const large: DynamicFormField = {key: 'large', type: 'select', required: true, values: options(11)}; + + // When they are paginated between two text fields + const pages = paginate([text('a'), small, large, text('b')]); + + // Then the large select, which opens a searchable list, is alone and breaks the run + expect(keysOf(pages)).toEqual([['a', 'small'], ['large'], ['b']]); + }); + + it('starts a new page after four questions in a row', () => { + // Given five short questions + const fields = [text('a'), text('b'), text('c'), text('d'), text('e')]; + + // When they are split into pages + const pages = paginate(fields); + + // Then the fifth does not fit the shared page + expect(keysOf(pages)).toEqual([['a', 'b', 'c', 'd'], ['e']]); + }); + + it('keeps a field revealed by its controller on the controller page without counting it', () => { + // Given four questions where the second reveals a fifth field + const revealed = text('other', {showWhen: {key: 'b', equals: ['x']}}); + + // When they are paginated + const pages = paginate([text('a'), text('b'), revealed, text('c'), text('d')]); + + // Then all of them stay on one page, since the revealed field is not a question of its own + expect(keysOf(pages)).toEqual([['a', 'b', 'other', 'c', 'd']]); + }); + + it('keeps the fields an own-page controller reveals with it', () => { + // Given an own-page select that reveals a text field + const select: DynamicFormField = {key: 'dep', type: 'select', required: true, dependsOn: {key: 'x', valuesBy: {}}}; + + // When paginated with a field after the revealed one + const pages = paginate([select, text('why', {showWhen: {key: 'dep', equals: ['y']}}), text('z')]); + + // Then the revealed field follows its controller and the next question starts a new page + expect(keysOf(pages)).toEqual([['dep', 'why'], ['z']]); + }); + + it('leaves out fields of an unknown type', () => { + // Given a field of a type this App version does not know + const unknown = {key: 'x', type: 'hologram', required: true}; + + // When paginated + const pages = paginate([unknown, text('a')]); + + // Then it does not produce an empty page + expect(keysOf(pages)).toEqual([['a']]); + }); +}); + +describe('getPages', () => { + it('names pages built from fields page-N', () => { + // Given a flat schema + const fields: DynamicFormSchemaField[] = [text('a'), {key: 'address', type: 'address', required: true}]; + + // When its pages are built + const pages = getPages({fields}); + + // Then the pages get stable keys + expect(pages.map((page) => page.key)).toEqual(['page-1', 'page-2']); + }); + + it('turns keys into URL-safe slugs, reserves confirm and makes keys unique', () => { + // Given pages whose keys are not URL-safe, collide, or take the confirmation route + const content = [text('a')]; + const schemaPages: DynamicFormPage[] = [ + {key: 'Bank Info', content}, + {key: 'bank-info', content}, + {key: 'confirm', content}, + {key: '###', content}, + ]; + + // When the schema's pages are read + const pages = getPages({pages: schemaPages}); + + // Then each is a unique route segment + expect(pages.map((page) => page.key)).toEqual(['bank-info', 'bank-info-2', 'confirm-2', 'page-4']); + }); +}); + +describe('getPageTitle', () => { + it('prefers the page title, translated when it has a key', () => { + // Given a page with a titleKey + const page = {key: 'p', titleKey: 'common.next', content: [text('a')]} as DynamicFormPage; + + // When its title is read + const title = getPageTitle(page, {}, translateLocal); + + // Then the translation is used + expect(title).toBe(translateLocal('common.next')); + }); + + it('falls back to the label of the lone question, ignoring a field it reveals', () => { + // Given an untitled page with one question and a field it reveals + const page: DynamicFormPage = {key: 'p', content: [text('a', {label: 'Alpha'}), text('b', {label: 'Beta', showWhen: {key: 'a', equals: ['x']}})]}; + + // When the revealed field shows + const title = getPageTitle(page, {a: 'x'}, translateLocal); + + // Then the title is still the question's label + expect(title).toBe('Alpha'); + }); + + it("prefers the lone question's wording over its label", () => { + // Given an untitled page whose only question has its wording as a question + const page: DynamicFormPage = {key: 'p', content: [text('a', {label: 'Account number', question: "What's your account number?"})]}; + + // When its title is read + const title = getPageTitle(page, {}, translateLocal); + + // Then the headline asks the question, so it does not repeat the input's label + expect(title).toBe("What's your account number?"); + }); + + it('has no title when there are several questions', () => { + // Given an untitled page with several visible questions + const page: DynamicFormPage = {key: 'p', content: [text('a', {label: 'Alpha', showWhen: {key: 'z', equals: ['1']}}), text('b', {label: 'Beta'}), text('c', {label: 'Gamma'})]}; + + // When its title is read + const title = getPageTitle(page, {}, translateLocal); + + // Then it is blank, since no one question names the page + expect(title).toBe(''); + }); +}); + +describe('getPageSectionName', () => { + it('uses the page title', () => { + // Given a titled page + const page: DynamicFormPage = {key: 'p', title: 'Bank details', content: [text('a', {label: 'Alpha'})]}; + + // When its section name is read + const name = getPageSectionName(page, {}, translateLocal); + + // Then it is the page title + expect(name).toBe('Bank details'); + }); + + it("uses the lone question's wording on an untitled page", () => { + // Given an untitled page whose only question has its wording + const page: DynamicFormPage = {key: 'p', content: [text('a', {label: 'Account number', question: "What's your account number?"})]}; + + // When its section name is read + const name = getPageSectionName(page, {}, translateLocal); + + // Then it is that wording + expect(name).toBe("What's your account number?"); + }); + + it('never uses a label', () => { + // Given untitled pages with one question and with several, none with its wording + const lonePage: DynamicFormPage = {key: 'p', content: [text('a', {label: 'Account number'})]}; + const sharedPage: DynamicFormPage = {key: 'q', content: [text('a', {label: 'Account number'}), text('b', {label: 'Settlement currency'})]}; + + // When their section names are read + const names = [getPageSectionName(lonePage, {}, translateLocal), getPageSectionName(sharedPage, {}, translateLocal)]; + + // Then both are blank, since the label is already on the row below and would only name one of several rows + expect(names).toEqual(['', '']); + }); +}); + +describe('isPageShown', () => { + it('hides a page whose own showWhen does not match, using a field of another page', () => { + // Given a page shown only for business, controlled from another page + const type = text('type'); + const page: DynamicFormPage = {key: 'biz', showWhen: {key: 'type', equals: ['BUSINESS']}, content: [text('ein')]}; + const allFields = getAllFields([{key: 'first', content: [type]}, page]); + + // When the user answers private, and then business + const isShownForPrivate = isPageShown(page, {type: 'PRIVATE'}, allFields); + const isShownForBusiness = isPageShown(page, {type: 'BUSINESS'}, allFields); + + // Then it follows the answer + expect(isShownForPrivate).toBe(false); + expect(isShownForBusiness).toBe(true); + }); + + it('hides a page with no visible field', () => { + // Given a page whose only field is hidden + const page: DynamicFormPage = {key: 'p', content: [text('a', {showWhen: {key: 'z', equals: ['1']}})]}; + + // When the flow checks whether to show it + const isShown = isPageShown(page, {}, getAllFields([page])); + + // Then the page is skipped + expect(isShown).toBe(false); + }); +}); diff --git a/tests/unit/components/DynamicForm/summarizeListItemTest.ts b/tests/unit/components/DynamicForm/summarizeListItemTest.ts new file mode 100644 index 000000000000..3655c585538b --- /dev/null +++ b/tests/unit/components/DynamicForm/summarizeListItemTest.ts @@ -0,0 +1,72 @@ +import summarizeListItem from '@components/DynamicForm/utils/summarizeListItem'; + +import type {DynamicFormField} from '@src/types/onyx'; + +import {translateLocal} from '../../../utils/TestHelper'; + +describe('summarizeListItem', () => { + it('names an entry by its leading text answers and describes it with up to two short ones', () => { + // Given an owner with a name, an SSN, a date of birth and three short answers + const itemFields: DynamicFormField[] = [ + {key: 'firstName', type: 'text', required: true}, + {key: 'lastName', type: 'text', required: true}, + {key: 'ssn', type: 'text', required: true, sensitive: true}, + {key: 'dateOfBirth', type: 'date', required: true}, + {key: 'ownership', type: 'percent', required: true}, + {key: 'role', type: 'select', required: true, values: [{key: 'CEO', label: 'Chief executive'}]}, + {key: 'title', type: 'text', required: false}, + ]; + const owner = {id: 'jane', firstName: 'Jane', lastName: 'Doe', ssn: '123456789', dateOfBirth: '1980-04-19', ownership: '40', role: 'CEO', title: 'Founder'}; + + // When the entry is summarized + const summary = summarizeListItem(owner, itemFields, translateLocal, 'Owner'); + + // Then the name is the title, and the description holds the first two short answers, leaving out the SSN and the long date + expect(summary).toEqual({title: 'Jane Doe', description: '40%, Chief executive'}); + }); + + it('names an entry without text answers by its first answer', () => { + // Given an entry holding only a choice + const itemFields: DynamicFormField[] = [{key: 'role', type: 'select', required: true, values: [{key: 'CEO', label: 'Chief executive'}]}]; + + // When it is summarized + const summary = summarizeListItem({id: 'one', role: 'CEO'}, itemFields, translateLocal, 'Owner'); + + // Then the choice names the row, so it is never left without a title + expect(summary).toEqual({title: 'Chief executive', description: ''}); + }); + + it('names an entry whose shown answers are all sensitive by the fallback title', () => { + // Given an entry whose only answer is sensitive + const itemFields: DynamicFormField[] = [{key: 'ssn', type: 'text', required: true, sensitive: true}]; + + // When it is summarized + const summary = summarizeListItem({id: 'one', ssn: '123456789'}, itemFields, translateLocal, 'Owner'); + + // Then the fallback names the row, so its row and its Edit and Remove labels are never blank + expect(summary).toEqual({title: 'Owner', description: ''}); + }); + + it('leaves out answers the entry now hides', () => { + // Given a company entry still holding the name it had while it was an individual + const itemFields: DynamicFormField[] = [ + { + key: 'ownerType', + type: 'select', + required: true, + values: [ + {key: 'INDIVIDUAL', label: 'Individual'}, + {key: 'COMPANY', label: 'Company'}, + ], + }, + {key: 'firstName', type: 'text', required: true, showWhen: {key: 'ownerType', equals: ['INDIVIDUAL']}}, + {key: 'companyName', type: 'text', required: true, showWhen: {key: 'ownerType', equals: ['COMPANY']}}, + ]; + + // When it is summarized + const summary = summarizeListItem({id: 'acme', ownerType: 'COMPANY', firstName: 'Jane', companyName: 'Acme'}, itemFields, translateLocal, 'Owner'); + + // Then the hidden name is ignored, as validation and submission ignore it + expect(summary).toEqual({title: 'Acme', description: 'Company'}); + }); +}); diff --git a/tests/unit/components/DynamicForm/toDynamicFormValuesTest.ts b/tests/unit/components/DynamicForm/toDynamicFormValuesTest.ts index 373681ef6f2a..ea5f5453bfe9 100644 --- a/tests/unit/components/DynamicForm/toDynamicFormValuesTest.ts +++ b/tests/unit/components/DynamicForm/toDynamicFormValuesTest.ts @@ -8,6 +8,7 @@ describe('toDynamicFormValues', () => { accepted: true, countries: ['GB', 'US'], files: [{name: 'passport.pdf'}], + directors: [{id: 'jane', firstName: 'Jane'}], errors: {name: 'Required'}, errorList: [{message: 'Required'}], mixedList: ['GB', 1], @@ -18,7 +19,7 @@ describe('toDynamicFormValues', () => { const values = toDynamicFormValues(draft); // Then only the answers remain - expect(values).toEqual({name: 'Jane', accepted: true, countries: ['GB', 'US'], files: [{name: 'passport.pdf'}]}); + expect(values).toEqual({name: 'Jane', accepted: true, countries: ['GB', 'US'], files: [{name: 'passport.pdf'}], directors: [{id: 'jane', firstName: 'Jane'}]}); }); it('returns no answers for a missing draft', () => { diff --git a/tests/unit/components/ExpenseFieldDropdown.test.tsx b/tests/unit/components/ExpenseFieldDropdown.test.tsx index dfca3286fb12..a51d7f622ffb 100644 --- a/tests/unit/components/ExpenseFieldDropdown.test.tsx +++ b/tests/unit/components/ExpenseFieldDropdown.test.tsx @@ -4,6 +4,8 @@ import type {ExpenseFieldDropdownHandle, ExpenseFieldDropdownRenderProps} from ' import ExpenseFieldDropdown from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; import Text from '@components/Text'; +import getSelectionListPopoverContentHeight from '@libs/getSelectionListPopoverContentHeight'; + import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -74,7 +76,7 @@ describe('ExpenseFieldDropdown', () => { jest.restoreAllMocks(); }); - const renderField = (shouldOpenInDropdown = true, onPress: () => void = jest.fn()) => + const renderField = (shouldOpenInDropdown = true, onPress: () => void = jest.fn(), listHeaderHeight?: number) => render( { shouldOpenInDropdown={shouldOpenInDropdown} onPress={onPress} renderDropdown={renderDropdown} + listHeaderHeight={listHeaderHeight} />, ); + describe('with a header above the list', () => { + const HEADER_HEIGHT = variables.componentSizeNormal; + + it('grows the container by the header so it still fits as many options', () => { + // Given a row with plenty of room below, whose list shows a header above its options + mockRowAt(ROW_TOP); + renderField(true, jest.fn(), HEADER_HEIGHT); + + // When the row is pressed + act(() => pressRow()); + + // Then the container is taller by the header, so the header doesn't take the place of an option + expect(renderedProps?.popoverHeight).toBe(getSelectionListPopoverContentHeight({optionCount: 4}) + HEADER_HEIGHT); + }); + + it('falls back to the full page when the room left fits one option but not the header too', () => { + // Given a row with room below for one option, but not for one option and the header + const onPress = jest.fn(); + mockWindowHeight = ROW_TOP + ROW_HEIGHT + 6 + getSelectionListPopoverContentHeight({optionCount: 1}) + HEADER_HEIGHT / 2; + mockRowAt(ROW_TOP); + renderField(true, onPress, HEADER_HEIGHT); + + // When the row is pressed + act(() => pressRow()); + + // Then it opens the page, rather than a container showing the header and no categories + expect(onPress).toHaveBeenCalledTimes(1); + expect(renderCount).toBe(0); + }); + }); + describe('open()', () => { const renderFieldWithRef = (shouldOpenInDropdown: boolean, onPress: () => void) => { const ref = createRef(); diff --git a/tests/unit/components/ExpensifyCardStatementPDFDownloadModalWrapperTest.tsx b/tests/unit/components/ExpensifyCardStatementPDFDownloadModalWrapperTest.tsx new file mode 100644 index 000000000000..e28cfd6b6ace --- /dev/null +++ b/tests/unit/components/ExpensifyCardStatementPDFDownloadModalWrapperTest.tsx @@ -0,0 +1,59 @@ +import {act, render} from '@testing-library/react-native'; + +import ExpensifyCardStatementPDFDownloadModal from '@components/ExpensifyCardStatementPDFDownloadModal'; +import ExpensifyCardStatementPDFDownloadModalWrapper from '@components/Modal/Global/ExpensifyCardStatementPDFDownloadModalWrapper'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; + +import React from 'react'; + +jest.mock('@components/ExpensifyCardStatementPDFDownloadModal', () => jest.fn(() => null)); + +const STATEMENT_PARAMS = {policyID: 'policy1', feedCountry: 'US', entryIDs: [123], statementKey: 'statement-key'}; + +describe('ExpensifyCardStatementPDFDownloadModalWrapper', () => { + const mockedModal = jest.mocked(ExpensifyCardStatementPDFDownloadModal); + const getLatestModalProps = () => mockedModal.mock.calls.at(-1)?.[0]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('removes itself from the modal stack only after the modal has finished hiding', () => { + // Given the wrapper rendered by the global modal stack + const closeModal = jest.fn(); + render( + , + ); + + // Then it opens the statement modal with the params passed through the show API + expect(getLatestModalProps()?.isVisible).toBe(true); + expect(getLatestModalProps()?.statementParams).toEqual(STATEMENT_PARAMS); + + // When a hide event fires while the modal is still visible + act(() => { + getLatestModalProps()?.onModalHide?.(); + }); + + // Then the wrapper stays on the stack + expect(closeModal).not.toHaveBeenCalled(); + + // When the user closes the modal + act(() => { + getLatestModalProps()?.onClose(); + }); + + // Then it starts hiding but is not removed until the hide animation ends, so callers awaiting the show + // promise run their cleanup after the modal is gone + expect(getLatestModalProps()?.isVisible).toBe(false); + expect(closeModal).not.toHaveBeenCalled(); + + act(() => { + getLatestModalProps()?.onModalHide?.(); + }); + expect(closeModal).toHaveBeenCalledWith({action: ModalActions.CLOSE}); + }); +}); diff --git a/tests/unit/components/FeatureTrainingModalWrapperTest.tsx b/tests/unit/components/FeatureTrainingModalWrapperTest.tsx new file mode 100644 index 000000000000..cc9e337d68b5 --- /dev/null +++ b/tests/unit/components/FeatureTrainingModalWrapperTest.tsx @@ -0,0 +1,102 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import FeatureTraining from '@components/FeatureTraining'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import FeatureTrainingModalWrapper from '@components/Modal/Global/FeatureTrainingModalWrapper'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import type ReanimatedModalProps from '@components/Modal/ReanimatedModal/types'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import {translateLocal} from '../../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +let mockReanimatedModalProps: ReanimatedModalProps | undefined; + +// Only the animation layer is replaced, so the test can report the end of the hide animation. Everything above it, +// FeatureTrainingModal and its confirm button included, is the real tree. +jest.mock('@components/Modal/ReanimatedModal', () => ({ + __esModule: true, + default: (props: ReanimatedModalProps) => { + mockReanimatedModalProps = props; + return props.children; + }, +})); + +// Rendered without a NavigationContainer on purpose: the global modal stack sits above NavigationRoot, so the +// wrapper has to render and confirm without a navigator, as it does in the app +async function renderWrapper({closeModal, shouldCloseOnConfirm}: {closeModal: jest.Mock; shouldCloseOnConfirm?: boolean}) { + render( + + + + {translateLocal('common.buttonConfirm')} + + + , + ); + await waitForBatchedUpdatesWithAct(); +} + +async function pressConfirm() { + fireEvent.press(screen.getByText(translateLocal('common.buttonConfirm'))); + await waitForBatchedUpdatesWithAct(); +} + +describe('FeatureTrainingModalWrapper', () => { + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS}); + await IntlStore.load(CONST.LOCALES.EN); + }); + + beforeEach(() => { + mockReanimatedModalProps = undefined; + }); + + it('takes its entry off the stack only once, even if the user confirms twice', async () => { + // Given a wrapper on the global stack, outside any navigator, that leaves the stack as soon as the user confirms + const closeModal = jest.fn(); + await renderWrapper({closeModal, shouldCloseOnConfirm: false}); + + // When the user presses the confirm button twice before the stack removes the entry + await pressConfirm(); + await pressConfirm(); + + // Then the stack is popped once, with the confirm, so a modal underneath is not taken down by the second call + expect(closeModal).toHaveBeenCalledTimes(1); + expect(closeModal).toHaveBeenCalledWith({action: ModalActions.CONFIRM}); + }); + + it('waits for the modal to hide before taking a confirmed entry off the stack when the modal closes on confirm', async () => { + // Given a wrapper outside any navigator whose modal animates out on confirm, as feature training modals do by default + const closeModal = jest.fn(); + await renderWrapper({closeModal}); + + // When the user presses the confirm button + await pressConfirm(); + + // Then the modal starts hiding, but the entry stays on the stack while it does + expect(mockReanimatedModalProps?.isVisible).toBe(false); + expect(closeModal).not.toHaveBeenCalled(); + + // When the hide animation ends + act(() => { + mockReanimatedModalProps?.onModalHide?.(); + }); + + // Then the entry leaves the stack with the confirm, so the caller knows the user confirmed rather than closed + expect(closeModal).toHaveBeenCalledTimes(1); + expect(closeModal).toHaveBeenCalledWith({action: ModalActions.CONFIRM}); + }); +}); diff --git a/tests/unit/components/Image/getImageSource.test.ts b/tests/unit/components/Image/getImageSource.test.ts index 33341f26e93d..d3298efc0e12 100644 --- a/tests/unit/components/Image/getImageSource.test.ts +++ b/tests/unit/components/Image/getImageSource.test.ts @@ -1,10 +1,12 @@ import getImageSource from '@components/Image/getImageSource'; +import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import type Session from '@src/types/onyx/Session'; const NOW = new Date('2026-04-15T00:00:00Z'); -const MOCK_URI = 'https://example.com/receipt.jpg'; +const MOCK_URI = `${CONFIG.EXPENSIFY.EXPENSIFY_URL}receipts/w_receipt.jpg`; +const ATTACKER_URI = 'https://attacker.example/receipt.jpg'; const MOCK_TOKEN = 'encrypted-token'; describe('getImageSource', () => { @@ -125,9 +127,57 @@ describe('getImageSource', () => { }); }); + it('returns the source without the token header for an image outside Expensify', () => { + // Given an image that claims to need the token but lives on a host that is not an Expensify server + const propsSource = {uri: ATTACKER_URI}; + const session: Session = { + encryptedAuthToken: MOCK_TOKEN, + creationDate: NOW.getTime(), + }; + + // When building the image source + const result = getImageSource({ + propsSource, + session, + isAuthTokenRequired: true, + isOffline: false, + }); + + // Then the image loads like any public image, with no token header for that host to read + expect(result).toEqual({ + source: propsSource, + shouldReauthenticate: false, + }); + }); + + it('does not ask for reauthentication for an image outside Expensify when the session is expired', () => { + // Given an expired session and an image that claims to need the token but lives outside Expensify + const propsSource = {uri: ATTACKER_URI}; + const session: Session = { + encryptedAuthToken: MOCK_TOKEN, + creationDate: NOW.getTime() - CONST.SESSION_EXPIRATION_TIME_MS - 1, + }; + + // When building the image source online + const result = getImageSource({ + propsSource, + session, + isAuthTokenRequired: true, + isOffline: false, + }); + + // Then the image loads without the token, because a fresh token would never be sent to that host either + expect(result).toEqual({ + source: propsSource, + shouldReauthenticate: false, + }); + }); + it('preserves numeric image sources', () => { // @ts-expect-error -- Numeric object URIs intentionally exercise the runtime compatibility branch not represented by the public image source model. - const propsSource: Parameters[0]['propsSource'] = {uri: 42}; + const propsSource: Parameters[0]['propsSource'] = { + uri: 42, + }; expect( getImageSource({ diff --git a/tests/unit/components/MoneyRequestConfirmationList/CategoryField.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/CategoryField.test.tsx index 42c6d56662af..93bf91bf66ba 100644 --- a/tests/unit/components/MoneyRequestConfirmationList/CategoryField.test.tsx +++ b/tests/unit/components/MoneyRequestConfirmationList/CategoryField.test.tsx @@ -328,6 +328,25 @@ describe('CategoryField', () => { // later one the user never pressed Category for expect(mockOpenDropdown).not.toHaveBeenCalled(); }); + + it('forgets an upgrade the user never left the form for once the row is pressed again', async () => { + // Given an expense with no workspace, whose category row armed a return to the list, but whose upgrade screen never opened + await givenManualExpense(); + const {rerender} = render(renderDropdownCategoryField({shouldNavigateToUpgradePath: true})); + fireEvent.press(screen.getByText('common.category')); + + // When the workspace arrives, the user presses the row again for the full-page Category step, and comes back + rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false})); + fireEvent.press(screen.getByText('common.category')); + mockIsFocused = false; + rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false})); + mockIsFocused = true; + rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false})); + await waitForBatchedUpdates(); + + // Then the list stays closed: the second press cleared the stale upgrade return, so it can't fire on this trip + expect(mockOpenDropdown).not.toHaveBeenCalled(); + }); }); it('opens the list in place for a draft workspace, whose categories only exist as drafts', async () => { diff --git a/tests/unit/components/MoneyRequestConfirmationList/CategoryFieldDropdown.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/CategoryFieldDropdown.test.tsx new file mode 100644 index 000000000000..d9f93747220c --- /dev/null +++ b/tests/unit/components/MoneyRequestConfirmationList/CategoryFieldDropdown.test.tsx @@ -0,0 +1,126 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import ConfirmationFieldsProvider from '@components/MoneyRequestConfirmationFields/Provider'; +import CategoryFieldDropdown from '@components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown'; +import type PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import type TextComponent from '@components/Text'; + +import Navigation from '@libs/Navigation/Navigation'; +import type TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../../../utils/collections/policies'; +import waitForBatchedUpdates from '../../../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../../../utils/waitForBatchedUpdatesWithAct'; + +const mockCallOrder: string[] = []; + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(() => mockCallOrder.push('navigate')), +})); + +// Holds the post-transition work until the test runs it, standing in for the pop-over finishing its close. +let mockRunPendingTransition: Parameters[0]['callback'] | undefined; +jest.mock('@libs/Navigation/TransitionTracker', () => ({ + runAfterTransitions: ({callback}: Parameters[0]): ReturnType => { + mockRunPendingTransition = callback; + return {cancel: () => {}}; + }, +})); + +jest.mock('@hooks/useUpdateTransactionCategory', () => () => ({updateCategory: jest.fn()})); + +jest.mock('@components/CategoryPicker/CategoryPickerModal', () => { + const {default: Pressable} = jest.requireActual<{default: typeof PressableWithoutFeedback}>('@components/Pressable/PressableWithoutFeedback'); + const {default: Text} = jest.requireActual<{default: typeof TextComponent}>('@components/Text'); + return ({onAddCategory}: {onAddCategory?: () => void}) => + onAddCategory ? ( + + addCategory + + ) : null; +}); + +const TRANSACTION_ID = '1'; +const REPORT_ID = 'reportID'; + +const renderDropdown = (onClose: () => void, canAddCategory = true) => + render( + + + , + ); + +describe('CategoryFieldDropdown', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockCallOrder.length = 0; + mockRunPendingTransition = undefined; + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('closes the pop-over before opening the add-category page', async () => { + // Given an admin with the category pop-over open + const onClose = jest.fn(() => mockCallOrder.push('close')); + renderDropdown(onClose); + await waitForBatchedUpdatesWithAct(); + + // When they press the add button + fireEvent.press(screen.getByLabelText('addCategory')); + + // Then the pop-over closes and nothing navigates until its close transition ends, since navigating while the + // modal is still up gets dropped on iOS + expect(onClose).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).not.toHaveBeenCalled(); + + mockRunPendingTransition?.(); + + // And only then the add-category page opens, carrying the expense it belongs to + expect(mockCallOrder).toEqual(['close', 'navigate']); + const route = jest.mocked(Navigation.navigate).mock.calls.at(0)?.at(0); + expect(route).toContain('add-category'); + expect(route).toContain(`transactionID=${TRANSACTION_ID}`); + expect(route).toContain(`reportID=${REPORT_ID}`); + }); + + it('offers no add button when the user cannot add a category', async () => { + // Given a user who cannot add categories to the workspace + // When the pop-over opens + renderDropdown(jest.fn(), false); + await waitForBatchedUpdatesWithAct(); + + // Then it has no add button, so the user can only pick from the existing categories + expect(screen.queryByLabelText('addCategory')).toBeNull(); + }); +}); diff --git a/tests/unit/components/MoneyRequestConfirmationListFooter/ExpenseFormFooterLayout.test.tsx b/tests/unit/components/MoneyRequestConfirmationListFooter/ExpenseFormFooterLayout.test.tsx new file mode 100644 index 000000000000..015e39991eca --- /dev/null +++ b/tests/unit/components/MoneyRequestConfirmationListFooter/ExpenseFormFooterLayout.test.tsx @@ -0,0 +1,208 @@ +import {render, screen} from '@testing-library/react-native'; + +import ConfirmationFieldsProvider from '@components/MoneyRequestConfirmationFields/Provider'; +import {useExpenseFormLayout} from '@components/MoneyRequestConfirmationList/sections/ExpenseFormLayoutContext'; +import ExpenseFormFooterLayout from '@components/MoneyRequestConfirmationListFooter/ExpenseFormFooterLayout'; +import type {ReceiptOptions} from '@components/MoneyRequestConfirmationListFooter/fieldGroupTypes'; +import Text from '@components/Text'; + +import {isRecord} from '@libs/ObjectUtils'; + +import CONST from '@src/CONST'; + +import type {ComponentType, ReactNode} from 'react'; + +import React from 'react'; +import {StyleSheet, View} from 'react-native'; + +type ReactNativeActual = { + View: ComponentType<{testID?: string; children?: ReactNode}>; + Text: ComponentType<{children?: ReactNode}>; +}; + +const mockStyles = { + dividerLine: {borderBottomWidth: 1}, + mv2: {marginVertical: 8}, +}; + +const mockReceiptSection = jest.fn(); + +jest.mock('@hooks/useThemeStyles', () => ({ + __esModule: true, + default: () => mockStyles, +})); + +// The real section resolves thumbnails from Onyx. The layout only places it, so render a marker and record its props. +jest.mock('@components/MoneyRequestConfirmationListFooter/sections/ReceiptSection', () => { + const {View: MockView} = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: unknown) => { + mockReceiptSection(props); + return ; + }, + }; +}); + +// The real button needs icons, tooltips and navigation. Only whether the layout offers it matters here. +jest.mock('@components/MoneyRequestConfirmationList/sections/AddReceiptButton', () => { + const {Text: MockText} = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => add-receipt-button, + }; +}); + +const noReceiptOptions: ReceiptOptions = {shouldDisplayReceipt: false, receiptPath: undefined, receiptFilename: undefined}; +const attachedReceiptOptions: ReceiptOptions = {shouldDisplayReceipt: true, receiptPath: 'file://receipt.jpg', receiptFilename: 'receipt.jpg'}; + +/** Stands in for the amount field, which renders the trailing action the layout provides beside it */ +function AmountFieldStandIn() { + const {amountTrailingAction, shouldUseDropdownRows} = useExpenseFormLayout(); + return ( + <> + {`dropdown-rows:${String(shouldUseDropdownRows)}`} + {amountTrailingAction} + + ); +} + +type RenderLayoutOptions = { + receiptOptions?: ReceiptOptions; + hasParticipantSection?: boolean; + isReadOnly?: boolean; + children?: ReactNode; +}; + +function renderLayout({receiptOptions = noReceiptOptions, hasParticipantSection = false, isReadOnly = false, children = }: RenderLayoutOptions = {}) { + return render( + + + {children} + + , + ); +} + +function getViewStyles() { + return screen + .UNSAFE_getAllByType(View) + .map((view) => StyleSheet.flatten(view.props.style)) + .filter(isRecord); +} + +function getDividers() { + return getViewStyles().filter((style) => style.borderBottomWidth === mockStyles.dividerLine.borderBottomWidth); +} + +/** The wrapper around the receipt preview is the only view styled with just the vertical margin */ +function getReceiptSpacers() { + return getViewStyles().filter((style) => style.marginVertical === mockStyles.mv2.marginVertical && style.borderBottomWidth === undefined); +} + +describe('ExpenseFormFooterLayout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows the add-receipt button only when there is no receipt and the user can add one', () => { + // Given an editable submit expense with no receipt attached yet + // When the footer layout renders + renderLayout(); + + // Then the compact add-receipt button is offered next to the amount, inside the bordered dropdown-row form + expect(screen.getByText('add-receipt-button')).toBeOnTheScreen(); + expect(screen.getByText('dropdown-rows:true')).toBeOnTheScreen(); + }); + + it('hides the add-receipt button once a receipt is attached', () => { + // Given the same form but with a receipt already attached + // When the footer layout renders + renderLayout({receiptOptions: attachedReceiptOptions}); + + // Then the button is gone because the receipt preview replaces it + expect(screen.queryByText('add-receipt-button')).not.toBeOnTheScreen(); + }); + + it('hides the add-receipt button while a receipt is still loading', () => { + // Given a receipt that is being stitched, so its path is not known yet + // When the footer layout renders + renderLayout({receiptOptions: {...noReceiptOptions, isLoadingReceipt: true}}); + + // Then the button is hidden, because the loading preview already occupies the receipt area + expect(screen.queryByText('add-receipt-button')).not.toBeOnTheScreen(); + }); + + it('hides the add-receipt button when the form is read-only', () => { + // Given a read-only confirmation with no receipt, where the user can't change anything + // When the footer layout renders + renderLayout({isReadOnly: true}); + + // Then no add-receipt button is offered + expect(screen.queryByText('add-receipt-button')).not.toBeOnTheScreen(); + }); + + it('draws a divider only when a participant section sits above the footer', () => { + // Given a footer rendered below a participant section + // When the footer layout renders + const {unmount} = renderLayout({hasParticipantSection: true}); + + // Then a divider separates the participants from the expense fields + expect(getDividers()).toHaveLength(1); + unmount(); + + // Given a footer with no participant section above it + // When the footer layout renders + renderLayout({hasParticipantSection: false}); + + // Then there is nothing to separate, so no divider is drawn + expect(getDividers()).toHaveLength(0); + }); + + it('spaces the receipt preview only when a receipt is shown', () => { + // Given a footer with an attached receipt + // When the footer layout renders + const {unmount} = renderLayout({receiptOptions: attachedReceiptOptions}); + + // Then the preview wrapper carries the 8px margin that keeps it clear of the divider and the first field + expect(getReceiptSpacers()).toHaveLength(1); + unmount(); + + // Given a footer with no receipt + // When the footer layout renders + renderLayout(); + + // Then the empty receipt area adds no spacing + expect(getReceiptSpacers()).toHaveLength(0); + }); + + it('renders the receipt section without its full-width empty state and passes the receipt options through', () => { + // Given a footer with an attached receipt + // When the footer layout renders + renderLayout({receiptOptions: attachedReceiptOptions}); + + // Then the receipt section hides its full-width empty state, since the compact button offers that action instead + expect(mockReceiptSection).toHaveBeenCalledWith(expect.objectContaining({...attachedReceiptOptions, shouldHideEmptyState: true})); + }); + + it('renders the variant content below the receipt preview', () => { + // Given variant-specific content passed as children + // When the footer layout renders + renderLayout({children: variant-fields}); + + // Then the content renders after the receipt section, so the receipt stays above the fields + const json = JSON.stringify(screen.toJSON()); + expect(screen.getByText('variant-fields')).toBeOnTheScreen(); + expect(json.indexOf('receipt-section')).toBeLessThan(json.indexOf('variant-fields')); + }); +}); diff --git a/tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx b/tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx new file mode 100644 index 000000000000..f4eb60243a76 --- /dev/null +++ b/tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx @@ -0,0 +1,523 @@ +/** Composed providers exercise signing, enrollment, and explicit wallet action cancellation. */ +import {act, render, waitFor} from '@testing-library/react-native'; + +import type {AuthorizeResult, RegisterResult} from '@components/MultifactorAuthentication/biometrics/shared/types'; +import {useMultifactorAuthenticationActions} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationActionsContext'; +import MultifactorAuthenticationContextProviders from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders'; +import {useMultifactorAuthentication} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext'; +import {useMultifactorAuthenticationState} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationStateContext'; + +import {getPaymentMethods} from '@libs/actions/PaymentMethods'; +import {requestValidateCodeAction} from '@libs/actions/User'; +import * as API from '@libs/API'; +import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; +import Navigation from '@libs/Navigation/Navigation'; +import {handleAddCardToWallet} from '@libs/Wallet'; +import {handleAddCardToWallet as addAndroid} from '@libs/Wallet/index.android'; + +import {authorizeTransaction, denyTransaction, registerAuthenticationKey} from '@userActions/MultifactorAuthentication'; + +import CONST from '@src/CONST'; +import type {Card, ProvisioningCardData} from '@src/types/onyx'; + +import type {TokenizationStatus} from '@expensify/react-native-wallet'; + +import {addCardToGoogleWallet, getSecureWalletInfo} from '@expensify/react-native-wallet'; +import React, {useEffect} from 'react'; + +import createMock from '../../../utils/createMock'; + +const signedResult: AuthorizeResult = { + success: true, + signedChallenge: {rawId: 'key', type: 'public-key', response: {authenticatorData: 'auth', clientDataJSON: 'client', signature: 'signature'}}, + authenticationMethod: {name: 'Face ID', marqetaValue: 'BIOMETRIC_FACE'}, +}; +let mockIsOffline = false; +const mockBiometrics = { + deviceVerificationType: 'BIOMETRICS_HSM', + serverKnownCredentialIDs: ['key'], + doesDeviceSupportAuthenticationMethod: jest.fn().mockResolvedValue(true), + areLocalCredentialsKnownToServer: jest.fn().mockResolvedValue(true), + authorize: jest.fn(async (_params: unknown, callback: (result: AuthorizeResult) => Promise) => callback(signedResult)), + register: jest.fn(async (callback: (result: RegisterResult) => Promise) => + callback({ + success: true, + keyInfo: {rawId: 'key', type: 'public-key', response: {clientDataJSON: 'client', attestationObject: 'attestation'}}, + }), + ), +}; + +jest.mock('@components/MultifactorAuthentication/biometrics/useBiometrics', () => ({__esModule: true, default: () => mockBiometrics})); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({__esModule: true, default: () => ({accountID: 123})})); +jest.mock('@hooks/useNetwork', () => ({__esModule: true, default: () => ({isOffline: mockIsOffline})})); +jest.mock('@libs/Wallet', () => ({handleAddCardToWallet: jest.fn()})); +jest.mock('@libs/API'); +jest.mock('@expensify/react-native-wallet', () => ({addCardToGoogleWallet: jest.fn(), getSecureWalletInfo: jest.fn()})); +jest.mock('@libs/actions/PaymentMethods', () => ({getPaymentMethods: jest.fn()})); +jest.mock('@libs/actions/User', () => ({requestValidateCodeAction: jest.fn()})); +jest.mock('@libs/getPlatform', () => ({__esModule: true, default: () => 'ios'})); +jest.mock('@libs/Navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn(), runAfterTransition: jest.fn()}})); +jest.mock('@components/MultifactorAuthentication/mfaNavigation', () => ({navigate: jest.fn()})); +jest.mock('@components/MultifactorAuthentication/useSyncMfaModalNavigatorWithHistory', () => ({__esModule: true, default: jest.fn()})); +jest.mock('@components/MultifactorAuthentication/observability/breadcrumbs', () => ({__esModule: true, default: jest.fn()})); +jest.mock('@components/MultifactorAuthentication/observability/trackMFAFlowStart', () => ({__esModule: true, default: jest.fn()})); +jest.mock('@components/MultifactorAuthentication/observability/trackMFAFlowOutcome', () => ({__esModule: true, default: jest.fn()})); +jest.mock('@userActions/MultifactorAuthentication', () => ({ + getDeviceBiometricsOnyxKey: () => 'deviceBiometrics_123', + requestAuthorizationChallenge: jest.fn().mockResolvedValue({httpStatusCode: 200, challenge: {allowCredentials: [], rpId: 'test', challenge: 'challenge'}}), + requestRegistrationChallenge: jest.fn().mockResolvedValue({httpStatusCode: 200, challenge: {rp: {}, user: {}, challenge: 'registration'}}), + registerAuthenticationKey: jest.fn().mockResolvedValue({httpStatusCode: 200}), + clearLocalMFAPublicKeyList: jest.fn(), + authorizeTransaction: jest.fn(), + denyTransaction: jest.fn(), + fireAndForgetDenyTransaction: jest.fn(), +})); + +let controls: ReturnType; +let actions: ReturnType; +let state: ReturnType; +function Probe() { + const currentControls = useMultifactorAuthentication(); + const currentActions = useMultifactorAuthenticationActions(); + const currentState = useMultifactorAuthenticationState(); + useEffect(() => { + controls = currentControls; + actions = currentActions; + state = currentState; + }, [currentControls, currentActions, currentState]); + return null; +} +const payload = {card: createMock({cardID: 123}), cardHolderName: 'Jane', cardDescription: 'Card'}; +const mockRequest = jest.mocked(API.makeRequestWithSideEffects); +async function startScenario() { + await act(async () => { + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET, payload); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); +} + +describe('Wallet MFA composed providers', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsOffline = false; + mockBiometrics.deviceVerificationType = 'BIOMETRICS_HSM'; + mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(true); + mockBiometrics.doesDeviceSupportAuthenticationMethod.mockResolvedValue(true); + mockBiometrics.authorize.mockImplementation(async (_params, callback) => callback(signedResult)); + jest.mocked(handleAddCardToWallet).mockResolvedValue('success'); + jest.mocked(authorizeTransaction).mockReset().mockResolvedValue({httpStatusCode: 200, reason: undefined, message: undefined}); + jest.mocked(denyTransaction) + .mockReset() + .mockResolvedValue({httpStatusCode: 200, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.FLOW_OUTCOMES.TRANSACTION_DENIED, message: undefined}); + }); + + it('does not launch a wallet until authorization signs, then completes through the scenario callback', async () => { + // Given the real providers and authorization waiting for user authentication. + let finishAuthorization: ((result: AuthorizeResult) => Promise) | undefined; + mockBiometrics.authorize.mockImplementation(async (_params, callback) => { + finishAuthorization = callback; + }); + render( + + + , + ); + + // When scenario initialization resolves but signing is still pending. + await startScenario(); + await waitFor(() => expect(mockBiometrics.authorize).toHaveBeenCalled()); + expect(handleAddCardToWallet).not.toHaveBeenCalled(); + await act(async () => { + await finishAuthorization?.(signedResult); + }); + + // Then native success alone triggers refresh and the MFA modal closes without its success screen. + await waitFor(() => expect(getPaymentMethods).toHaveBeenCalledTimes(1)); + expect(handleAddCardToWallet).toHaveBeenCalledTimes(1); + expect(state.isFlowComplete).toBe(true); + expect(state.isModalOpen).toBe(false); + expect(state.scenarioResponse?.body).toBeUndefined(); + }); + + it('requires the existing enrollment flow before wallet provisioning', async () => { + // Given a device without an enrolled credential. + mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(false); + render( + + + , + ); + + // When a UK/EU card starts wallet authentication. + await startScenario(); + + // Then existing validate-code enrollment starts and no wallet/native authorization runs prematurely. + await waitFor(() => expect(requestValidateCodeAction).toHaveBeenCalled()); + expect(handleAddCardToWallet).not.toHaveBeenCalled(); + expect(mockBiometrics.authorize).not.toHaveBeenCalled(); + }); + + it('continues existing enrollment into signing and wallet provisioning', async () => { + // Given the cardholder starts without an enrolled device credential. + mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(false); + render( + + + , + ); + await startScenario(); + await waitFor(() => expect(requestValidateCodeAction).toHaveBeenCalled()); + expect(handleAddCardToWallet).not.toHaveBeenCalled(); + + // When the existing enrollment flow receives its validate code and registers the device. + await act(async () => actions.dispatch({type: 'SET_VALIDATE_CODE', payload: '123456'})); + + // Then registration and authorization finish before the wallet action and successful refresh. + await waitFor(() => expect(getPaymentMethods).toHaveBeenCalledTimes(1)); + expect(mockBiometrics.register).toHaveBeenCalledTimes(1); + expect(mockBiometrics.authorize).toHaveBeenCalledTimes(1); + expect(state.isRegistrationComplete).toBe(true); + }); + + it('rejects unsupported authentication methods before native wallet work', async () => { + // Given this native-only scenario is offered a passkey authenticator. + mockBiometrics.deviceVerificationType = 'PASSKEYS'; + render( + + + , + ); + // When the real provider checks the scenario allowlist. + await startScenario(); + // Then existing failure UX is used without invoking native wallet provisioning. + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + expect(state.error?.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.AUTHENTICATION_TYPE_NOT_SUPPORTED); + expect(handleAddCardToWallet).not.toHaveBeenCalled(); + }); + + it('never provisions after authentication cancellation', async () => { + // Given the native authenticator returns the existing canceled result. + mockBiometrics.authorize.mockImplementation(async (_params, callback) => + callback({success: false, error: {reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, message: undefined}}), + ); + render( + + + , + ); + + // When the real provider completes authorization. + await startScenario(); + + // Then no wallet launches and existing failure handling completes the flow. + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + expect(handleAddCardToWallet).not.toHaveBeenCalled(); + expect(getPaymentMethods).not.toHaveBeenCalled(); + }); + + it.each(['confirmed cancel', 'offline close'] as const)('does not launch Google Wallet after %s while signed provisioning is pending', async (ending) => { + // Given the real composed providers have signed and the real Android helper is awaiting CreateDigitalWallet. + type APIResponse = NonNullable>>; + let finishRequest: ((response: APIResponse) => void) | undefined; + jest.mocked(handleAddCardToWallet).mockImplementation(addAndroid); + jest.mocked(getSecureWalletInfo).mockResolvedValue({deviceID: 'device', walletAccountID: 'wallet'}); + mockRequest.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRequest = resolve; + }), + ); + const view = render( + + + , + ); + await startScenario(); + await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)); + expect(mockRequest).toHaveBeenCalledWith( + SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET, + expect.objectContaining({signedChallenge: JSON.stringify(signedResult.signedChallenge), authenticationMethod: 'BIOMETRIC_FACE'}), + ); + const signal = jest.mocked(handleAddCardToWallet).mock.calls.at(0)?.[3]?.signal; + + // When back navigation cancels the flow before the already-sent server request succeeds. + if (ending === 'offline close') { + mockIsOffline = true; + view.rerender( + + + , + ); + } + await act(async () => controls.requestCancel()); + if (ending === 'confirmed cancel') { + expect(state.isCancelConfirmVisible).toBe(true); + expect(signal?.aborted).toBe(false); + await act(async () => controls.confirmCancel()); + } + expect(signal?.aborted).toBe(true); + const provisioningData: Pick = { + network: 'VISA', + opaquePaymentCard: 'opaque', + lastDigits: '1234', + userAddress: { + name: 'Jane', + address1: 'Street', + state: '', + city: 'London', + country: 'GB', + // eslint-disable-next-line @typescript-eslint/naming-convention -- Match the provisioning API response field. + postal_code: 'SW1A', + phone: '+441234', + }, + }; + await act(async () => { + finishRequest?.(createMock({jsonCode: 200, ...provisioningData})); + }); + + // Then cancellation blocks native continuation and success side effects, without undoing the server request. + expect(addCardToGoogleWallet).not.toHaveBeenCalled(); + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(getPaymentMethods).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + expect(state.isAuthorizationComplete).toBe(false); + expect(state.scenarioResponse).toBeUndefined(); + if (ending === 'offline close') { + expect(state.isModalOpen).toBe(false); + } else { + expect(state.error?.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED); + } + }); + + it.each(['canceled', 'success'] as const)('keeps explicit cancellation unsuccessful when the wallet later returns %s', async (status) => { + // Given the signed wallet action is pending and has received its cancellation signal. + let finishWallet: ((result: TokenizationStatus) => void) | undefined; + jest.mocked(handleAddCardToWallet).mockImplementation( + () => + new Promise((resolve) => { + finishWallet = resolve; + }), + ); + render( + + + , + ); + await startScenario(); + await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(1)); + const signal = jest.mocked(handleAddCardToWallet).mock.calls.at(0)?.[3]?.signal; + + // When explicit cancellation precedes a native canceled or late-success status. + await act(async () => controls.confirmCancel()); + await act(async () => { + finishWallet?.(status); + }); + + // Then the wallet scenario normalizes the aborted result without successful authorization or refresh. + expect(signal?.aborted).toBe(true); + expect(state.error?.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED); + expect(state.isAuthorizationComplete).toBe(false); + expect(state.scenarioResponse).toBeUndefined(); + expect(getPaymentMethods).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it.each(['cancel', 'reset'] as const)('ignores an old wallet result after %s and a replacement attempt starts', async (ending) => { + // Given the first wallet attempt is still waiting for native provisioning. + let finishFirstWallet: ((result: TokenizationStatus) => void) | undefined; + let finishSecondWallet: ((result: TokenizationStatus) => void) | undefined; + jest.mocked(handleAddCardToWallet) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstWallet = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishSecondWallet = resolve; + }), + ); + render( + + + , + ); + await startScenario(); + await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(1)); + const firstSignal = jest.mocked(handleAddCardToWallet).mock.calls.at(0)?.[3]?.signal; + + // When that flow ends and a replacement starts before the old wallet result arrives. + if (ending === 'cancel') { + await act(async () => controls.confirmCancel()); + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + } + await act(async () => actions.dispatch({type: 'RESET'})); + await startScenario(); + await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(2)); + const secondSignal = jest.mocked(handleAddCardToWallet).mock.calls.at(1)?.[3]?.signal; + const replacementState = state; + await act(async () => finishFirstWallet?.('success')); + + // Then the old result leaves the replacement untouched, including ownership of its cancellation signal. + expect(firstSignal?.aborted).toBe(true); + expect(state).toBe(replacementState); + expect(secondSignal?.aborted).toBe(false); + expect(getPaymentMethods).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + await act(async () => controls.confirmCancel()); + expect(secondSignal?.aborted).toBe(true); + await act(async () => finishSecondWallet?.('canceled')); + }); + + it('ignores an old scenario rejection after cancellation and replacement', async () => { + // Given a transaction action can reject after its flow has already been canceled. + let rejectTransaction: ((error: Error) => void) | undefined; + let finishWallet: ((result: TokenizationStatus) => void) | undefined; + jest.mocked(authorizeTransaction).mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectTransaction = reject; + }), + ); + jest.mocked(handleAddCardToWallet).mockImplementationOnce( + () => + new Promise((resolve) => { + finishWallet = resolve; + }), + ); + render( + + + , + ); + await act(async () => { + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); + await waitFor(() => expect(authorizeTransaction).toHaveBeenCalledTimes(1)); + await act(async () => controls.confirmCancel()); + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + await act(async () => actions.dispatch({type: 'RESET'})); + await startScenario(); + await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(1)); + const replacementState = state; + + // When the old action rejects while the replacement wallet is pending. + await act(async () => rejectTransaction?.(new Error('Transaction request failed'))); + + // Then the rejection cannot fail the replacement, which can still complete successfully. + expect(state).toBe(replacementState); + await act(async () => finishWallet?.('success')); + await waitFor(() => expect(getPaymentMethods).toHaveBeenCalledTimes(1)); + expect(state.error).toBeUndefined(); + expect(state.isAuthorizationComplete).toBe(true); + }); + + it('still reports an action rejection for the current flow', async () => { + // Given an existing scenario action rejects without being canceled or replaced. + jest.mocked(authorizeTransaction).mockRejectedValueOnce(new Error('Transaction request failed')); + render( + + + , + ); + + // When the active scenario reaches its action. + await act(async () => { + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); + + // Then normal failure handling still completes the current flow. + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + expect(state.error?.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNHANDLED_EXCEPTION); + expect(state.error?.message).toBe('Transaction request failed'); + expect(state.isAuthorizationComplete).toBe(false); + }); + + it('aborts a pending wallet action when the provider unmounts', async () => { + // Given native wallet work is still pending when its provider goes away. + let finishWallet: ((result: TokenizationStatus) => void) | undefined; + jest.mocked(handleAddCardToWallet).mockImplementationOnce( + () => + new Promise((resolve) => { + finishWallet = resolve; + }), + ); + const view = render( + + + , + ); + await startScenario(); + await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(1)); + const signal = jest.mocked(handleAddCardToWallet).mock.calls.at(0)?.[3]?.signal; + + // When the provider unmounts before native completion. + view.unmount(); + + // Then the helper sees cancellation and late success cannot refresh or navigate. + expect(signal?.aborted).toBe(true); + await act(async () => finishWallet?.('success')); + expect(getPaymentMethods).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('preserves an existing scenario-specific cancellation outcome', async () => { + // Given an existing transaction scenario is waiting for authentication. + mockBiometrics.authorize.mockImplementation(async () => {}); + render( + + + , + ); + await act(async () => { + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); + await waitFor(() => expect(mockBiometrics.authorize).toHaveBeenCalledTimes(1)); + + // When the user confirms cancellation through the existing onCancel hook. + await act(async () => controls.confirmCancel()); + + // Then its denial request and established failure outcome are unchanged. + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + expect(denyTransaction).toHaveBeenCalledWith({transactionID: 'transaction'}); + expect(state.error?.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.FLOW_OUTCOMES.TRANSACTION_DENIED); + expect(state.isAuthorizationComplete).toBe(false); + }); + + it('re-enrolls and signs again when an existing scenario requires registration', async () => { + // Given a signed non-wallet action reports that the server no longer recognizes the credential. + jest.mocked(authorizeTransaction).mockImplementationOnce(async () => { + mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(false); + return {httpStatusCode: 400, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.REGISTRATION_REQUIRED, message: undefined}; + }); + render( + + + , + ); + await act(async () => { + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); + await waitFor(() => expect(requestValidateCodeAction).toHaveBeenCalled()); + + // When the user completes the existing validate-code and soft-prompt enrollment steps. + await act(async () => { + actions.dispatch({type: 'SET_VALIDATE_CODE', payload: '123456'}); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); + + // Then the existing flow registers, re-signs and completes successfully. + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + expect(registerAuthenticationKey).toHaveBeenCalledTimes(1); + expect(mockBiometrics.authorize).toHaveBeenCalledTimes(2); + expect(authorizeTransaction).toHaveBeenCalledTimes(2); + expect(state.error).toBeUndefined(); + expect(state.isAuthorizationComplete).toBe(true); + }); +}); diff --git a/tests/unit/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.test.ts b/tests/unit/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.test.ts new file mode 100644 index 000000000000..fc0a87ae8872 --- /dev/null +++ b/tests/unit/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.test.ts @@ -0,0 +1,105 @@ +/** Wallet scenario completion owns refresh/navigation, never the initialization promise. */ +import AddToDigitalWallet from '@components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet'; + +import {getPaymentMethods} from '@libs/actions/PaymentMethods'; +import getPlatform from '@libs/getPlatform'; +import Navigation from '@libs/Navigation/Navigation'; +import {handleAddCardToWallet} from '@libs/Wallet'; +import WalletProvisioningError from '@libs/Wallet/WalletProvisioningError'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Card} from '@src/types/onyx'; + +import createMock from '../../../../../utils/createMock'; + +jest.mock('@libs/Wallet', () => ({handleAddCardToWallet: jest.fn()})); +jest.mock('@libs/actions/PaymentMethods', () => ({getPaymentMethods: jest.fn()})); +jest.mock('@libs/getPlatform', () => ({__esModule: true, default: jest.fn()})); +jest.mock('@libs/Navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn()}})); + +const params = { + card: createMock({cardID: 123}), + cardHolderName: 'Jane', + cardDescription: 'Card', + signedChallenge: {rawId: 'key', type: 'public-key' as const, response: {authenticatorData: 'auth', clientDataJSON: 'client', signature: 'signature'}}, + authenticationMethod: 'BIOMETRIC_FACE' as const, +}; + +describe('AddToDigitalWallet scenario', () => { + beforeEach(() => jest.clearAllMocks()); + + it.each(['success', 'canceled', 'error'] as const)('normalizes native %s without exposing provisioning data', async (status) => { + // Given authorization has produced a structured signed challenge. + jest.mocked(handleAddCardToWallet).mockResolvedValue(status); + const context = {signal: new AbortController().signal}; + + // When the scenario action owns the native wallet handoff. + const response = await AddToDigitalWallet.action(params, context); + + // Then only actual native success is successful, with no secrets in the response body. + expect(handleAddCardToWallet).toHaveBeenCalledWith(params.card, 'Jane', 'Card', { + authentication: {signedChallenge: params.signedChallenge, authenticationMethod: 'BIOMETRIC_FACE'}, + signal: context.signal, + }); + expect(response.httpStatusCode).toBe(status === 'success' ? 200 : undefined); + const expectedReasons = { + success: undefined, + canceled: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, + error: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNRECOGNIZED, + }; + expect(response.reason).toBe(expectedReasons[status]); + expect(response).not.toHaveProperty('body'); + expect(getPaymentMethods).not.toHaveBeenCalled(); + }); + + it('preserves normalized server failures', async () => { + // Given provisioning failed before the native bridge rejected. + const failure = {httpStatusCode: 403, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.UNRECOGNIZED, message: 'Rejected'}; + jest.mocked(handleAddCardToWallet).mockRejectedValue(new WalletProvisioningError(failure)); + + // When the scenario catches that failure. + const response = await AddToDigitalWallet.action(params, undefined); + + // Then the server status and reason survive without an invented successful status. + expect(response).toEqual(failure); + }); + + it('sanitizes native exceptions instead of exposing card data', async () => { + // Given native code rejected with a potentially sensitive error. + jest.mocked(handleAddCardToWallet).mockRejectedValue(new Error('sensitive native data')); + + // When the scenario normalizes it. + const response = await AddToDigitalWallet.action(params, undefined); + + // Then the existing generic failure screen receives only a local reason. + expect(response).toEqual({httpStatusCode: undefined, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNHANDLED_EXCEPTION, message: undefined}); + }); + + it.each([CONST.PLATFORM.IOS, CONST.PLATFORM.ANDROID])('refreshes successful %s tokenization and skips the MFA success screen', async (platform) => { + // Given native tokenization actually succeeded. + jest.mocked(getPlatform).mockReturnValue(platform); + + // When the provider invokes the scenario completion callback. + const result = await AddToDigitalWallet.callback(true, {httpStatusCode: 200}, params); + + // Then refresh occurs once and only iOS retains the added-card navigation. + expect(getPaymentMethods).toHaveBeenCalledTimes(1); + if (platform === CONST.PLATFORM.IOS) { + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_WALLET_CARD_ADDED_TO_WALLET.getRoute('123')); + } else { + expect(Navigation.navigate).not.toHaveBeenCalled(); + } + expect(result).toBe(CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SKIP_OUTCOME_SCREEN); + }); + + it('keeps existing failure UX without refresh or added-card navigation', async () => { + // Given authentication, provisioning, or native tokenization failed or was canceled. + // When the provider completes the failed scenario. + const result = await AddToDigitalWallet.callback(false, {httpStatusCode: undefined}, params); + // Then the shared failure UI owns dismissal/retry and success side effects never run. + expect(result).toBe(CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SHOW_OUTCOME_SCREEN); + expect(getPaymentMethods).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts b/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts index 9be45405a684..0775d39bbbe5 100644 --- a/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts +++ b/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts @@ -4,6 +4,23 @@ import type {MultifactorAuthenticationScenarioConfigRecord} from '@components/Mu import CONST from '@src/CONST'; describe('MultifactorAuthentication Scenarios Config', () => { + it('registers native-only digital wallet authentication with inherited failure UX', () => { + // Given the resolved scenario registry used by the MFA provider. + const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG as Record; + + // When a UK/EU card starts wallet authentication. + const scenario = config['ADD-TO-DIGITAL-WALLET']; + + // Then native HSM authentication owns provisioning and retains the shared failure screens. + expect(scenario).toBeDefined(); + expect(scenario.allowedAuthenticationMethods).toEqual([CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM]); + expect(scenario.action).toBeDefined(); + expect(scenario.callback).toBeDefined(); + expect(scenario.defaultClientFailureScreen).toBeDefined(); + expect(scenario.defaultServerFailureScreen).toBeDefined(); + expect(scenario.modals.cancelConfirmation).toBeDefined(); + }); + it('should have all required properties for every scenario config', () => { const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG as MultifactorAuthenticationScenarioConfigRecord; diff --git a/tests/unit/components/OdometerCameraCancelledCaptureTest.tsx b/tests/unit/components/OdometerCameraCancelledCaptureTest.tsx new file mode 100644 index 000000000000..43059b8e784e --- /dev/null +++ b/tests/unit/components/OdometerCameraCancelledCaptureTest.tsx @@ -0,0 +1,218 @@ +import {act, render} from '@testing-library/react-native'; + +import IOURequestStepOdometerImage from '@pages/iou/request/step/IOURequestStepOdometerImage/index.native'; + +import type {ReactNode} from 'react'; + +import React from 'react'; +import {Alert} from 'react-native'; + +let mockIsFocused = true; +let mockCapturePhoto: (() => void) | undefined; +let mockResolveCapture: (photo: unknown) => void = () => {}; +let mockRejectCapture: (error: unknown) => void = () => {}; + +jest.mock('@hooks/useNativeCamera', () => ({ + __esModule: true, + default: () => ({ + camera: { + current: { + takePhoto: () => + new Promise((resolve, reject) => { + mockResolveCapture = resolve; + mockRejectCapture = reject; + }), + }, + }, + device: {id: 'back', hasFlash: true}, + cameraPermissionStatus: 'granted', + flash: true, + setFlash: jest.fn(), + hasFlash: true, + didCapturePhoto: false, + setDidCapturePhoto: jest.fn(), + isAttachmentPickerActive: false, + setIsAttachmentPickerActive: jest.fn(), + isPlatformMuted: false, + askForPermissions: jest.fn(), + tapGesture: {}, + cameraFocusIndicatorAnimatedStyle: {}, + }), +})); + +jest.mock('@pages/iou/request/step/IOURequestStepScan/components/ScannerControlsBar', () => ({ + __esModule: true, + default: ({capturePhoto}: {capturePhoto: () => void}) => { + mockCapturePhoto = capturePhoto; + return null; + }, +})); + +jest.mock('@pages/iou/request/step/withFullTransactionOrNotFound', () => ({ + __esModule: true, + default: (component: T) => component, +})); +jest.mock('@pages/iou/request/step/StepScreenWrapper', () => ({ + __esModule: true, + default: ({children}: {children: ReactNode}) => children, +})); +let mockCrop: (image: unknown) => Promise = (image) => Promise.resolve(image); +jest.mock('@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio', () => ({ + cropImageToAspectRatio: (image: unknown) => mockCrop(image), +})); +jest.mock('@pages/iou/request/step/IOURequestStepScan/components/CameraViewport', () => () => null); +jest.mock('@pages/iou/request/step/IOURequestStepScan/components/CameraPermissionPrompt', () => () => null); + +jest.mock('@components/FullScreenLoaderContext', () => ({ + useFullScreenLoaderActions: () => ({setIsLoaderVisible: jest.fn()}), +})); +jest.mock('@hooks/useFilesValidation', () => ({__esModule: true, default: () => ({validateFiles: jest.fn()})})); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({})})); +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); +jest.mock('@hooks/useIsInLandscapeMode', () => ({__esModule: true, default: () => false})); +jest.mock('react-native-vision-camera', () => ({useCameraFormat: () => undefined})); +jest.mock('react-native-blob-util', () => ({ + __esModule: true, + default: {fs: {isDir: () => Promise.resolve(true), mkdir: () => Promise.resolve()}}, +})); +jest.mock('@libs/getReceiptsUploadFolderPath', () => ({__esModule: true, default: () => '/receipts'})); +jest.mock('@libs/telemetry/activeSpans', () => ({ + cancelSpan: jest.fn(), + endSpan: jest.fn(), + startSpan: jest.fn(), +})); + +jest.mock('@libs/ReceiptStorage', () => ({ + __esModule: true, + default: { + adopt: () => Promise.resolve('odometer.jpg'), + toLocalUri: (durableName: string) => `file:///receipts/${durableName}`, + }, +})); + +const mockLogInfo = jest.fn(); +const mockLogWarn = jest.fn(); +jest.mock('@libs/Log', () => ({ + __esModule: true, + default: { + info: (...args: unknown[]) => mockLogInfo(...args), + warn: (...args: unknown[]) => mockLogWarn(...args), + hmmm: jest.fn(), + alert: jest.fn(), + }, +})); + +const CAMERA_CLOSED_ERROR = new Error('androidx.camera.core.ImageCaptureException: Camera is closed.'); + +const ROUTE_PARAMS = { + action: 'create', + iouType: 'request', + transactionID: '1', + reportID: '1', + imageType: 'start', +}; + +function renderFocusedOdometerCamera() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const props = {route: {params: ROUTE_PARAMS}, navigation: {isFocused: () => mockIsFocused}, transaction: undefined} as unknown as React.ComponentProps< + typeof IOURequestStepOdometerImage + >; + render(); +} + +async function pressShutter() { + await act(async () => { + mockCapturePhoto?.(); + }); +} + +async function failCapture(error: unknown = CAMERA_CLOSED_ERROR) { + await act(async () => { + mockRejectCapture(error); + }); +} + +async function finishCapture() { + await act(async () => { + mockResolveCapture({path: '/tmp/photo.jpg', width: 1512, height: 2016}); + }); +} + +describe('Odometer camera capture cancelled by leaving the screen', () => { + let alertSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockIsFocused = true; + mockCapturePhoto = undefined; + mockCrop = (image) => Promise.resolve(image); + alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + }); + + afterEach(() => { + alertSpy.mockRestore(); + }); + + it('does not show the camera error alert when the capture fails after the user left the odometer camera', async () => { + // Given a flash capture is still in progress on the focused odometer camera + renderFocusedOdometerCamera(); + await pressShutter(); + + // When the user leaves the screen and the camera closing cancels that capture + mockIsFocused = false; + await failCapture(); + + // Then no alert appears on the screen the user went to, and the expected cancellation is logged as info, not as a warning + expect(alertSpy).not.toHaveBeenCalled(); + expect(mockLogInfo).toHaveBeenCalledWith('Photo capture cancelled by leaving the screen', false, {error: CAMERA_CLOSED_ERROR.message}); + expect(mockLogWarn).not.toHaveBeenCalled(); + }); + + it('still shows the camera error alert when the capture fails while the user is on the odometer camera', async () => { + // Given a capture is in progress on the focused odometer camera + renderFocusedOdometerCamera(); + await pressShutter(); + + // When the capture fails while the user is still there + await failCapture(); + + // Then the user is told the camera failed, and the failure is logged as a warning, as before + expect(alertSpy).toHaveBeenCalledWith('receipt.cameraErrorTitle', 'receipt.cameraErrorMessage'); + expect(mockLogWarn).toHaveBeenCalledWith('Error taking photo', CAMERA_CLOSED_ERROR.message); + }); + + it('still logs a warning, without an alert, when the camera fails for another reason after the user left the odometer camera', async () => { + // Given a capture is still in progress on the focused odometer camera + renderFocusedOdometerCamera(); + await pressShutter(); + + // When the user leaves the screen and the capture fails with an error that is not the camera closing + mockIsFocused = false; + const writeError = new Error('Failed to write the photo to disk'); + await failCapture(writeError); + + // Then no alert appears on the screen the user went to, but the failure stays a warning because it is a real camera error, not a cancellation + expect(alertSpy).not.toHaveBeenCalled(); + expect(mockLogWarn).toHaveBeenCalledWith('Error taking photo', writeError.message); + expect(mockLogInfo).not.toHaveBeenCalled(); + }); + + it('does not show the camera error alert when processing the photo fails after the user left the odometer camera', async () => { + // Given a capture is still in progress on the focused odometer camera, and cropping the photo will fail + const cropError = new Error('Could not crop the photo'); + mockCrop = () => Promise.reject(cropError); + renderFocusedOdometerCamera(); + await pressShutter(); + + // When the user leaves the screen and the photo is taken but fails to process + mockIsFocused = false; + await finishCapture(); + + // Then no alert appears on the screen the user went to, but the failure is still logged as a warning because it is a real error + expect(alertSpy).not.toHaveBeenCalled(); + expect(mockLogWarn).toHaveBeenCalledWith('Error cropping photo', cropError.message); + }); +}); diff --git a/tests/unit/components/ScanCameraCancelledCaptureTest.tsx b/tests/unit/components/ScanCameraCancelledCaptureTest.tsx new file mode 100644 index 000000000000..ddab9112fe55 --- /dev/null +++ b/tests/unit/components/ScanCameraCancelledCaptureTest.tsx @@ -0,0 +1,221 @@ +import {act, render} from '@testing-library/react-native'; + +import Camera from '@pages/iou/request/step/IOURequestStepScan/components/Camera/index.native'; + +import React from 'react'; +import {Alert} from 'react-native'; + +let mockIsFocused = true; +let mockCapturePhoto: (() => void) | undefined; +let mockResolveCapture: (photo: unknown) => void = () => {}; +let mockRejectCapture: (error: unknown) => void = () => {}; + +jest.mock('@hooks/useNativeCamera', () => ({ + __esModule: true, + default: () => ({ + camera: {current: {}}, + device: {id: 'back', hasFlash: true}, + cameraPermissionStatus: 'granted', + flash: true, + setFlash: jest.fn(), + hasFlash: true, + didCapturePhoto: false, + setDidCapturePhoto: jest.fn(), + isAttachmentPickerActive: false, + setIsAttachmentPickerActive: jest.fn(), + isPlatformMuted: false, + askForPermissions: jest.fn(), + tapGesture: {}, + cameraFocusIndicatorAnimatedStyle: {}, + }), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual>('@react-navigation/native'), + useNavigation: () => ({isFocused: () => mockIsFocused}), +})); + +jest.mock('@pages/iou/request/step/IOURequestStepScan/captureReceipt', () => ({ + __esModule: true, + default: () => + new Promise((resolve, reject) => { + mockResolveCapture = resolve; + mockRejectCapture = reject; + }), + shouldTakePhoto: () => true, +})); + +jest.mock('@pages/iou/request/step/IOURequestStepScan/components/ScannerControlsBar', () => ({ + __esModule: true, + default: ({capturePhoto}: {capturePhoto: () => void}) => { + mockCapturePhoto = capturePhoto; + return null; + }, +})); + +jest.mock('@pages/iou/request/step/IOURequestStepScan/components/CameraViewport', () => () => null); +jest.mock('@pages/iou/request/step/IOURequestStepScan/components/CameraPermissionPrompt', () => () => null); +jest.mock('@pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews', () => () => null); +jest.mock('@pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal', () => () => null); + +jest.mock('@pages/iou/request/step/IOURequestStepScan/components/MultiScanContext', () => ({ + useMultiScanState: () => ({isMultiScanEnabled: false, canUseMultiScan: false}), + useMultiScanActions: () => ({toggleMultiScan: jest.fn()}), +})); + +jest.mock('@pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade', () => ({ + __esModule: true, + default: () => ({hasPendingPhotoCapture: false, startPhotoCapture: jest.fn(), upgradeReceiptWithPhoto: jest.fn(), discardPendingPhoto: jest.fn()}), +})); + +jest.mock('@pages/iou/request/step/IOURequestStepScan/utils/startReceiptPrepareSpan', () => ({__esModule: true, default: jest.fn()})); + +let mockAdopt: () => Promise = () => Promise.resolve('receipt.jpg'); +jest.mock('@libs/ReceiptStorage', () => ({ + __esModule: true, + default: { + adopt: () => mockAdopt(), + toLocalUri: (durableName: string) => `file:///receipts/${durableName}`, + }, +})); + +jest.mock('@pages/iou/request/step/IOURequestStepScan/hooks/useCameraInitTelemetry', () => ({ + __esModule: true, + default: () => ({handleCameraInitialized: jest.fn()}), +})); + +jest.mock('react-native-vision-camera', () => ({useCameraFormat: () => undefined})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +jest.mock('@hooks/useIsInLandscapeMode', () => ({__esModule: true, default: () => false})); +jest.mock('@hooks/useWindowDimensions', () => ({__esModule: true, default: () => ({windowWidth: 390, windowHeight: 844})})); +jest.mock('@libs/getReceiptsUploadFolderPath', () => ({__esModule: true, default: () => '/receipts'})); +jest.mock('@libs/HapticFeedback', () => ({__esModule: true, default: {press: jest.fn()}})); +jest.mock('@libs/telemetry/activeSpans', () => ({ + cancelSpan: jest.fn(), + endSpanWithAttributes: jest.fn(), + getSpan: jest.fn(), + startSpan: jest.fn(), +})); + +const mockLogInfo = jest.fn(); +const mockLogWarn = jest.fn(); +jest.mock('@libs/Log', () => ({ + __esModule: true, + default: { + info: (...args: unknown[]) => mockLogInfo(...args), + warn: (...args: unknown[]) => mockLogWarn(...args), + hmmm: jest.fn(), + alert: jest.fn(), + }, +})); + +const CAMERA_CLOSED_ERROR = new Error('androidx.camera.core.ImageCaptureException: Camera is closed.'); + +function renderFocusedCamera() { + render( + , + ); +} + +function pressShutter() { + act(() => { + mockCapturePhoto?.(); + }); +} + +async function failCapture(error: unknown = CAMERA_CLOSED_ERROR) { + await act(async () => { + mockRejectCapture(error); + }); +} + +async function finishCapture() { + await act(async () => { + mockResolveCapture({path: '/tmp/photo.jpg', width: 1512, height: 2016}); + }); +} + +describe('Scan camera capture cancelled by leaving the screen', () => { + let alertSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockIsFocused = true; + mockCapturePhoto = undefined; + mockAdopt = () => Promise.resolve('receipt.jpg'); + alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + }); + + afterEach(() => { + alertSpy.mockRestore(); + }); + + it('does not show the camera error alert when the capture fails after the user left the scan screen', async () => { + // Given a flash capture is still in progress on the focused scan screen + renderFocusedCamera(); + pressShutter(); + + // When the user leaves the screen and the camera closing cancels that capture + mockIsFocused = false; + await failCapture(); + + // Then no alert appears on the screen the user went to, and the expected cancellation is logged as info, not as a warning + expect(alertSpy).not.toHaveBeenCalled(); + expect(mockLogInfo).toHaveBeenCalledWith('Photo capture cancelled by leaving the screen', false, {error: CAMERA_CLOSED_ERROR.message}); + expect(mockLogWarn).not.toHaveBeenCalled(); + }); + + it('still shows the camera error alert when the capture fails while the user is on the scan screen', async () => { + // Given a capture is in progress on the focused scan screen + renderFocusedCamera(); + pressShutter(); + + // When the capture fails while the user is still there + await failCapture(); + + // Then the user is told the camera failed, and the failure is logged as a warning, as before + expect(alertSpy).toHaveBeenCalledWith('receipt.cameraErrorTitle', 'receipt.cameraErrorMessage'); + expect(mockLogWarn).toHaveBeenCalledWith('Error taking photo', CAMERA_CLOSED_ERROR.message); + }); + + it('still logs a warning, without an alert, when the camera fails for another reason after the user left the scan screen', async () => { + // Given a capture is still in progress on the focused scan screen + renderFocusedCamera(); + pressShutter(); + + // When the user leaves the screen and the capture fails with an error that is not the camera closing + mockIsFocused = false; + const writeError = Object.assign(new Error('Failed to write the photo to disk'), {code: 'capture/file-io-error'}); + await failCapture(writeError); + + // Then no alert appears on the screen the user went to, but the failure stays a warning because it is a real camera error, not a cancellation + expect(alertSpy).not.toHaveBeenCalled(); + expect(mockLogWarn).toHaveBeenCalledWith('Error taking photo', writeError.message); + expect(mockLogInfo).not.toHaveBeenCalledWith('Photo capture cancelled by leaving the screen', expect.anything(), expect.anything()); + }); + + it('still logs a warning, without an alert, when saving the photo fails after the user left the scan screen', async () => { + // Given a capture is still in progress on the focused scan screen, and saving the photo will fail + const saveError = new Error('Could not move the photo into the receipts folder'); + mockAdopt = () => Promise.reject(saveError); + renderFocusedCamera(); + pressShutter(); + + // When the user leaves the screen and the photo is taken but fails to save + mockIsFocused = false; + await finishCapture(); + + // Then no alert appears on the screen the user went to, but this is a real storage failure, so it stays a warning instead of being logged as a cancellation + expect(alertSpy).not.toHaveBeenCalled(); + expect(mockLogWarn).toHaveBeenCalledWith('Error taking photo', saveError.message); + expect(mockLogInfo).not.toHaveBeenCalledWith('Photo capture cancelled by leaving the screen', expect.anything(), expect.anything()); + }); +}); diff --git a/tests/unit/components/SelectionList/useSelectionListKeyboardFocus.test.ts b/tests/unit/components/SelectionList/useSelectionListKeyboardFocus.test.ts index 79c1feab1d42..87b604d3f277 100644 --- a/tests/unit/components/SelectionList/useSelectionListKeyboardFocus.test.ts +++ b/tests/unit/components/SelectionList/useSelectionListKeyboardFocus.test.ts @@ -29,6 +29,8 @@ type Overrides = { shouldScrollToFocusedIndex?: boolean; shouldDebounceScrolling?: boolean; initialFocusedIndex?: number; + shouldAllowArrowUpToSearchInput?: boolean; + onArrowUpToSearchInput?: () => void; }; function renderKeyboardFocus(overrides: Overrides = {}) { @@ -100,6 +102,44 @@ describe('useSelectionListKeyboardFocus', () => { }); }); + describe('onArrowUpToSearchInput', () => { + it('is called when ArrowUp moves the focused index to the search input', () => { + // Given a list that lets ArrowUp leave the first row for the search input + const onArrowUpToSearchInput = jest.fn(); + renderKeyboardFocus({shouldAllowArrowUpToSearchInput: true, onArrowUpToSearchInput}); + + // When an arrow-key move sets the focused index to -1 + capturedConfig.onFocusedIndexChange?.(-1, true); + + // Then the list is told to move DOM focus to the search input, since a Tab-focused row would otherwise keep it + expect(onArrowUpToSearchInput).toHaveBeenCalledTimes(1); + }); + + it('is not called when the focused index is cleared without an arrow key', () => { + // Given a list that lets ArrowUp leave the first row for the search input + const onArrowUpToSearchInput = jest.fn(); + renderKeyboardFocus({shouldAllowArrowUpToSearchInput: true, onArrowUpToSearchInput}); + + // When the focused index is cleared externally, for example by pressing the search input + capturedConfig.onFocusedIndexChange?.(-1, false); + + // Then focus is left alone, because the search input already holds it + expect(onArrowUpToSearchInput).not.toHaveBeenCalled(); + }); + + it('is not called when the list does not opt in', () => { + // Given a list that keeps the default ArrowUp behavior + const onArrowUpToSearchInput = jest.fn(); + renderKeyboardFocus({onArrowUpToSearchInput}); + + // When an arrow-key move sets the focused index to -1 + capturedConfig.onFocusedIndexChange?.(-1, true); + + // Then DOM focus is not moved, preserving existing behavior for other lists + expect(onArrowUpToSearchInput).not.toHaveBeenCalled(); + }); + }); + describe('onArrowUpDownCallback', () => { it('disables hover styling and announces a programmatic scroll', () => { const {setShouldDisableHoverStyle, announceProgrammaticScroll} = renderKeyboardFocus(); diff --git a/tests/unit/components/Table/buildTableListDataTest.ts b/tests/unit/components/Table/buildTableListDataTest.ts index 522e9fbdae97..e2bd0c928492 100644 --- a/tests/unit/components/Table/buildTableListDataTest.ts +++ b/tests/unit/components/Table/buildTableListDataTest.ts @@ -7,32 +7,32 @@ const {COLUMN_HEADER_PLACEMENT} = CONST.TABLES; const pageHeader = 'Page header'; describe('getTableListMetadata', () => { - // Given a table with a page header whose columns fit and can't be resized + // Given a table with a page header on a platform that can't pin content inside the scroller // When the column header is placed - // Then it rides FlashList's sticky-row overlay, which is the cheapest way to keep it stuck - it('places the column header in the sticky row when there is no horizontal scroller', () => { + // Then it rides FlashList's sticky-row overlay, the only way left to keep it stuck + it('places the column header in the sticky row when the scroller cannot pin it', () => { const metadata = getTableListMetadata({ listHeaderElement: pageHeader, hasColumnHeaderElement: true, hasRows: true, isColumnHeaderHiddenInNarrowLayout: false, - hasHorizontalScrollContainer: false, + canStickColumnHeaderInScroller: false, }); expect(metadata.columnHeaderPlacement).toBe(COLUMN_HEADER_PLACEMENT.STICKY_ROW); expect(metadata.syntheticRowsBeforeData).toBe(1); }); - // Given a table with a page header that sits in a horizontal scroller, which every resizable table does even while its columns fit + // Given a table with a page header on a platform that can pin content inside the scroller // When the column header is placed - // Then it goes in the list header so it scrolls sideways with the columns, and doesn't move once a drag starts overflowing them - it('places the column header in the list header when there is a horizontal scroller', () => { + // Then it goes in the list header, which keeps it clear of the scrollbar and scrolls it sideways with the columns + it('places the column header in the list header when the scroller can pin it', () => { const metadata = getTableListMetadata({ listHeaderElement: pageHeader, hasColumnHeaderElement: true, hasRows: true, isColumnHeaderHiddenInNarrowLayout: false, - hasHorizontalScrollContainer: true, + canStickColumnHeaderInScroller: true, }); expect(metadata.columnHeaderPlacement).toBe(COLUMN_HEADER_PLACEMENT.LIST_HEADER); @@ -40,22 +40,22 @@ describe('getTableListMetadata', () => { }); // Given a table without a page header - // When the column header is placed, with or without a horizontal scroller + // When the column header is placed, on either kind of platform // Then it stays outside the list either way, since the ancestor scroller already carries it with the columns it('keeps the column header outside the list when there is no page header', () => { - for (const hasHorizontalScrollContainer of [false, true]) { + for (const canStickColumnHeaderInScroller of [false, true]) { const metadata = getTableListMetadata({ hasColumnHeaderElement: true, hasRows: true, isColumnHeaderHiddenInNarrowLayout: false, - hasHorizontalScrollContainer, + canStickColumnHeaderInScroller, }); expect(metadata.columnHeaderPlacement).toBe(COLUMN_HEADER_PLACEMENT.OUTSIDE_LIST); } }); - // Given a table with a horizontal scroller but no rows + // Given a table with a page header but no rows // When the column header is placed // Then there is none, since a header over an empty table only labels nothing it('renders no column header when there are no rows', () => { @@ -64,7 +64,7 @@ describe('getTableListMetadata', () => { hasColumnHeaderElement: true, hasRows: false, isColumnHeaderHiddenInNarrowLayout: false, - hasHorizontalScrollContainer: true, + canStickColumnHeaderInScroller: true, }); expect(metadata.columnHeaderPlacement).toBe(COLUMN_HEADER_PLACEMENT.NONE); diff --git a/tests/unit/components/Table/useColumnResizeTest.ts b/tests/unit/components/Table/useColumnResizeTest.ts index e253a428d273..e331d9cbee9a 100644 --- a/tests/unit/components/Table/useColumnResizeTest.ts +++ b/tests/unit/components/Table/useColumnResizeTest.ts @@ -1,10 +1,10 @@ import {act, renderHook} from '@testing-library/react-native'; -import {RESIZE_INDICATOR_OPACITY_VARIABLE, getColumnWidthVariableName} from '@components/Table/columnResize/columnWidthExpressions'; +import {RESIZE_GRIP_HOVER_VARIABLE, RESIZE_INDICATOR_OPACITY_VARIABLE, getColumnWidthVariableName} from '@components/Table/columnResize/columnWidthExpressions'; import type UseColumnResize from '@components/Table/columnResize/useColumnResize'; import type {UseColumnResizeParams} from '@components/Table/columnResize/useColumnResize/types'; -import {setTableColumnWidth} from '@libs/actions/TableColumnWidths'; +import {setTableColumnWidths} from '@libs/actions/TableColumnWidths'; import type React from 'react'; @@ -15,7 +15,7 @@ const {default: useColumnResize} = jest.requireActual<{default: typeof UseColumn jest.mock('@styles/utils/cursor', () => jest.requireActual>('@styles/utils/cursor/index.ts')); jest.mock('@libs/actions/TableColumnWidths', () => ({ - setTableColumnWidth: jest.fn(), + setTableColumnWidths: jest.fn(), })); const COLUMN_RESIZING_ID = 'testTable'; @@ -24,22 +24,42 @@ const NAME_COLUMN_KEY = 'name'; const resolvedColumnWidths = {name: 200, email: 200, role: 200}; -type PointerEventInit = {clientX: number; button?: number}; +/** Stands in for the resolver: the email column absorbs the whole resize. */ +function getResizedColumnWidths(columnKey: string, width: number): Record { + return {[columnKey]: width, email: 400 - width}; +} + +/** Stands in for the resolver's layout, wrapping the painted widths in what the layout was sized for. */ +function getStoredTableColumnWidths(columnWidths: Record, columnKey: string) { + return {availableColumnsWidth: 600, columnWidths, resizedColumnKeys: [columnKey]}; +} + +type PointerEventInit = {clientX: number; clientY?: number; button?: number}; /** A pointer event carrying only what the hook reads, aimed at the given handle. */ -function createPointerEvent(handleElement: HTMLDivElement, {clientX, button = 0}: PointerEventInit): React.PointerEvent { - const event = {button, clientX, clientY: 0, pointerId: 1, currentTarget: handleElement, preventDefault: jest.fn(), stopPropagation: jest.fn()}; +function createPointerEvent(handleElement: HTMLDivElement, {clientX, clientY = 0, button = 0}: PointerEventInit): React.PointerEvent { + const event = {button, clientX, clientY, pointerId: 1, currentTarget: handleElement, preventDefault: jest.fn(), stopPropagation: jest.fn()}; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the hook only reads the fields above return event as unknown as React.PointerEvent; } -/** Renders the hook with a scope element and one handle inside it, the way the table mounts them. */ +/** Renders the hook with a scope element and one heading cell holding a handle inside it, the way the table mounts them. */ function renderColumnResize(params?: Partial) { const scopeElement = document.createElement('div'); + const headingElement = document.createElement('div'); const handleElement = document.createElement('div'); - scopeElement.appendChild(handleElement); + scopeElement.appendChild(headingElement); + headingElement.appendChild(handleElement); + // jsdom has no layout: a 200x40 heading whose handle starts 2px inside its right edge and spans the 12px gap past it. + document.elementFromPoint = (x, y) => { + if (y > 40 || x > 212) { + return document.body; + } + + return x > 198 ? handleElement : headingElement; + }; // jsdom has no pointer capture. const setPointerCapture = jest.fn(); @@ -50,7 +70,9 @@ function renderColumnResize(params?: Partial) { const initialProps: UseColumnResizeParams = { columnResizingID: COLUMN_RESIZING_ID, resizableColumnKeys: [NAME_COLUMN_KEY], + getResizedColumnWidths, resolvedColumnWidths, + getStoredTableColumnWidths, columnGap: 12, ...params, }; @@ -76,7 +98,20 @@ function renderColumnResize(params?: Partial) { const readWidth = (columnKey: string) => scopeElement.style.getPropertyValue(getColumnWidthVariableName(columnKey)); - return {...hook, initialProps, scopeElement, handleElement, setPointerCapture, getHandleProps, readWidth}; + const readGripHover = () => handleElement.style.getPropertyValue(RESIZE_GRIP_HOVER_VARIABLE); + + /** Hands the handle element to the handle's ref, the way React does when it mounts the handle. */ + const mountHandle = () => { + const {ref} = getHandleProps(); + + if (typeof ref !== 'function') { + throw new Error('Expected the handle to take a callback ref'); + } + + return ref(handleElement); + }; + + return {...hook, initialProps, scopeElement, headingElement, handleElement, setPointerCapture, getHandleProps, readWidth, readGripHover, mountHandle}; } describe('useColumnResize', () => { @@ -87,7 +122,14 @@ describe('useColumnResize', () => { it('returns no controller when resizing is off', () => { // Given a table with no resizing ID, which is how native, narrow layouts and tables that didn't opt in render it - const params: UseColumnResizeParams = {columnResizingID: undefined, resizableColumnKeys: [NAME_COLUMN_KEY], resolvedColumnWidths, columnGap: 12}; + const params: UseColumnResizeParams = { + columnResizingID: undefined, + resizableColumnKeys: [NAME_COLUMN_KEY], + getResizedColumnWidths, + resolvedColumnWidths, + getStoredTableColumnWidths, + columnGap: 12, + }; // When the hook runs const {result} = renderHook(() => useColumnResize(params)); @@ -98,7 +140,14 @@ describe('useColumnResize', () => { it('returns a controller before the columns are measured', () => { // Given a resizable table on its first render, before layout, when no column is resizable yet - const params: UseColumnResizeParams = {columnResizingID: COLUMN_RESIZING_ID, resizableColumnKeys: [], resolvedColumnWidths: {}, columnGap: 12}; + const params: UseColumnResizeParams = { + columnResizingID: COLUMN_RESIZING_ID, + resizableColumnKeys: [], + getResizedColumnWidths, + resolvedColumnWidths: {}, + getStoredTableColumnWidths, + columnGap: 12, + }; // When the hook runs const {result} = renderHook(() => useColumnResize(params)); @@ -110,7 +159,7 @@ describe('useColumnResize', () => { }); it('paints a drag onto the scope and stores the dragged column on release', () => { - // Given a 200px column with two 200px columns after it + // Given a 200px column with two 200px columns after it, the first of which absorbs its resize const {handleElement, getHandleProps, readWidth} = renderColumnResize(); // When its edge is dragged 60px right @@ -119,10 +168,9 @@ describe('useColumnResize', () => { getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 160})); }); - // Then the width is painted straight onto the scope without a React render, and later columns keep their widths + // Then the dragged column and its absorber are painted straight onto the scope without a React render expect(readWidth('name')).toBe('260px'); - expect(readWidth('email')).toBe(''); - expect(readWidth('role')).toBe(''); + expect(readWidth('email')).toBe('140px'); expect(document.body.style.cursor).toBe('col-resize'); // When the pointer is released @@ -130,9 +178,9 @@ describe('useColumnResize', () => { getHandleProps().onPointerUp?.(createPointerEvent(handleElement, {clientX: 160})); }); - // Then the dragged column is stored, once - expect(setTableColumnWidth).toHaveBeenCalledTimes(1); - expect(setTableColumnWidth).toHaveBeenCalledWith(COLUMN_RESIZING_ID, 'name', 260); + // Then every painted width is saved once, with the dragged column marked as resized, so a reload moves nothing + expect(setTableColumnWidths).toHaveBeenCalledTimes(1); + expect(setTableColumnWidths).toHaveBeenCalledWith(COLUMN_RESIZING_ID, getStoredTableColumnWidths({name: 260, email: 140, role: 200}, 'name')); expect(document.body.style.cursor).toBe(''); }); @@ -152,8 +200,45 @@ describe('useColumnResize', () => { }); // Then nothing is stored, and the scope is cleared since no render follows to clear it - expect(setTableColumnWidth).not.toHaveBeenCalled(); + expect(setTableColumnWidths).not.toHaveBeenCalled(); + expect(readWidth('name')).toBe(''); + }); + + it('stores nothing and clears the scope when the table has no layout to save', () => { + // Given a resizable table that hands over no layout to save + const {handleElement, getHandleProps, readWidth} = renderColumnResize({getStoredTableColumnWidths: undefined}); + + act(() => { + getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); + getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 160})); + }); + + // When a drag that moved the column is released + act(() => { + getHandleProps().onPointerUp?.(createPointerEvent(handleElement, {clientX: 160})); + }); + + // Then nothing is stored, and the painted widths are cleared since no stored width renders to replace them + expect(setTableColumnWidths).not.toHaveBeenCalled(); expect(readWidth('name')).toBe(''); + expect(readWidth('email')).toBe(''); + }); + + it('keeps the painted widths when new resolved widths render mid-drag', () => { + // Given a column being dragged 60px right + const {handleElement, getHandleProps, readWidth, rerender, initialProps} = renderColumnResize(); + + act(() => { + getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); + getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 160})); + }); + + // When the table renders new resolved widths before the drag ends, e.g. because the window resized + rerender({...initialProps, resolvedColumnWidths: {name: 210, email: 190, role: 200}}); + + // Then the drag keeps painting where the pointer put it, so the column doesn't jump away from the pointer + expect(readWidth('name')).toBe('260px'); + expect(readWidth('email')).toBe('140px'); }); it('stores the width when the pointer capture is lost mid-drag', () => { @@ -171,12 +256,12 @@ describe('useColumnResize', () => { }); // Then the drag ends the same way a release does, so the width the user already sees is kept - expect(setTableColumnWidth).toHaveBeenCalledTimes(1); - expect(setTableColumnWidth).toHaveBeenCalledWith(COLUMN_RESIZING_ID, 'name', 240); + expect(setTableColumnWidths).toHaveBeenCalledTimes(1); + expect(setTableColumnWidths).toHaveBeenCalledWith(COLUMN_RESIZING_ID, expect.objectContaining({columnWidths: expect.objectContaining({name: 240}), resizedColumnKeys: ['name']})); expect(document.body.style.cursor).toBe(''); }); - it('shows the edge line only while dragging, not on hover', () => { + it('shows the edge line while dragging and hides it on release', () => { // Given a resizable column const {handleElement, getHandleProps} = renderColumnResize(); const readLineOpacity = () => handleElement.style.getPropertyValue(RESIZE_INDICATOR_OPACITY_VARIABLE); @@ -198,6 +283,123 @@ describe('useColumnResize', () => { expect(readLineOpacity()).toBe('0'); }); + it('shows the grip while the heading is hovered', () => { + // Given a mounted resizable column + const {headingElement, readGripHover, mountHandle} = renderColumnResize(); + + mountHandle(); + + // When the pointer enters its heading cell anywhere, not only over the edge + headingElement.dispatchEvent(new Event('pointerenter')); + + // Then the grip shows, so the user can find the edge before reaching it + expect(readGripHover()).toBe('1'); + + // When the pointer leaves the heading + headingElement.dispatchEvent(new Event('pointerleave')); + + // Then the grip goes away + expect(readGripHover()).toBe('0'); + }); + + it('stops tracking the heading once the handle unmounts', () => { + // Given a resizable column whose handle mounted and then unmounted + const {headingElement, readGripHover, mountHandle} = renderColumnResize(); + + const cleanUp = mountHandle(); + cleanUp?.(); + + // When the pointer enters the heading afterwards + headingElement.dispatchEvent(new Event('pointerenter')); + + // Then nothing is written, so a heading outliving its handle doesn't keep a listener writing to a detached element + expect(readGripHover()).toBe(''); + }); + + it('hides the grip when a drag is released off the heading', () => { + // Given a hovered heading whose edge is being dragged + const {headingElement, handleElement, getHandleProps, readGripHover, mountHandle} = renderColumnResize(); + + mountHandle(); + headingElement.dispatchEvent(new Event('pointerenter')); + + act(() => { + getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 190, clientY: 20})); + getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 400, clientY: 300})); + }); + + // When the pointer is released outside the heading, where pointer capture kept the heading from hearing it leave + act(() => { + getHandleProps().onPointerUp?.(createPointerEvent(handleElement, {clientX: 400, clientY: 300})); + }); + + // Then the grip hides rather than lingering on a heading the pointer isn't over + expect(readGripHover()).toBe('0'); + }); + + it('hides the grip when the pointer capture is lost off the heading', () => { + // Given a hovered heading whose edge is being dragged off the heading + const {headingElement, handleElement, getHandleProps, readGripHover, mountHandle} = renderColumnResize(); + + mountHandle(); + headingElement.dispatchEvent(new Event('pointerenter')); + + act(() => { + getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 190, clientY: 20})); + getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 400, clientY: 300})); + }); + + // When the browser takes the pointer capture back there, e.g. because the window lost focus + act(() => { + getHandleProps().onLostPointerCapture?.(createPointerEvent(handleElement, {clientX: 400, clientY: 300})); + }); + + // Then the grip hides, the same as a release there, rather than lingering on a heading the pointer isn't over + expect(readGripHover()).toBe('0'); + }); + + it('keeps the grip when a drag is released on the heading', () => { + // Given a hovered heading whose edge is being dragged + const {headingElement, handleElement, getHandleProps, readGripHover, mountHandle} = renderColumnResize(); + + mountHandle(); + headingElement.dispatchEvent(new Event('pointerenter')); + + act(() => { + getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 190, clientY: 20})); + getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 150, clientY: 20})); + }); + + // When the pointer is released still inside the heading + act(() => { + getHandleProps().onPointerUp?.(createPointerEvent(handleElement, {clientX: 150, clientY: 20})); + }); + + // Then the grip comes back, since the pointer is still over the heading + expect(readGripHover()).toBe('1'); + }); + + it('keeps the grip when a drag is released on the handle overhanging the heading', () => { + // Given a hovered heading whose edge is being dragged + const {headingElement, handleElement, getHandleProps, readGripHover, mountHandle} = renderColumnResize(); + + mountHandle(); + headingElement.dispatchEvent(new Event('pointerenter')); + + act(() => { + getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 190, clientY: 20})); + getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 203, clientY: 20})); + }); + + // When the pointer is released on the part of the handle past the heading's box, where a drag usually ends + act(() => { + getHandleProps().onPointerUp?.(createPointerEvent(handleElement, {clientX: 203, clientY: 20})); + }); + + // Then the grip comes back, since the browser still counts the pointer as inside the heading and won't fire another pointerenter + expect(readGripHover()).toBe('1'); + }); + it('keeps a dragged width above its floor', () => { // Given a 200px column that may not shrink below 180px const {handleElement, getHandleProps, readWidth} = renderColumnResize({dragMinWidths: {[NAME_COLUMN_KEY]: 180}}); diff --git a/tests/unit/components/Table/useDynamicColumnWidthsTest.ts b/tests/unit/components/Table/useDynamicColumnWidthsTest.ts new file mode 100644 index 000000000000..5bbf270a59b8 --- /dev/null +++ b/tests/unit/components/Table/useDynamicColumnWidthsTest.ts @@ -0,0 +1,137 @@ +import {renderHook} from '@testing-library/react-native'; + +import type {TableColumn, TableData} from '@components/Table/types'; +import useDynamicColumnWidths from '@components/Table/useDynamicColumnWidths'; + +import type {StoredTableColumnWidths} from '@src/types/onyx/TableColumnWidths'; + +// Text renders 10px per character, so a column's width follows its longest value. +jest.mock('@libs/measureTextWidth', () => ({ + __esModule: true, + default: (text: string) => text.length * 10, + canMeasureText: () => true, +})); + +// Row chrome the hook subtracts, with round numbers so the width left for the columns is easy to work out. +jest.mock('@hooks/useThemeStyles', () => ({ + __esModule: true, + default: () => ({gap3: {gap: 12}, ph3: {paddingHorizontal: 12}, ml1: {marginLeft: 4}, editableCellHeader: {}}), +})); + +jest.mock('@hooks/useLayoutSpacing', () => ({ + __esModule: true, + default: () => ({values: {pageGutter: 20}}), +})); + +type Tax = TableData & {name: string; description: string}; + +const COLUMNS: Array> = [ + {key: 'name', label: 'Name', sortable: true, dynamicSizing: {getContentToMeasure: (tax) => [{text: tax.name}]}}, + {key: 'description', label: 'Description', sortable: true, dynamicSizing: {getContentToMeasure: (tax) => [{text: tax.description}]}}, +]; + +const SHORT_DATA: Tax[] = [{keyForList: 'vat', name: 'VAT', description: 'Value added tax on goods and services sold in the region'}]; + +const LONG_DATA: Tax[] = [...SHORT_DATA, {keyForList: 'regional', name: 'Regional sales and use tax for the metropolitan transit district', description: 'Short'}]; + +/** 1000.5px wide with a 20px selection column, 24px of gaps, 40px of margin and 24px of padding, leaving 892px for the columns. */ +const TABLE_WIDTH = 1000.5; +const AVAILABLE_COLUMNS_WIDTH = 892; + +type HookProps = { + data: Tax[]; + storedTableColumnWidths?: StoredTableColumnWidths; + isEnabled?: boolean; + isColumnResizingEnabled?: boolean; + columns?: Array>; +}; + +function renderDynamicColumnWidths(initialProps: HookProps) { + return renderHook( + ({data, storedTableColumnWidths, isEnabled = true, isColumnResizingEnabled = true, columns = COLUMNS}: HookProps) => + useDynamicColumnWidths({columns, data, tableWidth: TABLE_WIDTH, isEnabled, hasSelectionColumn: true, isColumnResizingEnabled, storedTableColumnWidths}), + {initialProps}, + ); +} + +describe('useDynamicColumnWidths', () => { + it('keeps a dragged table where it was saved when its content grows', () => { + // Given a table whose layout was saved after the user dragged the name column + const {result, rerender} = renderDynamicColumnWidths({data: SHORT_DATA}); + const savedWidths = result.current.resolvedColumnWidths; + const stored = result.current.getStoredTableColumnWidths?.(savedWidths, 'name'); + + // When a tax with a far longer name is added + rerender({data: LONG_DATA, storedTableColumnWidths: stored}); + + // Then every column paints at its saved width, since content never moves the columns of a frozen table + expect(result.current.resolvedColumnWidths).toEqual(savedWidths); + }); + + it('sizes the columns from their content until the first drag', () => { + // Given a table nobody has dragged, whose names are short + const {result, rerender} = renderDynamicColumnWidths({data: SHORT_DATA}); + const shortNameWidth = result.current.resolvedColumnWidths.name ?? 0; + + // When a tax with a far longer name is added + rerender({data: LONG_DATA}); + + // Then the name column widens to fit it while the row still fills the width left for the columns + expect(result.current.resolvedColumnWidths.name).toBeGreaterThan(shortNameWidth); + expect((result.current.resolvedColumnWidths.name ?? 0) + (result.current.resolvedColumnWidths.description ?? 0)).toBe(AVAILABLE_COLUMNS_WIDTH); + }); + + it('saves the dragged column as resized along with the width the layout was sized for', () => { + // Given a table nobody has dragged + const {result, rerender} = renderDynamicColumnWidths({data: SHORT_DATA}); + const columnWidths = {name: 300, description: 592}; + + // When the name column's drag ends + const firstSave = result.current.getStoredTableColumnWidths?.(columnWidths, 'name'); + + // Then the save records the painted widths, the name column as resized and the 892px the columns had, so a + // reload can tell how much room changed since + expect(firstSave).toEqual({availableColumnsWidth: AVAILABLE_COLUMNS_WIDTH, columnWidths, resizedColumnKeys: ['name']}); + + // When the name column is dragged again, and then the description column + rerender({data: SHORT_DATA, storedTableColumnWidths: firstSave}); + const secondSave = result.current.getStoredTableColumnWidths?.(columnWidths, 'name'); + const thirdSave = result.current.getStoredTableColumnWidths?.(columnWidths, 'description'); + + // Then a column already resized is listed once, and a newly dragged one joins it, since a save replaces the record + expect(secondSave?.resizedColumnKeys).toEqual(['name']); + expect(thirdSave?.resizedColumnKeys).toEqual(['name', 'description']); + }); + + it('saves nothing when dynamic sizing is off', () => { + // Given a table that hasn't opted into dynamic sizing, which narrow layouts also turn off + // When the hook runs + const {result} = renderDynamicColumnWidths({data: SHORT_DATA, isEnabled: false}); + + // Then there is no edge to drag and no layout to save, so the table keeps its static tracks + expect(result.current.resizableColumnKeys).toEqual([]); + expect(result.current.getStoredTableColumnWidths).toBeUndefined(); + }); + + it('saves nothing when no column is sized from its content', () => { + // Given a table whose every column declares a fixed width + const fixedColumns: Array> = COLUMNS.map((column) => ({...column, width: 200, dynamicSizing: undefined})); + + // When the hook runs + const {result} = renderDynamicColumnWidths({data: SHORT_DATA, columns: fixedColumns}); + + // Then there is no edge to drag and no layout to save, since fixed widths hold fixed-size content + expect(result.current.resizableColumnKeys).toEqual([]); + expect(result.current.getStoredTableColumnWidths).toBeUndefined(); + }); + + it('saves nothing when resizing is off', () => { + // Given a content-sized table that hasn't opted into resizing + // When the hook runs + const {result} = renderDynamicColumnWidths({data: SHORT_DATA, isColumnResizingEnabled: false}); + + // Then there is no edge to drag and no layout to save, so content keeps sizing the columns + expect(result.current.resizableColumnKeys).toEqual([]); + expect(result.current.getStoredTableColumnWidths).toBeUndefined(); + }); +}); diff --git a/tests/unit/components/VacationDelegateMenuItemTest.tsx b/tests/unit/components/VacationDelegateMenuItemTest.tsx index 76da533e9261..baf0caa917f8 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(); }); @@ -246,4 +243,39 @@ 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, 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', () => { + // 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 pill, so the vacationer and admins can see when it ends + expect(screen.getByText('Jane Doe')).toBeTruthy(); + expect(screen.getByText('statusPage.vacationDelegate.until')).toBeTruthy(); + }); }); diff --git a/tests/unit/components/useDetachedSignEditingControllerTest.tsx b/tests/unit/components/useDetachedSignEditingControllerTest.tsx index 78370568dd5f..f0ec44e3ab59 100644 --- a/tests/unit/components/useDetachedSignEditingControllerTest.tsx +++ b/tests/unit/components/useDetachedSignEditingControllerTest.tsx @@ -68,7 +68,7 @@ describe('useDetachedSignEditingController', () => { expect(result.current.value).toBe('-12'); }); - it('toggles the sign and deletes it before the caret through its actions', () => { + it('toggles the sign and removes it through deleteBackward with the caret at the start', () => { // Given a positive value const {result} = renderController({value: '5'}); @@ -78,17 +78,62 @@ describe('useDetachedSignEditingController', () => { // Then the value is negative expect(result.current.value).toBe('-5'); - // When the caret is at the start and the sign before caret is deleted + // When the caret is at the start and the pad deletes backward act(() => result.current.handleSelectionChange(0, 0)); - act(() => { - const deleted = result.current.deleteSignBeforeCaret(); - expect(deleted).toBe(true); - }); + act(() => result.current.deleteBackward()); - // Then the value is positive again + // Then only the sign is removed, and the magnitude stays expect(result.current.value).toBe('5'); }); + it('removes the sign through deleteBackward when the magnitude is empty', () => { + // Given a negative value with no magnitude, as after the user typed only a minus + const {result} = renderController({value: '-'}); + + // When the pad deletes backward + act(() => result.current.deleteBackward()); + + // Then the sign is removed, since there is no digit to delete + expect(result.current.value).toBe(''); + }); + + it('deletes the digit and keeps the sign through deleteBackward with the caret mid-text', () => { + // Given a negative value with the caret between its digits + const {result} = renderController({value: '-12'}); + act(() => result.current.handleSelectionChange(1, 1)); + + // When the pad deletes backward + act(() => result.current.deleteBackward()); + + // Then the digit before the caret is deleted, because something precedes the caret, and the sign stays + expect(result.current.value).toBe('-2'); + }); + + it('does nothing through deleteBackward for a positive value with the caret at the start', () => { + // Given a positive value with the caret at the start + const {result, onInputChange} = renderController({value: '12'}); + act(() => result.current.handleSelectionChange(0, 0)); + + // When the pad deletes backward + act(() => result.current.deleteBackward()); + + // Then there is no sign or digit before the caret, so nothing changes + expect(result.current.value).toBe('12'); + expect(onInputChange).not.toHaveBeenCalled(); + }); + + it('clears only the sign on a backspace key press at the start of a negative value', () => { + // Given a negative value with the caret at the start + const {result} = renderController({value: '-12'}); + act(() => result.current.handleSelectionChange(0, 0)); + + // When the user presses backspace on the keyboard + act(() => result.current.handleKeyPress(buildKeyPressEvent('Backspace'))); + + // Then the sign is cleared and the magnitude is untouched, so a native deletion is not doubled by the controller + expect(result.current.value).toBe('12'); + }); + it('ignores a sign toggle when negative values are not allowed', () => { // Given a controller that disallows negative values const {result, onInputChange} = renderController({value: '5', allowNegative: false}); diff --git a/tests/unit/components/useNumericEditingControllerTest.ts b/tests/unit/components/useNumericEditingControllerTest.ts new file mode 100644 index 000000000000..6f06819c53ad --- /dev/null +++ b/tests/unit/components/useNumericEditingControllerTest.ts @@ -0,0 +1,124 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useNumericEditingController from '@components/NumericEditingController/hooks/useNumericEditingController'; + +import type * as NativeNavigation from '@react-navigation/native'; + +jest.mock('@hooks/useLocalize', () => () => ({ + fromLocaleDigit: (digit: string) => digit, + toLocaleDigit: (digit: string) => digit, +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: jest.fn(() => true), +})); + +const renderController = (value = '123') => { + const onInputChange = jest.fn(); + const hook = renderHook(() => useNumericEditingController({value, onInputChange})); + return {...hook, onInputChange}; +}; + +describe('useNumericEditingController', () => { + describe('insertAtSelection', () => { + it('inserts text at a collapsed caret', () => { + // Given a controller whose caret sits between "1" and "23" + const {result, onInputChange} = renderController('123'); + act(() => { + result.current.handleSelectionChange(1, 1); + }); + + // When text is inserted + act(() => { + result.current.insertAtSelection('9'); + }); + + // Then the text lands at the caret rather than at the end + expect(onInputChange).toHaveBeenLastCalledWith('1923'); + }); + + it('replaces a selected range', () => { + // Given a controller with "23" highlighted + const {result, onInputChange} = renderController('123'); + act(() => { + result.current.handleSelectionChange(1, 3); + }); + + // When text is inserted + act(() => { + result.current.insertAtSelection('9'); + }); + + // Then the highlighted range is replaced, as typing over a selection does + expect(onInputChange).toHaveBeenLastCalledWith('19'); + }); + + it('inserts text at the start when the caret is at the start', () => { + // Given a controller whose caret sits before the first digit + const {result, onInputChange} = renderController('123'); + act(() => { + result.current.handleSelectionChange(0, 0); + }); + + // When text is inserted + act(() => { + result.current.insertAtSelection('9'); + }); + + // Then the text is prepended + expect(onInputChange).toHaveBeenLastCalledWith('9123'); + }); + }); + + describe('deleteBackward', () => { + it('deletes the character before a collapsed caret', () => { + // Given a controller whose caret sits between "12" and "3" + const {result, onInputChange} = renderController('123'); + act(() => { + result.current.handleSelectionChange(2, 2); + }); + + // When backspace is applied + act(() => { + result.current.deleteBackward(); + }); + + // Then only the character before the caret is removed + expect(onInputChange).toHaveBeenLastCalledWith('13'); + }); + + it('deletes a selected range', () => { + // Given a controller with "23" highlighted + const {result, onInputChange} = renderController('123'); + act(() => { + result.current.handleSelectionChange(1, 3); + }); + + // When backspace is applied + act(() => { + result.current.deleteBackward(); + }); + + // Then the whole range is removed and no extra character before it + expect(onInputChange).toHaveBeenLastCalledWith('1'); + }); + + it('does nothing when the caret is at the start', () => { + // Given a controller whose caret sits before the first digit + const {result, onInputChange} = renderController('123'); + act(() => { + result.current.handleSelectionChange(0, 0); + }); + + // When backspace is applied + act(() => { + result.current.deleteBackward(); + }); + + // Then there is nothing before the caret to delete, so the value is unchanged + expect(onInputChange).not.toHaveBeenCalled(); + expect(result.current.formattedNumber).toBe('123'); + }); + }); +}); diff --git a/tests/unit/components/useNumericSelectionTest.ts b/tests/unit/components/useNumericSelectionTest.ts index a1be238ebed4..aa60ab9d0863 100644 --- a/tests/unit/components/useNumericSelectionTest.ts +++ b/tests/unit/components/useNumericSelectionTest.ts @@ -402,4 +402,67 @@ describe('useNumericSelection', () => { expect(result.current.selection).toEqual({start: 1, end: 1}); }); }); + + describe('setShouldUpdateSelection', () => { + it('drops native selection change events while updates are paused', () => { + // Given selection initialized at the end of the text + const {result} = renderSelection('12'); + + // When selection updates are paused + act(() => { + result.current.setShouldUpdateSelection(false); + }); + + // And a native selection change arrives + act(() => { + result.current.handleNativeSelectionChange(0, 0); + }); + + // Then the caret position does not change + expect(result.current.selection).toEqual({start: 2, end: 2}); + }); + + it('applies native selection change events after updates are resumed', () => { + // Given selection initialized at the end of the text and paused + const {result} = renderSelection('12'); + act(() => { + result.current.setShouldUpdateSelection(false); + }); + + // When selection updates are resumed + act(() => { + result.current.setShouldUpdateSelection(true); + }); + + // And a native selection change arrives + act(() => { + result.current.handleNativeSelectionChange(0, 0); + }); + + // Then the caret position updates + expect(result.current.selection).toEqual({start: 0, end: 0}); + }); + + it('resumes native selection change events when the screen regains focus', () => { + // Given selection updates paused by a long press that never reported its end, because the user left the screen + const {result, rerender} = renderSelection('12'); + act(() => { + result.current.setShouldUpdateSelection(false); + }); + mockUseIsFocused.mockReturnValue(false); + rerender({displayText: '12'}); + + // When the user returns to the screen + mockUseIsFocused.mockReturnValue(true); + rerender({displayText: '12'}); + + // And a native selection change arrives + act(() => { + result.current.handleNativeSelectionChange(0, 0); + }); + + // Then the caret follows it instead of staying stuck at the end + expect(result.current.selection).toEqual({start: 0, end: 0}); + }); + }); }); diff --git a/tests/unit/escapeCsvFieldTest.ts b/tests/unit/escapeCsvFieldTest.ts new file mode 100644 index 000000000000..6f92ef4b8e43 --- /dev/null +++ b/tests/unit/escapeCsvFieldTest.ts @@ -0,0 +1,28 @@ +import escapeCsvField from '@libs/escapeCsvField'; + +describe('escapeCsvField', () => { + it.each([ + ['', ''], + ['Café', 'Café'], + ['123.45', '123.45'], + ['Shop, "Central"\r\nBranch', '"Shop, ""Central""\r\nBranch"'], + ])('escapes CSV text %p without changing its contents', (value, expected) => { + // Given text that must remain in one spreadsheet cell + // When CSV escaping is applied + const result = escapeCsvField(value); + + // Then delimiters are quoted and embedded quotes are doubled + expect(result).toBe(expected); + }); + + it.each(['=1+1', '+1+1', '-1+1', '@SUM(A1:A2)', ' =1+1', '\tvalue', '\rvalue', '\nvalue'])('prevents spreadsheet formulas in %p', (value) => { + // Given text beginning with a spreadsheet formula or control character + // When it is exported as a CSV field + const result = escapeCsvField(value); + + // Then an apostrophe forces text interpretation, while line breaks stay inside quotes + const safeValue = `'${value}`; + const expected = /[\r\n]/.test(value) ? `"${safeValue}"` : safeValue; + expect(result).toBe(expected); + }); +}); diff --git a/tests/unit/extractAttachmentsTest.ts b/tests/unit/extractAttachmentsTest.ts new file mode 100644 index 000000000000..6edc8817136a --- /dev/null +++ b/tests/unit/extractAttachmentsTest.ts @@ -0,0 +1,61 @@ +import extractAttachments from '@components/Attachments/AttachmentCarousel/extractAttachments'; + +import CONST from '@src/CONST'; + +const ACCOUNT_ID = 1; +const VIDEO_SOURCE = 'https://www.expensify.com/chat-attachments/123/w_abc123'; + +function extractNoteAttachments(note: string) { + return extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, { + privateNotes: {[ACCOUNT_ID]: {note}}, + accountID: ACCOUNT_ID, + report: undefined, + isReportArchived: false, + }); +} + +describe('extractAttachments', () => { + it('uses data-name as the video file name when it is present', () => { + // Given a video tag with a data-name attribute and a different inner text + const note = ``; + + // When the attachments are extracted + const attachments = extractNoteAttachments(note); + + // Then the stored data-name is preferred + expect(attachments.at(0)?.file?.name).toBe('original.mp4'); + }); + + it('uses the inner text as the video file name when data-name is missing', () => { + // Given a server-rendered video tag that carries the original file name only as its inner text + const note = ``; + + // When the attachments are extracted + const attachments = extractNoteAttachments(note); + + // Then the original file name is used instead of the storage key from the URL + expect(attachments.at(0)?.file?.name).toBe('clip.mp4'); + }); + + it('falls back to the URL file name when data-name and inner text are missing', () => { + // Given a video tag with neither data-name nor inner text + const note = ``; + + // When the attachments are extracted + const attachments = extractNoteAttachments(note); + + // Then the last part of the source URL is used, since there is no other name to use + expect(attachments.at(0)?.file?.name).toBe('w_abc123'); + }); + + it('does not use text that follows a video as its file name', () => { + // Given an empty video tag followed by regular comment text + const note = `Some comment`; + + // When the attachments are extracted + const attachments = extractNoteAttachments(note); + + // Then the text outside the video tag is ignored + expect(attachments.at(0)?.file?.name).toBe('w_abc123'); + }); +}); diff --git a/tests/unit/fetchImageTest.ts b/tests/unit/fetchImageTest.ts new file mode 100644 index 000000000000..94ed4637f7f8 --- /dev/null +++ b/tests/unit/fetchImageTest.ts @@ -0,0 +1,108 @@ +import fetchImage from '@libs/fetchImage'; + +import CONFIG from '@src/CONFIG'; +import CONST from '@src/CONST'; + +import {getGlobalFetchMock} from '../utils/TestHelper'; + +const mockBlobUtilFetch = jest.fn string}>, [string, string, Record]>(() => Promise.resolve({path: () => '/cache/temp_image.jpg'})); + +jest.mock('react-native-blob-util', () => ({ + __esModule: true, + default: { + fs: {dirs: {CacheDir: '/cache'}}, + config: () => ({fetch: mockBlobUtilFetch}), + }, +})); + +const ENCRYPTED_TOKEN = 'testEncryptedToken123'; +const RECEIPT_URL = `${CONFIG.EXPENSIFY.EXPENSIFY_URL}receipts/w_receipt.jpg`; +const ATTACKER_IMAGE_URL = 'https://attacker.example/receipt.jpg'; + +/** + * Jest resolves `@libs/fetchImage` to the native file, so the web file is loaded by its path. + */ +function loadWebFetchImage(): typeof fetchImage { + let webFetchImage: typeof fetchImage | undefined; + jest.isolateModules(() => { + webFetchImage = jest.requireActual<{default: typeof fetchImage}>('../../src/libs/fetchImage/index.ts').default; + }); + + if (!webFetchImage) { + throw new Error('Failed to load the web fetchImage'); + } + return webFetchImage; +} + +describe('fetchImage', () => { + describe('native', () => { + beforeEach(() => { + mockBlobUtilFetch.mockClear(); + }); + + it('sends the token header to an Expensify server', async () => { + // Given a receipt image on an Expensify server + // When downloading it to edit on device + await fetchImage(RECEIPT_URL, ENCRYPTED_TOKEN); + + // Then the request carries the token the server needs + expect(mockBlobUtilFetch).toHaveBeenCalledWith('GET', RECEIPT_URL, { + [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: ENCRYPTED_TOKEN, + }); + }); + + it('sends no token header to a host outside Expensify', async () => { + // Given an image on a host that is not an Expensify server + // When downloading it to edit on device + await fetchImage(ATTACKER_IMAGE_URL, ENCRYPTED_TOKEN); + + // Then the request goes out with no headers, so that host never sees the token + expect(mockBlobUtilFetch).toHaveBeenCalledWith('GET', ATTACKER_IMAGE_URL, {}); + }); + }); + + describe('web', () => { + const originalFetch = global.fetch; + const mockFetch = getGlobalFetchMock({blob: () => Promise.resolve(new Blob())}); + + beforeAll(() => { + global.fetch = mockFetch; + jest.spyOn(URL, 'createObjectURL').mockReturnValue('blob:https://new.expensify.com/1234-5678'); + }); + + afterAll(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + beforeEach(() => { + mockFetch.mockClear(); + }); + + it('sends the token header to an Expensify server', async () => { + // Given a receipt image on an Expensify server + const webFetchImage = loadWebFetchImage(); + + // When downloading it to edit in the browser + await webFetchImage(RECEIPT_URL, ENCRYPTED_TOKEN); + + // Then the request carries the token the server needs + expect(mockFetch).toHaveBeenCalledWith(RECEIPT_URL, { + headers: {[CONST.CHAT_ATTACHMENT_TOKEN_KEY]: ENCRYPTED_TOKEN}, + }); + }); + + it('sends no token header to a host outside Expensify', async () => { + // Given an image on a host that is not an Expensify server + const webFetchImage = loadWebFetchImage(); + + // When downloading it to edit in the browser + await webFetchImage(ATTACKER_IMAGE_URL, ENCRYPTED_TOKEN); + + // Then the request goes out with no headers, so that host never sees the token + expect(mockFetch).toHaveBeenCalledWith(ATTACKER_IMAGE_URL, { + headers: undefined, + }); + }); + }); +}); diff --git a/tests/unit/focusComposerWithDelayTest.ts b/tests/unit/focusComposerWithDelayTest.ts index e086cd81b168..9be650067e15 100644 --- a/tests/unit/focusComposerWithDelayTest.ts +++ b/tests/unit/focusComposerWithDelayTest.ts @@ -1,7 +1,18 @@ +import type {WebComposerRef} from '@components/Composer/types'; + import focusComposerWithDelay from '@libs/focusComposerWithDelay'; +import requestKeyboardForFocusedComposer from '@libs/focusComposerWithDelay/requestKeyboardForFocusedComposer'; import CONST from '@src/CONST'; +import type {TextInput} from 'react-native'; + +import createMock from '../utils/createMock'; + +jest.mock('@libs/focusComposerWithDelay/requestKeyboardForFocusedComposer', () => + jest.fn, Parameters>(), +); + function createInput() { const input = document.createElement('textarea'); return {input, focus: jest.spyOn(input, 'focus')}; @@ -42,3 +53,47 @@ describe('focusComposerWithDelay', () => { expect(focus).toHaveBeenCalled(); }); }); + +describe('focusComposerWithDelay command categories', () => { + afterEach(() => jest.restoreAllMocks()); + + it('focuses a web command handle without inventing a DOM selection method', async () => { + // Given the public web handle exposes common commands and a numeric scroll getter. + const input = { + clear: jest.fn, Parameters>(), + blur: jest.fn, Parameters>(), + focus: jest.fn, Parameters>(), + isFocused: () => false, + scrollTop: 0, + } satisfies WebComposerRef; + + // When focus with a forced selection reaches the unchanged platform selection helper. + await focusComposerWithDelay(input)(false, {start: 2, end: 4}); + + // Then the common focus command executes without requiring native or DOM selection capabilities. + expect(input.focus).toHaveBeenCalledTimes(1); + expect(input.clear).not.toHaveBeenCalled(); + expect(input.blur).not.toHaveBeenCalled(); + }); + + it('keeps native top-level selection and requests the keyboard for an already focused input', async () => { + // Given native selection belongs to the input itself, without a wrapper. + const focus = jest.fn, Parameters>(); + const input = createMock({ + focus, + isFocused: () => false, + setSelection: jest.fn, Parameters>(), + }); + const requestKeyboard = jest.mocked(requestKeyboardForFocusedComposer); + + // When an unfocused input receives selection and a focused input requests its keyboard. + await focusComposerWithDelay(input)(false, {start: 1, end: 3}); + input.isFocused = () => true; + await focusComposerWithDelay(input)(false, {start: 2, end: 2}, true); + + // Then selection uses the top-level native command and the focused branch delegates its keyboard request. + expect(focus).toHaveBeenCalledTimes(1); + expect(input.setSelection).toHaveBeenCalledWith(1, 3); + expect(requestKeyboard).toHaveBeenCalledWith(input, {start: 2, end: 2}); + }); +}); diff --git a/tests/unit/getAbsorbedColumnWidthsTest.ts b/tests/unit/getAbsorbedColumnWidthsTest.ts new file mode 100644 index 000000000000..0e0e680e0c52 --- /dev/null +++ b/tests/unit/getAbsorbedColumnWidthsTest.ts @@ -0,0 +1,53 @@ +import type {ColumnAbsorber} from '@components/Table/columnResize/getAbsorbedColumnWidths'; +import getAbsorbedColumnWidths from '@components/Table/columnResize/getAbsorbedColumnWidths'; + +function absorber(columnKey: string, {minWidth = 100, maxWidth = Number.POSITIVE_INFINITY}: {minWidth?: number; maxWidth?: number} = {}): ColumnAbsorber { + return {columnKey, minWidth, maxWidth}; +} + +describe('getAbsorbedColumnWidths', () => { + it('splits an odd delta into whole px, giving the odd pixel to the earlier of two tied absorbers', () => { + // Given two 200px absorbers with the same room to shrink + const absorbers = [absorber('a'), absorber('b')]; + + // When they absorb a 25px widening + const widths = getAbsorbedColumnWidths(absorbers, {a: 200, b: 200}, 25); + + // Then they give 13px and 12px, so tracks stay whole px, the row keeps its width, and ties keep render order so a drag and a reload agree + expect(widths).toEqual({a: 187, b: 188}); + }); + + it('rounds fractional start widths and a fractional delta before splitting, so the shares sum exactly', () => { + // Given three absorbers starting at fractional widths, as a table measured in sub-pixels can hand them over + const absorbers = [absorber('a'), absorber('b'), absorber('c')]; + const startWidths = {a: 150.4, b: 149.6, c: 200}; + + // When they absorb a 12.5px widening + const widths = getAbsorbedColumnWidths(absorbers, startWidths, 12.5); + + // Then each lands on a whole px and together they give exactly the 13px the delta rounds to, so no fraction is lost or invented + expect(widths).toEqual({a: 146, b: 145, c: 196}); + }); + + it('passes the share of an absorber that hits its minimum on to the rest', () => { + // Given an absorber that can give 25px, rendered before one that can only give 10px + const absorbers = [absorber('email'), absorber('role', {minWidth: 190})]; + + // When they absorb a 25px widening + const widths = getAbsorbedColumnWidths(absorbers, {email: 200, role: 200}, 25); + + // Then the tight one stops at its minimum and the other gives the 15px left, so the row still fits + expect(widths).toEqual({email: 185, role: 190}); + }); + + it('passes the share of an absorber that hits its maximum on to the rest', () => { + // Given an absorber capped 5px above its width, so it truncates long values, next to an uncapped one + const absorbers = [absorber('card', {maxWidth: 205}), absorber('account')]; + + // When they absorb a 25px narrowing + const widths = getAbsorbedColumnWidths(absorbers, {card: 200, account: 200}, -25); + + // Then the capped one stops at its cap and the other takes the 20px left, so the row still fills the table + expect(widths).toEqual({card: 205, account: 220}); + }); +}); diff --git a/tests/unit/getBankAccountLastFourDigitsTest.ts b/tests/unit/getBankAccountLastFourDigitsTest.ts index d3aad4f045e9..727b6a58831c 100644 --- a/tests/unit/getBankAccountLastFourDigitsTest.ts +++ b/tests/unit/getBankAccountLastFourDigitsTest.ts @@ -1,6 +1,9 @@ import getBankAccountLastFourDigits from '@libs/getBankAccountLastFourDigits'; -import type {BankAccount} from '@src/types/onyx'; +import CONST from '@src/CONST'; +import type {BankAccount, Policy} from '@src/types/onyx'; + +import createRandomPolicy from '../utils/collections/policies'; function createBankAccountList(bankAccountID: number, accountNumber?: string): Record { return { @@ -13,35 +16,109 @@ function createBankAccountList(bankAccountID: number, accountNumber?: string): R } describe('libs/getBankAccountLastFourDigits', () => { + const POLICY_BANK_ACCOUNT_ID = 1111; + const PAYER_EMAIL = 'payer@test.com'; + const PAYER_ACCOUNT_ID = 101; + const NON_PAYER_ADMIN_ACCOUNT_ID = 102; + + const policy: Policy = { + ...createRandomPolicy(3, CONST.POLICY.TYPE.CORPORATE), + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + reimburser: PAYER_EMAIL, + achAccount: { + bankAccountID: POLICY_BANK_ACCOUNT_ID, + accountNumber: 'XXXXXX1111', + routingNumber: '123456789', + addressName: 'Test bank account', + bankName: 'Test bank', + reimburser: PAYER_EMAIL, + }, + }; + + it('uses the account stored on the payment action over anything resolved locally', () => { + // Given a payment action that carries its own masked account number + // When the digits are resolved for a non-payer admin's payment + // Then the action's number wins, since it is the only viewer-independent source + expect( + getBankAccountLastFourDigits({ + reimburserAccountID: PAYER_ACCOUNT_ID, + bankAccountID: undefined, + bankAccountList: {}, + policy, + accountNumber: 'XXXXXX0000', + payerAccountID: NON_PAYER_ADMIN_ACCOUNT_ID, + }), + ).toBe('0000'); + }); + it('returns the last four digits of the account the payment names', () => { + // Given the named account is in the viewer's bank account list const bankAccountList = createBankAccountList(123, '1234567890'); - expect(getBankAccountLastFourDigits(123, bankAccountList, '9999999999')).toBe('7890'); + // Then its account number is used + expect(getBankAccountLastFourDigits({reimburserAccountID: PAYER_ACCOUNT_ID, bankAccountID: 123, bankAccountList, policy, payerAccountID: PAYER_ACCOUNT_ID})).toBe('7890'); }); it('returns an empty string when the named account is missing from the list, so a multi-VBBA payment never shows the policy account digits', () => { + // Given the payment names an account the viewer doesn't have const bankAccountList = createBankAccountList(456, '1234567890'); - expect(getBankAccountLastFourDigits(123, bankAccountList, '9999999999')).toBe(''); + // Then nothing is shown rather than the workspace account + expect(getBankAccountLastFourDigits({reimburserAccountID: PAYER_ACCOUNT_ID, bankAccountID: 123, bankAccountList, policy, payerAccountID: PAYER_ACCOUNT_ID})).toBe(''); }); it('returns an empty string when the named account carries no account number', () => { + // Given the named account has no account data const bankAccountList = createBankAccountList(123); - expect(getBankAccountLastFourDigits(123, bankAccountList, '9999999999')).toBe(''); + // Then nothing is shown + expect(getBankAccountLastFourDigits({reimburserAccountID: PAYER_ACCOUNT_ID, bankAccountID: 123, bankAccountList, policy, payerAccountID: PAYER_ACCOUNT_ID})).toBe(''); }); it('returns an empty string when the payment names an account but the list has not loaded', () => { - expect(getBankAccountLastFourDigits(123, undefined, '9999999999')).toBe(''); + // Given no bank account list at all + // Then nothing is shown, since the named account can't be resolved + expect(getBankAccountLastFourDigits({reimburserAccountID: PAYER_ACCOUNT_ID, bankAccountID: 123, bankAccountList: undefined, policy, payerAccountID: PAYER_ACCOUNT_ID})).toBe(''); }); - it("falls back to the workspace's reimbursement account when the payment names no account", () => { - const bankAccountList = createBankAccountList(123, '1234567890'); + it('falls back to the workspace account for a payment made by the designated payer', () => { + // Given a payment by the designated payer that names no account + // Then the workspace account is assumed + expect(getBankAccountLastFourDigits({reimburserAccountID: PAYER_ACCOUNT_ID, bankAccountID: undefined, bankAccountList: {}, policy, payerAccountID: PAYER_ACCOUNT_ID})).toBe('1111'); + }); + + it('prefers the workspace account number from the bank account list over the stale one on the policy', () => { + // Given the viewer has the workspace account, whose number differs from the one cached on the policy + const bankAccountList = createBankAccountList(POLICY_BANK_ACCOUNT_ID, '9999999999'); + + // Then the number of the account that is actually debited is shown + expect(getBankAccountLastFourDigits({reimburserAccountID: PAYER_ACCOUNT_ID, bankAccountID: undefined, bankAccountList, policy, payerAccountID: PAYER_ACCOUNT_ID})).toBe('9999'); + }); + + it('does not attribute the workspace account to a payment made by a non-payer admin', () => { + // Given a payment by an admin who isn't the designated payer and names no account + // Then no digits are shown, since that admin paid from an account of their own + expect(getBankAccountLastFourDigits({reimburserAccountID: PAYER_ACCOUNT_ID, bankAccountID: undefined, bankAccountList: {}, policy, payerAccountID: NON_PAYER_ADMIN_ACCOUNT_ID})).toBe( + '', + ); + }); + + it('does not attribute the workspace account to a payment that names another bank account', () => { + // Given a payment by the designated payer that names an account other than the workspace one + // Then the workspace account is not assumed + expect(getBankAccountLastFourDigits({reimburserAccountID: PAYER_ACCOUNT_ID, bankAccountID: 2222, bankAccountList: {}, policy, payerAccountID: PAYER_ACCOUNT_ID})).toBe(''); + }); + + it('keeps the workspace account when the reimburser cannot be resolved from personal details', () => { + // Given a viewer who has never interacted with the designated payer, so their personal details aren't loaded - expect(getBankAccountLastFourDigits(undefined, bankAccountList, '9999999999')).toBe('9999'); + // Then the payer can't be ruled out, so the pre-existing workspace account fallback is kept + expect(getBankAccountLastFourDigits({reimburserAccountID: undefined, bankAccountID: undefined, bankAccountList: {}, policy, payerAccountID: PAYER_ACCOUNT_ID})).toBe('1111'); }); it('returns an empty string when the payment names no account and the workspace has none either', () => { - expect(getBankAccountLastFourDigits(undefined, undefined, undefined)).toBe(''); + // Given no bank account anywhere + // Then nothing is shown + expect(getBankAccountLastFourDigits({reimburserAccountID: PAYER_ACCOUNT_ID, bankAccountID: undefined, bankAccountList: undefined, policy: undefined})).toBe(''); }); }); diff --git a/tests/unit/getBottomSheetHeightTest.ts b/tests/unit/getBottomSheetHeightTest.ts index 790c3a76736d..79f21e206243 100644 --- a/tests/unit/getBottomSheetHeightTest.ts +++ b/tests/unit/getBottomSheetHeightTest.ts @@ -107,4 +107,41 @@ describe('getBottomSheetHeight', () => { // Then the caller's ceiling wins, less the top padding, instead of the dropdown cap expect(height).toBe(maxHeight - BOTTOM_SHEET_TOP_PADDING); }); + + it('fills the window up to the status bar when asked to', () => { + // Given a phone in landscape, where the sheet takes the whole screen rather than a share of it + const windowHeight = 400; + + // When the sheet is asked to fill the window while it would prefer the standard dropdown height + const height = getBottomSheetHeight({ + preferredHeight: CONST.POPOVER_DROPDOWN_MAX_HEIGHT, + windowHeight, + keyboardHeight: 0, + topSafeAreaInset: TOP_INSET, + minHeight: MIN_HEIGHT, + shouldFillWindow: true, + }); + + // Then it takes everything below the status bar, less its own top padding + expect(height).toBe(windowHeight - TOP_INSET - BOTTOM_SHEET_TOP_PADDING); + }); + + it('fills only the room above the keyboard when asked to fill the window', () => { + // Given a phone in landscape with the keyboard open + const windowHeight = 400; + const keyboardHeight = 150; + + // When the sheet is asked to fill the window + const height = getBottomSheetHeight({ + preferredHeight: CONST.POPOVER_DROPDOWN_MAX_HEIGHT, + windowHeight, + keyboardHeight, + topSafeAreaInset: TOP_INSET, + minHeight: MIN_HEIGHT, + shouldFillWindow: true, + }); + + // Then it stops at the keyboard, so nothing in it is hidden behind the keyboard + expect(height).toBe(windowHeight - keyboardHeight - TOP_INSET - BOTTOM_SHEET_TOP_PADDING); + }); }); diff --git a/tests/unit/getFrozenColumnWidthsTest.ts b/tests/unit/getFrozenColumnWidthsTest.ts new file mode 100644 index 000000000000..3f5ba72e0b8f --- /dev/null +++ b/tests/unit/getFrozenColumnWidthsTest.ts @@ -0,0 +1,215 @@ +import getFrozenColumnWidths from '@components/Table/columnResize/getFrozenColumnWidths'; +import type {ResizableColumn} from '@components/Table/columnResize/resolveDraggedColumnWidths'; + +import type {StoredTableColumnWidths} from '@src/types/onyx/TableColumnWidths'; + +/** A content-sized column that can be squeezed to 100px, which is where absorbing stops. */ +function column(key: string, {contentWidth = 0, maxWidth = Number.POSITIVE_INFINITY}: {contentWidth?: number; maxWidth?: number} = {}): ResizableColumn { + return {key, label: key, declaredWidth: undefined, contentWidth, minWidth: 100, maxWidth}; +} + +function fixedColumn(key: string, width: number): ResizableColumn { + return {key, label: key, declaredWidth: width, contentWidth: 0, minWidth: 0, maxWidth: Number.POSITIVE_INFINITY}; +} + +const ABCDE = ['a', 'b', 'c', 'd', 'e']; + +describe('getFrozenColumnWidths', () => { + it('repaints the saved widths exactly at the width they were saved at', () => { + // Given a layout saved at 600px after the user dragged the first column + const stored: StoredTableColumnWidths = {availableColumnsWidth: 600, columnWidths: {a: 250, b: 175, c: 175}, resizedColumnKeys: ['a']}; + + // When the table reloads at the same width, with content that would now size the columns differently + const widths = getFrozenColumnWidths({ + columns: [column('a', {contentWidth: 50}), column('b', {contentWidth: 500}), column('c', {contentWidth: 30})], + storedTableColumnWidths: stored, + availableColumnsWidth: 600, + }); + + // Then nothing moves, since content never resizes a frozen table and long values truncate instead + expect(widths).toEqual({a: 250, b: 175, c: 175}); + }); + + it('keeps the overflow a drag left when the table reloads, without squeezing the columns before the dragged one', () => { + // Given D was dragged until E hit its minimum and the 600px table overflowed by 200px + const stored: StoredTableColumnWidths = {availableColumnsWidth: 600, columnWidths: {a: 100, b: 150, c: 150, d: 300, e: 100}, resizedColumnKeys: ['d']}; + + // When the table reloads at the same width + const widths = getFrozenColumnWidths({ + columns: ABCDE.map((key) => column(key)), + storedTableColumnWidths: stored, + availableColumnsWidth: 600, + }); + + // Then B and C keep their widths, since the overflow is part of the saved layout + expect(widths).toEqual({a: 100, b: 150, c: 150, d: 300, e: 100}); + }); + + it('keeps a short row short when the table reloads', () => { + // Given a drag left the columns 100px short of a 600px table, the growable column filling the rest + const stored: StoredTableColumnWidths = {availableColumnsWidth: 600, columnWidths: {a: 150, b: 200, c: 150}, resizedColumnKeys: ['a']}; + + // When the table reloads at the same width + const widths = getFrozenColumnWidths({ + columns: ['a', 'b', 'c'].map((key) => column(key)), + storedTableColumnWidths: stored, + availableColumnsWidth: 600, + }); + + // Then the auto-sized columns don't grow into the gap, so the row paints as it did when saved + expect(widths).toEqual({a: 150, b: 200, c: 150}); + }); + + it('takes back the overflow a drag left before growing the columns when the table widens', () => { + // Given a saved layout that overflows a 600px table by 200px + const stored: StoredTableColumnWidths = {availableColumnsWidth: 600, columnWidths: {a: 100, b: 150, c: 150, d: 300, e: 100}, resizedColumnKeys: ['d']}; + + // When the table widens by 260px + const widths = getFrozenColumnWidths({ + columns: ABCDE.map((key) => column(key)), + storedTableColumnWidths: stored, + availableColumnsWidth: 860, + }); + + // Then the first 200px remove the overflow, like a drag would, and the auto-sized columns split the other 60px + expect(widths).toEqual({a: 115, b: 165, c: 165, d: 300, e: 115}); + }); + + it('fills the short row a drag left before shrinking the columns when the table narrows', () => { + // Given a saved layout 100px short of a 600px table + const stored: StoredTableColumnWidths = {availableColumnsWidth: 600, columnWidths: {a: 150, b: 200, c: 150}, resizedColumnKeys: ['a']}; + + // When the table narrows by 140px + const widths = getFrozenColumnWidths({ + columns: ['a', 'b', 'c'].map((key) => column(key)), + storedTableColumnWidths: stored, + availableColumnsWidth: 460, + }); + + // Then the empty 100px go first, and the auto-sized columns give the remaining 40px + expect(widths).toEqual({a: 150, b: 180, c: 130}); + }); + + it('splits extra room evenly among the columns the user never dragged', () => { + // Given a layout where the user dragged the second column + const stored: StoredTableColumnWidths = {availableColumnsWidth: 600, columnWidths: {a: 200, b: 200, c: 200}, resizedColumnKeys: ['b']}; + + // When the window widens by 100px + const widths = getFrozenColumnWidths({ + columns: ['a', 'b', 'c'].map((key) => column(key)), + storedTableColumnWidths: stored, + availableColumnsWidth: 700, + }); + + // Then the two auto-sized columns take 50px each and the dragged one keeps the width the user chose + expect(widths).toEqual({a: 250, b: 200, c: 250}); + }); + + it('shrinks the auto-sized columns down to their minimum and overflows the rest when the table narrows', () => { + // Given a layout where the user dragged the first column, and the others can be squeezed by 50px each + const stored: StoredTableColumnWidths = {availableColumnsWidth: 600, columnWidths: {a: 300, b: 150, c: 150}, resizedColumnKeys: ['a']}; + + // When the window narrows by 200px + const widths = getFrozenColumnWidths({ + columns: ['a', 'b', 'c'].map((key) => column(key)), + storedTableColumnWidths: stored, + availableColumnsWidth: 400, + }); + + // Then they stop at their minimum and the 100px they can't give scrolls, while the dragged column never moves + expect(widths).toEqual({a: 300, b: 100, c: 100}); + }); + + it('never moves a resized column, even when no other column can absorb', () => { + // Given every column was dragged by the user + const stored: StoredTableColumnWidths = {availableColumnsWidth: 600, columnWidths: {a: 200, b: 200, c: 200}, resizedColumnKeys: ['a', 'b', 'c']}; + + // When the window widens + const widths = getFrozenColumnWidths({ + columns: ['a', 'b', 'c'].map((key) => column(key)), + storedTableColumnWidths: stored, + availableColumnsWidth: 900, + }); + + // Then the widths the user chose hold, leaving the room for the growable column to fill + expect(widths).toEqual({a: 200, b: 200, c: 200}); + }); + + it('starts a column that appears with the data at its content width and makes room for it from the auto-sized columns', () => { + // Given a taxes layout saved with three columns, the first dragged by the user, while no tax had a custom code + const stored: StoredTableColumnWidths = {availableColumnsWidth: 600, columnWidths: {a: 200, b: 200, c: 200}, resizedColumnKeys: ['a']}; + + // When a tax gets a custom code, so the Tax code column appears, and its content needs 120px + const widths = getFrozenColumnWidths({ + columns: [column('a'), column('b'), column('c'), column('taxCode', {contentWidth: 120})], + storedTableColumnWidths: stored, + availableColumnsWidth: 600, + }); + + // Then it shows its content in full, since it has no saved width yet, and the two auto-sized columns give 60px each + expect(widths).toEqual({a: 200, b: 140, c: 140, taxCode: 120}); + }); + + it('caps a column that appears with the data at its maximum width and leaves the room change to the saved columns', () => { + // Given a layout saved with two columns, and a free-text column capped at 150px so its long values truncate + // instead of taking the whole row, which only the cap prevents since the column has no saved width yet + const stored: StoredTableColumnWidths = {availableColumnsWidth: 600, columnWidths: {a: 300, b: 300}, resizedColumnKeys: ['a']}; + + // When the capped column, whose content needs 400px, appears with the data while the table widens by 100px + const widths = getFrozenColumnWidths({ + columns: [column('a'), column('b'), column('c', {contentWidth: 400, maxWidth: 150})], + storedTableColumnWidths: stored, + availableColumnsWidth: 700, + }); + + // Then it starts at its cap, and only B, the auto-sized column in the save, gives the 50px the row is over by. + // Splitting it with the new column would put that one at 125px, below where its cap starts it. + expect(widths).toEqual({a: 300, b: 250, c: 150}); + }); + + it('splits the width of a column that disappears with the data among the auto-sized columns', () => { + // Given a taxes layout saved with four columns, the last being the Tax code column, and the first dragged by the user + const stored: StoredTableColumnWidths = {availableColumnsWidth: 600, columnWidths: {a: 150, b: 150, c: 150, taxCode: 150}, resizedColumnKeys: ['a']}; + + // When the last tax with a custom code is deleted, so the Tax code column disappears + const widths = getFrozenColumnWidths({ + columns: ['a', 'b', 'c'].map((key) => column(key)), + storedTableColumnWidths: stored, + availableColumnsWidth: 600, + }); + + // Then the 150px it freed goes to the auto-sized columns, so the row still fills the table + expect(widths).toEqual({a: 150, b: 225, c: 225}); + }); + + it('grows auto-sized columns no further than their maximum width', () => { + // Given an auto-sized column capped 20px above its saved width + const stored: StoredTableColumnWidths = {availableColumnsWidth: 600, columnWidths: {a: 200, b: 200, c: 200}, resizedColumnKeys: ['a']}; + + // When the window widens by 100px + const widths = getFrozenColumnWidths({ + columns: [column('a'), column('b', {maxWidth: 220}), column('c')], + storedTableColumnWidths: stored, + availableColumnsWidth: 700, + }); + + // Then the capped column stops at its cap and the other auto-sized column takes the rest + expect(widths).toEqual({a: 200, b: 220, c: 280}); + }); + + it('keeps declared widths as declared and never uses them to absorb', () => { + // Given a layout saved while the fixed status column declared 100px, which now declares 80px + const stored: StoredTableColumnWidths = {availableColumnsWidth: 600, columnWidths: {a: 260, status: 100, b: 240}, resizedColumnKeys: ['a']}; + + // When the window widens by 40px + const widths = getFrozenColumnWidths({ + columns: [column('a'), fixedColumn('status', 80), column('b')], + storedTableColumnWidths: stored, + availableColumnsWidth: 640, + }); + + // Then the status column paints its declared 80px, not the saved 100px, and the 20px it gives back is room like + // the 40px the window gained, so only the auto-sized content column grows, by 60px, since a declared width holds fixed-size content + expect(widths).toEqual({a: 260, status: 80, b: 300}); + }); +}); diff --git a/tests/unit/getInProgressBucketLabelTest.ts b/tests/unit/getInProgressBucketLabelTest.ts index db47340bef9c..659d11e06654 100644 --- a/tests/unit/getInProgressBucketLabelTest.ts +++ b/tests/unit/getInProgressBucketLabelTest.ts @@ -29,6 +29,7 @@ const weekGroup = (week: string): GroupedItem => { week, formattedWeek: DateUtils.getFormattedDateRangeForSearch(start, end, undefined), shortFormattedWeek: DateUtils.getShortFormattedDateRangeForSearch(start, end, undefined), + lineChartFormattedWeek: DateUtils.getShortFormattedDayForSearch(start, undefined, false), }; }; const quarterGroup = (year: number, quarter: number): GroupedItem => ({ @@ -47,6 +48,7 @@ const dayGroup = (day: string): GroupedItem => ({ day, formattedDay: DateUtils.formatToReadableString(day, undefined), shortFormattedDay: DateUtils.getShortFormattedDayForSearch(day, undefined), + lineChartFormattedDay: DateUtils.getShortFormattedDayForSearch(day, undefined, false), }); const merchantGroup = (merchant: string): GroupedItem => ({...BASE, groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, merchant, formattedMerchant: merchant}); diff --git a/tests/unit/getLineRunsTest.ts b/tests/unit/getLineRunsTest.ts new file mode 100644 index 000000000000..5f41815ec674 --- /dev/null +++ b/tests/unit/getLineRunsTest.ts @@ -0,0 +1,66 @@ +import getLineRuns from '@components/Charts/utils/getLineRuns'; + +describe('getLineRuns', () => { + it('dashes from the segment leading into the first open period when periods run oldest first', () => { + // Given three finished months followed by the current month and a later month holding future-dated expenses + const openPointFlags = [false, false, false, true, true]; + + // When the line is split into runs + const runs = getLineRuns(openPointFlags); + + // Then the finished months stay solid and the line is dashed from the last finished month onward + expect(runs).toEqual([ + {start: 0, end: 2, isDashed: false}, + {start: 2, end: 4, isDashed: true}, + ]); + }); + + it('keeps finished periods solid when periods run newest first', () => { + // Given a later month and the current month sorted before the finished months, as a descending sort orders them + const openPointFlags = [true, true, false, false, false]; + + // When the line is split into runs + const runs = getLineRuns(openPointFlags); + + // Then only the segments touching the open months are dashed, so the finished history stays solid + expect(runs).toEqual([ + {start: 0, end: 2, isDashed: true}, + {start: 2, end: 4, isDashed: false}, + ]); + }); + + it('dashes only around an open period between finished ones', () => { + // Given an open period sorted between finished ones, as sorting by total can place it + const openPointFlags = [false, false, true, false, false]; + + // When the line is split into runs + const runs = getLineRuns(openPointFlags); + + // Then the two segments touching the open period are dashed and the rest stays solid + expect(runs).toEqual([ + {start: 0, end: 1, isDashed: false}, + {start: 1, end: 3, isDashed: true}, + {start: 3, end: 4, isDashed: false}, + ]); + }); + + it('keeps a lone point solid', () => { + // Given a single period that hasn't ended + const openPointFlags = [true]; + + // When the line is split into runs + const runs = getLineRuns(openPointFlags); + + // Then it forms one solid run, since there is no segment leading into it to dash + expect(runs).toEqual([{start: 0, end: 0, isDashed: false}]); + }); + + it('returns no runs for an empty line', () => { + // Given no points + // When the line is split into runs + const runs = getLineRuns([]); + + // Then there is nothing to draw + expect(runs).toEqual([]); + }); +}); diff --git a/tests/unit/getReportPreviewSenderIDTest.ts b/tests/unit/getReportPreviewSenderIDTest.ts index daf99da8b6e2..0bb8068ff243 100644 --- a/tests/unit/getReportPreviewSenderIDTest.ts +++ b/tests/unit/getReportPreviewSenderIDTest.ts @@ -336,7 +336,8 @@ describe('getReportPreviewSenderID', () => { const dmChat = createMock({ reportID: 'dm-1', type: CONST.REPORT.TYPE.CHAT, - lastActorAccountID: MANAGER_ACCOUNT_ID, + // Contradicts `chatReportLastActorAccountID` below, so the test fails if the lookup reads it off `chatReport` again. + lastActorAccountID: OWNER_ACCOUNT_ID, participants: { [OWNER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, [MANAGER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, @@ -346,6 +347,7 @@ describe('getReportPreviewSenderID', () => { const result = getReportPreviewSenderID({ ...baseParams, chatReport: dmChat, + chatReportLastActorAccountID: MANAGER_ACCOUNT_ID, iouReport: makeIOUReport({transactionCount: 2, ownerAccountID: OWNER_ACCOUNT_ID, managerID: MANAGER_ACCOUNT_ID}), action: makeAction({childMoneyRequestCount: 2, childLastActorAccountID: OWNER_ACCOUNT_ID}), iouActions: [ diff --git a/tests/unit/getResizableColumnLayoutTest.ts b/tests/unit/getResizableColumnLayoutTest.ts index a6eab5f48338..312835f0ef69 100644 --- a/tests/unit/getResizableColumnLayoutTest.ts +++ b/tests/unit/getResizableColumnLayoutTest.ts @@ -1,5 +1,7 @@ -import getResizableColumnLayout from '@components/Table/columnResize/getResizableColumnLayout'; -import type {TableColumn} from '@components/Table/types'; +import {SCROLL_EXTENT_FLOOR_VARIABLE} from '@components/Table/columnResize/columnWidthExpressions'; +import getFrozenColumnWidths from '@components/Table/columnResize/getFrozenColumnWidths'; +import getResizableColumnLayout, {getAvailableColumnsWidth} from '@components/Table/columnResize/getResizableColumnLayout'; +import type {ResizableColumn} from '@components/Table/columnResize/resolveDraggedColumnWidths'; import CONST from '@src/CONST'; @@ -8,11 +10,28 @@ const {MIN_WIDTH} = CONST.TABLES.COLUMN_RESIZE; /** No selection, gaps, margin or padding, so the row is exactly the column sum. */ const rowChromeWidths = {selectionColumnWidth: 0, totalGapWidth: 0, rowMarginWidth: 0, rowPaddingWidth: 0}; -function column(key: string, label = key): TableColumn { - return {key, label, sortable: false}; +function column(key: string, {label = key, minWidth = 0, contentWidth = 0}: {label?: string; minWidth?: number; contentWidth?: number} = {}): ResizableColumn { + return {key, label, declaredWidth: undefined, contentWidth, minWidth, maxWidth: Number.POSITIVE_INFINITY}; } describe('getResizableColumnLayout', () => { + it('keeps the scroll extent at least as wide as the floor a drag sets', () => { + // Given a table scrolled right, where shrinking a column mid-drag would shrink the extent under the scroll position + const columns = [column('name'), column('role')]; + + // When the layout is built + const {scrollWidth} = getResizableColumnLayout({ + columns, + resolvedColumnWidths: {name: 400, role: 400}, + resizedColumnKeys: [], + tableWidth: 600, + rowChromeWidths, + }); + + // Then the extent is floored by the drag's variable too, so the filter bar pinned inside the scroller stays put + expect(scrollWidth).toContain(`var(${SCROLL_EXTENT_FLOOR_VARIABLE}, 0px)`); + }); + it('keeps the last column from shrinking while the row fills the table exactly', () => { // Given three 200px columns filling a 600px table const columns = [column('name'), column('email'), column('role')]; @@ -21,7 +40,7 @@ describe('getResizableColumnLayout', () => { const {dragMinWidths} = getResizableColumnLayout({ columns, resolvedColumnWidths: {name: 200, email: 200, role: 200}, - columnWidthOverrides: undefined, + resizedColumnKeys: [], tableWidth: 600, rowChromeWidths, }); @@ -38,7 +57,7 @@ describe('getResizableColumnLayout', () => { const {dragMinWidths} = getResizableColumnLayout({ columns, resolvedColumnWidths: {name: 200, email: 200, role: 250}, - columnWidthOverrides: undefined, + resizedColumnKeys: [], tableWidth: 600, rowChromeWidths, }); @@ -55,7 +74,7 @@ describe('getResizableColumnLayout', () => { const {dragMinWidths} = getResizableColumnLayout({ columns, resolvedColumnWidths: {name: 800, email: 800, role: 200}, - columnWidthOverrides: undefined, + resizedColumnKeys: [], tableWidth: 600, rowChromeWidths, }); @@ -64,32 +83,15 @@ describe('getResizableColumnLayout', () => { expect(dragMinWidths).toEqual({role: MIN_WIDTH}); }); - it('keeps the last headed column from shrinking when headless columns trail it', () => { - // Given a trailing headless arrow column, with the row filling a 600px table exactly - const columns = [column('name'), column('role'), column('arrow', '')]; - - // When the layout is built - const {dragMinWidths} = getResizableColumnLayout({ - columns, - resolvedColumnWidths: {name: 280, role: 280, arrow: 40}, - columnWidthOverrides: undefined, - tableWidth: 600, - rowChromeWidths, - }); - - // Then the last headed column can't shrink, since the freed room would stretch the fixed-size arrow column - expect(dragMinWidths).toEqual({role: 280}); - }); - it('grows the last headed column into leftover room, not the trailing headless column', () => { // Given columns 160px narrower than a 600px table, with a trailing headless arrow column - const columns = [column('name'), column('role'), column('arrow', '')]; + const columns = [column('name'), column('role'), column('arrow', {label: ''})]; // When the layout is built const {gridTemplateColumns, resolvedColumnWidths, dragMinWidths} = getResizableColumnLayout({ columns, resolvedColumnWidths: {name: 200, role: 200, arrow: 40}, - columnWidthOverrides: undefined, + resizedColumnKeys: [], tableWidth: 600, rowChromeWidths, }); @@ -101,4 +103,129 @@ describe('getResizableColumnLayout', () => { expect(resolvedColumnWidths.role).toBe(360); expect(dragMinWidths).toEqual({role: 360}); }); + + it('takes a drag out of the leftover the last headed column paints', () => { + // Given a 600px table whose last headed column is painted 100px wider than it needs + const columns = [column('name', {minWidth: 200}), column('email', {minWidth: 160}), column('role', {minWidth: 100}), column('arrow', {label: ''})]; + const params = { + columns, + resolvedColumnWidths: {name: 200, email: 160, role: 100, arrow: 40}, + resizedColumnKeys: [], + tableWidth: 600, + rowChromeWidths, + }; + + // When the first column is dragged 60px wider + const {getResizedColumnWidths} = getResizableColumnLayout(params); + + // Then the last headed column gives up its leftover, since the column between is already at its minimum + expect(getResizedColumnWidths('name', 260)).toEqual({name: 260, email: 160, role: 140}); + }); + + it('takes a drag out of the leftover a fixed-width last column fills, and repaints the same widths after the save', () => { + // Given a 760px table whose last headed column has a declared 72px width, leaving 88px of leftover room + const columns = [column('name', {minWidth: 100}), column('taxRate', {minWidth: 100}), column('taxCode', {minWidth: 100}), {...column('enabled'), declaredWidth: 72}]; + const {resolvedColumnWidths, getResizedColumnWidths} = getResizableColumnLayout({ + columns, + resolvedColumnWidths: {name: 300, taxRate: 150, taxCode: 150, enabled: 72}, + resizedColumnKeys: [], + tableWidth: 760, + rowChromeWidths, + }); + + // When the third column is dragged 50px wider and the painted widths are saved + const draggedWidths = getResizedColumnWidths('taxCode', 200); + const savedColumnWidths = {...resolvedColumnWidths, ...draggedWidths}; + const frozenWidths = getFrozenColumnWidths({ + columns, + storedTableColumnWidths: {availableColumnsWidth: 760, columnWidths: savedColumnWidths, resizedColumnKeys: ['taxCode']}, + availableColumnsWidth: 760, + }); + + // Then the fixed column keeps its declared width so the drag eats the leftover its track fills instead of overflowing, + // and the saved layout repaints without the columns before the dragged one jumping wider + expect(savedColumnWidths).toEqual({name: 300, taxRate: 150, taxCode: 200, enabled: 72}); + expect(frozenWidths).toEqual(savedColumnWidths); + }); + + it('squeezes a column after the dragged one to the minimum the layout uses, not its content, before the row overflows', () => { + // Given a 600px table whose free-text column shows 200px of content but the layout may squeeze it to 120px + const columns = [column('name', {minWidth: 120}), column('taxCode', {minWidth: 120, contentWidth: 200})]; + + // When the first column is dragged 100px wider + const {getResizedColumnWidths} = getResizableColumnLayout({ + columns, + resolvedColumnWidths: {name: 400, taxCode: 200}, + resizedColumnKeys: [], + tableWidth: 600, + rowChromeWidths, + }); + + // Then the free-text column truncates to 120px before the row overflows, matching what a reload lays out + expect(getResizedColumnWidths('name', 500)).toEqual({name: 500, taxCode: 120}); + }); + + it('gives an edge only to headed columns sized from their content', () => { + // Given a table mixing content-sized, fixed and headless columns + const columns = [column('name'), {...column('status'), declaredWidth: 80}, column('email'), column('arrow', {label: ''})]; + + // When the layout is built + const {resizableColumnKeys} = getResizableColumnLayout({ + columns, + resolvedColumnWidths: {name: 200, status: 80, email: 200, arrow: 40}, + resizedColumnKeys: [], + tableWidth: 520, + rowChromeWidths, + }); + + // Then only the content-sized ones get an edge, since fixed and headless columns hold fixed-size content + expect(resizableColumnKeys).toEqual(['name', 'email']); + }); + + it('keeps a last column already narrower than the drag bound where it is', () => { + // Given a last column painted at 40px, narrower than the drag bound, in a row that fills a 600px table exactly + const columns = [column('name'), column('role')]; + + // When the layout is built + const {dragMinWidths} = getResizableColumnLayout({ + columns, + resolvedColumnWidths: {name: 560, role: 40}, + resizedColumnKeys: [], + tableWidth: 600, + rowChromeWidths, + }); + + // Then a drag can't take it below its 40px, so pressing its edge doesn't make it jump out to the drag bound + expect(dragMinWidths).toEqual({role: 40}); + }); + + it('leaves the columns the table width minus every piece of row chrome, floored to whole px', () => { + // Given a 640.6px table with a 20px selection column, 36px of gaps, 40px of margin and 24px of padding + const chromeWidths = {selectionColumnWidth: 20, totalGapWidth: 36, rowMarginWidth: 40, rowPaddingWidth: 24}; + + // When the width left for the columns is measured + const availableColumnsWidth = getAvailableColumnsWidth(640.6, chromeWidths); + + // Then it is 520px, so a saved layout compares against whole-px tracks that never overflow the table by a fraction + expect(availableColumnsWidth).toBe(520); + }); + + it('fills the same column width a save records, once row chrome is taken out', () => { + // Given two 200px columns in a 640.6px table with a 20px selection column, 36px of gaps, 40px of margin and 24px of padding + const columns = [column('name'), column('role')]; + + // When the layout is built + const {resolvedColumnWidths, dragMinWidths} = getResizableColumnLayout({ + columns, + resolvedColumnWidths: {name: 200, role: 200}, + resizedColumnKeys: [], + tableWidth: 640.6, + rowChromeWidths: {selectionColumnWidth: 20, totalGapWidth: 36, rowMarginWidth: 40, rowPaddingWidth: 24}, + }); + + // Then the last column grows into the 520px a save measures and no further, so a reload at the same width repaints + // the row as it was dragged and the last column still can't shrink away from the table's edge + expect(resolvedColumnWidths).toEqual({name: 200, role: 320}); + expect(dragMinWidths).toEqual({role: 320}); + }); }); diff --git a/tests/unit/guardSubmissionTest.ts b/tests/unit/guardSubmissionTest.ts new file mode 100644 index 000000000000..e3ce2b5b2696 --- /dev/null +++ b/tests/unit/guardSubmissionTest.ts @@ -0,0 +1,102 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import {getSpan} from '@libs/telemetry/activeSpans'; + +import type {CreateTransactionParams} from '@pages/iou/request/step/confirmation/submission/types'; +import useSubmitLock from '@pages/iou/request/step/confirmation/submission/useSubmitLock'; +import guardSubmission from '@pages/iou/request/step/confirmation/submission/utils/guardSubmission'; + +import CONST from '@src/CONST'; + +import createMock from '../utils/createMock'; + +jest.mock('@libs/telemetry/activeSpans', () => ({ + getSpan: jest.fn(), +})); + +const PARAMS: CreateTransactionParams = {locationPermissionGranted: false, shouldHandleNavigation: true}; + +describe('guardSubmission', () => { + const setAttribute = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(getSpan).mockReturnValue(createMock>>({setAttribute})); + }); + + it('locks the submission and submits with the given params', () => { + // Given a submission path behind a lock that is free + const {result: lock} = renderHook(() => useSubmitLock()); + const submit = jest.fn(() => true); + + // When the guarded submit runs + let didSubmit = false; + act(() => { + didSubmit = guardSubmission(lock.current, submit)(PARAMS); + }); + + // Then it tags the submit span, takes the lock, and returns what the path's submit returned + expect(setAttribute).toHaveBeenCalledWith(CONST.TELEMETRY.ATTRIBUTE_LOCATION_SOURCE, CONST.TELEMETRY.SUBMIT_EXPENSE_LOCATION_SOURCE.NONE); + expect(lock.current.formHasBeenSubmitted.current).toBe(true); + expect(lock.current.isConfirmed).toBe(true); + expect(submit).toHaveBeenCalledWith(PARAMS); + expect(didSubmit).toBe(true); + }); + + it('stops before taking the lock when a distance rule blocks the submission', () => { + // Given a workspace distance rule that blocks this expense + const {result: lock} = renderHook(() => useSubmitLock()); + const submit = jest.fn(() => true); + const blockDistanceRequestIfNeeded = jest.fn(() => true); + + // When the guarded submit runs + let didSubmit = true; + act(() => { + didSubmit = guardSubmission(lock.current, submit, blockDistanceRequestIfNeeded)(PARAMS); + }); + + // Then nothing is locked or written, so the user can fix the expense and submit again + expect(blockDistanceRequestIfNeeded).toHaveBeenCalledTimes(1); + expect(lock.current.formHasBeenSubmitted.current).toBe(false); + expect(lock.current.isConfirmed).toBe(false); + expect(submit).not.toHaveBeenCalled(); + expect(didSubmit).toBe(false); + }); + + it('submits when the distance rule lets the expense through', () => { + // Given a distance check that doesn't block this expense + const {result: lock} = renderHook(() => useSubmitLock()); + const submit = jest.fn(() => false); + const blockDistanceRequestIfNeeded = jest.fn(() => false); + + // When the guarded submit runs + act(() => { + guardSubmission(lock.current, submit, blockDistanceRequestIfNeeded)(PARAMS); + }); + + // Then the check runs first and the submission goes ahead + expect(blockDistanceRequestIfNeeded).toHaveBeenCalledTimes(1); + expect(lock.current.formHasBeenSubmitted.current).toBe(true); + expect(submit).toHaveBeenCalledWith(PARAMS); + }); + + it('does not submit again while an earlier submission holds the lock', () => { + // Given a submission that is already in flight + const {result: lock} = renderHook(() => useSubmitLock()); + const submit = jest.fn(() => true); + act(() => { + guardSubmission(lock.current, submit)(PARAMS); + }); + submit.mockClear(); + + // When another tap runs the guarded submit + let didSubmitAgain = true; + act(() => { + didSubmitAgain = guardSubmission(lock.current, submit)(PARAMS); + }); + + // Then the path's submit isn't called, so the expense isn't created twice + expect(submit).not.toHaveBeenCalled(); + expect(didSubmitAgain).toBe(false); + }); +}); diff --git a/tests/unit/hooks/getTodoReportCountTest.ts b/tests/unit/hooks/getTodoReportCountTest.ts new file mode 100644 index 000000000000..0068170a2d81 --- /dev/null +++ b/tests/unit/hooks/getTodoReportCountTest.ts @@ -0,0 +1,104 @@ +import {getTodoReportCount} from '@hooks/useTodoSearchResults'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, SearchResults} from '@src/types/onyx'; + +import createMock from '../../utils/createMock'; + +// The server matched 120 reports. Its responses held reports A, B and C, and this device has listed all three. +const SERVER_REPORT_COUNT = 120; +const COUNTED_REPORT_IDS = {A: true, B: true, C: true}; +const SEEN_REPORT_IDS = COUNTED_REPORT_IDS; + +function buildServerSearch(search: Partial = {}): SearchResults['search'] { + return createMock({reportCount: SERVER_REPORT_COUNT, countedReportIDs: COUNTED_REPORT_IDS, ...search}); +} + +/** The to-do list on the device, holding the given reports */ +function buildLiveData(reportIDs: string[]): SearchResults['data'] { + const data = createMock({}); + for (const reportID of reportIDs) { + data[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] = createMock({reportID, type: CONST.REPORT.TYPE.EXPENSE}); + } + return data; +} + +describe('getTodoReportCount', () => { + it("has no report count when the server hasn't sent one", () => { + // Given no server snapshot yet + // When the report count is worked out + const reportCount = getTodoReportCount({serverSearch: undefined, liveData: buildLiveData(['B', 'C']), isLoaded: true, seenReportIDs: SEEN_REPORT_IDS}); + + // Then there is none to show + expect(reportCount).toBeUndefined(); + }); + + it("keeps the server's report count while every counted report is still in the list", () => { + // Given the device still holds every report the server's responses held + // When the report count is worked out + const reportCount = getTodoReportCount({serverSearch: buildServerSearch(), liveData: buildLiveData(['A', 'B', 'C']), isLoaded: true, seenReportIDs: SEEN_REPORT_IDS}); + + // Then it is the server's, which also covers the reports this device doesn't hold + expect(reportCount).toBe(SERVER_REPORT_COUNT); + }); + + it('takes a counted report that left the list off the server count', () => { + // Given report A was listed on this device and has left the list, so it's gone from it whatever the snapshot's rows still hold + // When the report count is worked out + const reportCount = getTodoReportCount({serverSearch: buildServerSearch(), liveData: buildLiveData(['B', 'C']), isLoaded: true, seenReportIDs: SEEN_REPORT_IDS}); + + // Then one report comes off, without waiting for the next search + expect(reportCount).toBe(SERVER_REPORT_COUNT - 1); + }); + + it('keeps a counted report this device never listed on the count, such as one its own rules hide', () => { + // Given report A was counted by the server but never listed here, as with a receipt still scanning on Needs approval + // When the report count is worked out + const reportCount = getTodoReportCount({serverSearch: buildServerSearch(), liveData: buildLiveData(['B', 'C']), isLoaded: true, seenReportIDs: {B: true, C: true}}); + + // Then the server's count stands, since "Select all" still acts on that report + expect(reportCount).toBe(SERVER_REPORT_COUNT); + }); + + it('leaves a report the server never counted out until the next response, such as a draft created on this device', () => { + // Given the list also holds a draft this device created, which isn't among the reports the server's responses held + // When the report count is worked out + const reportCount = getTodoReportCount({serverSearch: buildServerSearch(), liveData: buildLiveData(['A', 'B', 'C', 'created']), isLoaded: true, seenReportIDs: SEEN_REPORT_IDS}); + + // Then the server's count stands. The next first-page response counts the draft + expect(reportCount).toBe(SERVER_REPORT_COUNT); + }); + + it("keeps the server's report count while local data is still loading", () => { + // Given local data hasn't finished loading, so the list is still empty + // When the report count is worked out + const reportCount = getTodoReportCount({serverSearch: buildServerSearch(), liveData: buildLiveData([]), isLoaded: false, seenReportIDs: SEEN_REPORT_IDS}); + + // Then nothing counts as having left, since reports that still match are missing until then + expect(reportCount).toBe(SERVER_REPORT_COUNT); + }); + + it("keeps the server's report count when the snapshot has no record of the reports it counted", () => { + // Given a snapshot cached before the counted reports were recorded + // When the report count is worked out + const reportCount = getTodoReportCount({ + serverSearch: buildServerSearch({countedReportIDs: undefined}), + liveData: buildLiveData(['B', 'C']), + isLoaded: true, + seenReportIDs: SEEN_REPORT_IDS, + }); + + // Then it is the server's until the next response records them + expect(reportCount).toBe(SERVER_REPORT_COUNT); + }); + + it('never goes below zero', () => { + // Given a stale server count smaller than what has left + // When the report count is worked out + const reportCount = getTodoReportCount({serverSearch: buildServerSearch({reportCount: 1}), liveData: buildLiveData([]), isLoaded: true, seenReportIDs: SEEN_REPORT_IDS}); + + // Then it stops at zero + expect(reportCount).toBe(0); + }); +}); diff --git a/tests/unit/hooks/useAlertModals.test.ts b/tests/unit/hooks/useAlertModals.test.ts new file mode 100644 index 000000000000..266922ca16a4 --- /dev/null +++ b/tests/unit/hooks/useAlertModals.test.ts @@ -0,0 +1,52 @@ +import {renderHook} from '@testing-library/react-native'; + +import useAlertModals from '@hooks/useAlertModals'; + +const mockShowDecisionModal = jest.fn(); +jest.mock('@hooks/useDecisionModal', () => ({ + __esModule: true, + default: () => ({showDecisionModal: mockShowDecisionModal}), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +describe('useAlertModals', () => { + beforeEach(() => { + mockShowDecisionModal.mockClear(); + }); + + it('shows the one-button offline alert', () => { + // Given the hook is rendered + const {result} = renderHook(() => useAlertModals()); + + // When the offline alert is requested + result.current.showOfflineModal(); + + // Then the global decision modal shows the offline copy with a single confirm button, matching the local modals it replaced + expect(mockShowDecisionModal).toHaveBeenCalledTimes(1); + expect(mockShowDecisionModal).toHaveBeenCalledWith({ + title: 'common.youAppearToBeOffline', + prompt: 'common.offlinePrompt', + secondOptionText: 'common.buttonConfirm', + }); + }); + + it('shows the one-button download failed alert', () => { + // Given the hook is rendered + const {result} = renderHook(() => useAlertModals()); + + // When the download failed alert is requested + result.current.showDownloadErrorModal(); + + // Then the global decision modal shows the download failed copy with a single confirm button, matching the local modals it replaced + expect(mockShowDecisionModal).toHaveBeenCalledTimes(1); + expect(mockShowDecisionModal).toHaveBeenCalledWith({ + title: 'common.downloadFailedTitle', + prompt: 'common.downloadFailedDescription', + secondOptionText: 'common.buttonConfirm', + }); + }); +}); diff --git a/tests/unit/hooks/useAssignCard.test.ts b/tests/unit/hooks/useAssignCard.test.ts index 7e32d1926411..65c7a6074eb0 100644 --- a/tests/unit/hooks/useAssignCard.test.ts +++ b/tests/unit/hooks/useAssignCard.test.ts @@ -355,4 +355,64 @@ describe('useAssignCard', () => { ); }); }); + + describe('assignCard function - default transaction start date', () => { + it.each([CONST.COMPANY_CARD.FEED_BANK_NAME.CSV, CONST.COMPANY_CARD.FEED_BANK_NAME.CSV_CLASSIC])('should default to "From the beginning" for %s CSV feeds', (feedBankName) => { + // Given a CSV-uploaded feed, where we assume admins only include the transactions they want imported + const csvFeed: CompanyCardFeedWithDomainID = `${feedBankName}#${workspaceAccountID}`; + const csvFeedData: CombinedCardFeeds = { + [csvFeed]: { + liabilityType: 'corporate', + pending: false, + domainID: workspaceAccountID, + customFeedName: 'CSV cards', + feed: feedBankName, + }, + }; + jest.mocked(useCardFeeds).mockReturnValue([csvFeedData, {status: 'loaded'}, undefined, {}, workspaceAccountID]); + + const {result} = renderHook(() => + useAssignCard({ + feedName: csvFeed, + policyID: mockPolicyID, + setShouldShowOfflineModal: mockSetShouldShowOfflineModal, + }), + ); + + // When the admin starts assigning a card from that feed + result.current.assignCard('CSV card 1234', 'CSV card 1234'); + + // Then the start date defaults to "From the beginning" so every transaction in the file imports + expect(setAssignCardStepAndData).toHaveBeenCalledWith( + expect.objectContaining({ + cardToAssign: expect.objectContaining({ + dateOption: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING, + startDate: '', + }), + }), + ); + }); + + it('should not seed a start date for direct feeds', () => { + // Given a direct bank feed, which can return months of history + jest.mocked(useCardFeeds).mockReturnValue([mockDirectFeedData, {status: 'loaded'}, undefined, {}, workspaceAccountID]); + + const {result} = renderHook(() => + useAssignCard({ + feedName: mockDirectFeed, + policyID: mockPolicyID, + setShouldShowOfflineModal: mockSetShouldShowOfflineModal, + }), + ); + + // When the admin starts assigning a card from that feed + result.current.assignCard('Chase Checking 0000', 'Chase Checking 0000'); + + // Then no start date is seeded, so the later steps keep defaulting to today + expect(setAssignCardStepAndData).toHaveBeenCalledTimes(1); + const {cardToAssign} = jest.mocked(setAssignCardStepAndData).mock.calls.at(0)?.[0] ?? {}; + expect(cardToAssign?.dateOption).toBeUndefined(); + expect(cardToAssign?.startDate).toBeUndefined(); + }); + }); }); diff --git a/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts index a525f41c9dd0..86a3d8b8a99e 100644 --- a/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts +++ b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts @@ -70,6 +70,7 @@ function setupDefaultMocks() { isRestrictedToPreferredPolicy: false, preferredPolicyID: undefined, isRestrictedPolicyCreation: false, + isLoadingPreferredPolicy: false, }); jest.mocked(useHasActiveAdminPolicies).mockReturnValue(false); @@ -242,6 +243,7 @@ describe('useAutoCreateSubmitWorkspace', () => { isRestrictedToPreferredPolicy: false, preferredPolicyID: undefined, isRestrictedPolicyCreation: true, + isLoadingPreferredPolicy: false, }); // When the onboarding flow runs @@ -260,6 +262,7 @@ describe('useAutoCreateSubmitWorkspace', () => { isRestrictedToPreferredPolicy: false, preferredPolicyID: undefined, isRestrictedPolicyCreation: true, + isLoadingPreferredPolicy: false, }); // When the onboarding flow runs diff --git a/tests/unit/hooks/useAutocompleteSuggestions.test.ts b/tests/unit/hooks/useAutocompleteSuggestions.test.ts index 689233186b42..7c4d766a900e 100644 --- a/tests/unit/hooks/useAutocompleteSuggestions.test.ts +++ b/tests/unit/hooks/useAutocompleteSuggestions.test.ts @@ -114,6 +114,7 @@ jest.mock('@hooks/useExportedToFilterOptions', () => ({ })); const {parseForAutocomplete} = jest.requireMock<{parseForAutocomplete: jest.Mock}>('@libs/SearchAutocompleteUtils'); +const {getUserFriendlyKey} = jest.requireMock<{getUserFriendlyKey: jest.Mock}>('@libs/SearchQueryUtils'); const {getExpensifyTeamExclusions} = jest.requireMock<{getExpensifyTeamExclusions: jest.Mock}>('@libs/PolicyUtils'); const mockedUseNetwork = jest.mocked(useNetwork); const mockedOpenSearchCategoryFiltersPage = jest.mocked(openSearchCategoryFiltersPage); @@ -430,6 +431,23 @@ describe('useAutocompleteSuggestions', () => { expect(result.current.at(0)?.filterKey).toBe(CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); }); + it('suggests date presets after anyApproval:', () => { + // Given the user has typed the anyApproval filter key with no value yet + parseForAutocomplete.mockReturnValue({ + autocomplete: {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, value: ''}, + ranges: [], + }); + + // When the autocomplete suggestions are computed for that query + const {result} = renderHook(() => useAutocompleteSuggestions({...defaultParams, autocompleteQueryValue: 'anyApproval:'})); + + // Then the date presets are suggested for the anyApproval key, since it is a date filter like the other date keys + expect(result.current.length).toBeGreaterThan(0); + expect(result.current.at(0)?.filterKey).toBe(CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL); + expect(result.current.map((item) => item.text)).toEqual(['today', 'yesterday', 'lastWeek', 'lastMonth'].sort()); + expect(getUserFriendlyKey).toHaveBeenCalledWith(CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL); + }); + it('returns boolean suggestions for reimbursable key', () => { parseForAutocomplete.mockReturnValue({ autocomplete: {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE, value: ''}, @@ -442,6 +460,36 @@ describe('useAutocompleteSuggestions', () => { expect(result.current.at(0)?.filterKey).toBe(CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE); }); + it('returns yes and no suggestions for declined key', () => { + // Given the user has typed the declined key with no value yet + parseForAutocomplete.mockReturnValue({ + autocomplete: {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DECLINED, value: ''}, + ranges: [], + }); + + // When suggestions are computed + const {result} = renderHook(() => useAutocompleteSuggestions({...defaultParams, autocompleteQueryValue: 'declined:'})); + + // Then both boolean values are suggested for the declined key + expect(result.current.map((suggestion) => suggestion.text)).toEqual([CONST.SEARCH.BOOLEAN.NO, CONST.SEARCH.BOOLEAN.YES]); + expect(result.current.at(0)?.filterKey).toBe(CONST.SEARCH.SYNTAX_FILTER_KEYS.DECLINED); + }); + + it('returns yes and no suggestions for held key', () => { + // Given the user has typed the held key with no value yet + parseForAutocomplete.mockReturnValue({ + autocomplete: {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.HELD, value: ''}, + ranges: [], + }); + + // When suggestions are computed + const {result} = renderHook(() => useAutocompleteSuggestions({...defaultParams, autocompleteQueryValue: 'held:'})); + + // Then both boolean values are suggested for the held key + expect(result.current.map((suggestion) => suggestion.text)).toEqual([CONST.SEARCH.BOOLEAN.NO, CONST.SEARCH.BOOLEAN.YES]); + expect(result.current.at(0)?.filterKey).toBe(CONST.SEARCH.SYNTAX_FILTER_KEYS.HELD); + }); + it('returns in: suggestions for chat reports', () => { parseForAutocomplete.mockReturnValue({ autocomplete: {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.IN, value: 'gen'}, @@ -477,6 +525,41 @@ describe('useAutocompleteSuggestions', () => { expect(result.current).toEqual([]); }); + test.each(['createdBy:alice', 'ReportID:123', 'TAXRATE:20', 'report-field-Custom:value'])('consumes real non-canonical parser keys in %s', (query) => { + // Given the generated parser rather than the existing scenario stub. + parseForAutocomplete.mockImplementation(jest.requireActual('@libs/SearchAutocompleteUtils').parseForAutocomplete); + // When the production hook consumes a key without a suggestion branch. + const {result} = renderHook(() => useAutocompleteSuggestions({...defaultParams, autocompleteQueryValue: query})); + // Then it returns no suggestions without narrowing the parser to canonical keys. + expect(result.current).toEqual([]); + expect(parseForAutocomplete).toHaveBeenCalledWith(query); + }); + + it('uses real parser offsets and repeated substitutions for workspaces with the same name', () => { + // Given two selected workspaces whose display names collide but whose IDs differ. + parseForAutocomplete.mockImplementation(jest.requireActual('@libs/SearchAutocompleteUtils').parseForAutocomplete); + const policies: NonNullable> = { + first: createMock({id: 'A', name: 'Test Workspace'}), + second: createMock({id: 'B', name: 'Test Workspace'}), + third: createMock({id: 'C', name: 'Test Workspace'}), + }; + // When the production branch resolves occurrences by the existing indexed substitution keys. + const {result} = renderHook(() => + useAutocompleteSuggestions({ + ...defaultParams, + autocompleteQueryValue: 'workspace:"Test Workspace","Test Workspace",', + policies, + autocompleteSubstitutions: Object.fromEntries([ + ['policyID:Test Workspace', 'A'], + ['policyID:Test Workspace:1', 'B'], + ]), + }), + ); + // Then only the unselected policy remains despite the repeated display value. + expect(result.current).toHaveLength(1); + expect(result.current.at(0)).toMatchObject({autocompleteID: 'C', text: 'Test Workspace'}); + }); + it('excludes already selected workspaces by policy ID when names are duplicated', () => { parseForAutocomplete.mockReturnValue({ autocomplete: {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, value: 'test workspace'}, diff --git a/tests/unit/hooks/useBlurOnKeyboardHide.test.ts b/tests/unit/hooks/useBlurOnKeyboardHide.test.ts index 4d0e6fd68568..ec554ff99b1c 100644 --- a/tests/unit/hooks/useBlurOnKeyboardHide.test.ts +++ b/tests/unit/hooks/useBlurOnKeyboardHide.test.ts @@ -1,11 +1,11 @@ -import {renderHook} from '@testing-library/react-native'; +import {act, renderHook} from '@testing-library/react-native'; import useBlurOnKeyboardHide from '@hooks/useBlurOnKeyboardHide/index.android'; -import type {AppStateStatus} from 'react-native'; +import type {AppStateStatus, ScaledSize} from 'react-native'; import type {KeyboardEventData} from 'react-native-keyboard-controller'; -import {AppState} from 'react-native'; +import {AppState, Dimensions} from 'react-native'; const mockKeyboardListeners: Record void>> = {}; const mockRemove = jest.fn(); @@ -19,10 +19,17 @@ jest.mock('react-native-keyboard-controller', () => ({ }, })); +// eslint-disable-next-line @typescript-eslint/unbound-method +const mockedDimensionsAddEventListener = jest.mocked(Dimensions.addEventListener); + +const ANIMATED_DURATION = 250; +const PORTRAIT_WINDOW = Dimensions.get('window'); +const LANDSCAPE_WINDOW: ScaledSize = {...PORTRAIT_WINDOW, width: PORTRAIT_WINDOW.height, height: PORTRAIT_WINDOW.width}; + const appStateListeners: Array<(state: AppStateStatus) => void> = []; -function emitKeyboardEvent(event: 'keyboardDidShow' | 'keyboardDidHide', height: number) { - const eventData: KeyboardEventData = {height, duration: 0, timestamp: 0, target: -1, type: 'default', appearance: 'light'}; +function emitKeyboardEvent(event: 'keyboardWillShow' | 'keyboardDidShow' | 'keyboardDidHide', height: number, duration = ANIMATED_DURATION) { + const eventData: KeyboardEventData = {height, duration, timestamp: 0, target: -1, type: 'default', appearance: 'light'}; for (const handler of mockKeyboardListeners[event] ?? []) { handler(eventData); } @@ -35,16 +42,28 @@ function setAppState(state: AppStateStatus) { } } -type Step = {appState: AppStateStatus} | {show: true} | {hide: number}; +function setWindow(window: ScaledSize) { + mockedDimensionsAddEventListener.mock.calls.at(-1)?.[1]({window, screen: window}); +} + +type Step = {appState: AppStateStatus} | {willShow: true} | {show: true} | {hide: number; duration?: number} | {window: ScaledSize} | {wait: number}; function runSteps(steps: Step[]) { for (const step of steps) { if ('appState' in step) { setAppState(step.appState); + } else if ('willShow' in step) { + emitKeyboardEvent('keyboardWillShow', 300); } else if ('show' in step) { emitKeyboardEvent('keyboardDidShow', 300); + } else if ('window' in step) { + setWindow(step.window); + } else if ('wait' in step) { + act(() => { + jest.advanceTimersByTime(step.wait); + }); } else { - emitKeyboardEvent('keyboardDidHide', step.hide); + emitKeyboardEvent('keyboardDidHide', step.hide, step.duration); } } } @@ -53,6 +72,7 @@ describe('useBlurOnKeyboardHide', () => { const appStateRemove = jest.fn(); beforeEach(() => { + jest.useFakeTimers(); for (const key of Object.keys(mockKeyboardListeners)) { delete mockKeyboardListeners[key]; } @@ -65,6 +85,10 @@ describe('useBlurOnKeyboardHide', () => { setAppState('active'); }); + afterEach(() => { + jest.useRealTimers(); + }); + it.each<[name: string, steps: Step[], expectedBlurCalls: number]>([ ['blurs only on a zero-height hide while the app is active', [{hide: 100}, {hide: 0}], 1], ['does not blur when the keyboard hides because the app was backgrounded', [{appState: 'background'}, {hide: 0}], 0], @@ -73,20 +97,56 @@ describe('useBlurOnKeyboardHide', () => { ['skips only one hide per background trip', [{appState: 'background'}, {appState: 'active'}, {hide: 0}, {show: true}, {hide: 0}], 1], ['does not consume the skip flag on a partial-height hide after a background trip', [{appState: 'background'}, {appState: 'active'}, {hide: 100}, {hide: 0}], 0], ['clears a stale skip flag on keyboard show even without a foreground hide', [{appState: 'background'}, {appState: 'active'}, {show: true}, {hide: 100}, {hide: 0}], 1], + ['blurs right away on a predictive back hide, which reports no fixed duration', [{hide: 0, duration: -1}], 1], + ['does not blur an instant hide before the rotation delay has passed', [{hide: 0, duration: 0}, {wait: 299}], 0], + ['blurs an instant hide once no rotation explains it', [{hide: 0, duration: 0}, {wait: 300}], 1], + ['does not blur when a rotation follows the instant hide', [{hide: 0, duration: 0}, {window: LANDSCAPE_WINDOW}, {wait: 300}], 0], + ['does not blur when a rotation just preceded the instant hide', [{window: LANDSCAPE_WINDOW}, {wait: 50}, {hide: 0, duration: 0}, {wait: 300}], 0], + ['blurs an instant hide that comes long after a rotation', [{window: LANDSCAPE_WINDOW}, {wait: 1000}, {hide: 0, duration: 0}, {wait: 300}], 1], + ['does not blur when the keyboard comes back after an instant hide', [{hide: 0, duration: 0}, {show: true}, {wait: 300}], 0], + ['does not blur when the keyboard starts coming back before the rotation delay has passed', [{hide: 0, duration: 0}, {wait: 100}, {willShow: true}, {wait: 300}], 0], + ['does not blur when the app is backgrounded after an instant hide', [{hide: 0, duration: 0}, {appState: 'background'}, {wait: 300}], 0], + ['does not treat a height-only window change as a rotation', [{hide: 0, duration: 0}, {window: {...PORTRAIT_WINDOW, height: 500}}, {wait: 300}], 1], ])('%s', (_name, steps, expectedBlurCalls) => { + // Given an input whose blur is tracked const blur = jest.fn(); renderHook(() => useBlurOnKeyboardHide({current: {blur}})); + // When the keyboard, app state and window go through the steps of the scenario runSteps(steps); + // Then the input is blurred only when the user dismissed the keyboard, and never when the app goes to the background or the device rotates expect(blur).toHaveBeenCalledTimes(expectedBlurCalls); }); it('removes all subscriptions on unmount', () => { + // Given a mounted hook const {unmount} = renderHook(() => useBlurOnKeyboardHide({current: {blur: jest.fn()}})); + + // When it unmounts unmount(); + // Then every listener it added is removed, so no blur can reach an unmounted input expect(appStateRemove).toHaveBeenCalledTimes(1); - expect(mockRemove).toHaveBeenCalledTimes(2); + const dimensionsSubscription = mockedDimensionsAddEventListener.mock.results.at(-1); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(dimensionsSubscription?.type === 'return' && dimensionsSubscription.value.remove).toHaveBeenCalledTimes(1); + expect(mockRemove).toHaveBeenCalledTimes(3); + }); + + it('cancels a pending instant-hide blur on unmount', () => { + // Given an instant hide that scheduled a blur + const blur = jest.fn(); + const {unmount} = renderHook(() => useBlurOnKeyboardHide({current: {blur}})); + emitKeyboardEvent('keyboardDidHide', 0, 0); + + // When the hook unmounts before the delay passes + unmount(); + act(() => { + jest.advanceTimersByTime(300); + }); + + // Then the blur never runs, because the input it targets is gone + expect(blur).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index d0378318cc96..4835b04b9412 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -973,6 +973,44 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); }); + it('blocks a negative entered amount when the expense goes to a 1:1 user', () => { + // Given a scan addressed to a P2P recipient, where the To field is picked before the amount is entered + const filledFields = {isAmountSet: true, amount: -1234, isMerchantSet: true, merchant: 'Starbucks', isCreatedSet: true, created: '2025-01-15'}; + + // When the user fills in a negative amount and confirms + const {result} = renderHook(() => + useConfirmationValidation(createScanValidationParams(filledFields, {iouType: CONST.IOU.TYPE.CREATE, iouAmount: -1234, iouMerchant: 'Starbucks', isMerchantEmpty: false})), + ); + + // Then it is rejected, since P2P chats don't support negative amounts + expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + }); + + it.each([ + ['a workspace', POLICY_EXPENSE_CHAT_PARTICIPANT], + ['the self DM', SELF_DM_PARTICIPANT], + ['the current user seeded before the self-DM flag is set', {accountID: 1, isPolicyExpenseChat: false} as Participant], + ])('still allows a negative entered amount when the expense goes to %s', (_destination, participant) => { + // Given a scan addressed to a destination that supports negative amounts + const filledFields = {isAmountSet: true, amount: -1234, isMerchantSet: true, merchant: 'Starbucks', isCreatedSet: true, created: '2025-01-15', participants: [participant]}; + + // When the user fills in a negative amount and confirms + const {result} = renderHook(() => + useConfirmationValidation( + createScanValidationParams(filledFields, { + iouType: CONST.IOU.TYPE.CREATE, + iouAmount: -1234, + iouMerchant: 'Starbucks', + isMerchantEmpty: false, + selectedParticipants: [participant], + }), + ), + ); + + // Then it goes through, so only 1:1 expenses are held to the positive-amount rule + expect(result.current.validate()).toEqual({errorKey: null}); + }); + it('blocks confirmation when another receipt in a multi-scan is the partially filled one', () => { // The transaction on screen is a complete manual scan, but a sibling receipt is not. const {result} = renderHook(() => diff --git a/tests/unit/hooks/useContinueJoinWorkspaceOnboarding.test.ts b/tests/unit/hooks/useContinueJoinWorkspaceOnboarding.test.ts new file mode 100644 index 000000000000..7f5273891d52 --- /dev/null +++ b/tests/unit/hooks/useContinueJoinWorkspaceOnboarding.test.ts @@ -0,0 +1,276 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useContinueJoinWorkspaceOnboarding from '@hooks/useContinueJoinWorkspaceOnboarding'; + +import type {getOnboardingMessages} from '@libs/actions/Welcome/OnboardingFlow'; +import Log from '@libs/Log'; +import type * as NavigateAfterOnboarding from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import Navigation from '@libs/Navigation/Navigation'; + +import {completeOnboarding} from '@userActions/Report'; +import type * as ReportActions from '@userActions/Report'; +import type * as WelcomeActions from '@userActions/Welcome'; +import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@userActions/Welcome'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +import Onyx from 'react-native-onyx'; + +import createMock from '../../utils/createMock'; +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +type HookParams = Parameters[0]; + +const WORK_EMAIL = 'test@company.com'; +const MERGED_WORK_EMAIL = 'merged@company.com'; +const POLICY_ID = 'joined-policy'; +const mockJoinWorkspaceMessages = createMock['joinWorkspaceMessages']>({ + addWorkEmail: {message: 'Add work email', tasks: [{type: CONST.ONBOARDING_TASK_TYPE.ADD_WORK_EMAIL}]}, + validateEmail: {message: 'Validate email', tasks: [{type: CONST.ONBOARDING_TASK_TYPE.VALIDATE_EMAIL}]}, + joinWorkspace: {message: 'Join workspace', tasks: [{type: CONST.ONBOARDING_TASK_TYPE.JOIN_WORKSPACE}]}, + empty: {message: 'No workspaces', tasks: []}, +}); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: () => ({accountID: 123}), +})); +jest.mock('@hooks/useOnboardingMessages', () => ({ + __esModule: true, + default: () => ({joinWorkspaceMessages: mockJoinWorkspaceMessages}), +})); +jest.mock('@hooks/usePermissions', () => ({ + __esModule: true, + default: () => ({isBetaEnabled: () => false}), +})); +jest.mock('@hooks/useResponsiveLayout', () => ({ + __esModule: true, + default: () => ({isSmallScreenWidth: false}), +})); +jest.mock('@userActions/Report', () => ({ + ...jest.requireActual('@userActions/Report'), + completeOnboarding: jest.fn(), +})); +jest.mock('@userActions/Welcome', () => ({ + ...jest.requireActual('@userActions/Welcome'), + setOnboardingAdminsChatReportID: jest.fn(), + setOnboardingPolicyID: jest.fn(), +})); +jest.mock('@libs/navigateAfterOnboarding', () => ({ + ...jest.requireActual('@libs/navigateAfterOnboarding'), + navigateAfterOnboardingWithMicrotaskQueue: jest.fn(), +})); + +const mockCompleteOnboarding = jest.mocked(completeOnboarding); +const mockNavigateAfterOnboarding = jest.mocked(navigateAfterOnboardingWithMicrotaskQueue); +const navigate = jest.spyOn(Navigation, 'navigate'); + +function createParams(overrides: Partial = {}): HookParams { + return createMock({ + session: {email: WORK_EMAIL, accountID: 123}, + loginList: {[WORK_EMAIL]: {validatedDate: '2026-09-12 00:00:00'}}, + onboardingValues: {selfTourViewed: true}, + onboardingPersonalDetails: {firstName: ' Test ', lastName: ' User '}, + introSelected: {choice: CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE}, + conciergeReportID: '12345', + conciergeChat: {reportID: '12345'}, + reportNameValuePairs: {[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}12345`]: {private_isArchived: ''}}, + delegateAccountID: 456, + ...overrides, + }); +} + +describe('useContinueJoinWorkspaceOnboarding', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockCompleteOnboarding.mockReset().mockResolvedValue(undefined); + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + + it.each([undefined, '', ' '])('opens personal details when the saved first name is %p', async (firstName) => { + // Given no usable first name, even though the optional last name is present. + const params = createParams({onboardingPersonalDetails: createMock>({firstName, lastName: 'User'})}); + const {result} = renderHook(() => useContinueJoinWorkspaceOnboarding(params)); + await waitForBatchedUpdatesWithAct(); + + // When continuing with replacement navigation requested by the calling screen. + act(() => result.current({forceReplace: true})); + + // Then the required name step opens without prematurely completing or clearing onboarding state. + expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); + expect(mockCompleteOnboarding).not.toHaveBeenCalled(); + expect(setOnboardingPolicyID).not.toHaveBeenCalled(); + expect(setOnboardingAdminsChatReportID).not.toHaveBeenCalled(); + expect(mockNavigateAfterOnboarding).not.toHaveBeenCalled(); + }); + + it.each([false, true])('completes with the trimmed saved name and caller-provided onboarding data (anonymous: %s)', async (isAnonymousUser) => { + // Given a saved name and an admins chat that completion must receive before state is cleared. + const params = createParams({session: createMock({email: WORK_EMAIL, authTokenType: isAnonymousUser ? CONST.AUTH_TOKEN_TYPES.ANONYMOUS : undefined})}); + await Onyx.set(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID, '67890'); + const {result} = renderHook(() => useContinueJoinWorkspaceOnboarding(params)); + await waitForBatchedUpdatesWithAct(); + + // When the user continues without needing to re-enter their name. + act(() => result.current()); + await waitForBatchedUpdatesWithAct(); + + // Then completion gets the current caller data and successful completion clears the saved destinations. + expect(mockCompleteOnboarding).toHaveBeenCalledTimes(1); + expect(mockCompleteOnboarding).toHaveBeenCalledWith({ + engagementChoice: CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE, + onboardingMessage: mockJoinWorkspaceMessages.empty, + firstName: 'Test', + lastName: 'User', + adminsChatReportID: '67890', + introSelected: params.introSelected, + isSelfTourViewed: true, + conciergeChat: params.conciergeChat, + companyDomain: 'company.com', + workEmail: WORK_EMAIL, + currentUserAccountID: 123, + delegateAccountID: 456, + }); + expect(setOnboardingAdminsChatReportID).toHaveBeenCalledWith(); + expect(setOnboardingPolicyID).toHaveBeenCalledWith(); + expect(mockNavigateAfterOnboarding).toHaveBeenCalledTimes(1); + expect(mockNavigateAfterOnboarding).toHaveBeenCalledWith(false, false, params.conciergeReportID, params.reportNameValuePairs, isAnonymousUser, undefined, undefined, false); + expect(navigate).not.toHaveBeenCalled(); + }); + + it.each([ + {scenario: 'public email', email: 'test@gmail.com', isValidated: true, policyState: 'none', expectedMessage: 'addWorkEmail'}, + {scenario: 'unvalidated work email', email: WORK_EMAIL, isValidated: false, policyState: 'none', expectedMessage: 'validateEmail'}, + {scenario: 'validated work email with no workspace', email: WORK_EMAIL, isValidated: true, policyState: 'none', expectedMessage: 'empty'}, + {scenario: 'pending workspace request', email: WORK_EMAIL, isValidated: true, policyState: 'pending', expectedMessage: 'joinWorkspace'}, + {scenario: 'joined workspace', email: WORK_EMAIL, isValidated: true, policyState: 'joined', expectedMessage: 'joinWorkspace'}, + ] as const)('selects the completion message and destination for $scenario', async ({email, isValidated, policyState, expectedMessage}) => { + // Given the email and workspace state that determines which onboarding task remains outstanding. + const params = createParams({session: createMock({email}), loginList: {[email]: {validatedDate: isValidated ? '2026-09-12 00:00:00' : ''}}}); + if (policyState !== 'none') { + await Onyx.set(ONYXKEYS.ONBOARDING_POLICY_ID, POLICY_ID); + } + if (policyState === 'joined') { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {id: POLICY_ID}); + } + const {result} = renderHook(() => useContinueJoinWorkspaceOnboarding(params)); + await waitForBatchedUpdatesWithAct(); + + // When completion uses the saved name rather than the personal-details screen. + act(() => result.current()); + await waitForBatchedUpdatesWithAct(); + + // Then only an already-joined workspace drops the task and becomes the navigation destination. + expect(mockCompleteOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + onboardingMessage: policyState === 'joined' ? {...mockJoinWorkspaceMessages.joinWorkspace, tasks: []} : mockJoinWorkspaceMessages[expectedMessage], + }), + ); + expect(mockNavigateAfterOnboarding).toHaveBeenCalledWith( + false, + false, + params.conciergeReportID, + params.reportNameValuePairs, + false, + policyState === 'joined' ? POLICY_ID : undefined, + undefined, + false, + ); + expect(mockJoinWorkspaceMessages.joinWorkspace.tasks).toHaveLength(1); + }); + + it('waits for completion and ignores repeated calls before and after success', async () => { + // Given a pending completion request that must not enqueue another guided-setup write. + let resolveCompletion = () => {}; + mockCompleteOnboarding.mockReturnValueOnce( + new Promise((resolve) => { + resolveCompletion = resolve; + }), + ); + const params = createParams(); + const {result, rerender} = renderHook((props: HookParams) => useContinueJoinWorkspaceOnboarding(props), {initialProps: params}); + await waitForBatchedUpdatesWithAct(); + + // When multiple effects continue onboarding, including after a render with fresh caller data. + act(() => result.current()); + rerender({...params}); + act(() => result.current()); + + // Then completion starts once, and cleanup/navigation wait until that request resolves. + expect(mockCompleteOnboarding).toHaveBeenCalledTimes(1); + expect(setOnboardingPolicyID).not.toHaveBeenCalled(); + expect(setOnboardingAdminsChatReportID).not.toHaveBeenCalled(); + expect(mockNavigateAfterOnboarding).not.toHaveBeenCalled(); + await act(async () => resolveCompletion()); + await waitForBatchedUpdatesWithAct(); + act(() => result.current()); + expect(mockCompleteOnboarding).toHaveBeenCalledTimes(1); + expect(mockNavigateAfterOnboarding).toHaveBeenCalledTimes(1); + }); + + it('preserves onboarding state after failure and allows a retry', async () => { + // Given a request rejection, so the user must be able to retry without losing their destination. + const error = new Error('Completion failed'); + const warn = jest.spyOn(Log, 'warn').mockImplementation(() => {}); + mockCompleteOnboarding.mockRejectedValueOnce(error); + const {result} = renderHook(() => useContinueJoinWorkspaceOnboarding(createParams())); + await waitForBatchedUpdatesWithAct(); + + // When the first completion attempt fails. + act(() => result.current()); + await waitForBatchedUpdatesWithAct(); + + // Then failure is logged without clearing state, and the next attempt can complete successfully. + expect(warn).toHaveBeenCalledWith(expect.any(String), {error}); + expect(setOnboardingPolicyID).not.toHaveBeenCalled(); + expect(setOnboardingAdminsChatReportID).not.toHaveBeenCalled(); + expect(mockNavigateAfterOnboarding).not.toHaveBeenCalled(); + act(() => result.current()); + await waitForBatchedUpdatesWithAct(); + expect(mockCompleteOnboarding).toHaveBeenCalledTimes(2); + expect(mockNavigateAfterOnboarding).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it.each([false, true])('uses the latest merged-account data (redirect to Classic: %s)', async (shouldRedirectToClassicAfterMerge) => { + // Given a mounted screen whose props still refer to the original account before a merge. + const {result, rerender} = renderHook((props: HookParams) => useContinueJoinWorkspaceOnboarding(props), {initialProps: createParams()}); + await waitForBatchedUpdatesWithAct(); + const mergedParams = createParams({ + session: createMock({email: MERGED_WORK_EMAIL}), + loginList: {[MERGED_WORK_EMAIL]: {validatedDate: '2026-09-12 00:00:00'}}, + onboardingPersonalDetails: createMock({firstName: 'Merged', lastName: ''}), + onboardingValues: createMock>({shouldValidate: true, shouldRedirectToClassicAfterMerge}), + conciergeReportID: '99999', + conciergeChat: createMock({reportID: '99999'}), + }); + + // When the caller receives the merged account and then continues onboarding. + rerender(mergedParams); + act(() => result.current()); + await waitForBatchedUpdatesWithAct(); + + // Then completion uses fresh props, with the merged Concierge destination only for the NewDot merge flow. + expect(mockCompleteOnboarding).toHaveBeenCalledWith( + expect.objectContaining({firstName: 'Merged', lastName: '', workEmail: MERGED_WORK_EMAIL, conciergeChat: mergedParams.conciergeChat}), + ); + expect(mockNavigateAfterOnboarding).toHaveBeenCalledWith( + false, + false, + '99999', + mergedParams.reportNameValuePairs, + false, + undefined, + shouldRedirectToClassicAfterMerge ? undefined : '99999', + false, + ); + }); +}); diff --git a/tests/unit/hooks/useCopySelectionEvent.test.ts b/tests/unit/hooks/useCopySelectionEvent.test.ts new file mode 100644 index 000000000000..f81f1e34b5c2 --- /dev/null +++ b/tests/unit/hooks/useCopySelectionEvent.test.ts @@ -0,0 +1,227 @@ +import {renderHook} from '@testing-library/react-native'; + +import useCopySelectionEvent from '@hooks/useCopySelectionEvent'; + +import getClipboardText from '@libs/Clipboard/getClipboardText'; +import SelectionScraper from '@libs/SelectionScraper'; + +jest.mock('@libs/Clipboard/getClipboardText', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@libs/SelectionScraper', () => ({ + __esModule: true, + default: { + getCurrentSelection: jest.fn(), + }, +})); + +const mockGetClipboardText = jest.mocked(getClipboardText); +const mockGetCurrentSelection = jest.mocked(SelectionScraper.getCurrentSelection); + +describe('useCopySelectionEvent', () => { + beforeEach(() => { + jest.clearAllMocks(); + window.getSelection()?.removeAllRanges(); + }); + + const dispatchCopyEvent = (target: Document | HTMLElement = document) => { + const clipboardData = { + clearData: jest.fn(), + setData: jest.fn(), + }; + const event = new Event('copy', {bubbles: true, cancelable: true}); + Object.defineProperty(event, 'clipboardData', {value: clipboardData}); + target.dispatchEvent(event); + return {clipboardData, event}; + }; + + it('removes the browser copy listener on unmount', () => { + // Given the copy helper is mounted and then unmounted + const {unmount} = renderHook(() => useCopySelectionEvent()); + unmount(); + + // When the browser dispatches another copy event + dispatchCopyEvent(); + + // Then the unmounted helper does not process the selection + expect(mockGetCurrentSelection).not.toHaveBeenCalled(); + }); + + it('keeps another consumer active when one copy listener unmounts', () => { + // Given two selectable views are mounted with independent copy listeners + const selection = 'Draft Expense Report TJS 10.00'; + mockGetCurrentSelection.mockReturnValue(selection); + mockGetClipboardText.mockReturnValue(selection); + const firstConsumer = renderHook(() => useCopySelectionEvent()); + const secondConsumer = renderHook(() => useCopySelectionEvent()); + + // When the first view unmounts and a copy event occurs in the remaining view + firstConsumer.unmount(); + const {clipboardData, event} = dispatchCopyEvent(); + + // Then the remaining listener still normalizes the selected row exactly once + expect(event.defaultPrevented).toBe(true); + expect(mockGetCurrentSelection).toHaveBeenCalledTimes(1); + expect(clipboardData.setData).toHaveBeenNthCalledWith(1, 'text/html', selection); + expect(clipboardData.setData).toHaveBeenNthCalledWith(2, 'text/plain', selection); + secondConsumer.unmount(); + }); + + it('writes the scraped selection as HTML and plain text', () => { + // Given the scraper returns a formatted selection + const selection = 'Expense description'; + mockGetCurrentSelection.mockReturnValue(selection); + mockGetClipboardText.mockReturnValue('Expense description'); + renderHook(() => useCopySelectionEvent()); + + // When the browser dispatches a copy event + const {clipboardData, event} = dispatchCopyEvent(); + + // Then the scraper output is written once in both clipboard formats + expect(event.defaultPrevented).toBe(true); + expect(mockGetCurrentSelection).toHaveBeenCalledTimes(1); + expect(mockGetClipboardText).toHaveBeenCalledWith(selection); + expect(clipboardData.clearData).toHaveBeenCalledTimes(1); + expect(clipboardData.setData).toHaveBeenNthCalledWith(1, 'text/html', selection); + expect(clipboardData.setData).toHaveBeenNthCalledWith(2, 'text/plain', 'Expense description'); + }); + + it('writes a plain non-Markdown selection without additional filtering', () => { + // Given the scraper returns a plain copyable field value + const selection = 'Merchant'; + mockGetCurrentSelection.mockReturnValue(selection); + mockGetClipboardText.mockReturnValue(selection); + renderHook(() => useCopySelectionEvent()); + + // When the browser dispatches a copy event + const {clipboardData, event} = dispatchCopyEvent(); + + // Then the plain selection is written through the same clipboard path + expect(event.defaultPrevented).toBe(true); + expect(mockGetCurrentSelection).toHaveBeenCalledTimes(1); + expect(clipboardData.setData).toHaveBeenNthCalledWith(1, 'text/html', selection); + expect(clipboardData.setData).toHaveBeenNthCalledWith(2, 'text/plain', selection); + }); + + it('does not process an event that another copy handler already handled', () => { + // Given another copy handler has already handled the event + renderHook(() => useCopySelectionEvent()); + const event = new Event('copy', {bubbles: true, cancelable: true}); + event.preventDefault(); + Object.defineProperty(event, 'clipboardData', { + value: {clearData: jest.fn(), setData: jest.fn()}, + }); + + // When the event reaches this helper + document.dispatchEvent(event); + + // Then the selection is not scraped a second time + expect(mockGetCurrentSelection).not.toHaveBeenCalled(); + }); + + it('does nothing when there is no selected content', () => { + // Given there is no current browser selection + mockGetCurrentSelection.mockReturnValue(''); + renderHook(() => useCopySelectionEvent()); + + // When the browser dispatches a copy event + const {clipboardData, event} = dispatchCopyEvent(); + + // Then native copy behavior is left unchanged + expect(event.defaultPrevented).toBe(false); + expect(mockGetClipboardText).not.toHaveBeenCalled(); + expect(clipboardData.clearData).not.toHaveBeenCalled(); + expect(clipboardData.setData).not.toHaveBeenCalled(); + }); + + it('leaves copying from text inputs to the browser', () => { + // Given a text input is the source of the copy event + const input = document.createElement('input'); + input.value = 'Expense description'; + input.setSelectionRange(0, input.value.length); + document.body.appendChild(input); + renderHook(() => useCopySelectionEvent()); + + // When the input dispatches a copy event + const {clipboardData, event} = dispatchCopyEvent(input); + + // Then the helper preserves the input's native copy behavior + expect(event.defaultPrevented).toBe(false); + expect(mockGetCurrentSelection).not.toHaveBeenCalled(); + expect(clipboardData.setData).not.toHaveBeenCalled(); + input.remove(); + }); + + it('processes a row selection when a previously focused input remains the copy event target', () => { + // Given an input remains focused without selected input text while a copyable row is selected + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + input.setSelectionRange(0, 0); + const selection = 'Oct 6 Draft Expense Report TJS 10.00'; + mockGetCurrentSelection.mockReturnValue(selection); + mockGetClipboardText.mockReturnValue(selection); + renderHook(() => useCopySelectionEvent()); + + // When the browser targets the stale focused input for the copy event + const {clipboardData, event} = dispatchCopyEvent(input); + + // Then the selected row is normalized instead of falling back to native rich-text copying + expect(event.defaultPrevented).toBe(true); + expect(mockGetCurrentSelection).toHaveBeenCalledTimes(1); + expect(clipboardData.setData).toHaveBeenNthCalledWith(1, 'text/html', selection); + expect(clipboardData.setData).toHaveBeenNthCalledWith(2, 'text/plain', selection); + input.remove(); + }); + + it('leaves copying a selection inside editable content to the browser', () => { + // Given text is selected inside an editable element + const editor = document.createElement('div'); + Object.defineProperty(editor, 'isContentEditable', {value: true}); + editor.textContent = 'Expense description'; + document.body.appendChild(editor); + const range = document.createRange(); + range.selectNodeContents(editor); + const browserSelection = window.getSelection(); + browserSelection?.addRange(range); + renderHook(() => useCopySelectionEvent()); + + // When the editable element dispatches the copy event + const {clipboardData, event} = dispatchCopyEvent(editor); + + // Then its native rich-text copy behavior remains unchanged + expect(event.defaultPrevented).toBe(false); + expect(mockGetCurrentSelection).not.toHaveBeenCalled(); + expect(clipboardData.setData).not.toHaveBeenCalled(); + editor.remove(); + }); + + it('processes a row selection when a previously focused editable element remains the copy event target', () => { + // Given an editable element remains the event target while the browser selection is in a report row + const editor = document.createElement('div'); + Object.defineProperty(editor, 'isContentEditable', {value: true}); + const row = document.createElement('div'); + row.textContent = 'Oct 6 Draft Expense Report TJS 10.00'; + document.body.append(editor, row); + const range = document.createRange(); + range.selectNodeContents(row); + const browserSelection = window.getSelection(); + browserSelection?.addRange(range); + mockGetCurrentSelection.mockReturnValue(row.textContent); + mockGetClipboardText.mockReturnValue(row.textContent); + renderHook(() => useCopySelectionEvent()); + + // When the stale editable element dispatches the copy event + const {clipboardData, event} = dispatchCopyEvent(editor); + + // Then the report row is normalized instead of using native rich-text copying + expect(event.defaultPrevented).toBe(true); + expect(mockGetCurrentSelection).toHaveBeenCalledTimes(1); + expect(clipboardData.setData).toHaveBeenNthCalledWith(1, 'text/html', row.textContent); + expect(clipboardData.setData).toHaveBeenNthCalledWith(2, 'text/plain', row.textContent); + editor.remove(); + row.remove(); + }); +}); diff --git a/tests/unit/hooks/useCopyableTextRowPress.test.ts b/tests/unit/hooks/useCopyableTextRowPress.test.ts new file mode 100644 index 000000000000..fd8510e5cc62 --- /dev/null +++ b/tests/unit/hooks/useCopyableTextRowPress.test.ts @@ -0,0 +1,162 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import ControlSelection from '@libs/ControlSelection'; + +import type useCopyableTextRowPressModule from '../../../src/hooks/useCopyableTextRowPress/index'; +import type {isPressStartOnCopyableText as isPressStartOnCopyableTextModule} from '../../../src/hooks/useCopyableTextRowPress/index'; + +const copyableTextRowPressModule = jest.requireActual<{ + default: typeof useCopyableTextRowPressModule; + isPressStartOnCopyableText: typeof isPressStartOnCopyableTextModule; +}>('../../../src/hooks/useCopyableTextRowPress/index.ts'); +const {default: useCopyableTextRowPress, isPressStartOnCopyableText} = copyableTextRowPressModule; +const originalGetClientRectsDescriptor = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects'); + +function getCopyableTextElement(): HTMLElement { + const element = document.getElementById('copyableText'); + if (!element) { + throw new Error('Missing copyable text element'); + } + + return element; +} + +function selectElementText(element: HTMLElement) { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(element); + + selection?.removeAllRanges(); + selection?.addRange(range); +} + +describe('useCopyableTextRowPress', () => { + beforeEach(() => { + document.body.innerHTML = 'Copy me'; + window.getSelection()?.removeAllRanges(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + if (originalGetClientRectsDescriptor) { + Object.defineProperty(Range.prototype, 'getClientRects', originalGetClientRectsDescriptor); + } else { + Reflect.deleteProperty(Range.prototype, 'getClientRects'); + } + }); + + it('suppresses row press after selecting copyable text', () => { + const {result} = renderHook(() => useCopyableTextRowPress()); + const copyableTextElement = getCopyableTextElement(); + selectElementText(copyableTextElement); + + expect(result.current.markMouseDownOnCopyableText(copyableTextElement)).toBe(true); + expect(result.current.shouldSuppressCopyableTextRowPress()).toBe(true); + expect(result.current.shouldSuppressCopyableTextRowPress()).toBe(false); + }); + + it('suppresses the next row press for double-click selection starts', () => { + const {result} = renderHook(() => useCopyableTextRowPress()); + const copyableTextElement = getCopyableTextElement(); + + expect(result.current.markMouseDownOnCopyableText(copyableTextElement, true, {shouldSuppressNextPress: true})).toBe(true); + expect(result.current.shouldSuppressCopyableTextRowPress()).toBe(true); + }); + + it('suppresses row long press after touch starts on copyable text', () => { + const {result} = renderHook(() => useCopyableTextRowPress()); + const copyableTextElement = getCopyableTextElement(); + + expect(result.current.markTouchStartOnCopyableText({target: copyableTextElement})).toBe(true); + expect(result.current.shouldSuppressCopyableTextRowLongPress()).toBe(true); + }); + + it('tracks copyable touch starts for consumers that need to preserve native text selection', () => { + const {result} = renderHook(() => useCopyableTextRowPress({shouldTrackTouchStart: true})); + const copyableTextElement = getCopyableTextElement(); + const blockSelectionSpy = jest.spyOn(ControlSelection, 'block').mockImplementation(); + + act(() => { + result.current.markTouchStartOnCopyableText({target: copyableTextElement}); + }); + + expect(result.current.didTouchStartOnCopyableText).toBe(true); + result.current.handleCopyableTextRowPressIn({shouldEnableTextSelection: true, shouldBlockSelection: true}); + expect(blockSelectionSpy).not.toHaveBeenCalled(); + + act(() => { + result.current.markMouseDownOnCopyableText(copyableTextElement); + }); + + expect(result.current.didTouchStartOnCopyableText).toBe(false); + result.current.handleCopyableTextRowPressIn({shouldEnableTextSelection: true, shouldBlockSelection: true}); + expect(blockSelectionSpy).toHaveBeenCalledTimes(1); + }); + + it('does not suppress row press when the press starts outside copyable text', () => { + document.body.innerHTML = 'Regular text'; + const regularTextElement = document.getElementById('regularText'); + const {result} = renderHook(() => useCopyableTextRowPress()); + + expect(result.current.markMouseDownOnCopyableText(regularTextElement)).toBe(false); + expect(result.current.shouldSuppressCopyableTextRowPress()).toBe(false); + }); + + it('caches expensive copyable text hit testing for the same press', () => { + const {result} = renderHook(() => useCopyableTextRowPress()); + const copyableTextElement = getCopyableTextElement(); + const getClientRectsMock = jest.fn(() => [ + { + bottom: 20, + height: 20, + left: 0, + right: 100, + top: 0, + width: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }, + ]); + Object.defineProperty(Range.prototype, 'getClientRects', { + configurable: true, + value: getClientRectsMock, + }); + const pressEvent = { + clientX: 10, + clientY: 10, + target: copyableTextElement, + }; + + expect(result.current.isPressStartOnCopyableText(pressEvent)).toBe(true); + expect(result.current.isPressStartOnCopyableText(pressEvent)).toBe(true); + expect(getClientRectsMock).toHaveBeenCalledTimes(1); + }); + + it('does not treat blank space inside a copyable element as copyable text', () => { + // Given a copyable element whose layout extends beyond its rendered text + const copyableTextElement = getCopyableTextElement(); + Object.defineProperty(Range.prototype, 'getClientRects', { + configurable: true, + value: () => [ + { + bottom: 20, + height: 20, + left: 0, + right: 100, + top: 0, + width: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }, + ], + }); + + // When the touch starts within the element but outside the text glyph bounds + const isCopyableText = isPressStartOnCopyableText({clientX: 150, clientY: 10, target: copyableTextElement}); + + // Then the blank area remains available for the row long-press action + expect(isCopyableText).toBe(false); + }); +}); diff --git a/tests/unit/hooks/useEditComposerToggle.test.ts b/tests/unit/hooks/useEditComposerToggle.test.ts index 365ec0f2b7f5..c5db4184bec7 100644 --- a/tests/unit/hooks/useEditComposerToggle.test.ts +++ b/tests/unit/hooks/useEditComposerToggle.test.ts @@ -259,7 +259,8 @@ describe('useEditComposerToggle', () => { it('on narrow, when edit ends, restores prior draft and selection', () => { const onValueChange = jest.fn(); const onSelectionChange = jest.fn(); - const composerRef = makeComposerRef(); + const blur = jest.fn, Parameters>(); + const composerRef = makeComposerRef({blur}); const priorSelection: TextSelection = {start: 2, end: 5}; // Start with edit off so wasEditingRef is false; then turn editing on to capture previousDraftSelectionRef. @@ -289,7 +290,7 @@ describe('useEditComposerToggle', () => { expect(onValueChange).toHaveBeenCalledWith('restored'); expect(onSelectionChange).toHaveBeenCalledWith(priorSelection); - expect(composerRef.current?.blur).not.toHaveBeenCalled(); + expect(blur).not.toHaveBeenCalled(); }); it('resets the manual composer height after a submitted narrow composer edit ends', () => { diff --git a/tests/unit/hooks/useEditMessageComposerCommands.test.ts b/tests/unit/hooks/useEditMessageComposerCommands.test.ts new file mode 100644 index 000000000000..e2a780aae61a --- /dev/null +++ b/tests/unit/hooks/useEditMessageComposerCommands.test.ts @@ -0,0 +1,120 @@ +/** Covers the edit hook's delete-modal blur and deferred cancellation focus. */ +import {act, renderHook} from '@testing-library/react-native'; + +import type * as UseReportScrollManagerModule from '@hooks/useReportScrollManager'; + +import type * as ReportModule from '@libs/actions/Report'; + +import {showDeleteModal} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import useEditMessage from '@pages/inbox/report/ReportActionCompose/useEditMessage'; + +import {getFakeReportAction} from '../../utils/LHNTestUtils'; + +type EditMessageProps = Parameters[0]; + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({email: 'user@test.com'})); +jest.mock('@hooks/useOnyx', () => () => [undefined]); +jest.mock('@hooks/usePersonalDetails', () => ({useAllPersonalDetails: () => [undefined]})); +jest.mock('@hooks/useReportIDToNameMap', () => () => ({})); +jest.mock('@hooks/useReportIsArchived', () => () => false); +jest.mock('@hooks/useReportScrollManager', () => () => ({ + scrollToIndex: jest.fn< + ReturnType['scrollToIndex']>, + Parameters['scrollToIndex']> + >(), +})); +jest.mock('@libs/actions/Report', () => ({ + clearAllReportActionDrafts: jest.fn, Parameters>(), + editReportComment: jest.fn, Parameters>(), +})); +jest.mock('@pages/inbox/report/ReportActionEditMessageContext', () => ({useReportActionActiveEditActions: () => ({stopEditing: jest.fn(), submitEdit: jest.fn()})})); +jest.mock('@pages/inbox/report/useDraftMessageVideoAttributeCache', () => ({draftMessageVideoAttributeCache: new Map()})); +jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => ({showDeleteModal: jest.fn, Parameters>()})); + +function renderEditCommands(composerRef: EditMessageProps['composerRef']) { + // The hook's empty-draft branch only needs validation flush and a real report-action identity. + const debouncedCommentMaxLengthValidation = Object.assign( + jest.fn(() => true), + {flush: () => true, cancel: jest.fn()}, + ); + return renderHook(() => useEditMessage({reportID: '1', originalReportID: undefined, reportAction: getFakeReportAction(), debouncedCommentMaxLengthValidation, composerRef})); +} + +describe('useEditMessage composer commands', () => { + beforeEach(() => jest.clearAllMocks()); + afterEach(() => jest.restoreAllMocks()); + + it('blurs before the modal and focuses the replacement ref only when the idle callback executes', () => { + // Given a command-only composer handle and a later replacement while the modal is open. + const first = {blur: jest.fn(), focus: jest.fn()} satisfies NonNullable; + const replacement = {blur: jest.fn(), focus: jest.fn()} satisfies NonNullable; + const composerRef: EditMessageProps['composerRef'] = {current: first}; + const idle = jest.spyOn(globalThis, 'requestIdleCallback').mockImplementation(() => 1); + const {result} = renderEditCommands(composerRef); + + // When empty-message deletion opens a modal and cancellation schedules focus. + act(() => result.current.publishDraft(' ')); + const modalCall = jest.mocked(showDeleteModal).mock.calls.at(0); + expect(modalCall).toBeDefined(); + if (!modalCall) { + throw new Error('Expected delete modal'); + } + const cancel = modalCall[4]; + if (!cancel) { + throw new Error('Expected modal cancellation callback'); + } + cancel(); + composerRef.current = replacement; + + // Then blur precedes the modal and cancellation does not focus synchronously or capture a stale ref. + expect(first.blur).toHaveBeenCalledTimes(1); + const earlierCallOrder1 = first.blur.mock.invocationCallOrder.at(0); + const laterCallOrder1 = jest.mocked(showDeleteModal).mock.invocationCallOrder.at(0); + if (earlierCallOrder1 === undefined || laterCallOrder1 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder1).toBeLessThan(laterCallOrder1); + expect(first.focus).not.toHaveBeenCalled(); + expect(replacement.focus).not.toHaveBeenCalled(); + expect(idle).toHaveBeenCalledTimes(1); + const idleCall = idle.mock.calls.at(0); + expect(idleCall).toBeDefined(); + if (!idleCall) { + throw new Error('Expected deferred focus'); + } + act(() => idleCall[0]({didTimeout: false, timeRemaining: () => 10})); + expect(replacement.focus).toHaveBeenCalledTimes(1); + expect(first.focus).not.toHaveBeenCalled(); + }); + + it.each([true, false])('retains null-ref no-ops with initial absence %s', (isInitiallyAbsent) => { + // Given either initial absence or detachment while cancellation is deferred. + const commands = {blur: jest.fn(), focus: jest.fn()}; + const composerRef: EditMessageProps['composerRef'] = {current: isInitiallyAbsent ? null : commands}; + const idle = jest.spyOn(globalThis, 'requestIdleCallback').mockImplementation(() => 1); + const {result} = renderEditCommands(composerRef); + + // When cancellation runs after the composer has detached. + act(() => result.current.publishDraft('')); + const modalCall = jest.mocked(showDeleteModal).mock.calls.at(0); + if (!modalCall) { + throw new Error('Expected delete modal'); + } + const cancel = modalCall[4]; + if (!cancel) { + throw new Error('Expected modal cancellation callback'); + } + cancel(); + composerRef.current = null; + const idleCall = idle.mock.calls.at(0); + if (!idleCall) { + throw new Error('Expected deferred focus'); + } + act(() => idleCall[0]({didTimeout: false, timeRemaining: () => 10})); + + // Then modal cancellation tolerates absence and never invokes a detached handle. + expect(showDeleteModal).toHaveBeenCalledTimes(1); + expect(commands.blur).toHaveBeenCalledTimes(isInitiallyAbsent ? 0 : 1); + expect(commands.focus).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/hooks/useExpenseSubmission.test.ts b/tests/unit/hooks/useExpenseSubmission.test.ts index ba5e1b9da4ca..a2cf35642ae5 100644 --- a/tests/unit/hooks/useExpenseSubmission.test.ts +++ b/tests/unit/hooks/useExpenseSubmission.test.ts @@ -6,6 +6,8 @@ import Log from '@libs/Log'; // eslint-disable-next-line no-restricted-imports -- Namespace import is required to spy on getChatByParticipants without replacing the production module. import * as ReportUtils from '@libs/ReportUtils'; +import useSubmitLock from '@pages/iou/request/step/confirmation/submission/useSubmitLock'; +import type {SubmitLock} from '@pages/iou/request/step/confirmation/submission/useSubmitLock'; import useExpenseSubmission from '@pages/iou/request/step/confirmation/useExpenseSubmission'; import CONST from '@src/CONST'; @@ -199,6 +201,13 @@ function buildPerDiemTransaction(overrides: Partial = {}): Transact }); } +// Rendered once per test, outside the hook under test, so its state survives that hook's re-renders. +let submitLockResult: {current: SubmitLock}; + +beforeEach(() => { + submitLockResult = renderHook(() => useSubmitLock()).result; +}); + function buildParams(overrides: Partial[0]> = {}): Parameters[0] { const transaction = buildTransaction(); return { @@ -229,6 +238,7 @@ function buildParams(overrides: Partial[ isPolicyExpenseChat: false, draftTransactionIDs: [DRAFT_ID], privateIsArchivedMap: {}, + submitLock: submitLockResult.current, ...overrides, }; } @@ -1072,8 +1082,8 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { expect(mockStartSplitBillAction).not.toHaveBeenCalled(); expect(mockSplitBillAction).not.toHaveBeenCalled(); // The submit lock is released so the next tap works once the receipt map catches up. - expect(result.current.isConfirmed).toBe(false); - expect(result.current.formHasBeenSubmitted.current).toBe(false); + expect(submitLockResult.current.isConfirmed).toBe(false); + expect(submitLockResult.current.formHasBeenSubmitted.current).toBe(false); }); it('resolves the chat once and dismisses once when multiple receipts split into one group chat', async () => { diff --git a/tests/unit/hooks/useFetchRoute.test.ts b/tests/unit/hooks/useFetchRoute.test.ts new file mode 100644 index 000000000000..cf1b868fe178 --- /dev/null +++ b/tests/unit/hooks/useFetchRoute.test.ts @@ -0,0 +1,109 @@ +import {renderHook} from '@testing-library/react-native'; + +import useFetchRoute from '@hooks/useFetchRoute'; + +import {getRoute} from '@libs/actions/Transaction'; + +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +import createMock from '../../utils/createMock'; + +jest.mock('@hooks/useNetwork', () => () => ({ + isOffline: false, +})); + +jest.mock('@libs/actions/Transaction', () => ({ + getRoute: jest.fn(), +})); + +const mockWaypoints = { + waypoint0: {address: '123 Main St', lat: 10, lng: 20}, + waypoint1: {address: '456 Oak St', lat: 30, lng: 40}, +}; + +describe('useFetchRoute', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call getRoute for a reused route when commuter exclusion preview is stale', () => { + // Given a reused route transaction and a policy with HOME_AND_OFFICE commuter exclusions + const policy = createMock({ + id: 'policy1', + commuterExclusions: { + method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, + }, + }); + const transaction = createMock({ + transactionID: 'txn1', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + isReusedRoute: true, + comment: { + customUnit: {quantity: 100, routeDistanceMeters: 1000}, + }, + commuterExclusionPreview: null, + }); + + // When useFetchRoute runs + const {result} = renderHook(() => useFetchRoute(transaction, mockWaypoints, CONST.IOU.ACTION.CREATE, CONST.TRANSACTION.STATE.CURRENT, policy)); + + // Then it should fetch the route for the policy to get the commuter exclusion result + expect(result.current.shouldFetchRoute).toBe(true); + expect(getRoute).toHaveBeenCalledWith('txn1', mockWaypoints, CONST.TRANSACTION.STATE.CURRENT, 'policy1'); + }); + + it('should not call getRoute for a reused route when matching commuter exclusion preview is already present', () => { + // Given a reused route transaction that already has the commuter exclusion preview for the policy + const policy = createMock({ + id: 'policy1', + commuterExclusions: { + method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, + }, + }); + const transaction = createMock({ + transactionID: 'txn1', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + isReusedRoute: true, + comment: { + customUnit: {quantity: 100, routeDistanceMeters: 1000}, + }, + commuterExclusionPreview: { + policyID: 'policy1', + hasExclusion: false, + isWholeTripExcluded: false, + commuteDistanceMeters: 0, + }, + }); + + // When useFetchRoute runs + const {result} = renderHook(() => useFetchRoute(transaction, mockWaypoints, CONST.IOU.ACTION.CREATE, CONST.TRANSACTION.STATE.CURRENT, policy)); + + // Then it should not fetch the route again + expect(result.current.shouldFetchRoute).toBe(false); + expect(getRoute).not.toHaveBeenCalled(); + }); + + it('should not call getRoute for a reused route when the policy does not use commuter exclusions', () => { + // Given a reused route transaction and a policy without commuter exclusions + const policy = createMock({ + id: 'policy1', + }); + const transaction = createMock({ + transactionID: 'txn1', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + isReusedRoute: true, + comment: { + customUnit: {quantity: 100, routeDistanceMeters: 1000}, + }, + commuterExclusionPreview: null, + }); + + // When useFetchRoute runs + const {result} = renderHook(() => useFetchRoute(transaction, mockWaypoints, CONST.IOU.ACTION.CREATE, CONST.TRANSACTION.STATE.CURRENT, policy)); + + // Then it should not fetch the route because the reused route already has its distance + expect(result.current.shouldFetchRoute).toBe(false); + expect(getRoute).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/hooks/useFlattenedSections.test.ts b/tests/unit/hooks/useFlattenedSections.test.ts new file mode 100644 index 000000000000..d6c3afbbd891 --- /dev/null +++ b/tests/unit/hooks/useFlattenedSections.test.ts @@ -0,0 +1,63 @@ +import {renderHook} from '@testing-library/react-native'; + +import type CalendarPickerListItem from '@components/DatePicker/CalendarPicker/types'; +import useFlattenedSections, {shouldTreatItemAsDisabled} from '@components/SelectionList/hooks/useFlattenedSections'; +import type {Section} from '@components/SelectionList/SelectionListWithSections/types'; + +import {createElement} from 'react'; +import {View} from 'react-native'; + +describe('useFlattenedSections', () => { + const sections: Array> = [ + { + sectionIndex: 0, + title: 'First', + data: [ + {keyForList: 'same', value: 1, isSelected: true, isDisabled: true}, + {keyForList: 'off', value: 2, isDisabled: true}, + ], + }, + {sectionIndex: 1, customHeader: createElement(View), isDisabled: true, data: [{keyForList: 'same', value: 3, isSelected: true}]}, + {sectionIndex: 2, title: 'Empty', data: []}, + ]; + + it('preserves headers, rows, selection, disability, focus, and specialized fields', () => { + // Given selected-disabled, ordinary-disabled, section-disabled, repeated-key, and empty sections. + // When the real hook flattens them with a repeated focus key. + const {result} = renderHook(() => useFlattenedSections(sections, 'same')); + // Then headers and rows retain ordering, keys, counts, flags, and the first matching focus. + expect(result.current.flattenedData.map((item) => ('flatListKey' in item ? item.flatListKey : item.keyForList))).toEqual(['header-0', '0-same', '0-off', 'header-1', '1-same']); + expect(result.current.flattenedData.at(0)).toMatchObject({title: 'First', isDisabled: true}); + expect(result.current.flattenedData.at(3)).toMatchObject({customHeader: sections.at(1)?.customHeader, isDisabled: true}); + expect(result.current.flattenedData.at(4)).toMatchObject({value: 3, isDisabled: true}); + expect(result.current.selectedItems.map((item) => item.value)).toEqual([1, 3]); + expect(result.current.disabledIndexes).toEqual([0, 2, 3, 4]); + expect(result.current.itemsCount).toBe(3); + expect(result.current.initialFocusedIndex).toBe(1); + expect(result.current.firstFocusableIndex).toBe(1); + expect(result.current.selectedItems.some(shouldTreatItemAsDisabled)).toBe(false); + }); + + it('reuses unchanged inputs and recomputes when the focus or sections change', () => { + // Given the shared sections and a focus key that matches two rows. + const {result, rerender} = renderHook(({data, focus}: {data: Array>; focus: string}) => useFlattenedSections(data, focus), { + initialProps: {data: sections, focus: 'same'}, + }); + const first = result.current; + // When the inputs stay the same. + rerender({data: sections, focus: 'same'}); + // Then the compiled hook reuses its result. + expect(result.current).toBe(first); + // When the focus changes. + rerender({data: sections, focus: 'missing'}); + // Then the hook recomputes it without a matching row. + expect(result.current).not.toBe(first); + expect(result.current.initialFocusedIndex).toBe(-1); + const changedFocus = result.current; + // When the sections change to an empty list. + rerender({data: [{sectionIndex: 0, title: 'Empty', data: []}], focus: 'missing'}); + // Then the hook recomputes all derived fields. + expect(result.current).not.toBe(changedFocus); + expect(result.current).toEqual({flattenedData: [], disabledIndexes: [], itemsCount: 0, selectedItems: [], initialFocusedIndex: -1, firstFocusableIndex: 0}); + }); +}); diff --git a/tests/unit/hooks/useImageLoadStall.test.ts b/tests/unit/hooks/useImageLoadStall.test.ts new file mode 100644 index 000000000000..2a2189795545 --- /dev/null +++ b/tests/unit/hooks/useImageLoadStall.test.ts @@ -0,0 +1,111 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useImageLoadStall from '@hooks/useImageLoadStall'; + +import CONST from '@src/CONST'; + +const {IMAGE_LOAD_CEILING_TIMEOUT, IMAGE_LOAD_STALL_TIMEOUT} = CONST.TIMING; + +function renderStallWatch(onStalled = jest.fn()) { + const {result, rerender} = renderHook(({isActive}: {isActive: boolean}) => useImageLoadStall(isActive, onStalled), { + initialProps: {isActive: true}, + }); + return {onStalled, reportActivity: result.current, result, rerender}; +} + +describe('useImageLoadStall', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('reports once after the ceiling when no activity is ever reported', () => { + // Given an active load with no progress signal available + const {onStalled} = renderStallWatch(); + + // When the ceiling passes + act(() => { + jest.advanceTimersByTime(IMAGE_LOAD_CEILING_TIMEOUT); + }); + + // Then the load is reported as stalled exactly once + expect(onStalled).toHaveBeenCalledTimes(1); + act(() => { + jest.advanceTimersByTime(IMAGE_LOAD_CEILING_TIMEOUT); + }); + expect(onStalled).toHaveBeenCalledTimes(1); + }); + + it('keeps a slow load alive while activity keeps arriving', () => { + // Given an active load that reports progress in steps smaller than both windows + const {onStalled, reportActivity} = renderStallWatch(); + + for (let i = 0; i < 6; i++) { + act(() => { + jest.advanceTimersByTime(IMAGE_LOAD_CEILING_TIMEOUT - 1000); + reportActivity(); + }); + } + act(() => { + jest.advanceTimersByTime(IMAGE_LOAD_CEILING_TIMEOUT - 1000); + }); + + // Then no stall is reported even though the total span exceeds both windows + expect(onStalled).not.toHaveBeenCalled(); + }); + + it('arms the longer stall window once activity has been seen', () => { + // Given a load that has already reported activity + const {onStalled, reportActivity} = renderStallWatch(); + act(() => { + reportActivity(); + }); + + // When the ceiling (and a bit more) passes without further activity + act(() => { + jest.advanceTimersByTime(IMAGE_LOAD_CEILING_TIMEOUT + 1000); + }); + + // Then nothing fired yet: the active window is the longer stall timeout + expect(onStalled).not.toHaveBeenCalled(); + + // When the remaining stall window elapses + act(() => { + jest.advanceTimersByTime(IMAGE_LOAD_STALL_TIMEOUT - IMAGE_LOAD_CEILING_TIMEOUT - 1000); + }); + + // Then the stall is reported + expect(onStalled).toHaveBeenCalledTimes(1); + }); + + it('keeps one reporter identity across renders', () => { + // Given an active load, because the reporter is passed straight to the Image onProgress prop + const {result, rerender} = renderStallWatch(); + const firstReportActivity = result.current; + + // When the caller re-renders + rerender({isActive: true}); + rerender({isActive: true}); + + // Then the reporter identity does not change, so the image does not resubscribe on every render + expect(result.current).toBe(firstReportActivity); + }); + + it('does not watch an inactive load', () => { + // Given the watch goes inactive (cached image, offline, or load finished) + const onStalled = jest.fn(); + const {rerender} = renderStallWatch(onStalled); + rerender({isActive: false}); + + // When both windows pass + act(() => { + jest.advanceTimersByTime(IMAGE_LOAD_CEILING_TIMEOUT + IMAGE_LOAD_STALL_TIMEOUT); + }); + + // Then nothing was reported + expect(onStalled).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/hooks/useInsightsFilters.test.ts b/tests/unit/hooks/useInsightsFilters.test.ts index 3dfd1c26d3a6..e8b78f2604e5 100644 --- a/tests/unit/hooks/useInsightsFilters.test.ts +++ b/tests/unit/hooks/useInsightsFilters.test.ts @@ -1,5 +1,8 @@ import {act, renderHook, waitFor} from '@testing-library/react-native'; +import * as API from '@libs/API'; +import {WRITE_COMMANDS} from '@libs/API/types'; + import INSIGHTS_DASHBOARD_SPECS from '@pages/Insights/dashboardSpecs'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; import {buildInsightsQueryString} from '@pages/Insights/insightsQueries'; @@ -42,12 +45,24 @@ async function getStoredQuery(): Promise { return typeof searchFilter === 'object' ? (searchFilter as {query: string}).query : undefined; } +async function storeFilters(filters: InsightsFilters) { + await Onyx.merge(ONYXKEYS.SEARCH_FILTERS, {[SPEND_SEARCH_KEY]: {query: buildInsightsQueryString(filters)}}); + await waitForBatchedUpdates(); +} + describe('useInsightsFilters', () => { + afterEach(() => { + jest.useRealTimers(); + }); + beforeAll(() => { Onyx.init({keys: ONYXKEYS}); }); beforeEach(async () => { + // Pin the date, so the presets cover a known range + jest.useFakeTimers({doNotFake: ['nextTick', 'setImmediate']}); + jest.setSystemTime(new Date(2026, 9, 8)); await Onyx.clear(); await waitForBatchedUpdates(); }); @@ -73,7 +88,7 @@ describe('useInsightsFilters', () => { // Given a dashboard last left on a single workspace, grouped by quarter, in USD await setUpActivePolicy('PLN'); const stored: InsightsFilters = { - date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_MONTH}, + date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS}, policyIDs: ['A1'], groupBy: CONST.SEARCH.GROUP_BY.QUARTER, groupCurrency: 'USD', @@ -136,7 +151,7 @@ describe('useInsightsFilters', () => { await setUpActivePolicy('PLN'); await Onyx.merge(ONYXKEYS.SEARCH_FILTERS, { [SPEND_SEARCH_KEY]: { - query: buildInsightsQueryString({date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_MONTH}, policyIDs: ['A1'], groupBy: CONST.SEARCH.GROUP_BY.MONTH, groupCurrency: 'USD'}), + query: buildInsightsQueryString({date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS}, policyIDs: ['A1'], groupBy: CONST.SEARCH.GROUP_BY.MONTH, groupCurrency: 'USD'}), }, }); await waitForBatchedUpdates(); @@ -150,6 +165,90 @@ describe('useInsightsFilters', () => { }); // Then the stored query carries the new bucket and everything the other controls had already narrowed - expect(await getStoredQuery()).toBe('groupBy:quarter groupCurrency:USD policyID:A1 date:last-month'); + expect(await getStoredQuery()).toBe('groupBy:quarter groupCurrency:USD policyID:A1 date:last-12-months'); + }); + + it('keeps the selected grouping saved when a new date range rules it out', async () => { + // Given a dashboard over the last 12 months grouped by quarter + await setUpActivePolicy('PLN'); + await storeFilters({date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS}, policyIDs: [], groupBy: CONST.SEARCH.GROUP_BY.QUARTER, groupCurrency: 'USD'}); + const {result} = renderHook(() => useInsightsFilters(CONST.INSIGHTS.DASHBOARD.SPEND)); + await waitFor(() => expect(result.current.isResolved).toBe(true)); + + // When the date is changed to this month, which only offers Day and Week + await act(async () => { + result.current.setFilters({date: {preset: CONST.SEARCH.DATE_PRESETS.THIS_MONTH}}); + await waitForBatchedUpdates(); + }); + + // Then quarter stays saved, so it comes back once a range that offers it is picked + expect(await getStoredQuery()).toBe('groupBy:quarter groupCurrency:USD date:this-month'); + expect(result.current.filters.groupBy).toBe(CONST.SEARCH.GROUP_BY.QUARTER); + }); + + it('keeps the saved grouping on a single day, which only offers day', async () => { + // Given a dashboard over the last 12 months grouped by quarter + await setUpActivePolicy('PLN'); + await storeFilters({date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS}, policyIDs: [], groupBy: CONST.SEARCH.GROUP_BY.QUARTER, groupCurrency: 'USD'}); + const {result} = renderHook(() => useInsightsFilters(CONST.INSIGHTS.DASHBOARD.SPEND)); + await waitFor(() => expect(result.current.isResolved).toBe(true)); + + // When the date is changed to a single day + await act(async () => { + result.current.setFilters({date: {on: '2026-03-04'}}); + await waitForBatchedUpdates(); + }); + + // Then the grouping stays quarter, ready for when a range is picked again + expect(result.current.filters.groupBy).toBe(CONST.SEARCH.GROUP_BY.QUARTER); + }); + + describe('saving to the server', () => { + let writeSpy: jest.SpyInstance; + + beforeEach(() => { + writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('saves a Compare mode change to the server, since it does not fetch the dashboard again', async () => { + // Given a dashboard with no Compare mode picked + await setUpActivePolicy('PLN'); + const stored: InsightsFilters = {date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS}, policyIDs: [], groupBy: CONST.SEARCH.GROUP_BY.MONTH, groupCurrency: 'USD'}; + await storeFilters(stored); + const {result} = renderHook(() => useInsightsFilters(CONST.INSIGHTS.DASHBOARD.SPEND)); + await waitFor(() => expect(result.current.isResolved).toBe(true)); + + // When the user compares against the previous period + await act(async () => { + result.current.setFilters({compare: CONST.SEARCH.COMPARE.PREVIOUS_PERIOD}); + await waitForBatchedUpdates(); + }); + + // Then the full query is saved under the dashboard's search key, so the choice survives signing out + const inputQuery = buildInsightsQueryString({...stored, compare: CONST.SEARCH.COMPARE.PREVIOUS_PERIOD}); + expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SAVE_SEARCH_FILTERS, {jsonQuery: JSON.stringify({searchKey: SPEND_SEARCH_KEY, inputQuery})}, expect.anything()); + }); + + it('does not save other filter changes to the server, since GetInsights saves them', async () => { + // Given a dashboard grouped by month + await setUpActivePolicy('PLN'); + await storeFilters({date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS}, policyIDs: [], groupBy: CONST.SEARCH.GROUP_BY.MONTH, groupCurrency: 'USD'}); + const {result} = renderHook(() => useInsightsFilters(CONST.INSIGHTS.DASHBOARD.SPEND)); + await waitFor(() => expect(result.current.isResolved).toBe(true)); + + // When the user groups by quarter + await act(async () => { + result.current.setFilters({groupBy: CONST.SEARCH.GROUP_BY.QUARTER}); + await waitForBatchedUpdates(); + }); + + // Then no separate save is sent, and the selection is only stored locally + expect(writeSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.SAVE_SEARCH_FILTERS, expect.anything(), expect.anything()); + expect(await getStoredQuery()).toBe('groupBy:quarter groupCurrency:USD date:last-12-months'); + }); }); }); diff --git a/tests/unit/hooks/useLandscapeOnBlurProxy.test.ts b/tests/unit/hooks/useLandscapeOnBlurProxy.test.ts new file mode 100644 index 000000000000..95fb971dfc8a --- /dev/null +++ b/tests/unit/hooks/useLandscapeOnBlurProxy.test.ts @@ -0,0 +1,282 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useLandscapeOnBlurProxy from '@hooks/useLandscapeOnBlurProxy/index.android'; + +import type {FocusEvent, HostInstance, ScaledSize} from 'react-native'; +import type {KeyboardEventData} from 'react-native-keyboard-controller'; + +import {Dimensions, TextInput} from 'react-native'; + +const mockKeyboardState = {isKeyboardActive: false, isKeyboardAnimatingRef: {current: false}}; +let mockIsInLandscapeMode = false; + +jest.mock('@hooks/useKeyboardState', () => () => mockKeyboardState); +jest.mock('@hooks/useIsInLandscapeMode', () => () => mockIsInLandscapeMode); + +const mockKeyboardListeners = new Map void>>(); +const mockKeyboardListenerRemove = jest.fn(); + +jest.mock('react-native-keyboard-controller', () => ({ + KeyboardEvents: { + addListener: jest.fn((event: string, handler: (e: KeyboardEventData) => void) => { + mockKeyboardListeners.set(event, [...(mockKeyboardListeners.get(event) ?? []), handler]); + return {remove: mockKeyboardListenerRemove}; + }), + }, +})); + +// The react-native manual mock (__mocks__/react-native.ts) replaces Dimensions with an inert addEventListener, +// so the tests fire the window changes through the handler the hook registers there. +// eslint-disable-next-line @typescript-eslint/unbound-method +const mockedDimensionsAddEventListener = jest.mocked(Dimensions.addEventListener); + +const PORTRAIT_WINDOW = Dimensions.get('window'); +const LANDSCAPE_WINDOW: ScaledSize = {...PORTRAIT_WINDOW, width: PORTRAIT_WINDOW.height, height: PORTRAIT_WINDOW.width}; +const REFOCUS_DELAY_MS = 100; +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the proxy only forwards the event, it never reads it +const blurEvent = {} as FocusEvent; + +function setWindow(window: ScaledSize) { + mockedDimensionsAddEventListener.mock.calls.at(-1)?.[1]({window, screen: window}); +} + +function emitKeyboardEvent(event: string, duration: number) { + const eventData: KeyboardEventData = {height: 0, duration, timestamp: 0, target: -1, type: 'default', appearance: 'light'}; + for (const handler of mockKeyboardListeners.get(event) ?? []) { + handler(eventData); + } +} + +function startKeyboardHide(duration: number) { + emitKeyboardEvent('keyboardWillHide', duration); +} + +function hideKeyboard(duration: number) { + emitKeyboardEvent('keyboardDidHide', duration); +} + +function advance(ms: number) { + act(() => { + jest.advanceTimersByTime(ms); + }); +} + +function renderProxy() { + const focus = jest.fn(); + const onBlur = jest.fn(); + const {result, unmount} = renderHook(() => useLandscapeOnBlurProxy({current: {focus}}, onBlur)); + return {focus, onBlur, unmount, blur: () => result.current?.(blurEvent)}; +} + +describe('useLandscapeOnBlurProxy', () => { + beforeEach(() => { + jest.useFakeTimers(); + mockedDimensionsAddEventListener.mockClear(); + mockKeyboardListeners.clear(); + mockKeyboardListenerRemove.mockClear(); + mockKeyboardState.isKeyboardActive = false; + mockKeyboardState.isKeyboardAnimatingRef.current = false; + mockIsInLandscapeMode = false; + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('forwards every blur to the caller and does not re-focus a plain blur', () => { + // Given an input that is not rotating and has no keyboard opening + const {focus, onBlur, blur} = renderProxy(); + + // When it is blurred and the re-focus delay passes + blur(); + advance(REFOCUS_DELAY_MS); + + // Then the caller still hears about the blur, while the input is left blurred because nothing explains it + expect(onBlur).toHaveBeenCalledWith(blurEvent); + expect(focus).not.toHaveBeenCalled(); + }); + + it.each<[name: string, window: ScaledSize]>([ + ['portrait to landscape', LANDSCAPE_WINDOW], + ['landscape to portrait', {...PORTRAIT_WINDOW, width: PORTRAIT_WINDOW.width - 1}], + ])('re-focuses when the %s width change lands after the blur', (_name, window) => { + // Given an input blurred by a rotation whose width change has not reached JS yet + const {focus, blur} = renderProxy(); + blur(); + + // When the width change arrives inside the re-focus delay + setWindow(window); + advance(REFOCUS_DELAY_MS); + + // Then the input gets its focus back, because render-time orientation could not have caught this rotation + expect(focus).toHaveBeenCalledTimes(1); + }); + + it('re-focuses when the width change shortly preceded the blur', () => { + // Given a rotation that already changed the window width + const {focus, blur} = renderProxy(); + setWindow(LANDSCAPE_WINDOW); + advance(200); + + // When the layout catch-up blurs the input soon after + blur(); + advance(REFOCUS_DELAY_MS); + + // Then the input is re-focused, because the blur is still attributed to that rotation + expect(focus).toHaveBeenCalledTimes(1); + }); + + it('does not re-focus a blur that comes long after a rotation', () => { + // Given a rotation that finished well before + const {focus, blur} = renderProxy(); + setWindow(LANDSCAPE_WINDOW); + advance(1000); + + // When the user blurs the input + blur(); + advance(REFOCUS_DELAY_MS); + + // Then the blur is respected, because the rotation no longer explains it + expect(focus).not.toHaveBeenCalled(); + }); + + it('does not take focus back from another input the user moved to right after a rotation', () => { + // Given an input that a rotation blurred and that got its focus back + const {focus, blur} = renderProxy(); + setWindow(LANDSCAPE_WINDOW); + advance(50); + blur(); + advance(REFOCUS_DELAY_MS); + expect(focus).toHaveBeenCalledTimes(1); + + // When the user taps another input while the rotation is still recent + advance(150); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- only its presence matters to the proxy + jest.spyOn(TextInput.State, 'currentlyFocusedInput').mockReturnValue({} as HostInstance); + blur(); + advance(REFOCUS_DELAY_MS); + + // Then the other input keeps its focus, because the user moved it there on purpose + expect(focus).toHaveBeenCalledTimes(1); + }); + + it('does not re-focus after the user dismisses the keyboard right after a rotation', () => { + // Given a rotation that just changed the window width + const {focus, blur} = renderProxy(); + setWindow(LANDSCAPE_WINDOW); + advance(200); + + // When the user dismisses the keyboard with a back press, which hides it with an animation and blurs the input + startKeyboardHide(250); + advance(250); + hideKeyboard(250); + blur(); + advance(REFOCUS_DELAY_MS); + + // Then the input stays blurred, because the dismissal, not the rotation, explains this blur + expect(focus).not.toHaveBeenCalled(); + }); + + it('does not re-focus after the user dismisses the keyboard and a rotation lands during the hide animation', () => { + // Given the user dismissed the keyboard with a back press, and its animated hide has started + const {focus, blur} = renderProxy(); + startKeyboardHide(250); + + // When a rotation changes the window width before the hide animation ends, and the input is blurred once it does + advance(100); + setWindow(LANDSCAPE_WINDOW); + advance(150); + hideKeyboard(250); + blur(); + advance(REFOCUS_DELAY_MS); + + // Then the input stays blurred, because the end of the animated hide still marks it as a user dismissal + expect(focus).not.toHaveBeenCalled(); + }); + + it('does not re-focus when the input is dismissed right after a rotation and the hide animation is still running', () => { + // Given a rotation that just changed the window width + const {focus, blur} = renderProxy(); + setWindow(LANDSCAPE_WINDOW); + advance(200); + + // When Keyboard.dismiss() blurs the input first and the animated hide starts, but has not finished, before the re-focus delay passes + blur(); + startKeyboardHide(250); + advance(REFOCUS_DELAY_MS); + + // Then the input stays blurred, because the start of an animated hide already marks it as a user dismissal + expect(focus).not.toHaveBeenCalled(); + }); + + it('still re-focuses when the rotation tears the keyboard down without an animation', () => { + // Given a rotation that just changed the window width + const {focus, blur} = renderProxy(); + setWindow(LANDSCAPE_WINDOW); + + // When the keyboard disappears instantly, as it does on rotation, and the input is blurred + hideKeyboard(0); + blur(); + advance(REFOCUS_DELAY_MS); + + // Then the input is re-focused, because an instant hide is part of the rotation rather than a user dismissal + expect(focus).toHaveBeenCalledTimes(1); + }); + + it('does not treat a height-only window change as a rotation', () => { + // Given a blurred input + const {focus, blur} = renderProxy(); + blur(); + + // When only the window height changes, as the soft keyboard does on Android + setWindow({...PORTRAIT_WINDOW, height: PORTRAIT_WINDOW.height - 300}); + advance(REFOCUS_DELAY_MS); + + // Then the input stays blurred + expect(focus).not.toHaveBeenCalled(); + }); + + it('re-focuses a blur that happens while the keyboard is opening in landscape', () => { + // Given an input in landscape whose keyboard is still animating open + mockIsInLandscapeMode = true; + mockKeyboardState.isKeyboardActive = true; + mockKeyboardState.isKeyboardAnimatingRef.current = true; + const {focus, blur} = renderProxy(); + + // When native blurs it before the keyboard finishes opening + blur(); + advance(REFOCUS_DELAY_MS); + + // Then it is re-focused, because the blur came from the input being pushed behind the opening keyboard + expect(focus).toHaveBeenCalledTimes(1); + }); + + it('removes the Dimensions and keyboard listeners on unmount', () => { + // Given a mounted proxy + const {unmount} = renderProxy(); + + // When it unmounts + unmount(); + + // Then its window and keyboard listeners are detached + const subscription = mockedDimensionsAddEventListener.mock.results.at(-1); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(subscription?.type === 'return' && subscription.value.remove).toHaveBeenCalledTimes(1); + expect(mockKeyboardListenerRemove).toHaveBeenCalledTimes(2); + }); + + it('cancels a pending re-focus on unmount', () => { + // Given an input blurred by a rotation, with its re-focus still pending + const {focus, blur, unmount} = renderProxy(); + setWindow(LANDSCAPE_WINDOW); + blur(); + + // When the proxy unmounts before the re-focus delay passes + unmount(); + advance(REFOCUS_DELAY_MS); + + // Then the re-focus never runs, because the input it targets is gone + expect(focus).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/hooks/useMergeTransactionsTest.ts b/tests/unit/hooks/useMergeTransactionsTest.ts new file mode 100644 index 000000000000..b5fc82f4d9d0 --- /dev/null +++ b/tests/unit/hooks/useMergeTransactionsTest.ts @@ -0,0 +1,59 @@ +import {renderHook, waitFor} from '@testing-library/react-native'; + +import useMergeTransactions from '@hooks/useMergeTransactions'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {MergeTransaction, SearchResults} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import createRandomTransaction from '../../utils/collections/transaction'; +import createMock from '../../utils/createMock'; + +let mockCurrentSearchResults: SearchResults | undefined; + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchQueryContext: () => ({ + currentSearchHash: 12345, + }), + useSearchResultsContext: () => ({ + currentSearchResults: mockCurrentSearchResults, + }), +})); + +jest.mock('@hooks/usePolicyForMovingExpenses', () => ({ + __esModule: true, + default: () => ({policyForMovingExpensesID: undefined}), +})); + +describe('useMergeTransactions', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + mockCurrentSearchResults = undefined; + }); + + it('falls back to the Onyx transactions while the search snapshot has no data yet', async () => { + // Given a merge started from search while the new search's snapshot only has `search` (optimistic loading state), + // with transactions that exist in Onyx but not in the merge draft, so the hook looks them up in the snapshot first + mockCurrentSearchResults = createMock({search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE}}); + const onyxTransaction1 = createRandomTransaction(1); + const onyxTransaction2 = createRandomTransaction(2); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1`, onyxTransaction1); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}2`, onyxTransaction2); + const mergeTransaction = createMock({targetTransactionID: '1', sourceTransactionID: '2'}); + + // When the hook resolves the target and source transactions + const {result} = renderHook(() => useMergeTransactions({mergeTransaction})); + + // Then it falls back to the Onyx transactions instead of throwing on the missing snapshot data + await waitFor(() => { + expect(result.current.targetTransaction).toEqual(onyxTransaction1); + expect(result.current.sourceTransaction).toEqual(onyxTransaction2); + }); + }); +}); diff --git a/tests/unit/hooks/useMoneyRequestParticipantsPolicyTags.test.ts b/tests/unit/hooks/useMoneyRequestParticipantsPolicyTags.test.ts index 4409ac911dc6..373b7e334731 100644 --- a/tests/unit/hooks/useMoneyRequestParticipantsPolicyTags.test.ts +++ b/tests/unit/hooks/useMoneyRequestParticipantsPolicyTags.test.ts @@ -21,6 +21,12 @@ jest.mock('@hooks/useCurrencyList', () => ({ useCurrencyListActions: () => ({convertToDisplayString: mockConvertToDisplayString}), })); +const mockFormatPhoneNumber = jest.fn((phoneNumber: string) => phoneNumber); +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({formatPhoneNumber: mockFormatPhoneNumber}), +})); + const translate = jest.fn().mockReturnValue('translated'); describe('useMoneyRequestParticipantsPolicyTags', () => { @@ -74,6 +80,7 @@ describe('useMoneyRequestParticipantsPolicyTags', () => { translate, convertToDisplayString: mockConvertToDisplayString, dateFnsLocale: undefined, + formatPhoneNumber: mockFormatPhoneNumber, }); }); diff --git a/tests/unit/hooks/useOtherFeedsForFeedSelector.test.tsx b/tests/unit/hooks/useOtherFeedsForFeedSelector.test.tsx index d86f0c6fef5c..c00667bc8367 100644 --- a/tests/unit/hooks/useOtherFeedsForFeedSelector.test.tsx +++ b/tests/unit/hooks/useOtherFeedsForFeedSelector.test.tsx @@ -213,6 +213,7 @@ describe('useOtherFeedsForFeedSelector', () => { // Use toMatchObject so Jest does not deep-compare leftElement (React nodes use private fields and break toEqual). expect(result.current.at(0)).toMatchObject({ value: '999_oauth.chase.com', + selectionValue: `oauth.chase.com${CONST.COMPANY_CARD.FEED_KEY_SEPARATOR}999`, feed: 'oauth.chase.com', fundID: 999, country: 'US', @@ -223,6 +224,27 @@ describe('useOtherFeedsForFeedSelector', () => { expect(React.isValidElement(result.current.at(0)?.leftElement)).toBe(true); }); + it('should leave selectionValue undefined when the feed has no valid fundID', () => { + // Given a feed whose fundID cannot be parsed, so there is no `${feed}${separator}${fundID}` key it could match in the available list + mockVisibleFeeds([ + { + id: '_oauth.chase.com', + feed: 'oauth.chase.com', + fundID: '', + name: 'Chase feed', + linkedPolicyIDs: ['policy_unrelated'], + }, + ]); + + // When the hook builds the other-workspace rows + const {result} = renderHook(() => useOtherFeedsForFeedSelector(currentPolicyID)); + + // Then the row is still listed, but without a selection key, so the page falls back to the row value instead of a bogus `feed#NaN` key + expect(result.current).toHaveLength(1); + expect(result.current.at(0)?.selectionValue).toBeUndefined(); + expect(mockGetCardFeedWithDomainID).not.toHaveBeenCalled(); + }); + it('should set isSelected when feed id matches the selected feed from useCompanyCards', () => { const feedId = '999_oauth.chase.com'; mockUseCompanyCards.mockReturnValue({feedName: feedId, companyCardFeeds: {}}); diff --git a/tests/unit/hooks/usePolicyForMovingExpenses.test.tsx b/tests/unit/hooks/usePolicyForMovingExpenses.test.tsx new file mode 100644 index 000000000000..3be24978f879 --- /dev/null +++ b/tests/unit/hooks/usePolicyForMovingExpenses.test.tsx @@ -0,0 +1,113 @@ +import {renderHook, waitFor} from '@testing-library/react-native'; + +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +import type {ReactNode} from 'react'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../../utils/collections/policies'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +/** + * Onyx resolves from the in-memory cache synchronously under Jest, so the real cold-start window can't be + * reproduced by just rendering. Stub the loading check instead — it is the only thing the hydration guard reads. + */ +let mockArePolicyResultsLoading = false; +jest.mock('@src/types/utils/isLoadingOnyxValue', () => ({ + __esModule: true, + default: (): boolean => mockArePolicyResultsLoading, +})); + +const CURRENT_USER_LOGIN = 'member@example.com'; +const CURRENT_USER_ACCOUNT_ID = 1; +const POLICY_ID = '1234'; + +// `Policy['role']` is typed as required, but the backend only populates it for the user's default/active +// workspace, so drop it to model a real member's non-active workspace. +const {role, ...groupPolicyWithoutGlobalRole} = createRandomPolicy(Number(POLICY_ID), CONST.POLICY.TYPE.TEAM, 'Group workspace'); + +/** + * A group workspace the user belongs to, where the member's role arrives only inside `employeeList`. This is the + * shape that used to make a real member look workspace-less and get routed to the reports upgrade screen. + */ +const policyWithRoleOnlyInEmployeeList: Omit = { + ...groupPolicyWithoutGlobalRole, + id: POLICY_ID, + pendingAction: undefined, + employeeList: { + [CURRENT_USER_LOGIN]: {role: CONST.POLICY.ROLE.USER}, + }, +}; + +function Wrapper({children}: {children: ReactNode}) { + return {children}; +} + +describe('usePolicyForMovingExpenses', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockArePolicyResultsLoading = false; + await Onyx.clear(); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID, email: CURRENT_USER_LOGIN}); + await waitForBatchedUpdates(); + }); + + it('does not route to the upgrade path before the policy collection has loaded', async () => { + // Given Onyx has not finished reading the policy collection yet, so it reads back empty + mockArePolicyResultsLoading = true; + + // When the hook runs + const {result} = renderHook(() => usePolicyForMovingExpenses(), {wrapper: Wrapper}); + await waitForBatchedUpdates(); + + // Then it reports "I don't know yet" rather than claiming a real member has no workspace + expect(result.current.arePoliciesLoaded).toBe(false); + expect(result.current.shouldNavigateToUpgradePath).toBe(false); + }); + + it('routes to the upgrade path once the collection has loaded and the user genuinely has no workspace', async () => { + // Given the policy collection has loaded and contains no group workspace for this user + const {result} = renderHook(() => usePolicyForMovingExpenses(), {wrapper: Wrapper}); + + // When hydration completes + await waitFor(() => expect(result.current.arePoliciesLoaded).toBe(true)); + + // Then the upgrade path is the correct answer + expect(result.current.shouldNavigateToUpgradePath).toBe(true); + }); + + it('resolves a workspace whose role is only present in employeeList', async () => { + // Given a group workspace where the member's role is only in `employeeList`, not on `policy.role` + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policyWithRoleOnlyInEmployeeList); + await waitForBatchedUpdates(); + + // When the hook resolves a policy for moving expenses + const {result} = renderHook(() => usePolicyForMovingExpenses(), {wrapper: Wrapper}); + await waitFor(() => expect(result.current.arePoliciesLoaded).toBe(true)); + + // Then that workspace is used and the user is not sent to the reports upgrade screen + expect(result.current.policyForMovingExpensesID).toBe(POLICY_ID); + expect(result.current.shouldNavigateToUpgradePath).toBe(false); + }); + + it('lets the backend auto-select the policy for an unreported managed card transaction', async () => { + // Given the user has no resolvable policy, but the transaction is a managed card transaction + const {result} = renderHook(() => usePolicyForMovingExpenses(undefined, undefined, undefined, true), {wrapper: Wrapper}); + await waitForBatchedUpdates(); + + // Then an empty policyID is returned so the backend picks the preferred policy, with no upgrade screen + expect(result.current.policyForMovingExpensesID).toBeUndefined(); + expect(result.current.shouldNavigateToUpgradePath).toBe(false); + }); +}); diff --git a/tests/unit/hooks/useReopenAfterReturn.test.ts b/tests/unit/hooks/useReopenAfterReturn.test.ts new file mode 100644 index 000000000000..80a115a875ee --- /dev/null +++ b/tests/unit/hooks/useReopenAfterReturn.test.ts @@ -0,0 +1,129 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useReopenAfterReturn from '@hooks/useReopenAfterReturn'; +import type {UseReopenAfterReturnParams} from '@hooks/useReopenAfterReturn'; + +import type TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import type React from 'react'; + +// Mirrors `useFocusEffect`: runs the effect while the screen is focused, and again whenever the effect changes. +let mockIsFocused = true; +jest.mock('@react-navigation/native', () => { + const {useEffect} = jest.requireActual('react'); + return { + ...jest.requireActual>('@react-navigation/native'), + useIsFocused: () => mockIsFocused, + useFocusEffect: (effect: () => void | (() => void)) => { + const isFocused = mockIsFocused; + useEffect(() => (isFocused ? effect() : undefined), [effect, isFocused]); + }, + }; +}); + +// Run the post-transition work right away, since no screen actually animates in a unit test. +jest.mock('@libs/Navigation/TransitionTracker', () => ({ + runAfterTransitions: ({callback}: Parameters[0]): ReturnType => { + callback(); + return {cancel: () => {}}; + }, +})); + +type Props = Omit; + +describe('useReopenAfterReturn', () => { + let onReturn: jest.Mock; + + const renderReopen = (initialProps: Props = {}) => renderHook((props: Props) => useReopenAfterReturn({onReturn, ...props}), {initialProps}); + + // Leaves the screen and comes back, re-rendering with each focus change the way navigation would. + const leaveAndReturn = (rerender: (props: Props) => void, props: Props = {}) => { + mockIsFocused = false; + rerender(props); + mockIsFocused = true; + rerender(props); + }; + + beforeEach(() => { + mockIsFocused = true; + onReturn = jest.fn(); + }); + + it('runs once the user comes back from the screen they were sent to', () => { + // Given a screen that is about to send the user elsewhere + const {result, rerender} = renderReopen(); + act(() => result.current.setIsDeparting(true)); + + // When the user leaves and comes back + leaveAndReturn(rerender); + + // Then the return runs, and only once + expect(onReturn).toHaveBeenCalledTimes(1); + leaveAndReturn(rerender); + expect(onReturn).toHaveBeenCalledTimes(1); + }); + + it('does nothing for a focus it did not send the user away for', () => { + // Given a screen that never marked a departure + const {rerender} = renderReopen(); + + // When the user leaves and comes back on their own + leaveAndReturn(rerender); + + // Then nothing reopens + expect(onReturn).not.toHaveBeenCalled(); + }); + + it('drops the return when the user comes back without what it needs', () => { + // Given a screen that sent the user away + const {result, rerender} = renderReopen(); + act(() => result.current.setIsDeparting(true)); + + // When they come back in a state the return can't use, and later return again once it could + leaveAndReturn(rerender, {shouldCancel: true}); + leaveAndReturn(rerender); + + // Then nothing reopens, since the first return already dropped it + expect(onReturn).not.toHaveBeenCalled(); + }); + + it('waits on the screen until it can reopen, but not past leaving it again', () => { + // Given a screen that sent the user away, and comes back before it can reopen + const {result, rerender} = renderReopen(); + act(() => result.current.setIsDeparting(true)); + leaveAndReturn(rerender, {canReopen: false}); + expect(onReturn).not.toHaveBeenCalled(); + + // When the user leaves again for something unrelated, and comes back once it could reopen + leaveAndReturn(rerender, {canReopen: true}); + + // Then nothing reopens, since that later focus was not the return it was waiting for + expect(onReturn).not.toHaveBeenCalled(); + }); + + it('reopens as soon as it can while the user is still back on the screen', () => { + // Given a screen the user came back to before it could reopen + const {result, rerender} = renderReopen(); + act(() => result.current.setIsDeparting(true)); + leaveAndReturn(rerender, {canReopen: false}); + + // When it becomes able to while still focused + rerender({canReopen: true}); + + // Then the return runs + expect(onReturn).toHaveBeenCalledTimes(1); + }); + + it('does nothing once a departure that never left the screen is disarmed', () => { + // Given a screen that armed a return, but whose navigation never took the user away + const {result, rerender} = renderReopen(); + act(() => result.current.setIsDeparting(true)); + + // When the next press stays on the screen and disarms it, and the user later leaves and comes back for something else + act(() => result.current.setIsDeparting(false)); + leaveAndReturn(rerender); + + // Then nothing reopens on that unrelated trip + expect(onReturn).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/hooks/useReviewFlaggedExpenses.test.ts b/tests/unit/hooks/useReviewFlaggedExpenses.test.ts index 9f2a60c6cc86..d493df89226d 100644 --- a/tests/unit/hooks/useReviewFlaggedExpenses.test.ts +++ b/tests/unit/hooks/useReviewFlaggedExpenses.test.ts @@ -347,6 +347,51 @@ describe('useReviewFlaggedExpenses', () => { expect(result.current.count).toBe(2); }); + /** + * Regression guard: eligibility depends on forwarding history, but the hook doesn't subscribe to every + * flagged report's full action history. A forward that lands via REPORT_ACTIONS alone (e.g. it hydrates + * after REPORT on startup), with no accompanying report write, must still clear the row. + */ + it('clears the count once a forward lands, even when only REPORT_ACTIONS changes and the report itself does not', async () => { + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}policyA`, { + id: 'policyA', + type: CONST.POLICY.TYPE.TEAM, + autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, + }); + await Onyx.set( + `${ONYXKEYS.COLLECTION.REPORT}r1`, + createMockReport({ + reportID: 'r1', + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: ACCOUNT_ID, + policyID: 'policyA', + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }), + ); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}t1`, {transactionID: 't1', reportID: 'r1', amount: 100, currency: 'USD', created: '2024-01-01', merchant: 'Test Merchant'}); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}t1`, [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}]); + }); + await waitForBatchedUpdatesWithAct(); + + const {result} = renderHook(() => useReviewFlaggedExpenses()); + await waitForBatchedUpdatesWithAct(); + expect(result.current.count).toBe(1); + + // The forward lands on its own; r1 itself is never written again. + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}r1`, { + submitted: {reportActionID: 'submitted', actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2024-01-01 00:00:00.000'}, + forwarded: {reportActionID: 'forwarded', actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, created: '2024-01-02 00:00:00.000'}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + expect(result.current.count).toBe(0); + }); + it('keeps reviewExpenses referentially stable across background Onyx writes while blurred', async () => { await act(async () => { await seedFlaggedExpenses({transactionID: 't1', reportID: 'r1'}); diff --git a/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts b/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts index 1b59af832309..30d8355b4d9c 100644 --- a/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts @@ -11,6 +11,7 @@ import {getExpensifyCardStatementPDF} from '@libs/actions/CompanyCards'; import {exportReportsToPDF} from '@libs/actions/Export'; import {exportReportToPDF} from '@libs/actions/Report'; import {getExpensifyCardStatementSelection} from '@libs/ExpensifyCardStatementUtils'; +import type {ExpensifyCardStatementParams} from '@libs/ExpensifyCardStatementUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -34,12 +35,42 @@ jest.mock('@libs/actions/CompanyCards', () => ({ getExpensifyCardStatementPDF: jest.fn(() => Promise.resolve({statementKey: 'statement-key'})), })); +// Mirrors the global modal stack for a fixed id: a show call while the modal is open updates it in place and +// returns the same promise, and the promise resolves once the modal closes (after it has finished hiding). +let mockStatementModalPromise: Promise<{action: string}> | undefined; +let mockResolveStatementModal: () => void = () => {}; +const mockShowExpensifyCardStatementPDFDownloadModal = jest.fn, [{statementParams: ExpensifyCardStatementParams}]>(() => { + if (!mockStatementModalPromise) { + mockStatementModalPromise = new Promise((resolve) => { + mockResolveStatementModal = () => { + mockStatementModalPromise = undefined; + resolve({action: 'CLOSE'}); + }; + }); + } + return mockStatementModalPromise; +}); +const mockCloseExpensifyCardStatementPDFDownloadModal = jest.fn(() => mockResolveStatementModal()); +jest.mock('@hooks/useExpensifyCardStatementPDFDownloadModal', () => ({ + __esModule: true, + default: () => ({ + showExpensifyCardStatementPDFDownloadModal: mockShowExpensifyCardStatementPDFDownloadModal, + closeExpensifyCardStatementPDFDownloadModal: mockCloseExpensifyCardStatementPDFDownloadModal, + }), +})); + let mockIsOffline = false; jest.mock('@hooks/useNetwork', () => ({ __esModule: true, default: () => ({isOffline: mockIsOffline}), })); +const mockShowDecisionModal = jest.fn(); +jest.mock('@hooks/useDecisionModal', () => ({ + __esModule: true, + default: () => ({showDecisionModal: mockShowDecisionModal}), +})); + jest.mock('@hooks/useEnvironment', () => ({ __esModule: true, default: () => ({isProduction: false, isDevelopment: true, environment: 'development'}), @@ -234,6 +265,8 @@ describe('useSearchBulkActions - Download report', () => { beforeEach(async () => { jest.clearAllMocks(); + mockStatementModalPromise = undefined; + mockResolveStatementModal = () => {}; mockIsOffline = false; await Onyx.clear(); mockSelectedTransactions = {}; @@ -587,7 +620,7 @@ describe('useSearchBulkActions - Download report', () => { // Then no incomplete export is started and the existing download error is shown expect(exportReportsToPDF).not.toHaveBeenCalled(); - expect(result.current.isDownloadErrorModalVisible).toBe(true); + expect(mockShowDecisionModal).toHaveBeenCalledWith(expect.objectContaining({title: 'common.downloadFailedTitle', prompt: 'common.downloadFailedDescription'})); }); it('should show Export as PDF for selected Expensify Card settlement groups', async () => { @@ -611,6 +644,7 @@ describe('useSearchBulkActions - Download report', () => { }); it('should request an Expensify Card statement PDF when Export as PDF is triggered', async () => { + // Given one selected Expensify Card settlement const groupKey = `${CONST.SEARCH.GROUP_PREFIX}123`; mockSelectedTransactions = { txn0: makeSelectedTransaction({groupKey, reportID: undefined}), @@ -626,24 +660,37 @@ describe('useSearchBulkActions - Download report', () => { expect(getDownloadStatementPDFOption(result.current.headerButtonsOptions)).toBeDefined(); }); + // When Export as PDF is triggered const exportAsPDFOption = getDownloadStatementPDFOption(result.current.headerButtonsOptions); await act(async () => { await exportAsPDFOption?.onSelected?.(); }); + // Then the statement is requested and the global modal opens before the server returns the statementKey expect(getExpensifyCardStatementPDF).toHaveBeenCalledTimes(1); expect(getExpensifyCardStatementPDF).toHaveBeenCalledWith('policy1', 'US', [123]); - expect(result.current.isExpensifyCardStatementPDFModalVisible).toBe(true); - // The selection is kept while the modal is open (the modal renders inside the selection-gated bar), then - // cleared when the modal hides. + expect(mockShowExpensifyCardStatementPDFDownloadModal).toHaveBeenCalledTimes(2); + expect(mockShowExpensifyCardStatementPDFDownloadModal.mock.calls.at(0)?.[0].statementParams).toEqual(expect.objectContaining({entryIDs: [123]})); + expect(mockShowExpensifyCardStatementPDFDownloadModal.mock.calls.at(0)?.[0].statementParams.statementKey).toBeUndefined(); + + // And the open modal is then updated with the statementKey so it can download the generated file + expect(mockShowExpensifyCardStatementPDFDownloadModal.mock.calls.at(1)?.[0].statementParams).toEqual(expect.objectContaining({entryIDs: [123], statementKey: 'statement-key'})); + + // And the selection is kept while the modal is open expect(mockClearSelectedTransactions).not.toHaveBeenCalled(); - act(() => { - result.current.handleExpensifyCardStatementPDFModalHide(); + + // When the modal finishes hiding + await act(async () => { + mockResolveStatementModal(); + await Promise.resolve(); }); + + // Then the selection is cleared expect(mockClearSelectedTransactions).toHaveBeenCalled(); }); it('should surface the error modal when the statement PDF request fails', async () => { + // Given a statement request that fails jest.mocked(getExpensifyCardStatementPDF).mockRejectedValueOnce(new Error('request failed')); const groupKey = `${CONST.SEARCH.GROUP_PREFIX}123`; mockSelectedTransactions = { @@ -659,20 +706,30 @@ describe('useSearchBulkActions - Download report', () => { expect(getDownloadStatementPDFOption(result.current.headerButtonsOptions)).toBeDefined(); }); + // When Export as PDF is triggered const exportAsPDFOption = getDownloadStatementPDFOption(result.current.headerButtonsOptions); await act(async () => { await exportAsPDFOption?.onSelected?.(); }); - // The loading modal is dismissed and the download-error modal is shown instead of hanging on "waiting". + // Then the loading modal is closed and the download-error modal is shown instead of hanging on "waiting" await waitFor(() => { - expect(result.current.isDownloadErrorModalVisible).toBe(true); + expect(mockShowDecisionModal).toHaveBeenCalledWith(expect.objectContaining({title: 'common.downloadFailedTitle', prompt: 'common.downloadFailedDescription'})); + }); + expect(mockCloseExpensifyCardStatementPDFDownloadModal).toHaveBeenCalledTimes(1); + + // When the closed modal finishes hiding + await act(async () => { + mockResolveStatementModal(); + await Promise.resolve(); }); - expect(result.current.isExpensifyCardStatementPDFModalVisible).toBe(false); - expect(result.current.expensifyCardStatementPDFParams).toBeUndefined(); + + // Then the selection is cleared, since the error modal lives on the global stack and no longer needs the bar mounted + expect(mockClearSelectedTransactions).toHaveBeenCalled(); }); it('should not let a stale failed request close the modal for a newer export', async () => { + // Given two settlements, and a first export that rejects only after a second export has started const firstGroupKey = `${CONST.SEARCH.GROUP_PREFIX}123`; const secondGroupKey = `${CONST.SEARCH.GROUP_PREFIX}456`; mockCurrentSearchResults = makeCurrentSearchResults({ @@ -680,7 +737,6 @@ describe('useSearchBulkActions - Download report', () => { [secondGroupKey]: makeSettlementGroup({entryID: 456, accountNumber: '5678', debitPosted: '2026-05-30'}), }); - // The first export rejects only after we have started a second export for a different settlement. let rejectFirstRequest: (error: Error) => void = () => {}; jest.mocked(getExpensifyCardStatementPDF) .mockReturnValueOnce( @@ -691,36 +747,79 @@ describe('useSearchBulkActions - Download report', () => { .mockReturnValueOnce(Promise.resolve({statementKey: 'statement-key-456'})); mockSelectedTransactions = {firstTxn: makeSelectedTransaction({groupKey: firstGroupKey, reportID: undefined})}; - const {result} = renderHookWithProvider(() => useSearchBulkActions({queryJSON: expensifyCardStatementQueryJSON})); + const {result, rerender} = renderHookWithProvider(() => useSearchBulkActions({queryJSON: expensifyCardStatementQueryJSON})); await waitFor(() => { expect(getDownloadStatementPDFOption(result.current.headerButtonsOptions)).toBeDefined(); }); - // Start the first export, then switch the selection and start the second. + // When the first export starts, the user closes its modal, switches the selection and starts the second await act(async () => { await getDownloadStatementPDFOption(result.current.headerButtonsOptions)?.onSelected?.(); }); - mockSelectedTransactions = {secondTxn: makeSelectedTransaction({groupKey: secondGroupKey, reportID: undefined})}; - // Re-render so the hook picks up the new selection, then start the second export. - act(() => { - result.current.handleExpensifyCardStatementPDFModalHide(); + await act(async () => { + mockResolveStatementModal(); + await Promise.resolve(); }); + mockSelectedTransactions = {secondTxn: makeSelectedTransaction({groupKey: secondGroupKey, reportID: undefined})}; + rerender({}); await waitFor(() => { expect(getDownloadStatementPDFOption(result.current.headerButtonsOptions)).toBeDefined(); }); await act(async () => { await getDownloadStatementPDFOption(result.current.headerButtonsOptions)?.onSelected?.(); }); + mockCloseExpensifyCardStatementPDFDownloadModal.mockClear(); - // The now-stale first request fails. It must not close the second export's modal or show an error. + // And the now-stale first request fails await act(async () => { rejectFirstRequest(new Error('request failed')); await Promise.resolve(); }); - expect(result.current.isDownloadErrorModalVisible).toBe(false); - expect(result.current.isExpensifyCardStatementPDFModalVisible).toBe(true); - expect(result.current.expensifyCardStatementPDFParams?.entryIDs).toEqual([456]); + // Then it neither closes the second export's modal nor shows an error + expect(mockShowDecisionModal).not.toHaveBeenCalled(); + expect(mockCloseExpensifyCardStatementPDFDownloadModal).not.toHaveBeenCalled(); + expect(mockShowExpensifyCardStatementPDFDownloadModal.mock.calls.at(-1)?.[0].statementParams).toEqual(expect.objectContaining({entryIDs: [456], statementKey: 'statement-key-456'})); + }); + + it('should not reopen the modal when the statementKey arrives after the user closed it', async () => { + // Given a statement request that is still in flight + let resolveRequest: (response: {statementKey: string}) => void = () => {}; + jest.mocked(getExpensifyCardStatementPDF).mockReturnValueOnce( + new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}123`; + mockSelectedTransactions = { + txn0: makeSelectedTransaction({groupKey, reportID: undefined}), + }; + mockCurrentSearchResults = makeCurrentSearchResults({ + [groupKey]: makeSettlementGroup(), + }); + + const {result} = renderHookWithProvider(() => useSearchBulkActions({queryJSON: expensifyCardStatementQueryJSON})); + await waitFor(() => { + expect(getDownloadStatementPDFOption(result.current.headerButtonsOptions)).toBeDefined(); + }); + await act(async () => { + await getDownloadStatementPDFOption(result.current.headerButtonsOptions)?.onSelected?.(); + }); + expect(mockShowExpensifyCardStatementPDFDownloadModal).toHaveBeenCalledTimes(1); + + // When the user closes the modal and the response lands afterwards + await act(async () => { + mockResolveStatementModal(); + await Promise.resolve(); + }); + await act(async () => { + resolveRequest({statementKey: 'statement-key'}); + await Promise.resolve(); + }); + + // Then the modal is not shown again, because a show call with the same id after close would open a new one + expect(mockShowExpensifyCardStatementPDFDownloadModal).toHaveBeenCalledTimes(1); + expect(mockShowDecisionModal).not.toHaveBeenCalled(); }); it('should open the multi-feed alert instead of requesting a statement PDF', async () => { @@ -748,7 +847,9 @@ describe('useSearchBulkActions - Download report', () => { }); expect(getExpensifyCardStatementPDF).not.toHaveBeenCalled(); - expect(result.current.isExpensifyCardStatementMultiFeedAlertVisible).toBe(true); + expect(mockShowDecisionModal).toHaveBeenCalledWith( + expect.objectContaining({title: 'search.expensifyCardStatementPDF.title', prompt: 'search.expensifyCardStatementPDF.oneFeedAtATime'}), + ); }); it('should export a cross-workspace settlement as the whole settlement (no policyID)', async () => { @@ -809,7 +910,7 @@ describe('useSearchBulkActions - Download report', () => { [secondGroupKey]: makeSettlementGroup({entryID: 456, count: 2, total: 2000, accountNumber: '5678', debitPosted: '2026-05-30'}), }); - const {result} = renderHookWithProvider(() => useSearchBulkActions({queryJSON: expensifyCardStatementQueryJSON})); + const {result, rerender} = renderHookWithProvider(() => useSearchBulkActions({queryJSON: expensifyCardStatementQueryJSON})); await waitFor(() => { expect(getDownloadStatementPDFOption(result.current.headerButtonsOptions)).toBeDefined(); @@ -833,9 +934,12 @@ describe('useSearchBulkActions - Download report', () => { secondTxn1: makeSelectedTransaction({groupKey: secondGroupKey, reportID: undefined}), }; - act(() => { - result.current.handleExpensifyCardStatementPDFModalHide(); + // Close the first modal and re-render so the hook picks up the new selection. + await act(async () => { + mockResolveStatementModal(); + await Promise.resolve(); }); + rerender({}); await act(async () => { await snapshottedOnSelected?.(); diff --git a/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts b/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts index e7559784eb1a..06b4f72d9263 100644 --- a/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts @@ -31,6 +31,12 @@ jest.mock('@hooks/useNetwork', () => ({ default: () => ({isOffline: mockIsOffline}), })); +const mockShowDecisionModal = jest.fn(); +jest.mock('@hooks/useDecisionModal', () => ({ + __esModule: true, + default: () => ({showDecisionModal: mockShowDecisionModal}), +})); + jest.mock('@hooks/useEnvironment', () => ({ __esModule: true, default: () => ({isProduction: false, isDevelopment: true, environment: 'development'}), @@ -344,7 +350,7 @@ describe('useSearchBulkActions - Download receipts', () => { }); expect(exportReceiptsToZip).not.toHaveBeenCalled(); - expect(result.current.isOfflineModalVisible).toBe(true); + expect(mockShowDecisionModal).toHaveBeenCalledWith(expect.objectContaining({title: 'common.youAppearToBeOffline', prompt: 'common.offlinePrompt'})); }); }); @@ -451,7 +457,7 @@ describe('useSearchBulkActions - Download receipts', () => { }); expect(exportReceiptsToZip).not.toHaveBeenCalled(); - expect(result.current.isOfflineModalVisible).toBe(true); + expect(mockShowDecisionModal).toHaveBeenCalledWith(expect.objectContaining({title: 'common.youAppearToBeOffline', prompt: 'common.offlinePrompt'})); }); }); }); diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index d3b6991c18d5..af91676bd091 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -151,6 +151,12 @@ jest.mock('@hooks/useNetwork', () => ({ default: () => ({isOffline: mockIsOffline}), })); +const mockShowDecisionModal = jest.fn(); +jest.mock('@hooks/useDecisionModal', () => ({ + __esModule: true, + default: () => ({showDecisionModal: mockShowDecisionModal}), +})); + jest.mock('@hooks/useEnvironment', () => ({ __esModule: true, default: () => ({isProduction: true, isDevelopment: false, environment: 'production'}), @@ -1379,12 +1385,34 @@ describe('useSearchBulkActions - export options', () => { // Then: the offline modal opens and nothing is queued, matching bulk pay's offline behavior. await waitFor(() => { - expect(result.current.isOfflineModalVisible).toBe(true); + expect(mockShowDecisionModal).toHaveBeenCalledWith(expect.objectContaining({title: 'common.youAppearToBeOffline', prompt: 'common.offlinePrompt'})); }); expect(queueBulkMarkAsExported).not.toHaveBeenCalled(); expect(mockClearSelectedTransactions).not.toHaveBeenCalled(); }); + it('shows the empty-report download error with the empty report count when every selected report has no expenses', async () => { + // Given a selected report that the search snapshot says has no expenses, so there is nothing to export + mockCurrentSearchResults = makeSearchResults([{...makeSnapshotReport(), transactionCount: 0}]); + mockSelectedReports = [makeSelectedReport()]; + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportOptionByText(result.current.headerButtonsOptions, 'export.currentView')).toBeDefined(); + }); + + // When the user runs the Current view CSV export + getExportOptionByText(result.current.headerButtonsOptions, 'export.currentView')?.onSelected?.(); + + // Then the download error explains the reports are empty, because the prompt is passed when the modal is shown rather than read from shared state + await waitFor(() => { + expect(mockShowDecisionModal).toHaveBeenCalledWith(expect.objectContaining({title: 'common.downloadFailedTitle', prompt: 'common.downloadFailedEmptyReportDescription'})); + }); + expect(exportSearchItemsToCSV).not.toHaveBeenCalled(); + }); + it('marks the specific selected report IDs, not the search query, for a limited (non-select-all) selection', async () => { // Given: a finite selection of specific reports ("Select all" is NOT checked). mockAreAllMatchingItemsSelected = false; @@ -1428,7 +1456,10 @@ describe('useSearchBulkActions - export options', () => { }); }); - it('hides templates when a full group is selected in an explicitly grouped search', async () => { + it('shows templates when a full group is selected in an explicitly grouped search', async () => { + // Regression test for https://github.com/Expensify/App/issues/100074: selecting a group row used to leave + // "Current view" as the only export option, so the templates were only reachable by ticking every line item + // in the group one by one. A group selection exports through a query filter, so the templates apply to it too. mockSelectedTransactions = { tx1: makeSelectedTransaction({ groupKey: `${CONST.SEARCH.GROUP_PREFIX}category`, @@ -1438,6 +1469,101 @@ describe('useSearchBulkActions - export options', () => { const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + await waitFor(() => { + expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual( + expect.arrayContaining(['export.currentView', 'Custom template', 'export.expenseLevelExport', 'export.reportLevelExport']), + ); + }); + + // The basic export stays hidden on a grouped search because it carries fewer columns than "Current view". + expect(getExportOptionTexts(result.current.headerButtonsOptions)).not.toContain('export.basicExport'); + }); + + it('shows templates when a collapsed group is selected before its children have loaded', async () => { + // The other selection shape for the same user action: a group selected while collapsed is stored under a + // single `group_` stub instead of one entry per child, and it must offer the same templates. + mockSelectedTransactions = { + [`${CONST.SEARCH.GROUP_PREFIX}category`]: makeSelectedTransaction(), + }; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual( + expect.arrayContaining(['export.currentView', 'Custom template', 'export.expenseLevelExport', 'export.reportLevelExport']), + ); + }); + }); + + it('exports an expanded group through the group query when a template is picked', async () => { + // Given an expanded group selected through its header, stored as one entry per loaded child rather than a `group_` stub + mockSelectedTransactions = { + tx1: makeSelectedTransaction({ + groupKey: `${CONST.SEARCH.GROUP_PREFIX}category`, + isSelectedViaGroup: true, + }), + }; + + // Given the group row as it arrives in the search snapshot, which the group's filter entry is derived from + const searchResults = makeSearchResults([]); + Object.assign(searchResults.data, {[`${CONST.SEARCH.GROUP_PREFIX}category`]: {category: 'Travel'}}); + mockCurrentSearchResults = searchResults; + jest.mocked(getSelectedGroupFilterEntry).mockReturnValue({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, value: 'Travel'}); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportOptionByText(result.current.headerButtonsOptions, 'Custom template')).toBeDefined(); + }); + + // When a template is picked + getExportOptionByText(result.current.headerButtonsOptions, 'Custom template')?.onSelected?.(); + + // Then it exports through a query filtered to the group instead of the loaded IDs, so children that `limit:` left unloaded are not dropped + expect(queueExportSearchWithTemplate).toHaveBeenCalledTimes(1); + const [parameters] = jest.mocked(queueExportSearchWithTemplate).mock.calls.at(0) ?? []; + expect(parameters?.transactionIDList).toEqual([]); + expect(parameters?.reportIDList).toEqual([]); + const query: unknown = JSON.parse(parameters?.jsonQuery ?? '{}'); + + // Then the query is ungrouped, because the template export has no group mode and would otherwise return one row per group + expect(query).not.toHaveProperty('groupBy'); + expect(JSON.stringify(query)).toContain('Travel'); + }); + + it('shows templates when a group selection is mixed with individually picked expenses', async () => { + // Given a group selected through its header alongside an expense picked on its own from another group + mockSelectedTransactions = { + [`${CONST.SEARCH.GROUP_PREFIX}category`]: makeSelectedTransaction(), + tx2: makeSelectedTransaction(), + }; + + // When the export options are built + const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + + // Then the templates are offered, because a template export sends the group as a query filter and the picked expense as an ID, so neither side is dropped + await waitFor(() => { + expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual( + expect.arrayContaining(['export.currentView', 'Custom template', 'export.expenseLevelExport', 'export.reportLevelExport']), + ); + }); + }); + + it('keeps only Current view for a group selection on a time-based grouping', async () => { + // Given a group selected on a search grouped by month, which has no filter a selected group can be turned into + mockSelectedTransactions = { + [`${CONST.SEARCH.GROUP_PREFIX}2026-10`]: makeSelectedTransaction(), + }; + const monthGroupedExpenseQueryJSON: SearchQueryJSON = { + ...groupedExpenseQueryJSON, + inputQuery: `type:expense groupBy:${CONST.SEARCH.GROUP_BY.MONTH}`, + groupBy: CONST.SEARCH.GROUP_BY.MONTH, + }; + + // When the export options are built + const {result} = renderHook(() => useSearchBulkActions({queryJSON: monthGroupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + + // Then no template is offered, because the template export could not scope the group and would only show the download error await waitFor(() => { expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual(['export.currentView']); }); @@ -1453,7 +1579,7 @@ describe('useSearchBulkActions - export options', () => { mockGetExportTemplates.mockImplementation(defaultExportTemplates); }); - /** Offer the Reconciliation template alongside the templates that stay hidden for a group selection. */ + /** Offer the Reconciliation template alongside the other templates. */ function mockTemplatesIncludingReconciliation() { mockGetExportTemplates.mockReturnValue({ customTemplates: [{name: 'Custom template', templateName: 'customTemplate', type: 'in-app', policyID: undefined, description: ''}], @@ -1488,8 +1614,8 @@ describe('useSearchBulkActions - export options', () => { }; } - it('offers the Reconciliation template, and only that template, for a card group selection', async () => { - // Given a user who qualifies for the Reconciliation template, offered alongside the templates that stay hidden for a group selection + it('offers every template, including Reconciliation, for a card group selection', async () => { + // Given a user who qualifies for the Reconciliation template, offered alongside the other templates mockTemplatesIncludingReconciliation(); // Given a ticked card group row, because selecting the card group rather than the individual expenses is how an admin reconciles a statement @@ -1498,9 +1624,14 @@ describe('useSearchBulkActions - export options', () => { // When the export menu is built for a search grouped by card const {result} = renderHook(() => useSearchBulkActions({queryJSON: cardGroupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); - // Then Reconciliation is the one template still offered, because the selected card groups can be expressed as a `cardID:` filter while the other templates have no way to describe a group + // Then every template is offered, because the selected card groups become a `cardID:` filter on an ungrouped query that any template can export await waitFor(() => { - expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual(['export.currentView', 'export.reconciliationAllExpenses']); + expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual([ + 'export.currentView', + 'Custom template', + 'export.expenseLevelExport', + 'export.reconciliationAllExpenses', + ]); }); }); @@ -1514,25 +1645,14 @@ describe('useSearchBulkActions - export options', () => { // When the export menu is built for a search grouped by card const {result} = renderHook(() => useSearchBulkActions({queryJSON: cardGroupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); - // Then the template is offered just the same, because both shapes a card group selection can take have to reach the card group exception or the option would come and go with how far the list happened to be scrolled + // Then the templates are offered just the same, because both shapes a card group selection can take have to be treated alike or the options would come and go with how far the list happened to be scrolled await waitFor(() => { - expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual(['export.currentView', 'export.reconciliationAllExpenses']); - }); - }); - - it('keeps every template hidden when the group selection is grouped by something other than card', async () => { - // Given a user who qualifies for the Reconciliation template - mockTemplatesIncludingReconciliation(); - - // Given a ticked group row - selectCardGroup(); - - // When the export menu is built for a search grouped by category instead of by card - const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); - - // Then no template is offered at all, because the exception is limited to card groups and every other grouping keeps the existing rule that a group cannot be scoped - await waitFor(() => { - expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual(['export.currentView']); + expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual([ + 'export.currentView', + 'Custom template', + 'export.expenseLevelExport', + 'export.reconciliationAllExpenses', + ]); }); }); @@ -1545,10 +1665,11 @@ describe('useSearchBulkActions - export options', () => { // When the export menu is built for a search grouped by card const {result} = renderHook(() => useSearchBulkActions({queryJSON: cardGroupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); - // Then only Current view is offered, because the card group exception only narrows the templates the user was already entitled to and must never hand out one they were not + // Then Reconciliation is not offered, because a card group selection only shows the templates the user was already entitled to and must never hand out one they were not await waitFor(() => { - expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual(['export.currentView']); + expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual(['export.currentView', 'Custom template', 'export.expenseLevelExport', 'export.reportLevelExport']); }); + expect(getExportOptionTexts(result.current.headerButtonsOptions)).not.toContain('export.reconciliationAllExpenses'); }); // Regression test for detecting the group export from the child metadata: with only `isSelectedViaGroup` @@ -1701,7 +1822,7 @@ describe('useSearchBulkActions - export options', () => { // Then nothing is exported, because dropping groupBy without a cardID filter would export every card in the search await waitFor(() => { - expect(result.current.isDownloadErrorModalVisible).toBe(true); + expect(mockShowDecisionModal).toHaveBeenCalledWith(expect.objectContaining({title: 'common.downloadFailedTitle', prompt: 'common.downloadFailedDescription'})); }); expect(queueExportSearchWithTemplate).not.toHaveBeenCalled(); }); @@ -1760,16 +1881,12 @@ describe('useSearchBulkActions - export options', () => { }); it('keeps the Export entry as a submenu even when only one export option is available', async () => { - // Regression test for https://github.com/Expensify/App/issues/98779: a full group selection offers a single - // export option ('export.currentView'). While other bulk actions sit alongside it (Hold here), the Export - // entry must still open the Export submenu (keeping its generic label and subMenuItems) rather than - // collapsing straight into that single option. + // Regression test for https://github.com/Expensify/App/issues/98779: a deleted selection on a grouped search + // offers a single export option ('export.currentView'). While other bulk actions sit alongside it (Undelete + // here), the Export entry must still open the Export submenu (keeping its generic label and subMenuItems) + // rather than collapsing straight into that single option. mockSelectedTransactions = { - tx1: makeSelectedTransaction({ - groupKey: `${CONST.SEARCH.GROUP_PREFIX}category`, - isSelectedViaGroup: true, - canHold: true, - }), + tx1: makeSelectedTransaction({reportID: CONST.REPORT.TRASH_REPORT_ID}), }; const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); @@ -1793,10 +1910,7 @@ describe('useSearchBulkActions - export options', () => { // keeps the nested shape regardless. mockAreAllMatchingItemsSelected = true; mockSelectedTransactions = { - tx1: makeSelectedTransaction({ - groupKey: `${CONST.SEARCH.GROUP_PREFIX}category`, - isSelectedViaGroup: true, - }), + tx1: makeSelectedTransaction({reportID: CONST.REPORT.TRASH_REPORT_ID}), }; const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); diff --git a/tests/unit/hooks/useSearchBulkActionsPayTest.ts b/tests/unit/hooks/useSearchBulkActionsPayTest.ts index 01a6d3bf7cc6..42039125021b 100644 --- a/tests/unit/hooks/useSearchBulkActionsPayTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsPayTest.ts @@ -124,6 +124,12 @@ jest.mock('@hooks/useNetwork', () => ({ default: () => ({isOffline: mockIsOffline}), })); +const mockShowDecisionModal = jest.fn(); +jest.mock('@hooks/useDecisionModal', () => ({ + __esModule: true, + default: () => ({showDecisionModal: mockShowDecisionModal}), +})); + jest.mock('@hooks/useEnvironment', () => ({ __esModule: true, default: () => ({isProduction: false, isDevelopment: true, environment: 'development'}), @@ -338,7 +344,7 @@ describe('useSearchBulkActions - Pay option', () => { }); // Then the offline modal should open and no payment should be triggered because payments must not be queued while offline - expect(result.current.isOfflineModalVisible).toBe(true); + expect(mockShowDecisionModal).toHaveBeenCalledWith(expect.objectContaining({title: 'common.youAppearToBeOffline', prompt: 'common.offlinePrompt'})); expect(payMoneyRequest).not.toHaveBeenCalled(); }); diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index 369ca3dfc0d2..d5c952ef1bbd 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -6,7 +6,7 @@ import type {SearchQueryJSON, SelectedReports, SelectedTransactions} from '@comp import useSearchBulkActions from '@hooks/useSearchBulkActions'; import {unholdRequest} from '@libs/actions/IOU/Hold'; -import {getExportTemplates, queueExportSearchItemsToCSV, queueExportSearchWithTemplate} from '@libs/actions/Search'; +import {exportSearchItemsToCSV, getExportTemplates, queueExportSearchItemsToCSV, queueExportSearchWithTemplate} from '@libs/actions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -19,6 +19,7 @@ import createMock from '../../utils/createMock'; const mockQueueExportSearchItemsToCSV = jest.mocked(queueExportSearchItemsToCSV); const mockQueueExportSearchWithTemplate = jest.mocked(queueExportSearchWithTemplate); +const mockExportSearchItemsToCSV = jest.mocked(exportSearchItemsToCSV); const mockGetExportTemplates = jest.mocked(getExportTemplates); const mockUnholdRequest = jest.mocked(unholdRequest); @@ -106,6 +107,12 @@ jest.mock('@hooks/useNetwork', () => ({ default: () => ({isOffline: mockIsOffline}), })); +const mockShowDecisionModal = jest.fn(); +jest.mock('@hooks/useDecisionModal', () => ({ + __esModule: true, + default: () => ({showDecisionModal: mockShowDecisionModal}), +})); + jest.mock('@hooks/useEnvironment', () => ({ __esModule: true, default: () => ({isProduction: false, isDevelopment: true, environment: 'development'}), @@ -168,7 +175,7 @@ let mockSelectedTransactions: SelectedTransactions = {}; let mockExcludedTransactions: SelectedTransactions = {}; let mockSelectedReports: SelectedReports[] = []; let mockAreAllMatchingItemsSelected = false; -let mockCurrentSearchResults: {search: {type: string}; data: Record} | undefined; +let mockCurrentSearchResults: {search: {type: string}; data?: Record} | undefined; jest.mock('@components/Search/SearchContext', () => ({ useSearchSelectionContext: () => ({ @@ -373,6 +380,38 @@ describe('useSearchBulkActions - CSV export flow', () => { expect(exportPayload?.jsonQuery).not.toContain('group_123'); }); + it('exports a selected merchant group with an exact merchant match', async () => { + // Given a merchant-grouped search where the user ticked the "Amazon" group, so its loaded child is stamped with the group key + const merchantGroupKey = `${CONST.SEARCH.GROUP_PREFIX}123` as const; + mockSelectedTransactions = {tx1: makeSelectedTransaction({groupKey: merchantGroupKey, isSelectedViaGroup: true})}; + mockCurrentSearchResults = { + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE}, + data: { + [merchantGroupKey]: {merchant: 'Amazon', count: 3, total: 300, currency: 'USD'}, + }, + }; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0); + }); + + // When the user exports the current view of that group selection + const onSelected = getExportMenuItems(result.current.headerButtonsOptions).find((item) => item.text === 'export.currentView')?.onSelected; + + await act(async () => { + onSelected?.(); + }); + + // Then the backend receives the merchant filter as `eq`, because a `contains` match on "Amazon" would also + // export "Amazon Marketplace" expenses that are not in the selected group + const [exportPayload] = mockExportSearchItemsToCSV.mock.calls.at(-1) ?? []; + expect(exportPayload?.isGroupExport).toBe(true); + expect(exportPayload?.jsonQuery).toContain(`"operator":"${CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}","left":"${CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT}","right":"Amazon"`); + expect(exportPayload?.jsonQuery).not.toContain(`"operator":"${CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}"`); + }); + it('preserves excluded group negations when the query already filters the grouped field', async () => { const firstExcludedGroupKey = `${CONST.SEARCH.GROUP_PREFIX}123` as const; const secondExcludedGroupKey = `${CONST.SEARCH.GROUP_PREFIX}456` as const; @@ -515,6 +554,35 @@ describe('useSearchBulkActions - CSV export flow', () => { }); }); + it('does not crash when selected reports remain while the snapshot has no data', async () => { + // Given a report is still selected while a new search's snapshot only has `search` (its optimistic loading state), + // because the selection is cleared from an effect that runs after this render + mockSelectedTransactions = {tx1: makeSelectedTransaction({reportID: 'report1'})}; + mockSelectedReports = [ + { + reportID: 'report1', + policyID: 'policy1', + action: CONST.SEARCH.ACTION_TYPES.VIEW, + canPay: false, + canApprove: false, + canSubmit: false, + canChangeApprover: false, + total: 100, + chatReportID: undefined, + }, + ]; + mockCurrentSearchResults = {search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}}; + + // When the bulk actions are created for an expense-report search + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + // Then the hook renders its options instead of throwing, and Merge stays hidden until the results load + await waitFor(() => { + expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0); + }); + expect(result.current.headerButtonsOptions.some((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.MERGE_REPORTS)).toBe(false); + }); + it('handleBasicExport with manual selection does not track any export', async () => { mockAreAllMatchingItemsSelected = false; mockSelectedTransactions = {tx1: makeSelectedTransaction()}; @@ -658,7 +726,7 @@ describe('useSearchBulkActions - CSV export flow', () => { expect(mockUnholdRequest).toHaveBeenCalledTimes(2); expect(mockUnholdRequest).toHaveBeenCalledWith(expect.objectContaining({transactionID: 'tx1', reportID: 'childReport1', isOffline: true})); expect(mockUnholdRequest).toHaveBeenCalledWith(expect.objectContaining({transactionID: 'tx2', reportID: 'childReport2', isOffline: true})); - expect(result.current.isOfflineModalVisible).toBe(false); + expect(mockShowDecisionModal).not.toHaveBeenCalled(); }); }); }); diff --git a/tests/unit/hooks/useSelectedTransactionsActions.test.ts b/tests/unit/hooks/useSelectedTransactionsActions.test.ts index 08e6d5a8350b..ad70016152fe 100644 --- a/tests/unit/hooks/useSelectedTransactionsActions.test.ts +++ b/tests/unit/hooks/useSelectedTransactionsActions.test.ts @@ -1030,18 +1030,7 @@ describe('useSelectedTransactionsActions', () => { mergeOption?.onSelected?.(); - expect(setupMergeTransactionDataAndNavigate).toHaveBeenCalledWith( - transaction.transactionID, - [transaction], - mockLocalCompare, - mockGetCurrencyDecimals, - [], - false, - false, - undefined, - CURRENT_USER_ACCOUNT_ID, - undefined, - ); + expect(setupMergeTransactionDataAndNavigate).toHaveBeenCalledWith(transaction.transactionID, [transaction], mockLocalCompare, mockGetCurrencyDecimals, [], false, false, undefined); }); it('should resolve IOU actions from the Onyx collection when the paginated report actions miss them', async () => { diff --git a/tests/unit/hooks/useSelectionListSearch.test.ts b/tests/unit/hooks/useSelectionListSearch.test.ts index 8a924be299dc..ddb57fe3b9f7 100644 --- a/tests/unit/hooks/useSelectionListSearch.test.ts +++ b/tests/unit/hooks/useSelectionListSearch.test.ts @@ -9,10 +9,17 @@ jest.mock('@hooks/useLocalize', () => ({ default: () => ({translate: (key: string) => key}), })); -type Item = {value: string; text: string; keyForList: string}; +type Item = {value: string; text: string; keyForList: string; isSelected?: boolean}; const buildItem = (value: string): Item => ({value, text: value, keyForList: value}); +/** Build `count` items ("Item 01"…) with the one at `selectedIndex` marked selected. */ +const buildItems = (count: number, selectedIndex: number): Item[] => + Array.from({length: count}, (_, index) => { + const value = String(index + 1).padStart(2, '0'); + return {value, text: `Item ${value}`, keyForList: value, isSelected: index === selectedIndex}; + }); + describe('useSelectionListSearch', () => { beforeEach(() => { jest.useFakeTimers(); @@ -69,6 +76,115 @@ describe('useSelectionListSearch', () => { expect(result.current.textInputOptions.headerMessage).toBeUndefined(); }); + it('pins the selected item to the top of a long list', () => { + // Given a long list (>= item-limit threshold) whose selected item sits in the middle + const data = buildItems(13, 6); + + // When the hook orders the list + const {result} = renderHook(() => useSelectionListSearch(data)); + + // Then the selected item is moved to the top (it would otherwise be in its natural 7th position) + expect(result.current.filteredData.at(0)?.value).toBe('07'); + expect(result.current.filteredData.at(0)?.value).not.toBe('01'); + }); + + it('pins the saved selection once the list loads asynchronously', () => { + // Given the list renders before its data has loaded from Onyx + const {result, rerender} = renderHook((data: Item[]) => useSelectionListSearch(data), {initialProps: [] as Item[]}); + expect(result.current.filteredData).toEqual([]); + + // When the data arrives with the 7th item already selected + rerender(buildItems(13, 6)); + + // Then the saved selection is pinned to the top, even though the first render was empty + expect(result.current.filteredData.at(0)?.value).toBe('07'); + }); + + it('pins every pre-selected item to the top of a long multi-select list', () => { + // Given a long list with three pre-selected items scattered through it + const data = Array.from({length: 13}, (_, index) => { + const value = String(index + 1).padStart(2, '0'); + return {value, text: `Item ${value}`, keyForList: value, isSelected: index === 2 || index === 6 || index === 10}; + }); + + // When the hook orders the list + const {result} = renderHook(() => useSelectionListSearch(data)); + + // Then all three selected items move to the top (not just the first), keeping their relative order + expect(result.current.filteredData.slice(0, 3).map((item) => item.value)).toEqual(['03', '07', '11']); + }); + + it('keeps the originally selected item pinned when the live selection changes (staged picker)', () => { + // Given a long list that opened with the 7th item selected + const {result, rerender} = renderHook((data: Item[]) => useSelectionListSearch(data), {initialProps: buildItems(13, 6)}); + expect(result.current.filteredData.at(0)?.value).toBe('07'); + + // When the user stages a different selection without saving (the 3rd item becomes selected) + rerender(buildItems(13, 2)); + + // Then the originally selected item stays pinned at the top instead of the newly tapped one jumping up... + expect(result.current.filteredData.at(0)?.value).toBe('07'); + // ...and the new pick only gets the checkmark, staying in its natural position + expect(result.current.filteredData.find((item) => item.value === '03')?.isSelected).toBe(true); + }); + + it('keeps the selected item pinned at the top while searching', () => { + // Given a long list with a mid-list selection + const data = buildItems(13, 6); + const {result} = renderHook(() => useSelectionListSearch(data)); + + // When a search matches the selected item along with others + searchAndSettle(result.current.textInputOptions.onChangeText, 'Item'); + + // Then the selected item stays pinned at the top of the filtered results + expect(result.current.filteredData.at(0)?.value).toBe('07'); + }); + + it('does not reorder a list below the item-limit threshold', () => { + // Given a short list (< item-limit threshold) with a mid-list selection + const data = buildItems(8, 3); + + // When the hook orders the list + const {result} = renderHook(() => useSelectionListSearch(data)); + + // Then the natural order is kept, so short lists are left untouched + expect(result.current.filteredData.at(0)?.value).toBe('01'); + }); + + it('reports the pinned values so callers share one source of truth', () => { + // Given a long list that opened with the 7th item selected + const data = buildItems(13, 6); + + // When the hook orders the list + const {result} = renderHook(() => useSelectionListSearch(data)); + + // Then it reports exactly the value it pinned, so callers do not have to re-derive the pin + expect(result.current.pinnedValues).toEqual(['07']); + }); + + it('reports no pinned values when nothing was selected when the list opened', () => { + // Given a long list that opened with nothing selected, then the first row becomes selected (optimistic tap) + const {result, rerender} = renderHook((data: Item[]) => useSelectionListSearch(data), {initialProps: buildItems(13, -1)}); + expect(result.current.pinnedValues).toEqual([]); + + // When the live selection flips to the first row + rerender(buildItems(13, 0)); + + // Then nothing is reported as pinned, so the caller does not move the clear row under a row that was never pinned + expect(result.current.pinnedValues).toEqual([]); + }); + + it('reports no pinned values for a list below the item-limit threshold', () => { + // Given a short list (< item-limit threshold) with a mid-list selection + const data = buildItems(8, 3); + + // When the hook orders the list + const {result} = renderHook(() => useSelectionListSearch(data)); + + // Then nothing is pinned, matching the fact that short lists keep their natural order + expect(result.current.pinnedValues).toEqual([]); + }); + it('clears the no-results message once a search matches again', () => { // Given a search that matched nothing const data = [buildItem('apple')]; diff --git a/tests/unit/hooks/useShareActionsTest.ts b/tests/unit/hooks/useShareActionsTest.ts new file mode 100644 index 000000000000..d92ded15706d --- /dev/null +++ b/tests/unit/hooks/useShareActionsTest.ts @@ -0,0 +1,143 @@ +import {renderHook} from '@testing-library/react-native'; + +import useShareActions from '@hooks/useShareActions'; + +import Clipboard from '@libs/Clipboard'; +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {Policy, Report} from '@src/types/onyx'; + +import createMock from '../../utils/createMock'; + +const REPORT_ID = 'report1'; +const CURRENT_USER_ACCOUNT_ID = 1; +const OTHER_ACCOUNT_ID = 2; +const ENVIRONMENT_URL = 'https://new.expensify.com'; + +const submittedReport: Report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + managerID: OTHER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, +}; + +let mockReport: Report | undefined = submittedReport; + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), +})); + +jest.mock('@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute', () => ({ + __esModule: true, + default: (path: string) => path, +})); + +jest.mock('@libs/Clipboard', () => ({ + setString: jest.fn(), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: () => ({}), +})); + +jest.mock('@hooks/useEnvironment', () => ({ + __esModule: true, + default: () => ({environmentURL: ENVIRONMENT_URL}), +})); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: () => ({accountID: CURRENT_USER_ACCOUNT_ID}), +})); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: () => [mockReport], +})); + +function getShareOptions(policy?: Policy) { + const {result} = renderHook(() => useShareActions({reportID: REPORT_ID, policy})); + return result.current[CONST.REPORT.SECONDARY_ACTIONS.SHARE].subMenuItems ?? []; +} + +describe('useShareActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockReport = submittedReport; + }); + + it('lists share report first for the submitter of a submitted report', () => { + // Given a submitted expense report owned by the current user + // When the share options are built + const options = getShareOptions(); + + // Then share report comes before the options every report has + expect(options.map((option) => option.text)).toEqual(['common.shareReport', 'common.shareCode', 'referralProgram.referralFriend.header', 'qrCodes.copy']); + }); + + it('hides share report on an open report', () => { + // Given the same report before it is submitted + mockReport = {...submittedReport, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; + + // When the share options are built + const options = getShareOptions(); + + // Then a report that was never submitted cannot be emailed + expect(options.map((option) => option.text)).not.toContain('common.shareReport'); + }); + + it('hides share report from a member who is not the submitter, the approver or an admin', () => { + // Given a submitted report that someone else owns and approves + mockReport = {...submittedReport, ownerAccountID: OTHER_ACCOUNT_ID}; + + // When the share options are built for a regular workspace member + const memberOptions = getShareOptions(createMock({id: 'policy1', role: CONST.POLICY.ROLE.USER})); + + // Then they cannot share it, while a workspace admin can + expect(memberOptions.map((option) => option.text)).not.toContain('common.shareReport'); + const adminOptions = getShareOptions(createMock({id: 'policy1', role: CONST.POLICY.ROLE.ADMIN})); + expect(adminOptions.at(0)?.text).toBe('common.shareReport'); + }); + + it('opens the share report page', () => { + // Given the share options of a report the current user can share + const options = getShareOptions(); + + // When share report is selected + options.at(0)?.onSelected?.(); + + // Then the picker for the recipient opens + expect(Navigation.navigate).toHaveBeenCalledWith(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_REPORT.path); + }); + + it('opens share code with only the QR code', () => { + // Given the share options of a report + const options = getShareOptions(); + + // When share code is selected + options.at(1)?.onSelected?.(); + + // Then the share code page opens without the copy and referral rows the share menu already has + expect(Navigation.navigate).toHaveBeenCalledWith('share-code?shouldShowQRCodeOnly=true'); + }); + + it('copies the report URL', () => { + // Given the share options of a report + const options = getShareOptions(); + + // When copy URL is selected + options.at(-1)?.onSelected?.(); + + // Then the link to the report is on the clipboard + expect(Clipboard.setString).toHaveBeenCalledWith(`${ENVIRONMENT_URL}/${ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)}`); + }); +}); diff --git a/tests/unit/hooks/useShouldAddOrReplaceReceipt.test.ts b/tests/unit/hooks/useShouldAddOrReplaceReceipt.test.ts new file mode 100644 index 000000000000..4858808b94d1 --- /dev/null +++ b/tests/unit/hooks/useShouldAddOrReplaceReceipt.test.ts @@ -0,0 +1,140 @@ +import {renderHook} from '@testing-library/react-native'; + +import useShouldAddOrReplaceReceipt from '@pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt'; + +import initOnyxDerivedValues from '@userActions/OnyxDerived'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, ReportAction, Transaction} from '@src/types/onyx'; +import type {Receipt} from '@src/types/onyx/Transaction'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +const ACCOUNT_ID = 1; +const MANAGER_ACCOUNT_ID = 2; +const POLICY_ID = 'policy1'; +const REPORT_ID = '100'; +const TRANSACTION_ID = 'txn1'; + +const policy: Policy = { + id: POLICY_ID, + name: 'Policy', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + owner: 'owner@test.com', + outputCurrency: CONST.CURRENCY.USD, +}; + +const expenseReport: Report = { + reportID: REPORT_ID, + policyID: POLICY_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: ACCOUNT_ID, + managerID: MANAGER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + participants: { + [ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [MANAGER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, +}; + +const iouAction: ReportAction = { + reportActionID: 'action1', + actorAccountID: ACCOUNT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: { + IOUReportID: REPORT_ID, + IOUTransactionID: TRANSACTION_ID, + amount: 1000, + currency: CONST.CURRENCY.USD, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }, + message: [{type: 'COMMENT', html: 'USD 10.00 expense', text: 'USD 10.00 expense', isEdited: false, whisperedTo: [], isDeletedParentAction: false, deleted: ''}], + created: '2025-01-01 12:00:00', +}; + +function buildTransaction(state: Receipt['state'], iouRequestType?: Transaction['iouRequestType']): Transaction { + return { + transactionID: TRANSACTION_ID, + reportID: REPORT_ID, + amount: 1000, + currency: CONST.CURRENCY.USD, + comment: {}, + created: '2025-01-01', + merchant: 'Merchant', + iouRequestType, + receipt: {source: 'https://example.com/receipt.jpg', filename: 'receipt.jpg', state}, + }; +} + +async function renderForTransaction(transaction: Transaction) { + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, transaction); + const {result} = renderHook(() => useShouldAddOrReplaceReceipt(REPORT_ID)); + await waitForBatchedUpdatesWithAct(); + return result; +} + +describe('useShouldAddOrReplaceReceipt', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + initOnyxDerivedValues(); + }); + + beforeEach(async () => { + await Onyx.clear(); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: ACCOUNT_ID, email: 'submitter@test.com'}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, expenseReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[iouAction.reportActionID]: iouAction}); + await waitForBatchedUpdatesWithAct(); + }); + + it('allows replacing the receipt of a manual expense while its receipt is SCAN_READY', async () => { + // Given a manual expense whose receipt was just replaced, which starts a backend scan the user never sees + const transaction = buildTransaction(CONST.IOU.RECEIPT_STATE.SCAN_READY, CONST.IOU.REQUEST_TYPE.MANUAL); + + // When the composer checks whether a dropped file should go to the expense's receipt + const result = await renderForTransaction(transaction); + + // Then a dropped file replaces the receipt instead of falling back to a new expense or an attachment + expect(result.current.transactionID).toBe(TRANSACTION_ID); + expect(result.current.shouldAddOrReplaceReceipt).toBe(true); + }); + + it('blocks replacing the receipt of a scan expense while its receipt is SCAN_READY', async () => { + // Given a scan expense whose receipt is waiting to be scanned, so the user sees a scan in progress + const transaction = buildTransaction(CONST.IOU.RECEIPT_STATE.SCAN_READY, CONST.IOU.REQUEST_TYPE.SCAN); + + // When the composer checks whether a dropped file should go to the expense's receipt + const result = await renderForTransaction(transaction); + + // Then the receipt can't be replaced until the scan finishes + expect(result.current.shouldAddOrReplaceReceipt).toBe(false); + }); + + it('blocks replacing the receipt while it is SCAN_READY when the expense has no iouRequestType', async () => { + // Given an expense with no iouRequestType, which getRequestType would treat as manual + const transaction = buildTransaction(CONST.IOU.RECEIPT_STATE.SCAN_READY); + + // When the composer checks whether a dropped file should go to the expense's receipt + const result = await renderForTransaction(transaction); + + // Then the scan still blocks the replace, because only an explicit manual type is exempt + expect(result.current.shouldAddOrReplaceReceipt).toBe(false); + }); + + it('allows replacing the receipt of a scan expense once its receipt is SCAN_COMPLETE', async () => { + // Given a scan expense whose scan has finished + const transaction = buildTransaction(CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE, CONST.IOU.REQUEST_TYPE.SCAN); + + // When the composer checks whether a dropped file should go to the expense's receipt + const result = await renderForTransaction(transaction); + + // Then a dropped file replaces the receipt + expect(result.current.shouldAddOrReplaceReceipt).toBe(true); + }); +}); diff --git a/tests/unit/hooks/useThumbnailDimensions.test.tsx b/tests/unit/hooks/useThumbnailDimensions.test.tsx new file mode 100644 index 000000000000..c2b5a08e318b --- /dev/null +++ b/tests/unit/hooks/useThumbnailDimensions.test.tsx @@ -0,0 +1,76 @@ +import {renderHook} from '@testing-library/react-native'; + +import {IsInSidePanelContext} from '@hooks/useIsInSidePanel'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThumbnailDimensions from '@hooks/useThumbnailDimensions'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +import createMock from '../../utils/createMock'; + +jest.mock('@hooks/useResponsiveLayout', () => ({ + __esModule: true, + default: jest.fn(), +})); + +function mockNarrowLayout(shouldUseNarrowLayout: boolean) { + jest.mocked(useResponsiveLayout).mockReturnValue(createMock>({shouldUseNarrowLayout})); +} + +function renderInSidePanel(width: number, height: number) { + return renderHook(() => useThumbnailDimensions(width, height), { + wrapper: ({children}: {children: React.ReactNode}) => {children}, + }); +} + +describe('useThumbnailDimensions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses the wide screen size on a wide layout outside the side panel', () => { + // Given a wide layout and a landscape image rendered in the main chat + mockNarrowLayout(false); + + // When the thumbnail dimensions are calculated + const {result} = renderHook(() => useThumbnailDimensions(800, 400)); + + // Then the main chat has room for the full wide screen thumbnail + expect(result.current.thumbnailDimensionsStyles).toEqual({width: CONST.THUMBNAIL_IMAGE.WIDE_SCREEN.SIZE, aspectRatio: 2}); + }); + + it('uses the small screen size on a wide layout inside the side panel', () => { + // Given a wide layout and a landscape image rendered in the side panel + mockNarrowLayout(false); + + // When the thumbnail dimensions are calculated + const {result} = renderInSidePanel(800, 400); + + // Then the thumbnail uses the small size, because the side panel is too narrow for the wide screen size and would clip it + expect(result.current.thumbnailDimensionsStyles).toEqual({width: CONST.THUMBNAIL_IMAGE.SMALL_SCREEN.SIZE, aspectRatio: 2}); + }); + + it('uses the small screen size for portrait images inside the side panel', () => { + // Given a wide layout and a portrait image rendered in the side panel + mockNarrowLayout(false); + + // When the thumbnail dimensions are calculated + const {result} = renderInSidePanel(400, 800); + + // Then the height is capped at the small size so the thumbnail fits the side panel + expect(result.current.thumbnailDimensionsStyles).toEqual({height: CONST.THUMBNAIL_IMAGE.SMALL_SCREEN.SIZE, aspectRatio: 0.5}); + }); + + it('uses the small screen size on a narrow layout', () => { + // Given a narrow layout outside the side panel + mockNarrowLayout(true); + + // When the thumbnail dimensions are calculated + const {result} = renderHook(() => useThumbnailDimensions(800, 400)); + + // Then the existing narrow layout behavior is unchanged + expect(result.current.thumbnailDimensionsStyles).toEqual({width: CONST.THUMBNAIL_IMAGE.SMALL_SCREEN.SIZE, aspectRatio: 2}); + }); +}); diff --git a/tests/unit/hooks/useTimeSensitiveAddDepositAccount.test.ts b/tests/unit/hooks/useTimeSensitiveAddDepositAccount.test.ts new file mode 100644 index 000000000000..4377c76253a1 --- /dev/null +++ b/tests/unit/hooks/useTimeSensitiveAddDepositAccount.test.ts @@ -0,0 +1,126 @@ +import {renderHook, waitFor} from '@testing-library/react-native'; + +import useTimeSensitiveAddDepositAccount from '@pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddDepositAccount'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type {ValueOf} from 'type-fest'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const POLICY_ID = 'policy1'; +const BANK_ACCOUNT_ID = '101'; + +function makePolicy(isCollectDepositAccountsEnabled: boolean) { + return { + id: POLICY_ID, + isCollectDepositAccountsEnabled, + }; +} + +function makeBankAccount(type: ValueOf | undefined, state: ValueOf) { + return { + methodID: Number(BANK_ACCOUNT_ID), + accountData: {type, state}, + }; +} + +describe('useTimeSensitiveAddDepositAccount', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, {}); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('shows when a workspace collects deposit accounts and the user has no deposit account', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, makePolicy(true)); + + const {result} = renderHook(() => useTimeSensitiveAddDepositAccount()); + + await waitFor(() => expect(result.current.shouldShowAddDepositAccount).toBe(true)); + }); + + it('does not show when the collecting workspace is archived', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {...makePolicy(true), archivedDate: '2026-08-01 00:00:00.000'}); + + const {result} = renderHook(() => useTimeSensitiveAddDepositAccount()); + await waitForBatchedUpdates(); + + expect(result.current.shouldShowAddDepositAccount).toBe(false); + }); + + it('does not show when the collecting workspace is pending deletion', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {...makePolicy(true), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}); + + const {result} = renderHook(() => useTimeSensitiveAddDepositAccount()); + await waitForBatchedUpdates(); + + expect(result.current.shouldShowAddDepositAccount).toBe(false); + }); + + it('shows when the only open account has no explicit personal type', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, makePolicy(true)); + await Onyx.merge(ONYXKEYS.BANK_ACCOUNT_LIST, { + [BANK_ACCOUNT_ID]: makeBankAccount(undefined, CONST.BANK_ACCOUNT.STATE.OPEN), + }); + + const {result} = renderHook(() => useTimeSensitiveAddDepositAccount()); + + await waitFor(() => expect(result.current.shouldShowAddDepositAccount).toBe(true)); + }); + + it('does not show when no workspace collects deposit accounts', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, makePolicy(false)); + + const {result} = renderHook(() => useTimeSensitiveAddDepositAccount()); + await waitForBatchedUpdates(); + + expect(result.current.shouldShowAddDepositAccount).toBe(false); + }); + + it('does not show when the user already has an open personal deposit account', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, makePolicy(true)); + await Onyx.merge(ONYXKEYS.BANK_ACCOUNT_LIST, { + [BANK_ACCOUNT_ID]: makeBankAccount(CONST.BANK_ACCOUNT.TYPE.PERSONAL, CONST.BANK_ACCOUNT.STATE.OPEN), + }); + + const {result} = renderHook(() => useTimeSensitiveAddDepositAccount()); + await waitForBatchedUpdates(); + + expect(result.current.shouldShowAddDepositAccount).toBe(false); + }); + + it('shows when the only personal account is not open yet', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, makePolicy(true)); + await Onyx.merge(ONYXKEYS.BANK_ACCOUNT_LIST, { + [BANK_ACCOUNT_ID]: makeBankAccount(CONST.BANK_ACCOUNT.TYPE.PERSONAL, CONST.BANK_ACCOUNT.STATE.PENDING), + }); + + const {result} = renderHook(() => useTimeSensitiveAddDepositAccount()); + + await waitFor(() => expect(result.current.shouldShowAddDepositAccount).toBe(true)); + }); + + it('shows when the only open account is a business account', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, makePolicy(true)); + await Onyx.merge(ONYXKEYS.BANK_ACCOUNT_LIST, { + [BANK_ACCOUNT_ID]: makeBankAccount(CONST.BANK_ACCOUNT.TYPE.BUSINESS, CONST.BANK_ACCOUNT.STATE.OPEN), + }); + + const {result} = renderHook(() => useTimeSensitiveAddDepositAccount()); + + await waitFor(() => expect(result.current.shouldShowAddDepositAccount).toBe(true)); + }); +}); diff --git a/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts b/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts index 932a5698f818..b97c00d759b6 100644 --- a/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts +++ b/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts @@ -17,6 +17,7 @@ const POLICY_ID = '1'; function makeHomeAndOfficePolicy(isOfficeWorkArrangement?: boolean, hasOfficeWorkArrangement?: boolean): Policy { return { ...createRandomPolicy(Number(POLICY_ID)), + areDistanceRatesEnabled: true, commuterExclusions: { method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, ...(isOfficeWorkArrangement === undefined ? {} : {isOfficeWorkArrangement}), @@ -68,6 +69,19 @@ describe('useTimeSensitiveHomeAddress', () => { }); }); + it('asks for an address when Distance rates are disabled but home and office exclusions remain active', async () => { + // Given a workspace whose saved commuter setting is still enforced by the server + await setUpWorkspace({...makeHomeAndOfficePolicy(true), areDistanceRatesEnabled: false}); + + // When the time sensitive items are resolved + const {result} = renderHook(() => useTimeSensitiveHomeAddress()); + + // Then the task appears because disabling Distance rates does not remove the commuter setting + await waitFor(() => { + expect(result.current.shouldShowAddHomeAddress).toBe(true); + }); + }); + it('stays quiet when the workspace default is no regular workplace', async () => { // Given a workspace on the method whose members have no regular workplace await setUpWorkspace(makeHomeAndOfficePolicy(false)); diff --git a/tests/unit/hooks/useTimeSensitiveNetSuiteMigration.test.ts b/tests/unit/hooks/useTimeSensitiveNetSuiteMigration.test.ts new file mode 100644 index 000000000000..c9c2430ddfdd --- /dev/null +++ b/tests/unit/hooks/useTimeSensitiveNetSuiteMigration.test.ts @@ -0,0 +1,319 @@ +import {renderHook} from '@testing-library/react-native'; + +import useTimeSensitiveNetSuiteMigration from '@pages/home/TimeSensitiveSection/hooks/useTimeSensitiveNetSuiteMigration'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type Policy from '@src/types/onyx/Policy'; + +import type {PartialDeep} from 'type-fest'; + +import Onyx from 'react-native-onyx'; + +import createMock from '../../utils/createMock'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +function makePolicy(id: string, overrides: PartialDeep = {}): Policy { + return createMock({ + id, + name: overrides.name ?? `Workspace ${id}`, + role: overrides.role ?? CONST.POLICY.ROLE.ADMIN, + ...overrides, + }); +} + +/** Returns a policy with a verified TBA NetSuite connection that requires migration. */ +function makeNetSuitePolicy(id: string, nameOverride?: string): Policy { + return makePolicy(id, { + name: nameOverride ?? `NetSuite Workspace ${id}`, + connections: { + netsuite: { + accountID: 'account123', + verified: true, + tokenID: 'token123', + tokenSecret: 'secret456', + options: {config: {syncOptions: {}}}, + }, + }, + }); +} + +describe('useTimeSensitiveNetSuiteMigration', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + describe('when no policies qualify', () => { + it('returns an empty array when there are no policies', () => { + // Given no policies in Onyx + // When the hook renders + const {result} = renderHook(() => useTimeSensitiveNetSuiteMigration()); + + // Then no migration policies are returned + expect(result.current.netSuiteMigrationPolicies).toEqual([]); + }); + + it('returns an empty array when the policy has no NetSuite connection', async () => { + // Given an admin policy with no accounting connections + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy1`, makePolicy('policy1')); + await waitForBatchedUpdates(); + + // When the hook renders + const {result} = renderHook(() => useTimeSensitiveNetSuiteMigration()); + + // Then no migration policies are returned + expect(result.current.netSuiteMigrationPolicies).toEqual([]); + }); + + it('returns an empty array when the NetSuite connection is not verified', async () => { + // Given an admin policy with an unverified NetSuite connection + const policy = makePolicy('policy1', { + connections: { + netsuite: {verified: false, tokenID: 'token', tokenSecret: 'secret'}, + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy1`, policy); + await waitForBatchedUpdates(); + + // When the hook renders + const {result} = renderHook(() => useTimeSensitiveNetSuiteMigration()); + + // Then the connection is excluded because it is unverified + expect(result.current.netSuiteMigrationPolicies).toEqual([]); + }); + + it('returns an empty array when the NetSuite connection has no tokenID', async () => { + // Given a verified NetSuite connection without a TBA token (not a TBA connection) + const policy = makePolicy('policy1', { + connections: { + netsuite: {verified: true, tokenID: undefined, tokenSecret: 'secret'}, + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy1`, policy); + await waitForBatchedUpdates(); + + // When the hook renders + const {result} = renderHook(() => useTimeSensitiveNetSuiteMigration()); + + // Then the connection is excluded because it has no TBA credentials to migrate + expect(result.current.netSuiteMigrationPolicies).toEqual([]); + }); + + it('returns an empty array when the NetSuite connection is already on OAuth 2.0', async () => { + // Given a verified NetSuite connection where authType is already oauth2 + const policy = makePolicy('policy1', { + connections: { + netsuite: { + accountID: 'account123', + verified: true, + tokenID: 'token', + tokenSecret: 'secret', + config: {authType: 'oauth2'}, + }, + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy1`, policy); + await waitForBatchedUpdates(); + + // When the hook renders + const {result} = renderHook(() => useTimeSensitiveNetSuiteMigration()); + + // Then the policy is excluded because the connection is already migrated + expect(result.current.netSuiteMigrationPolicies).toEqual([]); + }); + + it('returns an empty array when the user is not an admin on the policy', async () => { + // Given a member-role policy with a qualifying NetSuite connection + const policy = makePolicy('policy1', { + role: CONST.POLICY.ROLE.USER, + connections: { + netsuite: {verified: true, tokenID: 'token', tokenSecret: 'secret'}, + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy1`, policy); + await waitForBatchedUpdates(); + + // When the hook renders + const {result} = renderHook(() => useTimeSensitiveNetSuiteMigration()); + + // Then the policy is excluded because only admins see the migration nudge + expect(result.current.netSuiteMigrationPolicies).toEqual([]); + }); + + it('returns an empty array when the policy is pending deletion', async () => { + // Given a qualifying policy is being deleted + const policy = { + ...makeNetSuitePolicy('policy1'), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy1`, policy); + await waitForBatchedUpdates(); + + // When the hook renders + const {result} = renderHook(() => useTimeSensitiveNetSuiteMigration()); + + // Then the policy is excluded while deletion is pending + expect(result.current.netSuiteMigrationPolicies).toEqual([]); + }); + }); + + describe('when policies qualify', () => { + it('returns the policy when it has a qualifying TBA NetSuite connection', async () => { + // Given an admin policy with a verified, un-migrated TBA NetSuite connection + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy1`, makeNetSuitePolicy('policy1', 'Acme Corp')); + await waitForBatchedUpdates(); + + // When the hook renders + const {result} = renderHook(() => useTimeSensitiveNetSuiteMigration()); + + // Then the policy is included in the migration list + expect(result.current.netSuiteMigrationPolicies).toHaveLength(1); + expect(result.current.netSuiteMigrationPolicies.at(0)).toMatchObject({ + policyID: 'policy1', + policyName: 'Acme Corp', + }); + }); + + it('returns the policy when authType is explicitly tba (not yet migrated)', async () => { + // Given a verified NetSuite connection where authType is set to tba + const policy = makePolicy('policy1', { + name: 'TBA Corp', + connections: { + netsuite: { + accountID: 'account123', + verified: true, + tokenID: 'token', + tokenSecret: 'secret', + config: {authType: 'tba'}, + options: {config: {syncOptions: {}}}, + }, + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy1`, policy); + await waitForBatchedUpdates(); + + // When the hook renders + const {result} = renderHook(() => useTimeSensitiveNetSuiteMigration()); + + // Then the policy is included because authType tba is not yet migrated + expect(result.current.netSuiteMigrationPolicies).toHaveLength(1); + expect(result.current.netSuiteMigrationPolicies.at(0)?.policyID).toBe('policy1'); + }); + + it('returns multiple policies when several admin workspaces need migration', async () => { + // Given three admin workspaces with qualifying TBA NetSuite connections + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy1`, makeNetSuitePolicy('policy1', 'Alpha')); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy2`, makeNetSuitePolicy('policy2', 'Beta')); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy3`, makeNetSuitePolicy('policy3', 'Gamma')); + await waitForBatchedUpdates(); + + // When the hook renders + const {result} = renderHook(() => useTimeSensitiveNetSuiteMigration()); + + // Then all three policies are returned + expect(result.current.netSuiteMigrationPolicies).toHaveLength(3); + const ids = result.current.netSuiteMigrationPolicies.map((p) => p.policyID); + expect(ids).toContain('policy1'); + expect(ids).toContain('policy2'); + expect(ids).toContain('policy3'); + }); + + it('filters out non-qualifying policies and includes only qualifying ones', async () => { + // Given one qualifying TBA policy and one already-migrated policy + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy1`, makeNetSuitePolicy('policy1', 'Needs Migration')); + await Onyx.merge( + `${ONYXKEYS.COLLECTION.POLICY}policy2`, + makePolicy('policy2', { + name: 'Already Migrated', + connections: { + netsuite: { + verified: true, + tokenID: 'token', + tokenSecret: 'secret', + config: {authType: 'oauth2'}, + }, + }, + }), + ); + await waitForBatchedUpdates(); + + // When the hook renders + const {result} = renderHook(() => useTimeSensitiveNetSuiteMigration()); + + // Then only the un-migrated policy is returned + expect(result.current.netSuiteMigrationPolicies).toHaveLength(1); + expect(result.current.netSuiteMigrationPolicies.at(0)?.policyID).toBe('policy1'); + }); + + it('excludes shared connections when one TBA policy uses an unsupported setting', async () => { + // Given two policies share a NetSuite account and one imports tax + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy1`, makeNetSuitePolicy('policy1', 'Simple Workspace')); + await Onyx.merge( + `${ONYXKEYS.COLLECTION.POLICY}policy2`, + makePolicy('policy2', { + connections: { + netsuite: { + accountID: 'account123', + verified: true, + tokenID: 'token123', + tokenSecret: 'secret456', + options: {config: {syncOptions: {syncTax: true}}}, + }, + }, + }), + ); + await waitForBatchedUpdates(); + + // When the hook renders + const {result} = renderHook(() => useTimeSensitiveNetSuiteMigration()); + + // Then neither policy is offered because OAuth migration updates both policies + expect(result.current.netSuiteMigrationPolicies).toEqual([]); + }); + }); + + describe('reactivity to Onyx changes', () => { + it('updates when a policy is migrated to OAuth 2.0 (authType becomes oauth2)', async () => { + // Given a qualifying TBA policy + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy1`, makeNetSuitePolicy('policy1')); + await waitForBatchedUpdates(); + + const {result, rerender} = renderHook(() => useTimeSensitiveNetSuiteMigration()); + expect(result.current.netSuiteMigrationPolicies).toHaveLength(1); + + // When the policy is migrated (authType updated to oauth2) + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy1`, { + connections: {netsuite: {config: {authType: 'oauth2'}}}, + }); + await waitForBatchedUpdates(); + rerender({}); + + // Then the policy is removed from the migration list + expect(result.current.netSuiteMigrationPolicies).toHaveLength(0); + }); + + it('updates when a new qualifying policy is added to Onyx', async () => { + // Given no policies initially + const {result, rerender} = renderHook(() => useTimeSensitiveNetSuiteMigration()); + expect(result.current.netSuiteMigrationPolicies).toHaveLength(0); + + // When a new qualifying policy is written to Onyx + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy1`, makeNetSuitePolicy('policy1', 'New Workspace')); + await waitForBatchedUpdates(); + rerender({}); + + // Then the new policy appears in the migration list + expect(result.current.netSuiteMigrationPolicies).toHaveLength(1); + expect(result.current.netSuiteMigrationPolicies.at(0)?.policyName).toBe('New Workspace'); + }); + }); +}); diff --git a/tests/unit/hooks/useTodoSearchResultsTest.ts b/tests/unit/hooks/useTodoSearchResultsTest.ts new file mode 100644 index 000000000000..cfedc3f1e050 --- /dev/null +++ b/tests/unit/hooks/useTodoSearchResultsTest.ts @@ -0,0 +1,143 @@ +import {renderHook} from '@testing-library/react-native'; + +import useTodoSearchResults from '@hooks/useTodoSearchResults'; + +import type {SearchKey} from '@libs/SearchKeyUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchResults} from '@src/types/onyx'; + +import createMock from '../../utils/createMock'; + +const mockLoadingKeys = new Set(); +const mockReportCollectionKey = ONYXKEYS.COLLECTION.REPORT; +let mockReports: Record = {}; +jest.mock('react-native-onyx', () => ({ + __esModule: true, + ...jest.requireActual>('react-native-onyx'), + useOnyx: (key: string) => [key === mockReportCollectionKey ? mockReports : undefined, {status: mockLoadingKeys.has(key) ? 'loading' : 'loaded'}], +})); + +jest.mock('@hooks/usePersonalDetails', () => ({ + useAllPersonalDetailsWithoutSnapshots: () => [undefined, {status: mockLoadingKeys.has('personalDetailsList') ? 'loading' : 'loaded'}], +})); + +// Every report in the collection counts as a to-do, standing in for the device's classification rules +jest.mock('@libs/TodosUtils', () => ({ + ...jest.requireActual>('@libs/TodosUtils'), + getTodoReportsForSearchKey: (_searchKey: string, {allReports}: {allReports?: Record}) => ({ + reports: Object.values(allReports ?? {}), + transactionsByReportID: {}, + }), +})); + +/** Puts the given reports in the device's to-do list */ +function listReports(reportIDs: string[]) { + mockReports = Object.fromEntries(reportIDs.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {reportID}])); +} + +const SERVER_REPORT_COUNT = 120; + +// The server counted 120 drafts. Its responses held A, B and C. +const serverSearch = createMock({reportCount: SERVER_REPORT_COUNT, countedReportIDs: {A: true, B: true, C: true}}); + +type RenderProps = {searchKey: SearchKey; search: SearchResults['search']}; +const DRAFTS: RenderProps = {searchKey: CONST.SEARCH.SEARCH_KEYS.SUBMIT, search: serverSearch}; + +function renderDrafts() { + return renderHook(({searchKey, search}: RenderProps) => useTodoSearchResults(searchKey, undefined, search), {initialProps: DRAFTS}); +} + +describe('useTodoSearchResults', () => { + beforeEach(() => { + mockLoadingKeys.clear(); + listReports([]); + }); + + it('takes a counted report off once this device has listed it and it leaves', () => { + // Given Drafts listing all three counted reports + listReports(['A', 'B', 'C']); + const {result, rerender} = renderDrafts(); + expect(result.current?.metadata.reportCount).toBe(SERVER_REPORT_COUNT); + + // When report A leaves the list + listReports(['B', 'C']); + rerender(DRAFTS); + + // Then it comes off the server's count + expect(result.current?.metadata.reportCount).toBe(SERVER_REPORT_COUNT - 1); + }); + + it('keeps a counted report this device never listed on the count, since its own rules hide it', () => { + // Given Drafts listing only A and B, as when the device's rules hide C while the server still counts it + listReports(['A', 'B']); + const {result, rerender} = renderDrafts(); + + // When report A then leaves the list + listReports(['B']); + rerender(DRAFTS); + + // Then only A comes off. C was never listed here, so it never counts as having left + expect(result.current?.metadata.reportCount).toBe(SERVER_REPORT_COUNT - 1); + }); + + it('only remembers reports listed once everything has loaded', () => { + // Given Drafts listing A while report actions are still loading, before the rules that could hide it are known + mockLoadingKeys.add(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + listReports(['A', 'B', 'C']); + const {result, rerender} = renderDrafts(); + + // When everything loads and A turns out not to be a to-do here + mockLoadingKeys.clear(); + listReports(['B', 'C']); + rerender(DRAFTS); + + // Then A doesn't come off, since it was only listed before the device could tell + expect(result.current?.metadata.reportCount).toBe(SERVER_REPORT_COUNT); + }); + + it('remembers the listed reports per to-do list', () => { + // Given Drafts listing A, B and C + listReports(['A', 'B', 'C']); + const {result, rerender} = renderDrafts(); + + // When Needs approval opens with the same counted reports, without A in its list + listReports(['B', 'C']); + rerender({searchKey: CONST.SEARCH.SEARCH_KEYS.APPROVE, search: serverSearch}); + + // Then A doesn't come off there, since this device never listed it for that list + expect(result.current?.metadata.reportCount).toBe(SERVER_REPORT_COUNT); + + // When Drafts opens again, still without A + rerender(DRAFTS); + + // Then A comes off, since Drafts listed it before the switch + expect(result.current?.metadata.reportCount).toBe(SERVER_REPORT_COUNT - 1); + }); + + it.each([ + ONYXKEYS.COLLECTION.REPORT, + ONYXKEYS.COLLECTION.POLICY, + ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + ONYXKEYS.COLLECTION.TRANSACTION, + ONYXKEYS.COLLECTION.REPORT_ACTIONS, + ONYXKEYS.COLLECTION.REPORT_METADATA, + ONYXKEYS.BANK_ACCOUNT_LIST, + ONYXKEYS.SESSION, + 'personalDetailsList', + ONYXKEYS.COLLECTION.RULE, + ])("keeps the server's count while %s is still loading", (loadingKey) => { + // Given Drafts that listed all three counted reports + listReports(['A', 'B', 'C']); + const {result, rerender} = renderDrafts(); + + // When one of the collections that decides whether a report is a to-do is loading, and A is missing from the list meanwhile + mockLoadingKeys.add(loadingKey); + listReports(['B', 'C']); + rerender(DRAFTS); + + // Then nothing comes off, because reports that still match can be missing from the list until it loads + expect(result.current?.metadata.reportCount).toBe(SERVER_REPORT_COUNT); + }); +}); diff --git a/tests/unit/hooks/useUpdateFilterQuery.test.ts b/tests/unit/hooks/useUpdateFilterQuery.test.ts index d4df7ec92cb0..193f216e9b2b 100644 --- a/tests/unit/hooks/useUpdateFilterQuery.test.ts +++ b/tests/unit/hooks/useUpdateFilterQuery.test.ts @@ -1,9 +1,10 @@ -import {renderHook} from '@testing-library/react-native'; +import {act, renderHook} from '@testing-library/react-native'; import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; import type {SearchQueryJSON} from '@components/Search/types'; import Navigation from '@libs/Navigation/Navigation'; +import {buildFilterFormValuesFromQuery, buildQueryStringWithResetFilters, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -25,8 +26,9 @@ jest.mock('@components/Search/SearchContext', () => ({ })); jest.mock('@libs/Navigation/Navigation'); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); -const queryJSON: SearchQueryJSON = { +const baseQueryJSON: SearchQueryJSON = { hash: 0, recentSearchHash: 0, similarSearchHash: 0, @@ -52,6 +54,7 @@ function getSetParamsCall() { describe('useUpdateFilterQuery', () => { beforeEach(() => { + jest.clearAllMocks(); onyxData[ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM] = {type: CONST.SEARCH.DATA_TYPES.EXPENSE} satisfies Partial; mockGetSearchKeyForQuery.mockReset(); mockGetSearchKeyForQuery.mockReturnValue(CONST.SEARCH.SEARCH_KEYS.REPORTS); @@ -60,7 +63,7 @@ describe('useUpdateFilterQuery', () => { describe('setFilterQueryParams', () => { it('writes the re-derived search key to the route when the type changes', () => { - const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + const {result} = renderHook(() => useUpdateFilterQuery(baseQueryJSON)); result.current.setFilterQueryParams({type: CONST.SEARCH.DATA_TYPES.INVOICE}); @@ -70,7 +73,7 @@ describe('useUpdateFilterQuery', () => { it('clears the search key when the new query matches no search', () => { mockGetSearchKeyForQuery.mockReturnValue(undefined); - const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + const {result} = renderHook(() => useUpdateFilterQuery(baseQueryJSON)); result.current.setFilterQueryParams({type: CONST.SEARCH.DATA_TYPES.INVOICE}); @@ -78,7 +81,7 @@ describe('useUpdateFilterQuery', () => { }); it('leaves the search key param alone when the type is set to the one the form already holds', () => { - const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + const {result} = renderHook(() => useUpdateFilterQuery(baseQueryJSON)); result.current.setFilterQueryParams({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); @@ -86,7 +89,7 @@ describe('useUpdateFilterQuery', () => { }); it('leaves the search key param alone when the type is unchanged', () => { - const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + const {result} = renderHook(() => useUpdateFilterQuery(baseQueryJSON)); result.current.setFilterQueryParams({type: CONST.SEARCH.DATA_TYPES.EXPENSE, merchant: 'Amazon'}); @@ -94,11 +97,72 @@ describe('useUpdateFilterQuery', () => { }); it('leaves the search key param alone when no type is provided', () => { - const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + const {result} = renderHook(() => useUpdateFilterQuery(baseQueryJSON)); result.current.setFilterQueryParams({merchant: 'Amazon'}); expect(getSetParamsCall()).not.toHaveProperty('searchKey'); }); }); + + function setup() { + const queryJSON = buildSearchQueryJSON('merchant=I merchant*:I'); + if (!queryJSON) { + throw new Error('Invalid test query'); + } + + const form = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); + onyxData[ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM] = form; + + return {...renderHook(() => useUpdateFilterQuery(queryJSON)), form, queryJSON}; + } + + function getNavigatedQuery() { + const [params] = jest.mocked(Navigation.setParams).mock.calls.at(-1) ?? []; + if (!params || !('q' in params) || typeof params.q !== 'string') { + throw new Error('Expected navigation to a search query'); + } + + return buildSearchQueryJSON(params.q); + } + + test.each(['updateFilterQueryParams', 'setFilterQueryParams'] as const)('retains mixed Merchant predicates through %s', (method) => { + const {result, form, queryJSON} = setup(); + const updates: Partial = {currency: ['VND']}; + + act(() => { + result.current[method](method === 'setFilterQueryParams' ? {...form, ...updates} : updates); + }); + + expect(getNavigatedQuery()?.flatFilters.filter((filter) => filter.key === 'merchant')).toEqual(queryJSON.flatFilters); + expect(getNavigatedQuery()?.flatFilters).toContainEqual({key: 'currency', filters: [{operator: 'eq', value: 'VND'}]}); + }); + + test('removes all positive Merchant predicates when clearing the Merchant chip', () => { + const {result} = setup(); + + act(() => { + result.current.updateFilterQueryParams({merchant: undefined}); + }); + + expect(getNavigatedQuery()?.flatFilters).toEqual([]); + }); + + test('removes Merchant predicates when resetting without a default search', () => { + const {queryJSON} = setup(); + const resetQuery = buildSearchQueryJSON(buildQueryStringWithResetFilters(queryJSON, undefined)); + + expect(resetQuery?.flatFilters).toEqual([]); + }); + + test('restores default Merchant predicates when resetting filters', () => { + const {queryJSON} = setup(); + const defaultQuery = buildSearchQueryJSON('merchant*:Amazon,Uber'); + if (!defaultQuery) { + throw new Error('Invalid default query'); + } + const resetQuery = buildSearchQueryJSON(buildQueryStringWithResetFilters(queryJSON, defaultQuery)); + + expect(resetQuery?.flatFilters).toEqual(defaultQuery.flatFilters); + }); }); diff --git a/tests/unit/inlineEditing/PolicyInlineEdit.test.ts b/tests/unit/inlineEditing/PolicyInlineEdit.test.ts index c7b9505b2816..c854796e359a 100644 --- a/tests/unit/inlineEditing/PolicyInlineEdit.test.ts +++ b/tests/unit/inlineEditing/PolicyInlineEdit.test.ts @@ -2,6 +2,7 @@ import type PolicyData from '@hooks/usePolicyData/types'; import { canUpdateExpensifyCardLimitTypeInline, + getExpensifyCardLimitInlineEditError, getExpensifyCardLimitInlineUpdate, renameCategoryInline, updateExpensifyCardLimitInline, @@ -17,6 +18,7 @@ import {renamePolicyCategory} from '@userActions/Policy/Category'; import {updateWorkspaceMembersRole} from '@userActions/Policy/Member'; import CONST from '@src/CONST'; +import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; import type {Card, Policy} from '@src/types/onyx'; import type {PartialDeep} from 'type-fest'; @@ -63,6 +65,14 @@ function getOptimisticCardNameValuePairs(cardID: number): Record(path: TPath, ...parameters: TranslationParameters): string { + if (parameters.length === 0) { + return path; + } + return `${path}|${parameters.map(String).join('|')}`; +} + const encodedFoodAndDrink = 'Food & Drink'; const policyData = createMock({ @@ -103,46 +113,51 @@ describe('PolicyInlineEdit', () => { describe('renameCategoryInline', () => { it('does not persist when the sanitized name is unchanged', () => { - renameCategoryInline(policyData, 'Food', ' Food ', true); + renameCategoryInline(translate, policyData, 'Food', ' Food ', true); expect(mockRenamePolicyCategory).not.toHaveBeenCalled(); }); it('does not persist an invalid name', () => { - renameCategoryInline(policyData, 'Food', 'Travel', true); + // Given a category name that already exists on the policy + // When that name is saved from the table + const rejection = renameCategoryInline(translate, policyData, 'Food', 'Travel', true); + // Then the edit is dropped and the caller can toast the same duplicate-name error as the form + expect(rejection).toBe('workspace.categories.existingCategoryError'); expect(mockRenamePolicyCategory).not.toHaveBeenCalled(); }); it('does not persist an HTML-like name', () => { // Given a category renamed from the table rather than the Name page // When the new name contains an HTML-like token the Name page rejects - // Then the rename is dropped, because that token must not be saved from the table either - renameCategoryInline(policyData, 'Food', '', true); + const rejection = renameCategoryInline(translate, policyData, 'Food', '', true); + // Then the edit is dropped with the invalid-name error, because that token must not be saved from the table either + expect(rejection).toBe('workspace.categories.invalidCategoryName'); expect(mockRenamePolicyCategory).not.toHaveBeenCalled(); }); it('delegates a valid rename to the canonical action', () => { - renameCategoryInline(policyData, 'Food', ' Meals ', true); + renameCategoryInline(translate, policyData, 'Food', ' Meals ', true); expect(mockRenamePolicyCategory).toHaveBeenCalledWith(policyData, {oldName: 'Food', newName: 'Meals'}, true); }); it('does not persist a name that matches an HTML-encoded category', () => { - renameCategoryInline(policyData, 'Food', 'Food & Drink', true); + renameCategoryInline(translate, policyData, 'Food', 'Food & Drink', true); expect(mockRenamePolicyCategory).not.toHaveBeenCalled(); }); it('does not persist when the sanitized name matches the decoded stored name', () => { - renameCategoryInline(policyData, encodedFoodAndDrink, ' Food & Drink ', true); + renameCategoryInline(translate, policyData, encodedFoodAndDrink, ' Food & Drink ', true); expect(mockRenamePolicyCategory).not.toHaveBeenCalled(); }); it('persists a rename of an HTML-encoded category using the raw stored name', () => { - renameCategoryInline(policyData, encodedFoodAndDrink, 'Snacks', true); + renameCategoryInline(translate, policyData, encodedFoodAndDrink, 'Snacks', true); expect(mockRenamePolicyCategory).toHaveBeenCalledWith(policyData, {oldName: encodedFoodAndDrink, newName: 'Snacks'}, true); }); @@ -163,6 +178,15 @@ describe('PolicyInlineEdit', () => { }); describe('getExpensifyCardLimitInlineUpdate', () => { + it('returns the field error for an invalid limit so the page can toast it', () => { + // Given a limit the card form rejects because it is not a whole dollar amount + // When the table asks whether that limit is an error + const rejection = getExpensifyCardLimitInlineEditError(translate, '10.5'); + + // Then the page can show the same whole-dollar message as the card form, and still skip the confirm modal + expect(rejection).toBe('iou.error.invalidIntegerAmount'); + }); + it('returns undefined for an invalid limit so the confirm modal is not shown', () => { // Given a card with a current limit of $100 const card = buildCard(); diff --git a/tests/unit/inlineEditing/TransactionInlineEdit.test.ts b/tests/unit/inlineEditing/TransactionInlineEdit.test.ts index a19cae171a98..5133366d5b1a 100644 --- a/tests/unit/inlineEditing/TransactionInlineEdit.test.ts +++ b/tests/unit/inlineEditing/TransactionInlineEdit.test.ts @@ -21,6 +21,7 @@ import {createTransactionThreadReport} from '@userActions/Report'; import type * as ReportActions from '@userActions/Report'; import CONST from '@src/CONST'; +import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyTagLists, Report, ReportAction, ReportNameValuePairs, Transaction} from '@src/types/onyx'; @@ -28,6 +29,14 @@ import Onyx from 'react-native-onyx'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; +/** Echoes the translation path and any interpolated values so assertions can lock the chosen message. */ +function translate(path: TPath, ...parameters: TranslationParameters): string { + if (parameters.length === 0) { + return path; + } + return `${path}|${parameters.map(String).join('|')}`; +} + // The delegate boundary is the assertion point: editTransaction*Inline only builds params and calls through. jest.mock('@userActions/IOU/UpdateMoneyRequest', () => ({ updateMoneyRequestDate: jest.fn(), @@ -731,10 +740,10 @@ describe('TransactionInlineEdit', () => { // the state of a Search row the user has not opened: it exists only in the search snapshot. it.each([ ['date', () => editTransactionDateInline(buildParams(), '2026-01-15', undefined), () => updateMoneyRequestDate, {value: '2026-01-15'}], - ['merchant', () => editTransactionMerchantInline(buildParams(), 'Cafe'), () => updateMoneyRequestMerchant, {value: 'Cafe'}], - ['description', () => editTransactionDescriptionInline(buildParams(), 'Lunch'), () => updateMoneyRequestDescription, {comment: 'Lunch'}], + ['merchant', () => editTransactionMerchantInline(translate, buildParams(), 'Cafe'), () => updateMoneyRequestMerchant, {value: 'Cafe'}], + ['description', () => editTransactionDescriptionInline(translate, buildParams(), 'Lunch'), () => updateMoneyRequestDescription, {comment: 'Lunch'}], ['category', () => editTransactionCategoryInline(buildParams(), 'Benefits'), () => updateMoneyRequestCategory, {category: 'Benefits'}], - ['amount', () => editTransactionAmountInline(buildParams(), 500), () => updateMoneyRequestAmountAndCurrency, {amount: 500}], + ['amount', () => editTransactionAmountInline(translate, buildParams(), 500), () => updateMoneyRequestAmountAndCurrency, {amount: 500}], ['tag', () => editTransactionTagInline(buildParams(), 'Project1'), () => updateMoneyRequestTag, {tag: 'Project1'}], ])('forwards the caller transaction when editing %s', (_field, edit, getDelegate, expectedChange) => { edit(); @@ -743,13 +752,150 @@ describe('TransactionInlineEdit', () => { }); it('clears the merchant instead of discarding the edit', () => { - // isValidMerchant only allows an empty merchant on an unreported expense, which it can only - // tell from the transaction. Without one the edit was dropped before any API call. - editTransactionMerchantInline(buildParams(), ''); + // Given an unreported expense, where an empty merchant is a real clear rather than a required-field failure + // When the merchant is cleared + const rejection = editTransactionMerchantInline(translate, buildParams(), ''); + // Then the edit is saved as the partial merchant, so there is no error to toast + expect(rejection).toBeUndefined(); expect(updateMoneyRequestMerchant).toHaveBeenCalledWith(expect.objectContaining({value: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT})); }); + it('rejects an empty merchant on an expense report without saving', () => { + // Given a reported expense, where clearing the merchant is not allowed the way it is for an unreported expense + const expenseReport: Report = {reportID: 'expense-report-1', type: CONST.REPORT.TYPE.EXPENSE}; + const reportedTransaction: Transaction = {...snapshotTransaction, reportID: expenseReport.reportID}; + const parentReportAction = { + reportActionID: '999', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2026-08-12', + } as ReportAction; + + // When the merchant is cleared to whitespace + const rejection = editTransactionMerchantInline(translate, {...buildParams(), parentReport: expenseReport, transaction: reportedTransaction, parentReportAction}, ' '); + + // Then the edit is dropped before any write, including the transaction thread that building params would create + expect(rejection).toBe('common.error.fieldRequired'); + expect(updateMoneyRequestMerchant).not.toHaveBeenCalled(); + expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); + }); + + it('rejects a placeholder merchant without saving', () => { + // Given the seeded "(none)" placeholder, which is not a merchant the user actually entered + // When that placeholder is saved from the table + const rejection = editTransactionMerchantInline(translate, buildParams(), CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT); + + // Then the edit is dropped, because saving the placeholder would look like a successful clear + expect(rejection).toBe('iou.error.invalidMerchant'); + expect(updateMoneyRequestMerchant).not.toHaveBeenCalled(); + }); + + it('rejects a merchant past the byte limit without saving', () => { + // Given a merchant longer than the form allows, measured in bytes the same way the merchant step does + const tooLongMerchant = 'a'.repeat(CONST.MERCHANT_NAME_MAX_BYTES + 1); + + // When that merchant is saved from the table + const rejection = editTransactionMerchantInline(translate, buildParams(), tooLongMerchant); + + // Then the edit is dropped so the cell can revert instead of storing a value the form would refuse + expect(rejection).toBe(`common.error.characterLimitExceedCounter|${tooLongMerchant.length}|${CONST.MERCHANT_NAME_MAX_BYTES}`); + expect(updateMoneyRequestMerchant).not.toHaveBeenCalled(); + }); + + it('rejects a merchant that contains an HTML tag without saving', () => { + // Given a merchant the form would reject for HTML, which inline edit never sees because it skips FormProvider + // When that merchant is saved from the table + const rejection = editTransactionMerchantInline(translate, buildParams(), ''); + + // Then the edit is dropped with the same invalid character failure the form shows + expect(rejection).toBe('common.error.invalidCharacter'); + expect(updateMoneyRequestMerchant).not.toHaveBeenCalled(); + }); + + it('rejects a merchant that is both too long and tagged as invalid character', () => { + // Given a merchant that is past the byte limit and also contains a tag, so both failures apply at once + const taggedMerchant = `${'a'.repeat(CONST.MERCHANT_NAME_MAX_BYTES)}`; + + // When that merchant is saved from the table + const rejection = editTransactionMerchantInline(translate, buildParams(), taggedMerchant); + + // Then HTML wins, matching FormProvider, which replaces the length error when a tag is present + expect(rejection).toBe('common.error.invalidCharacter'); + expect(updateMoneyRequestMerchant).not.toHaveBeenCalled(); + }); + + it.each(['<✓>', '<@>'])('rejects a merchant with a non-standard tag %s without saving', (merchant) => { + // Given a merchant the merchant step rejects through strict HTML validation, which the default tag check allows + // When that merchant is saved from the table + const rejection = editTransactionMerchantInline(translate, buildParams(), merchant); + + // Then the edit is dropped with the same invalid character failure the merchant step shows + expect(rejection).toBe('common.error.invalidCharacter'); + expect(updateMoneyRequestMerchant).not.toHaveBeenCalled(); + }); + + it('rejects a zero amount on an invoice without saving', () => { + // Given an invoice, where a zero amount is invalid even though an unreported expense can store one + const invoiceReport: Report = {reportID: 'invoice-report-1', type: CONST.REPORT.TYPE.INVOICE}; + + // When the amount is set to zero + const rejection = editTransactionAmountInline(translate, {...buildParams(), parentReport: invoiceReport}, 0); + + // Then the edit is dropped so the previous amount stays in place + expect(rejection).toBe('iou.error.invalidAmount'); + expect(updateMoneyRequestAmountAndCurrency).not.toHaveBeenCalled(); + }); + + it('rejects a description past the character limit without saving', () => { + // Given a description longer than the description step allows + const tooLongDescription = 'a'.repeat(CONST.DESCRIPTION_LIMIT + 1); + + // When that description is saved from the table + const rejection = editTransactionDescriptionInline(translate, buildParams(), tooLongDescription); + + // Then the edit is dropped so the cell reverts instead of storing text the description step would refuse + expect(rejection).toBe(`common.error.characterLimitExceedCounter|${tooLongDescription.length}|${CONST.DESCRIPTION_LIMIT}`); + expect(updateMoneyRequestDescription).not.toHaveBeenCalled(); + }); + + it('saves a description that only exceeds the limit because of surrounding spaces', () => { + // Given a description the description step accepts once FormProvider trims it + const description = 'a'.repeat(CONST.DESCRIPTION_LIMIT); + const paddedDescription = ` ${description} `; + + // When that description is saved from the table + const rejection = editTransactionDescriptionInline(translate, buildParams(), paddedDescription); + + // Then the trimmed description is saved, matching the description step + expect(rejection).toBeUndefined(); + expect(updateMoneyRequestDescription).toHaveBeenCalledWith(expect.objectContaining({comment: description})); + }); + + it('counts a too-long description after trimming surrounding spaces', () => { + // Given text that is still over the limit once the spaces the description step ignores are removed + const description = 'a'.repeat(CONST.DESCRIPTION_LIMIT + 1); + const paddedDescription = ` ${description} `; + + // When that description is saved from the table + const rejection = editTransactionDescriptionInline(translate, buildParams(), paddedDescription); + + // Then the toast uses the trimmed length, the same count the description step shows + expect(rejection).toBe(`common.error.characterLimitExceedCounter|${description.length}|${CONST.DESCRIPTION_LIMIT}`); + expect(updateMoneyRequestDescription).not.toHaveBeenCalled(); + }); + + it('rejects a description that contains an HTML tag without saving', () => { + // Given a description that is also past the length limit, so both failures apply at once + const taggedDescription = `${'a'.repeat(CONST.DESCRIPTION_LIMIT)}`; + + // When that description is saved from the table + const rejection = editTransactionDescriptionInline(translate, buildParams(), taggedDescription); + + // Then HTML wins, matching FormProvider, which replaces the length error when a tag is present + expect(rejection).toBe('common.error.invalidCharacter'); + expect(updateMoneyRequestDescription).not.toHaveBeenCalled(); + }); + describe('transaction thread creation', () => { const CONCIERGE_CHAT: Report = {reportID: 'concierge-inline-edit-1'}; const CREATED_THREAD: Report = {reportID: 'inline-edit-thread-1'}; @@ -765,7 +911,7 @@ describe('TransactionInlineEdit', () => { mockCreateTransactionThreadReport.mockReturnValue(CREATED_THREAD); // A parent IOU action but no transaction thread anywhere: the edit must create the thread first. - editTransactionMerchantInline({...buildParams(), parentReportAction: iouParentReportAction, conciergeChat: CONCIERGE_CHAT}, 'Cafe'); + editTransactionMerchantInline(translate, {...buildParams(), parentReportAction: iouParentReportAction, conciergeChat: CONCIERGE_CHAT}, 'Cafe'); // The threaded conciergeChat reaches createTransactionThreadReport instead of the deprecated module-level lookup... expect(mockCreateTransactionThreadReport).toHaveBeenCalledWith( @@ -778,14 +924,14 @@ describe('TransactionInlineEdit', () => { it('reuses an existing transaction thread without creating a new one', () => { const existingThread: Report = {reportID: 'existing-thread-1'}; - editTransactionMerchantInline({...buildParams(), parentReportAction: iouParentReportAction, transactionThreadReport: existingThread}, 'Cafe'); + editTransactionMerchantInline(translate, {...buildParams(), parentReportAction: iouParentReportAction, transactionThreadReport: existingThread}, 'Cafe'); expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); expect(updateMoneyRequestMerchant).toHaveBeenCalledWith(expect.objectContaining({transactionThreadReport: existingThread})); }); it('does not create a thread when there is no parent report action to anchor it', () => { - editTransactionMerchantInline({...buildParams(), conciergeChat: CONCIERGE_CHAT}, 'Cafe'); + editTransactionMerchantInline(translate, {...buildParams(), conciergeChat: CONCIERGE_CHAT}, 'Cafe'); expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); expect(updateMoneyRequestMerchant).toHaveBeenCalledWith(expect.objectContaining({transactionThreadReport: undefined})); diff --git a/tests/unit/inlineEditing/editableCellHooks.test.ts b/tests/unit/inlineEditing/editableCellHooks.test.ts index e85e3b54fc34..186fd19d652a 100644 --- a/tests/unit/inlineEditing/editableCellHooks.test.ts +++ b/tests/unit/inlineEditing/editableCellHooks.test.ts @@ -3,6 +3,33 @@ import {act, renderHook} from '@testing-library/react-native'; import useInlineEditState from '@components/EditableCell/useInlineEditState'; import usePopoverEditState from '@components/EditableCell/usePopoverEditState'; +import CONST from '@src/CONST'; + +import type {View} from 'react-native'; + +import createMock from '../../utils/createMock'; + +let mockWindowHeight = 900; + +jest.mock('@hooks/useWindowDimensions', () => () => ({windowHeight: mockWindowHeight})); + +// The hook opens the popover inside a requestAnimationFrame callback, so run it synchronously to keep the tests simple. +jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 0; +}); + +const PADDING = CONST.MODAL.POPOVER_MENU_PADDING; +const PREFERRED_HEIGHT = CONST.POPOVER_DROPDOWN_MAX_HEIGHT; +const MIN_HEIGHT = CONST.POPOVER_DROPDOWN_MIN_USABLE_HEIGHT; + +type MeasureInWindow = (callback: (x: number, y: number, width: number, height: number) => void) => void; + +/** Measurements of the anchor cell, as `measureInWindow` would report them */ +type AnchorRect = {x: number; y: number; width: number; height: number}; + +const CELL: AnchorRect = {x: 50, y: 0, width: 120, height: 32}; + type InlineHookParameters = Parameters>; type InlineSetupOptions = { @@ -45,6 +72,17 @@ const handlePopoverSave = (result: PopoverHookResult, value: T) => { act(() => result.current.handleSave(value)); }; +/** Renders the hook with its anchor measurement stubbed out to `anchor`, then opens the popover */ +const openPopoverForAnchor = (anchor: AnchorRect, options: PopoverSetupOptions = {}) => { + const measureInWindow: MeasureInWindow = (callback) => callback(anchor.x, anchor.y, anchor.width, anchor.height); + const {result} = setupPopover({value: 'hello', ...options}); + + result.current.anchorRef.current = createMock({measureInWindow}); + act(() => result.current.startEditing()); + + return result; +}; + describe('useInlineEditState', () => { it('starts with isEditing=false and localValue equal to the initial value', () => { const {result} = setupInline('hello'); @@ -426,6 +464,10 @@ describe('useInlineEditState', () => { }); describe('usePopoverEditState', () => { + beforeEach(() => { + mockWindowHeight = 900; + }); + it('starts with isEditing=false and isPopoverVisible=false', () => { const {result} = setupPopover({value: 'hello'}); @@ -454,4 +496,125 @@ describe('usePopoverEditState', () => { expect(onSave).toHaveBeenCalledWith('world'); expect(result.current.isPopoverVisible).toBe(false); }); + + describe('positioning', () => { + it('opens below the cell at full height when there is enough room below', () => { + // Given a cell near the top of a tall window, which leaves room for the full-height popover below it + mockWindowHeight = 900; + const anchor = {...CELL, y: 100}; + + // When the user starts editing the cell + const result = openPopoverForAnchor(anchor); + + // Then the popover opens below the cell at its preferred height, because below is the default side + expect(result.current.shouldOpenAbove).toBe(false); + expect(result.current.anchorAlignment.vertical).toBe(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP); + expect(result.current.popoverPosition).toEqual({horizontal: anchor.x, vertical: anchor.y + anchor.height + PADDING}); + expect(result.current.popoverHeight).toBe(PREFERRED_HEIGHT); + expect(result.current.isPopoverVisible).toBe(true); + }); + + it('flips fully above the cell, bottom-edge aligned, when it does not fit below but fits above', () => { + // Given a cell low in the window, with too little room below for the popover but enough above it + mockWindowHeight = 900; + const anchor = {...CELL, y: 600}; + + // When the user starts editing the cell + const result = openPopoverForAnchor(anchor); + + // Then the popover opens above the cell at full height, so it doesn't cover the cell being edited + expect(result.current.shouldOpenAbove).toBe(true); + expect(result.current.anchorAlignment.vertical).toBe(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM); + expect(result.current.popoverPosition.vertical).toBe(anchor.y - PADDING); + expect(result.current.popoverHeight).toBe(PREFERRED_HEIGHT); + }); + + it('shrinks to the space above when neither side fits and above has more room', () => { + // Given a short window where the popover fits on neither side of the cell, and above has more room + mockWindowHeight = 500; + const anchor = {...CELL, y: 300}; + + // When the user starts editing the cell + const result = openPopoverForAnchor(anchor); + + // Then the popover opens above and shrinks to that space, because the roomier side shows the most options + const spaceAbove = anchor.y - PADDING - PADDING; + expect(result.current.shouldOpenAbove).toBe(true); + expect(result.current.anchorAlignment.vertical).toBe(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM); + expect(result.current.popoverHeight).toBe(spaceAbove); + expect(result.current.popoverHeight).toBeLessThan(PREFERRED_HEIGHT); + expect(result.current.popoverPosition.vertical).toBe(anchor.y - PADDING); + expect(result.current.popoverPosition.vertical - result.current.popoverHeight).toBeGreaterThanOrEqual(0); + }); + + it('shrinks to fit below rather than flipping when below has more room than above', () => { + // Given a short window where the popover fits on neither side of the cell, and below has more room + mockWindowHeight = 500; + const anchor = {...CELL, y: 100}; + + // When the user starts editing the cell + const result = openPopoverForAnchor(anchor); + + // Then the popover stays below and shrinks to that space, because flipping would leave even less room + const spaceBelow = mockWindowHeight - (anchor.y + anchor.height + PADDING) - PADDING; + expect(result.current.shouldOpenAbove).toBe(false); + expect(result.current.anchorAlignment.vertical).toBe(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP); + expect(result.current.popoverHeight).toBe(spaceBelow); + expect(result.current.popoverPosition.vertical).toBe(anchor.y + anchor.height + PADDING); + }); + + it('stops shrinking at the minimum usable height on an extremely short window', () => { + // Given a window so short that the space on either side of the cell is below the minimum usable height + mockWindowHeight = 300; + const anchor = {...CELL, y: 150}; + + // When the user starts editing the cell + const result = openPopoverForAnchor(anchor); + + // Then the popover keeps the minimum usable height, because a smaller list would be too cramped to use + const spaceAbove = anchor.y - PADDING - PADDING; + expect(spaceAbove).toBeLessThan(MIN_HEIGHT); + expect(result.current.popoverHeight).toBe(MIN_HEIGHT); + }); + + it('uses the caller-provided preferred height to pick the side to open on', () => { + // Given a cell with 380px free below it, which is too little for the 416px dropdown but enough for the 366px calendar + mockWindowHeight = 928; + const anchor = {...CELL, y: 500}; + + // When the calendar opens and passes its own height + const result = openPopoverForAnchor(anchor, {popoverHeight: CONST.POPOVER_DATE_MAX_HEIGHT}); + + // Then it opens below at its own height, because the side is picked from the real popover height + expect(result.current.shouldOpenAbove).toBe(false); + expect(result.current.anchorAlignment.vertical).toBe(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP); + expect(result.current.popoverHeight).toBe(CONST.POPOVER_DATE_MAX_HEIGHT); + }); + + it('keeps a preferred height below the minimum usable height instead of growing it', () => { + // Given a short picker, like the two-role member picker, whose content is shorter than the minimum usable height + const shortListHeight = MIN_HEIGHT - 40; + mockWindowHeight = 300; + const anchor = {...CELL, y: 150}; + + // When it opens on a window too short to fit even the minimum usable height + const result = openPopoverForAnchor(anchor, {popoverHeight: shortListHeight}); + + // Then it keeps its own height, because the minimum only limits how far a taller picker shrinks + expect(result.current.popoverHeight).toBe(shortListHeight); + }); + + it('anchors to the right edge of the cell when anchorEdge is RIGHT', () => { + // Given a cell in a right-aligned column, where the popover should line up with the cell's right edge + mockWindowHeight = 900; + const anchor = {...CELL, y: 100}; + + // When the user starts editing the cell with a RIGHT anchor edge + const result = openPopoverForAnchor(anchor, {anchorEdge: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT}); + + // Then the popover is anchored to the right edge of the cell instead of the left + expect(result.current.anchorAlignment.horizontal).toBe(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT); + expect(result.current.popoverPosition.horizontal).toBe(anchor.x + anchor.width); + }); + }); }); diff --git a/tests/unit/insightsCompareTest.ts b/tests/unit/insightsCompareTest.ts new file mode 100644 index 000000000000..5122668bc1c3 --- /dev/null +++ b/tests/unit/insightsCompareTest.ts @@ -0,0 +1,54 @@ +import resolveComparisonPeriods from '@pages/Insights/insightsCompare'; + +import CONST from '@src/CONST'; + +describe('resolveComparisonPeriods', () => { + beforeEach(() => { + // Given a fixed today, so the resolved periods are the same on every run + jest.useFakeTimers().setSystemTime(new Date('2026-09-22T12:00:00')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('compares the year so far against the same stretch of the year before', () => { + // When the page reports on the year to date + const periods = resolveComparisonPeriods({preset: CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE}); + + // Then each period runs from January 1st to the same day of its own year, and each day pairs with its date a year before + expect(periods).toEqual({primary: {start: '2026-01-01', end: '2026-09-22'}, comparison: {start: '2025-01-01', end: '2025-09-22'}, monthsApart: 12}); + }); + + it('compares a month against the month before it', () => { + // When the page reports on last month + const periods = resolveComparisonPeriods({preset: CONST.SEARCH.DATE_PRESETS.LAST_MONTH}); + + // Then both periods are whole months, a month apart + expect(periods).toEqual({primary: {start: '2026-08-01', end: '2026-08-31'}, comparison: {start: '2026-07-01', end: '2026-07-31'}, monthsApart: 1}); + }); + + it('compares the last twelve months against the twelve before them', () => { + // When the page reports on the last 12 months + const periods = resolveComparisonPeriods({preset: CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS}); + + // Then the periods sit back to back, each a year long and bounded by whole months + expect(periods).toEqual({primary: {start: '2025-10-01', end: '2026-09-30'}, comparison: {start: '2024-10-01', end: '2025-09-30'}, monthsApart: 12}); + }); + + it('compares a custom range against the range of the same length before it', () => { + // When the page reports on ten days in March + const periods = resolveComparisonPeriods({from: '2026-03-11', to: '2026-03-20'}); + + // Then the compared period is the ten days ending the day before it starts, paired day by day rather than by date + expect(periods).toEqual({primary: {start: '2026-03-11', end: '2026-03-20'}, comparison: {start: '2026-03-01', end: '2026-03-10'}}); + }); + + it('compares a custom range of whole months against the same number of days before it', () => { + // When the page reports on October as a custom range + const periods = resolveComparisonPeriods({from: '2026-10-01', to: '2026-10-31'}); + + // Then the compared period is the 31 days before it, which starts on August 31st rather than September 1st + expect(periods?.comparison).toEqual({start: '2026-08-31', end: '2026-09-30'}); + }); +}); diff --git a/tests/unit/insightsGroupByUtilsTest.ts b/tests/unit/insightsGroupByUtilsTest.ts new file mode 100644 index 000000000000..f539c11a09ac --- /dev/null +++ b/tests/unit/insightsGroupByUtilsTest.ts @@ -0,0 +1,110 @@ +import type {InsightsFilters} from '@pages/Insights/insightsFilters'; +import {getFittingGroupBy, getInsightsGroupByOptions, getQueryGroupBy} from '@pages/Insights/insightsGroupByUtils'; + +import CONST from '@src/CONST'; + +const {DAY, WEEK, MONTH, QUARTER, YEAR} = CONST.SEARCH.GROUP_BY; +const {THIS_MONTH, LAST_MONTH, YEAR_TO_DATE, LAST_12_MONTHS} = CONST.SEARCH.DATE_PRESETS; + +describe('insightsGroupByUtils', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2026, 9, 8)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('getInsightsGroupByOptions', () => { + it.each<[string, InsightsFilters['date'], Array]>([ + ['this month', {preset: THIS_MONTH}, [DAY, WEEK]], + ['last month', {preset: LAST_MONTH}, [DAY, WEEK]], + ['year to date in October', {preset: YEAR_TO_DATE}, [WEEK, MONTH, QUARTER]], + ['last 12 months', {preset: LAST_12_MONTHS}, [WEEK, MONTH, QUARTER]], + ['a range under two weeks', {from: '2026-03-01', to: '2026-03-10'}, [DAY]], + ['a range of 13 days', {from: '2026-03-01', to: '2026-03-13'}, [DAY]], + ['a range of 14 days', {from: '2026-03-01', to: '2026-03-14'}, [DAY, WEEK]], + ['a range of 62 days', {from: '2026-03-01', to: '2026-05-01'}, [DAY, WEEK, MONTH]], + ['a range of 63 days', {from: '2026-03-01', to: '2026-05-02'}, [WEEK, MONTH]], + ['a range of exactly two months', {from: '2026-01-01', to: '2026-02-28'}, [DAY, WEEK, MONTH]], + ['a range of two years', {from: '2024-01-01', to: '2025-12-31'}, [WEEK, MONTH, QUARTER, YEAR]], + ['a single day', {on: '2026-03-04'}, [DAY]], + ])('offers the options that fit %s', (_label, date, expected) => { + // Given a dashboard reporting on that date range + + // When the Group by options are listed + const options = getInsightsGroupByOptions(date); + + // Then only groupings that give a readable trend for its length are offered, in the dropdown's order + expect(options).toEqual(expected); + }); + + it('offers Day and Week for year to date in January', () => { + // Given it's late January, so the year so far is a few weeks long + jest.setSystemTime(new Date(2026, 0, 20)); + + // When the Group by options are listed for year to date + const options = getInsightsGroupByOptions({preset: YEAR_TO_DATE}); + + // Then the range is too short for monthly points and short enough for daily ones + expect(options).toEqual([DAY, WEEK]); + }); + }); + + describe('getFittingGroupBy', () => { + it('keeps a Group by the date range offers', () => { + // Given a monthly chart over the last 12 months, which offers Month + + // When the Group by is fitted to the range + const groupBy = getFittingGroupBy(MONTH, getInsightsGroupByOptions({preset: LAST_12_MONTHS})); + + // Then the selection stays as it is + expect(groupBy).toBe(MONTH); + }); + + it('switches to the nearest offered option when the range rules the selection out', () => { + // Given a quarterly chart moved to this month, which only offers Day and Week + + // When the Group by is fitted to the range + const groupBy = getFittingGroupBy(QUARTER, getInsightsGroupByOptions({preset: THIS_MONTH})); + + // Then Week is picked, the closest grouping to Quarter that the month offers + expect(groupBy).toBe(WEEK); + }); + + it('switches Day to Week when the range is too long for daily points', () => { + // Given a daily chart moved to the last 12 months + + // When the Group by is fitted to the range + const groupBy = getFittingGroupBy(DAY, getInsightsGroupByOptions({preset: LAST_12_MONTHS})); + + // Then Week is picked, the closest grouping to Day that the range offers + expect(groupBy).toBe(WEEK); + }); + }); + + describe('getQueryGroupBy', () => { + it('queries the nearest offered option when the date range rules the selection out', () => { + // Given a quarterly dashboard moved to this month, which only offers Day and Week + const filters: InsightsFilters = {date: {preset: THIS_MONTH}, policyIDs: [], groupBy: QUARTER, groupCurrency: 'USD'}; + + // When the Group by to query with is picked + const groupBy = getQueryGroupBy(filters); + + // Then Week is queried, the closest grouping to Quarter that the month offers + expect(groupBy).toBe(WEEK); + }); + + it('queries a single date by day, whatever grouping is selected', () => { + // Given a quarterly dashboard moved to a single date + const filters: InsightsFilters = {date: {on: '2026-03-04'}, policyIDs: [], groupBy: QUARTER, groupCurrency: 'USD'}; + + // When the Group by to query with is picked + const groupBy = getQueryGroupBy(filters); + + // Then its one point is grouped by day + expect(groupBy).toBe(DAY); + }); + }); +}); diff --git a/tests/unit/insightsQueriesTest.ts b/tests/unit/insightsQueriesTest.ts index cf78f1d50a66..eeca7d4fef29 100644 --- a/tests/unit/insightsQueriesTest.ts +++ b/tests/unit/insightsQueriesTest.ts @@ -20,6 +20,7 @@ const SPEND_SPEC = INSIGHTS_DASHBOARD_SPECS[CONST.INSIGHTS.DASHBOARD.SPEND]; type InsightsPayload = { hash: number; + groupBy: string; inputQuery: string; }; @@ -70,7 +71,7 @@ describe('insightsQueries', () => { }); }); - it('sends the same request whether or not the dashboard is compared', () => { + it('requests the same data whether or not the dashboard is compared, saving the Compare mode along', () => { // Given the spend dashboard shown on its own const standalone = buildInsightsJsonQuery(CONST.INSIGHTS.DASHBOARD.SPEND, FILTERS, true); @@ -79,7 +80,12 @@ describe('insightsQueries', () => { const compared = buildInsightsJsonQuery(CONST.INSIGHTS.DASHBOARD.SPEND, filters, true); // Then every response carries all comparisons, so switching one never fetches again or stores the response apart - expect(compared).toEqual(standalone); + expect(compared?.hash).toBe(standalone?.hash); + expect(compared?.snapshotHashes).toEqual(standalone?.snapshotHashes); + + // And the backend saves the Compare mode with the rest of the filters, so it's restored after signing in again + expect(parsePayload(compared)?.inputQuery).toBe(buildInsightsQueryString(filters)); + expect(parsePayload(compared)?.inputQuery).toContain(`compare:${CONST.SEARCH.COMPARE.PREVIOUS_PERIOD}`); }); it('requests only the current period for users without the compare beta', () => { @@ -144,10 +150,24 @@ describe('insightsQueries', () => { // When its request is built const request = buildInsightsJsonQuery(CONST.INSIGHTS.DASHBOARD.SPEND, filters, true); - // Then the query names that day, the same way it names a preset + // Then the query names that day the same way it names a preset, and its one point is grouped by day, the only grouping a single date offers expect(parsePayload(request)?.inputQuery).toBe('groupBy:month groupCurrency:USD date:2026-03-04'); + expect(parsePayload(request)?.groupBy).toBe(CONST.SEARCH.GROUP_BY.DAY); expect(parsePayload(request)).toEqual(expect.objectContaining({insightsHashes: buildExpectedHashes(filters)})); }); + + it('saves the selected grouping while querying the one fitted to the date range', () => { + // Given a quarterly dashboard narrowed to a month, which only offers Day and Week + const filters: InsightsFilters = {...FILTERS, date: {from: '2026-03-01', to: '2026-03-31'}, groupBy: CONST.SEARCH.GROUP_BY.QUARTER}; + + // When its request is built + const payload = parsePayload(buildInsightsJsonQuery(CONST.INSIGHTS.DASHBOARD.SPEND, filters, true)); + + // Then the backend saves quarter, so a longer range brings it back, while the charts are grouped by week + expect(payload?.inputQuery).toBe('groupBy:quarter groupCurrency:USD date>=2026-03-01 date<=2026-03-31'); + expect(payload?.groupBy).toBe(CONST.SEARCH.GROUP_BY.WEEK); + }); + it('leaves the workspaces out of the query until some are selected', () => { // Given a dashboard requested with no workspace selected const allWorkspaces = buildInsightsJsonQuery(CONST.INSIGHTS.DASHBOARD.SPEND, FILTERS, true); @@ -219,8 +239,8 @@ describe('insightsQueries', () => { }); it('groups the headline chart by day while supporting charts keep their own group-by', () => { - // Given the page filters grouped by day - const filters: InsightsFilters = {...FILTERS, groupBy: CONST.SEARCH.GROUP_BY.DAY}; + // Given the page filters over a month, grouped by day + const filters: InsightsFilters = {...FILTERS, date: {from: '2026-03-01', to: '2026-03-31'}, groupBy: CONST.SEARCH.GROUP_BY.DAY}; // When the filters are applied to the headline chart and to every supporting chart const headlineQuery = applyInsightsFilters(SPEND_SPEC.headlineChart, filters); @@ -230,5 +250,16 @@ describe('insightsQueries', () => { expect(buildSearchQueryJSON(headlineQuery)?.groupBy).toBe(CONST.SEARCH.GROUP_BY.DAY); expect(supportingGroupBys).toEqual(SPEND_SPEC.supportingCharts.map((chart) => chart.groupBy)); }); + + it('groups the headline chart by day on a single date, whatever grouping is saved', () => { + // Given the page filters saved as quarterly and narrowed to a single date + const filters: InsightsFilters = {...FILTERS, date: {on: '2026-03-04'}, groupBy: CONST.SEARCH.GROUP_BY.QUARTER}; + + // When the filters are applied to the headline chart + const headlineQuery = applyInsightsFilters(SPEND_SPEC.headlineChart, filters); + + // Then its one point is grouped by day, so it's labeled with the date rather than the quarter + expect(buildSearchQueryJSON(headlineQuery)?.groupBy).toBe(CONST.SEARCH.GROUP_BY.DAY); + }); }); }); diff --git a/tests/unit/isCameraClosedErrorTest.ts b/tests/unit/isCameraClosedErrorTest.ts new file mode 100644 index 000000000000..b7575ae48bb2 --- /dev/null +++ b/tests/unit/isCameraClosedErrorTest.ts @@ -0,0 +1,36 @@ +import isCameraClosedError from '@pages/iou/request/step/IOURequestStepScan/isCameraClosedError'; + +describe('isCameraClosedError', () => { + it('recognizes the CameraX error for a capture cancelled by the camera closing', () => { + // Given the error vision-camera passes through when CameraX closes the camera mid-capture + const error = {code: 'unknown/unknown', message: 'Camera is closed.'}; + + // When it is classified + const result = isCameraClosedError(error); + + // Then it counts as the camera closing, so leaving the screen mid-capture is not logged as a failure + expect(result).toBe(true); + }); + + it('recognizes the vision-camera error for a session that is already gone', () => { + // Given the error vision-camera rejects with when the capture reaches native after the session closed + const error = {code: 'session/camera-not-ready', message: 'The Camera is not ready yet! Wait for the onInitialized() callback!'}; + + // When it is classified + const result = isCameraClosedError(error); + + // Then it counts as the camera closing too + expect(result).toBe(true); + }); + + it('does not treat other camera errors as the camera closing', () => { + // Given real capture failures, and values that are not errors at all + const errors: unknown[] = [{code: 'capture/file-io-error', message: 'Failed to write the photo to disk'}, new Error('Could not crop the photo'), 'Camera is closed', null, undefined]; + + // When each is classified + const results = errors.map(isCameraClosedError); + + // Then none count as the camera closing, so they keep their warning logs + expect(results).toEqual([false, false, false, false, false]); + }); +}); diff --git a/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts b/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts index ba7cf42b8162..80bf397b3058 100644 --- a/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts +++ b/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts @@ -120,6 +120,28 @@ describe('tokenizeForReveal', () => { expect(tokenizeForReveal(html).at(-1)).toBe(html); }); + it('preserves raw text escaping inside noscript at every reveal stage', () => { + // Given text whose ampersand must stay unescaped under its raw text parent. + const html = ''; + + // When the text is progressively clipped, each clone still needs that parent context. + const stages = tokenizeForReveal(html); + + // Then no intermediate stage introduces entity escaping that changes the existing output. + expect(stages).toEqual(['', '', '', '']); + }); + + it('preserves HTML serialization inside a MathML integration point at every reveal stage', () => { + // Given an HTML child whose empty attribute serializes differently in MathML. + const html = 'ab'; + + // When the child is progressively clipped, its parent must still switch serialization back to HTML. + const stages = tokenizeForReveal(html); + + // Then both partial and complete stages retain the existing HTML attribute serialization. + expect(stages).toEqual(['', 'a', 'ab']); + }); + it('produces enough anchors that a typical multi-paragraph response trickles smoothly', () => { // Realistic shape — Xero-style 5-step instructions. const html = diff --git a/tests/unit/libs/VacationDelegateUtilsTest.ts b/tests/unit/libs/VacationDelegateUtilsTest.ts new file mode 100644 index 000000000000..006e14e29da2 --- /dev/null +++ b/tests/unit/libs/VacationDelegateUtilsTest.ts @@ -0,0 +1,146 @@ +import { + formatVacationDelegateClearDateTime, + getActiveVacationDelegate, + getVacationDelegateClearAfter, + getVacationDelegateClearDate, + getVacationDelegateClearDateTime, + getVacationDelegateLocalClearDateTime, + isVacationDelegateClearAfterTooSoon, + isVacationDelegateClearDatePassed, + isVacationDelegateExpired, +} from '@libs/VacationDelegateUtils'; + +describe('VacationDelegateUtils', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-09-28T12:00:00Z')); + }); + + afterEach(() => { + 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 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', 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('', '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(''); + }); + }); + + 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('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 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(''); + }); + }); + + 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); + }); + }); + + 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 + 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 + // 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); + }); + }); +}); diff --git a/tests/unit/libs/Wallet.test.ts b/tests/unit/libs/Wallet.test.ts new file mode 100644 index 000000000000..0ed229ff1bc9 --- /dev/null +++ b/tests/unit/libs/Wallet.test.ts @@ -0,0 +1,321 @@ +import * as API from '@libs/API'; +import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; +import type * as WalletModule from '@libs/Wallet'; +import {handleAddCardToWallet as addAndroid} from '@libs/Wallet/index.android'; +import {handleAddCardToWallet as addIOS} from '@libs/Wallet/index.ios'; +import type {WalletAuthentication} from '@libs/Wallet/types'; + +import CONST from '@src/CONST'; +import type {Card, ProvisioningCardData} from '@src/types/onyx'; + +/** Exercises the actual platform handoffs with the API and native bridge mocked. */ +import {addCardToAppleWallet, addCardToGoogleWallet, getSecureWalletInfo} from '@expensify/react-native-wallet'; + +import pkg from '../../../package.json'; +import createMock from '../../utils/createMock'; + +jest.mock('@libs/API'); +jest.mock('@expensify/react-native-wallet', () => ({ + addCardToAppleWallet: jest.fn(), + addCardToGoogleWallet: jest.fn(), + getSecureWalletInfo: jest.fn(), + checkWalletAvailability: jest.fn(), + getCardStatusByIdentifier: jest.fn(), + getCardStatusBySuffix: jest.fn(), +})); + +type APIResponse = NonNullable>>; +const card = createMock({cardID: 123, lastFourPAN: '1234'}); +const authentication: WalletAuthentication = { + signedChallenge: {rawId: 'key', type: 'public-key', response: {authenticatorData: 'auth', clientDataJSON: 'client', signature: 'signature'}}, + authenticationMethod: 'BIOMETRIC_FACE', +}; +const iosPayload = {encryptedPassData: 'encrypted', activationData: 'activation', ephemeralPublicKey: 'ephemeral'}; +const androidPayload: Pick = { + network: 'VISA', + opaquePaymentCard: 'opaque', + lastDigits: '1234', + userAddress: { + name: 'Jane', + address1: 'Street', + address2: '', + state: 'London', + city: 'London', + country: 'GB', + // eslint-disable-next-line @typescript-eslint/naming-convention -- Match the backend field so the test exercises its native address mapping. + postal_code: 'SW1A', + phone: '+441234', + }, +}; +const mockRequest = jest.mocked(API.makeRequestWithSideEffects); +const mockApple = jest.mocked(addCardToAppleWallet); +const mockGoogle = jest.mocked(addCardToGoogleWallet); +const {handleAddCardToWallet: addWeb} = jest.requireActual('@libs/Wallet/index.ts'); + +describe('Wallet platform provisioning', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(getSecureWalletInfo).mockResolvedValue({deviceID: 'device', walletAccountID: 'wallet'}); + mockGoogle.mockResolvedValue('success'); + mockApple.mockImplementation(async (_data, issuer) => { + await issuer('nonce', 'nonce-signature', ['certificate']); + return 'success'; + }); + }); + + it('waits for the iOS issuer callback and sends signed fields only on CreateDigitalWallet', async () => { + // Given a native sheet that has not yet supplied its nonce and certificates. + mockRequest.mockResolvedValue(createMock({jsonCode: 200, ...iosPayload})); + let issuerCallback: Parameters[1] | undefined; + let finish: ((status: 'success') => void) | undefined; + mockApple.mockImplementation((_data, issuer) => { + issuerCallback = issuer; + return new Promise((resolve) => { + finish = resolve; + }); + }); + const pending = addIOS(card, 'Jane', 'Card', {authentication}); + expect(mockRequest).not.toHaveBeenCalled(); + + // When Apple supplies the actual issuer inputs after presentation. + const payload = await issuerCallback?.('actual-nonce', 'actual-signature', ['actual-certificate']); + finish?.('success'); + await expect(pending).resolves.toBe('success'); + + // Then serialization happens once at the API boundary, without UI metadata. + expect(payload).toEqual(iosPayload); + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET, { + platform: 'ios', + appVersion: pkg.version, + cardID: 123, + nonce: 'actual-nonce', + nonceSignature: 'actual-signature', + certificates: JSON.stringify({certificates: ['actual-certificate']}), + signedChallenge: JSON.stringify(authentication.signedChallenge), + authenticationMethod: authentication.authenticationMethod, + }); + }); + + it('maps signed Android provisioning into Google native card data', async () => { + // Given a successful provisioning response and secure device/wallet identifiers. + mockRequest.mockResolvedValue(createMock({jsonCode: 200, ...androidPayload})); + + // When signed provisioning completes before tokenization. + await expect(addAndroid(card, 'Jane', 'Card', {authentication})).resolves.toBe('success'); + + // Then only API fields are sent and the existing native address mapping is retained. + expect(mockRequest).toHaveBeenCalledWith(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET, { + platform: 'android', + appVersion: pkg.version, + cardID: 123, + deviceID: 'device', + walletAccountID: 'wallet', + signedChallenge: JSON.stringify(authentication.signedChallenge), + authenticationMethod: authentication.authenticationMethod, + }); + expect(mockGoogle).toHaveBeenCalledWith({ + network: 'VISA', + opaquePaymentCard: 'opaque', + cardHolderName: 'Jane', + lastDigits: '1234', + userAddress: { + name: 'Jane', + addressOne: 'Street', + addressTwo: '', + administrativeArea: 'London', + locality: 'London', + countryCode: 'GB', + postalCode: 'SW1A', + phoneNumber: '+441234', + }, + }); + }); + + it.each(['ios', 'android'] as const)('keeps %s legacy provisioning unsigned', async (platform) => { + // Given a legacy card with no authentication context. + mockRequest.mockResolvedValue(createMock({jsonCode: 200, ...(platform === 'ios' ? iosPayload : androidPayload)})); + + // When its existing platform helper is used. + await (platform === 'ios' ? addIOS : addAndroid)(card, 'Jane', 'Card'); + + // Then the command stays unchanged and no authentication fields are introduced. + expect(mockRequest).toHaveBeenCalledTimes(1); + const params = mockRequest.mock.calls.at(0)?.[1]; + expect(params).not.toHaveProperty('signedChallenge'); + expect(params).not.toHaveProperty('authenticationMethod'); + }); + + it('accepts a UK address without a province or second address line', async () => { + // Given a valid UK address where the province is empty and address2 is absent. + const payload = {...androidPayload, userAddress: {...androidPayload.userAddress, address2: undefined, state: ''}}; + mockRequest.mockResolvedValue(createMock({jsonCode: 200, ...payload})); + // When signed provisioning maps that address. + await expect(addAndroid(card, 'Jane', 'Card', {authentication})).resolves.toBe('success'); + // Then optional address content is preserved without rejecting valid UK data. + expect(mockGoogle).toHaveBeenCalledWith(expect.objectContaining({userAddress: expect.objectContaining({administrativeArea: '', addressTwo: undefined})})); + }); + + it.each([400, 500])('preserves API %s when the iOS bridge subsequently rejects', async (jsonCode) => { + // Given an issuer rejection followed by a separate native rejection. + mockRequest.mockResolvedValue(createMock({jsonCode, message: 'Rejected'})); + mockApple.mockImplementation(async (_data, issuer) => { + await issuer('nonce', 'signature', []).catch(() => undefined); + throw new Error('native rejection'); + }); + + // When the signed issuer request fails. + const pending = addIOS(card, 'Jane', 'Card', {authentication}); + + // Then the original normalized API failure survives and no unsigned retry occurs. + await expect(pending).rejects.toMatchObject({ + response: { + httpStatusCode: jsonCode, + reason: jsonCode === 400 ? CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.UNRECOGNIZED : CONST.MULTIFACTOR_AUTHENTICATION.REASON.SERVER_ERRORS.UNRECOGNIZED, + }, + }); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it.each(['ios', 'android'] as const)('rejects malformed %s success data without native continuation', async (platform) => { + // Given a successful HTTP result without usable provisioning fields. + mockRequest.mockResolvedValue(createMock({jsonCode: 200})); + + // When secure provisioning runs. + const pending = (platform === 'ios' ? addIOS : addAndroid)(card, 'Jane', 'Card', {authentication}); + + // Then the helper fails closed instead of handing empty data to the native SDK. + await expect(pending).rejects.toMatchObject({response: {reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNRECOGNIZED}}); + expect(mockGoogle).not.toHaveBeenCalled(); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['ios', {...iosPayload, activationData: ''}], + ['ios', {...iosPayload, encryptedPassData: 123}], + ['android', {...androidPayload, opaquePaymentCard: ''}], + ['android', {...androidPayload, userAddress: {...androidPayload.userAddress, state: undefined}}], + ] as const)('validates individual %s provisioning fields', async (platform, payload) => { + // Given an otherwise successful response has one unusable field. + mockRequest.mockResolvedValue(createMock({jsonCode: 200, ...payload})); + // When the signed platform helper validates native input. + const pending = (platform === 'ios' ? addIOS : addAndroid)(card, 'Jane', 'Card', {authentication}); + // Then malformed data is rejected before native continuation. + await expect(pending).rejects.toMatchObject({response: {reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNRECOGNIZED}}); + expect(mockGoogle).not.toHaveBeenCalled(); + }); + + it.each(['ios', 'android'] as const)('rejects transport failures on %s without unsigned fallback', async (platform) => { + // Given a rejected provisioning transport. + mockRequest.mockRejectedValue(new Error('transport failed')); + + // When the signed platform helper requests card data. + const pending = (platform === 'ios' ? addIOS : addAndroid)(card, 'Jane', 'Card', {authentication}); + + // Then a normalized failure replaces raw transport details. + await expect(pending).rejects.toMatchObject({response: {reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNHANDLED_API_RESPONSE}}); + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(mockGoogle).not.toHaveBeenCalled(); + }); + + it('rejects a second iOS issuer callback instead of replaying a signed challenge', async () => { + // Given Apple's Try Again reenters the issuer callback after successful provisioning. + mockRequest.mockResolvedValue(createMock({jsonCode: 200, ...iosPayload})); + mockApple.mockImplementation(async (_data, issuer) => { + await issuer('first', 'signature', []); + await issuer('second', 'signature', []); + return 'success'; + }); + + // When the native wrapper attempts to reuse that authentication. + const pending = addIOS(card, 'Jane', 'Card', {authentication}); + + // Then a fresh user authentication is required and no second request is sent. + await expect(pending).rejects.toThrow('fresh authentication'); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it.each([400, 500])('does not continue Google tokenization after API %s', async (jsonCode) => { + // Given the server rejects signed Android provisioning. + mockRequest.mockResolvedValue(createMock({jsonCode})); + // When the Android helper receives that response. + const pending = addAndroid(card, 'Jane', 'Card', {authentication}); + // Then no native tokenization or unsigned retry occurs. + await expect(pending).rejects.toMatchObject({response: {httpStatusCode: jsonCode}}); + expect(mockGoogle).not.toHaveBeenCalled(); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it('does not accept an iOS success status without an issuer request', async () => { + // Given native code unexpectedly reports success without issuer provisioning. + mockApple.mockResolvedValue('success'); + // When the secure helper receives that status. + const pending = addIOS(card, 'Jane', 'Card', {authentication}); + // Then no successful MFA result can be synthesized without provisioning. + await expect(pending).rejects.toThrow('without provisioning'); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('does not return issuer data after an in-flight iOS attempt is aborted', async () => { + // Given cancellation occurs while the issuer request is pending. + const controller = new AbortController(); + mockRequest.mockImplementation(async () => { + controller.abort(); + return createMock({jsonCode: 200, ...iosPayload}); + }); + // When the response arrives for the abandoned attempt. + const pending = addIOS(card, 'Jane', 'Card', {authentication, signal: controller.signal}); + // Then the callback rejects rather than passing issuer data to native code (dismissal remains deferred). + await expect(pending).rejects.toThrow('canceled'); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it.each(['canceled', 'error'] as const)('preserves native Google %s after valid provisioning', async (status) => { + // Given provisioning succeeds but Google tokenization does not. + mockRequest.mockResolvedValue(createMock({jsonCode: 200, ...androidPayload})); + mockGoogle.mockResolvedValue(status); + // When the native Google flow completes. + const result = await addAndroid(card, 'Jane', 'Card', {authentication}); + // Then tokenization status, not the earlier API success, controls the scenario outcome. + expect(result).toBe(status); + }); + + it.each(['canceled', 'error'] as const)('preserves native iOS %s without inventing provisioning success', async (status) => { + // Given native presentation ends before any issuer callback. + mockApple.mockResolvedValue(status); + + // When the signed helper runs. + const result = await addIOS(card, 'Jane', 'Card', {authentication}); + + // Then native cancellation and failure stay distinct and no API request occurs. + expect(result).toBe(status); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('does not start Google tokenization after cancellation while provisioning is pending', async () => { + // Given a request that will finish after the MFA attempt is canceled. + const controller = new AbortController(); + mockRequest.mockImplementation(async () => { + controller.abort(); + return createMock({jsonCode: 200, ...androidPayload}); + }); + + // When cancellation occurs before native continuation. + const result = await addAndroid(card, 'Jane', 'Card', {authentication, signal: controller.signal}); + + // Then Google receives no card data from the abandoned attempt. + expect(result).toBe('canceled'); + expect(mockGoogle).not.toHaveBeenCalled(); + }); + + it('keeps the web fallback inert', async () => { + // Given a non-native platform. + // When a helper is called defensively with signed authentication. + const pending = addWeb(card, 'Jane', 'Card', {authentication}); + // Then it cannot provision or invoke either native wallet. + await expect(pending).rejects.toThrow('not supported'); + expect(mockRequest).not.toHaveBeenCalled(); + expect(mockApple).not.toHaveBeenCalled(); + expect(mockGoogle).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/libs/actions/TransactionThreadNavigationTest.ts b/tests/unit/libs/actions/TransactionThreadNavigationTest.ts index b0fabc4c81f3..e8dfd0e9506e 100644 --- a/tests/unit/libs/actions/TransactionThreadNavigationTest.ts +++ b/tests/unit/libs/actions/TransactionThreadNavigationTest.ts @@ -4,6 +4,7 @@ import { clearActiveTransactionIDsForSource, disownActiveTransactionIDs, getActiveTransactionIDs, + removeActiveTransactionIDsForReport, setActiveTransactionIDs, shouldRefreshActiveTransactionIDs, } from '@libs/actions/TransactionThreadNavigation'; @@ -279,4 +280,71 @@ describe('TransactionThreadNavigation carousel ownership', () => { expect(getActiveTransactionIDs()).toEqual({ids: SPEND_PAGE_IDS, descriptors: null, source: SEARCH_SOURCE, snapshotHash: SEARCH_HASH}); }); }); + + /** + * Regression guard for https://github.com/Expensify/App/issues/103944: removing an expense from a two-expense + * report left it in the report's carousel, so the report kept its arrows and stepping to the removed expense + * opened the self-DM. + */ + describe('removeActiveTransactionIDsForReport', () => { + it('clears a report row carousel that drops to one expense', async () => { + // Given an expense opened from report B's list, seeding the carousel with both of B's expenses + await setActiveTransactionIDs(REPORT_B_IDS, {source: CAROUSEL_SOURCE.reportRow('B')}); + + // When one of them is removed from report B + await removeActiveTransactionIDsForReport(['B1'], 'B'); + + // Then there is nothing left to page through, so the carousel is cleared and no arrows show + expect((await readCarouselFromOnyx()).ids).toBeUndefined(); + expect(getActiveTransactionIDs()).toEqual({ids: null, descriptors: null, source: null, snapshotHash: null}); + }); + + it('keeps paging through the expenses still on the report', async () => { + // Given report B's own carousel with three expenses + await setActiveTransactionIDs(['B1', 'B2', 'B3'], {source: REPORT_SOURCE}); + + // When one expense moves to another report + await removeActiveTransactionIDsForReport(['B2'], 'B'); + + // Then the other two stay in the carousel under the same owner + expect(getActiveTransactionIDs()).toEqual({ids: ['B1', 'B3'], descriptors: null, source: REPORT_SOURCE, snapshotHash: null}); + expect((await readCarouselFromOnyx()).ids).toEqual(['B1', 'B3']); + }); + + it('trims again on a later move, since the module state matches Onyx', async () => { + // Given report B's own carousel with three expenses, one of which was already moved out + await setActiveTransactionIDs(['B1', 'B2', 'B3'], {source: REPORT_SOURCE}); + await removeActiveTransactionIDsForReport(['B2'], 'B'); + + // When a second expense moves out of report B + await removeActiveTransactionIDsForReport(['B3'], 'B'); + + // Then the carousel is cleared, so the arrows don't come back for the last expense + expect((await readCarouselFromOnyx()).ids).toBeUndefined(); + }); + + it('leaves a search carousel alone, since it spans several reports', async () => { + // Given a carousel seeded from the Spend page, which holds expenses from reports A and B + await setActiveTransactionIDs(SPEND_PAGE_IDS, {source: SEARCH_SOURCE, snapshotHash: SEARCH_HASH}); + + // When an expense is removed from report B + await removeActiveTransactionIDsForReport(['B1'], 'B'); + + // Then the search carousel is untouched, because the expense is still one of its results + expect(getActiveTransactionIDs()).toEqual({ids: SPEND_PAGE_IDS, descriptors: null, source: SEARCH_SOURCE, snapshotHash: SEARCH_HASH}); + expect((await readCarouselFromOnyx()).ids).toEqual(SPEND_PAGE_IDS); + }); + + it("leaves another report's carousel alone", async () => { + // Given report B's carousel + await setActiveTransactionIDs(REPORT_B_IDS, {source: REPORT_SOURCE}); + + // When an expense leaves a different report + await removeActiveTransactionIDsForReport(['B1'], 'A'); + + // Then report B's carousel is untouched + expect(getActiveTransactionIDs()).toEqual({ids: REPORT_B_IDS, descriptors: null, source: REPORT_SOURCE, snapshotHash: null}); + expect((await readCarouselFromOnyx()).ids).toEqual(REPORT_B_IDS); + }); + }); }); diff --git a/tests/unit/libs/getClipboardTextTest.ts b/tests/unit/libs/getClipboardTextTest.ts index 9de3a26ceef8..fb7ad723f67b 100644 --- a/tests/unit/libs/getClipboardTextTest.ts +++ b/tests/unit/libs/getClipboardTextTest.ts @@ -30,7 +30,7 @@ describe('getClipboardText', () => { const result = getClipboardText(selection); expect(result).toBe('[Expensify](https://expensify.com)'); - expect(mockedParser.htmlToMarkdown.mock.calls).toContainEqual([selection, {reportIDToName: undefined}]); + expect(mockedParser.htmlToMarkdown.mock.calls).toContainEqual([selection, {reportIDToName: undefined, accountIDToName: undefined}]); }); it('returns the parser output without modification', () => { @@ -40,7 +40,7 @@ describe('getClipboardText', () => { const result = getClipboardText('test'); expect(result).toBe(expected); - expect(mockedParser.htmlToMarkdown.mock.calls).toContainEqual(['test', {reportIDToName: undefined}]); + expect(mockedParser.htmlToMarkdown.mock.calls).toContainEqual(['test', {reportIDToName: undefined, accountIDToName: undefined}]); }); it('forwards the report name map so a report mention resolves instead of rendering as "#Hidden"', () => { @@ -51,6 +51,6 @@ describe('getClipboardText', () => { getClipboardText(selection, reportIDToName); // Then the map reaches the parser, which is what turns `` into the room name - expect(mockedParser.htmlToMarkdown.mock.calls).toContainEqual([selection, {reportIDToName}]); + expect(mockedParser.htmlToMarkdown.mock.calls).toContainEqual([selection, {reportIDToName, accountIDToName: undefined}]); }); }); diff --git a/tests/unit/nativeTabScreenListenersTest.ts b/tests/unit/nativeTabScreenListenersTest.ts new file mode 100644 index 000000000000..c96f17635948 --- /dev/null +++ b/tests/unit/nativeTabScreenListenersTest.ts @@ -0,0 +1,45 @@ +import navigateToInboxTab from '@components/Navigation/NavigationTabBar/navigateToInboxTab'; + +import tabScreenListeners, {isNativeTabSelectionEnabled} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +jest.mock('@components/Navigation/NavigationTabBar/navigateToInboxTab', () => jest.fn()); +jest.mock('@libs/telemetry/startTabNavigationSpans'); + +describe('tabScreenListeners', () => { + it('opens the Inbox chat list only when Inbox is pressed from another tab with a report open', () => { + // Given Inbox left with a report open, which the native bar does not switch to, so JS opens it at the chat list like InboxTabButton + const inboxWithReport = {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, state: {routes: [{name: SCREENS.INBOX}, {name: SCREENS.REPORT}], index: 1}}; + const inboxAtChatList = {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, state: {routes: [{name: SCREENS.INBOX}], index: 0}}; + const pressInbox = (route: Parameters[0]['route'], isFocused: boolean) => tabScreenListeners({route, navigation: {isFocused: () => isFocused}}).tabPress(); + + // When the user presses Inbox while it is focused, from another tab at the chat list, and from another tab with a report open + pressInbox(inboxWithReport, true); + pressInbox(inboxAtChatList, false); + pressInbox(inboxWithReport, false); + + // Then only the last press runs the shared Inbox navigation, since the native switch already lands on the chat list + expect(navigateToInboxTab).toHaveBeenCalledTimes(1); + }); + + it('leaves to JS only the switches that would land somewhere else than the JS tab buttons', () => { + // Given a signed-in user whose Inbox and Workspaces tabs show what their tab buttons open, one whose do not, and an anonymous user + const ready = {isAnonymousUser: false, isInboxAtChatList: true, isWorkspacesTabRestored: true}; + const unrestored = {isAnonymousUser: false, isInboxAtChatList: false, isWorkspacesTabRestored: false}; + const anonymous = {...ready, isAnonymousUser: true}; + + // When the native selection is decided + // Then Inbox and Workspaces are switched natively only when ready, Spend always is, and anonymous users are kept from Spend, Insights, Workspaces and Account + expect(isNativeTabSelectionEnabled(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, ready)).toBe(true); + expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, ready)).toBe(true); + expect(isNativeTabSelectionEnabled(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, unrestored)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, unrestored)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, unrestored)).toBe(true); + expect(isNativeTabSelectionEnabled(SCREENS.INSIGHTS, anonymous)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, anonymous)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, anonymous)).toBe(false); + expect(isNativeTabSelectionEnabled(SCREENS.HOME, anonymous)).toBe(true); + }); +}); diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index dc53e8312e37..e3001f6215bf 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -1,7 +1,8 @@ -import {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, setDeepLinkToOpenAfterOnboarding} from '@libs/navigateAfterOnboarding'; import dismissOnboardingModalBeforeExit from '@libs/Navigation/helpers/OnboardingNavigationUtils'; import Navigation from '@libs/Navigation/Navigation'; import type * as ReportUtils from '@libs/ReportUtils'; +import {runSessionCleanupCallbacks} from '@libs/SessionCleanup'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; import SidePanelActions from '@userActions/SidePanel'; @@ -97,18 +98,23 @@ describe('navigateAfterOnboarding', () => { return Onyx.clear(); }); + afterEach(() => { + // The parked link lives in a module-level variable, so a test that parks without consuming would leak into the next one. + runSessionCleanupCallbacks(); + }); + it('should navigate to the admin room report if onboardingAdminsChatReportID is provided', () => { const navigate = jest.spyOn(Navigation, 'navigate'); const testSession = {email: 'realaccount@gmail.com'}; - navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, (testSession?.email ?? '').includes('+')); + navigateAfterOnboarding(false, true, '', {}, false, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, (testSession?.email ?? '').includes('+')); expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); }); it('should navigate to home if onboardingAdminsChatReportID is not provided on larger screens and no report is topmost', () => { const navigate = jest.spyOn(Navigation, 'navigate'); - navigateAfterOnboarding(false, true, '', {}, undefined, undefined); + navigateAfterOnboarding(false, true, '', {}, false, undefined, undefined); // Without an admins chat report, we fall back to HOME to trigger guard evaluation instead of opening a report. expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); @@ -122,7 +128,7 @@ describe('navigateAfterOnboarding', () => { finishDismissal = afterTransition; }); - navigateAfterOnboardingWithMicrotaskQueue(false, true, '', {}); + navigateAfterOnboardingWithMicrotaskQueue(false, true, '', {}, false); expect(dismissOnboardingModalBeforeExit).toHaveBeenCalledTimes(1); expect(navigate).not.toHaveBeenCalled(); @@ -136,7 +142,7 @@ describe('navigateAfterOnboarding', () => { const navigate = jest.spyOn(Navigation, 'navigate'); mockIsReportTopmostSplitNavigator.mockReturnValue(true); - navigateAfterOnboarding(false, true, '', {}, undefined, undefined); + navigateAfterOnboarding(false, true, '', {}, false, undefined, undefined); expect(navigate).not.toHaveBeenCalled(); }); @@ -156,7 +162,7 @@ describe('navigateAfterOnboarding', () => { mockFindLastAccessedReport.mockReturnValue(lastAccessedReport); mockShouldOpenOnAdminRoom.mockReturnValue(false); - navigateAfterOnboarding(true, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); + navigateAfterOnboarding(true, true, REPORT_ID, {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); }); @@ -165,7 +171,7 @@ describe('navigateAfterOnboarding', () => { mockFindLastAccessedReport.mockReturnValue(lastAccessedReport); mockShouldOpenOnAdminRoom.mockReturnValue(false); - navigateAfterOnboarding(true, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); + navigateAfterOnboarding(true, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); expect(Navigation.navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); }); @@ -174,7 +180,7 @@ describe('navigateAfterOnboarding', () => { mockFindLastAccessedReport.mockReturnValue(lastAccessedReport); mockShouldOpenOnAdminRoom.mockReturnValue(false); - navigateAfterOnboarding(true, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); + navigateAfterOnboarding(true, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); expect(Navigation.navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); }); @@ -184,18 +190,21 @@ describe('navigateAfterOnboarding', () => { mockFindLastAccessedReport.mockReturnValue(lastAccessedReport); mockShouldOpenOnAdminRoom.mockReturnValue(true); - navigateAfterOnboarding(true, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); + navigateAfterOnboarding(true, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID), undefined); }); - it('should pass reportNameValuePairs when looking up last accessed report', () => { + it('should pass reportNameValuePairs and the anonymous flag when looking up last accessed report', () => { + // Given archived report data and an anonymous caller const reportNameValuePairs = {[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${REPORT_ID}`]: {private_isArchived: '2024-02-01 04:56:47.233'}}; mockFindLastAccessedReport.mockReturnValue(undefined); mockShouldOpenOnAdminRoom.mockReturnValue(false); - navigateAfterOnboarding(true, true, '', reportNameValuePairs, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); + // When navigating after onboarding on a small screen + navigateAfterOnboarding(true, true, '', reportNameValuePairs, true, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); - expect(mockFindLastAccessedReport).toHaveBeenCalledWith(false, undefined, false, undefined, reportNameValuePairs); + // Then both values reach findLastAccessedReport instead of being read from module state + expect(mockFindLastAccessedReport).toHaveBeenCalledWith(false, undefined, true, false, undefined, reportNameValuePairs); }); it('should navigate to Concierge room if user uses a test email', () => { @@ -205,13 +214,88 @@ describe('navigateAfterOnboarding', () => { mockShouldOpenOnAdminRoom.mockReturnValue(true); const testSession = {email: 'test+account@gmail.com'}; - navigateAfterOnboarding(true, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, (testSession?.email ?? '').includes('+')); + navigateAfterOnboarding(true, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, (testSession?.email ?? '').includes('+')); expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID), undefined); }); it('should navigate to the admin room when the inboxAdminsBespoke variant is assigned', () => { const navigate = jest.spyOn(Navigation, 'navigate'); - navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.INBOX_ADMINS_BESPOKE}); + navigateAfterOnboarding(false, true, '', {}, false, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.INBOX_ADMINS_BESPOKE}); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); + }); + + it('should open a deep link parked before onboarding instead of the default destination', () => { + // Given a deep link that was captured before the account finished onboarding + const navigate = jest.spyOn(Navigation, 'navigate'); + const openDeepLink = jest.fn(() => true); + setDeepLinkToOpenAfterOnboarding(openDeepLink); + + // When onboarding finishes + navigateAfterOnboarding(false, true, '', {}, false, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); + + // Then the parked link wins and no default destination is used + expect(openDeepLink).toHaveBeenCalledTimes(1); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('should open a parked deep link even when the trackExpensesWithConcierge variant would send the user to Concierge', () => { + // Given an account assigned the variant whose branch returns before the default destination is chosen + const navigate = jest.spyOn(Navigation, 'navigate'); + const openDeepLink = jest.fn(() => true); + setDeepLinkToOpenAfterOnboarding(openDeepLink); + + // When onboarding finishes + navigateAfterOnboarding(true, true, REPORT_ID, {}, false, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, { + variantOverride: CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE, + }); + + // Then the parked link is still opened, since the variant branch would otherwise skip it + expect(openDeepLink).toHaveBeenCalledTimes(1); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('should still use the default destination when the parked deep link throws', () => { + // Given a parked link that fails to open, after the onboarding modal is already dismissed + const navigate = jest.spyOn(Navigation, 'navigate'); + const openDeepLink = jest.fn(() => { + throw new Error('boom'); + }); + setDeepLinkToOpenAfterOnboarding(openDeepLink); + + // When onboarding finishes + navigateAfterOnboarding(false, true, '', {}, false, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); + + // Then the user still lands somewhere instead of on an empty screen + expect(openDeepLink).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); + }); + + it('should fall back to the default destination when the parked deep link cannot be opened', () => { + // Given a parked link that reports it did not navigate + const navigate = jest.spyOn(Navigation, 'navigate'); + const openDeepLink = jest.fn(() => false); + setDeepLinkToOpenAfterOnboarding(openDeepLink); + + // When onboarding finishes + navigateAfterOnboarding(false, true, '', {}, false, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); + + // Then the default destination is used, because nothing else was opened + expect(openDeepLink).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); + }); + + it('should only replay a parked deep link once', () => { + // Given a link parked once and two onboarding exits + const navigate = jest.spyOn(Navigation, 'navigate'); + const openDeepLink = jest.fn(() => true); + setDeepLinkToOpenAfterOnboarding(openDeepLink); + + // When onboarding exits twice + navigateAfterOnboarding(false, true, '', {}, false, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); + navigateAfterOnboarding(false, true, '', {}, false, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); + + // Then the link opens only the first time and the second exit uses the default destination + expect(openDeepLink).toHaveBeenCalledTimes(1); expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); }); @@ -220,7 +304,7 @@ describe('navigateAfterOnboarding', () => { const navigate = jest.spyOn(Navigation, 'navigate'); // When onboarding finishes with the homePageNoRHP arm of the experiment - navigateAfterOnboarding(false, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.HOME_PAGE_NO_RHP}); + navigateAfterOnboarding(false, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.HOME_PAGE_NO_RHP}); // Then the user starts on Home, so Home's Getting started is the only onboarding surface they see expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); @@ -236,7 +320,9 @@ describe('navigateAfterOnboarding', () => { await Onyx.set(ONYXKEYS.ONBOARDING_COMPANY_SIZE, companySize); // When onboarding finishes with the homePageNoRHP arm - navigateAfterOnboarding(false, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.HOME_PAGE_NO_RHP}); + navigateAfterOnboarding(false, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, { + variantOverride: CONST.ONBOARDING_RHP_VARIANT.HOME_PAGE_NO_RHP, + }); await waitForBatchedUpdates(); // Then the arm behaves the same at every company size: Home, and the side panel is never opened @@ -255,7 +341,7 @@ describe('navigateAfterOnboarding', () => { mockIsReportTopmostSplitNavigator.mockReturnValue(true); // When onboarding finishes with the homePageNoRHP arm - navigateAfterOnboarding(false, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.HOME_PAGE_NO_RHP}); + navigateAfterOnboarding(false, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.HOME_PAGE_NO_RHP}); await waitForBatchedUpdates(); // Then the report stays where it is, and the side panel is not opened over it @@ -269,7 +355,7 @@ describe('navigateAfterOnboarding', () => { await Onyx.set(ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT, CONST.ONBOARDING_RHP_VARIANT.HOME_PAGE_NO_RHP); // When onboarding finishes without a variant override - navigateAfterOnboarding(false, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); + navigateAfterOnboarding(false, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); // Then the stored variant still applies and the user lands on Home rather than the admin room expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); diff --git a/tests/unit/pages/HomePage.test.tsx b/tests/unit/pages/HomePage.test.tsx index 78284e673804..053ae1bba7ae 100644 --- a/tests/unit/pages/HomePage.test.tsx +++ b/tests/unit/pages/HomePage.test.tsx @@ -58,12 +58,7 @@ jest.mock('@components/ScrollView', () => { } return MockScrollView; }); -jest.mock('@components/Navigation/NavigationTabBar', () => { - function MockNavigationTabBar() { - return null; - } - return MockNavigationTabBar; -}); +jest.mock('@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps', () => () => ({})); jest.mock('@components/Navigation/QuickCreationActionsBar', () => { function MockQuickCreationActionsBar() { return null; @@ -152,7 +147,7 @@ jest.mock('@pages/home/ForYouSection', () => { return MockForYouSection; }); jest.mock('@pages/home/UpcomingTravelSection', () => mockSection('UpcomingTravelSection')); -jest.mock('@pages/home/RecentlyAddedSection', () => mockSection('RecentlyAddedSection'), {virtual: true}); +jest.mock('@pages/home/RecentlyAddedSection', () => mockSection('RecentlyAddedSection')); jest.mock('@pages/home/YourSpendSection', () => mockSection('YourSpendSection')); jest.mock('@pages/home/InsightsSection', () => mockSection('InsightsSection')); diff --git a/tests/unit/pages/Search/SearchSavePageTest.tsx b/tests/unit/pages/Search/SearchSavePageTest.tsx index 9ca581247845..12992acbccf4 100644 --- a/tests/unit/pages/Search/SearchSavePageTest.tsx +++ b/tests/unit/pages/Search/SearchSavePageTest.tsx @@ -11,6 +11,7 @@ import * as SearchUIUtils from '@libs/SearchUIUtils'; import SearchSavePage from '@pages/Search/SearchSavePage'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type {Card, CardList} from '@src/types/onyx'; @@ -53,16 +54,24 @@ jest.mocked(useOnyx).mockImplementation((key) => { return [undefined, {status: 'loaded'}]; } }); +// Without a loaded locale, translate returns key paths and translated filter values never show the user's text. +beforeAll(() => IntlStore.load(CONST.LOCALES.EN)); beforeEach(() => jest.clearAllMocks()); it.each([[['12']], [['12', '23']], [['123']]])('renders canonical card selection %j exactly', (cardID) => { + // Given a saved search form with cards, feeds, tax rates and a Merchant filter that uses the default "contains" match form = {cardID, feed: ['feed-a', 'feed-b'], taxRate: ['tax-a', 'tax-b'], merchant: 'Coffee Shop'}; + + // When the save page renders the filter summary const output = JSON.stringify(render().toJSON()); + + // Then only the selected card titles, the feed and tax display values, and the localized Merchant label show const expectedDescriptions = cardID.at(0) === '123' ? [false, false, true] : [true, cardID.length === 2, false]; expect(['Selected Alpha', 'Selected Beta', 'Unselected Overlap'].map((text) => output.includes(text))).toEqual(expectedDescriptions); expect(['12', '23', '123'].map((rawID) => output.includes(rawID))).toEqual([false, false, false]); expect(jest.mocked(useFilterFeedValue)).toHaveBeenCalledWith(['feed-a', 'feed-b']); expect(jest.mocked(useFilterTaxRateValue)).toHaveBeenCalledWith(['tax-a', 'tax-b']); - expect(['feed:feed-a|feed-b', 'tax:tax-a|tax-b', 'Coffee Shop'].every((text) => output.includes(text))).toBe(true); + // The output is a JSON string, so the quotes in the Merchant label are escaped. + expect(['feed:feed-a|feed-b', 'tax:tax-a|tax-b', 'Contains \\"Coffee Shop\\"'].every((text) => output.includes(text))).toBe(true); }); it('renders scalar feed and tax values without calling array display hooks', () => { jest.spyOn(SearchUIUtils, 'mapFiltersFormToLabelValueList').mockReturnValueOnce([ @@ -73,3 +82,23 @@ it('renders scalar feed and tax values without calling array display hooks', () expect(['feed-scalar', 'tax-scalar'].every((text) => output.includes(text))).toBe(true); expect([jest.mocked(useFilterFeedValue).mock.calls.length, jest.mocked(useFilterTaxRateValue).mock.calls.length]).toEqual([0, 0]); }); +it("lists the Spend footer's choices with the display settings, in the footer's own wording", () => { + // Given a search saved with the footer switched to the report count, the reimbursable total and EUR + form = {footerCount: CONST.SEARCH.FOOTER_COUNT.REPORTS, footerTotal: CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE, footerCurrency: CONST.CURRENCY.EUR}; + + // When the save page renders its summary + const output = JSON.stringify(render().toJSON()); + + // Then each choice shows under its own label, so the user sees what the saved search will reopen on + expect(['Count', 'Reports', 'Total', 'Reimbursable', 'Currency', CONST.CURRENCY.EUR].every((text) => output.includes(text))).toBe(true); +}); +it('lists no footer rows for a search that keeps the footer defaults', () => { + // Given a search whose footer was left on its defaults, which the query leaves out + form = {}; + + // When the save page renders its summary + const output = JSON.stringify(render().toJSON()); + + // Then no footer choice is listed + expect(['Count', 'Reimbursable', 'Currency'].some((text) => output.includes(text))).toBe(false); +}); diff --git a/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx index 8874a502717e..44b26b0824d5 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx @@ -17,6 +17,7 @@ import waitForBatchedUpdates from '../../../../utils/waitForBatchedUpdates'; jest.mock('@libs/Navigation/Navigation'); jest.mock('@src/libs/actions/BankAccounts', () => ({ openPersonalBankAccountSetupView: jest.fn(), + openDepositAccountSetup: jest.fn(), })); jest.mock('@react-navigation/native', () => ({ diff --git a/tests/unit/pages/home/TimeSensitiveSection/AddDepositAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/AddDepositAccountTest.tsx new file mode 100644 index 000000000000..0521d48ab4ad --- /dev/null +++ b/tests/unit/pages/home/TimeSensitiveSection/AddDepositAccountTest.tsx @@ -0,0 +1,141 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; +import createDynamicRoute from '@src/libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@src/libs/Navigation/Navigation'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useTimeSensitiveAddBankAccount from '@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddBankAccount'; +import useTimeSensitiveAddDepositAccount from '@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddDepositAccount'; +import useTimeSensitiveAddPaymentCard from '@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddPaymentCard'; +import useTimeSensitiveItems from '@src/pages/home/TimeSensitiveSection/useTimeSensitiveItems'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; + +import type * as NativeNavigation from '@react-navigation/native'; + +import {View} from 'react-native'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../../../../utils/waitForBatchedUpdates'; + +jest.mock('@libs/Navigation/Navigation'); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useFocusEffect: jest.fn(), +})); + +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: jest.fn((key: string) => key)}))); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({ + Bank: () => null, + })), +})); + +jest.mock('@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddDepositAccount', () => + jest.fn(() => ({ + shouldShowAddDepositAccount: true, + })), +); + +jest.mock('@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddBankAccount', () => + jest.fn(() => ({ + shouldShowAddBankAccount: false, + })), +); + +jest.mock('@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddPaymentCard', () => + jest.fn(() => ({ + shouldShowAddPaymentCard: false, + })), +); + +jest.mock('@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards', () => + jest.fn(() => ({ + shouldShowAddShippingAddress: false, + shouldShowActivateCard: false, + shouldShowReviewCardFraud: false, + shouldShowAddVirtualCardPersonalDetails: false, + cardsNeedingShippingAddress: [], + cardsNeedingActivation: [], + cardsWithFraud: [], + virtualCardsNeedingPersonalDetails: [], + })), +); + +jest.mock('@hooks/useCardFeedErrors', () => + jest.fn(() => ({ + cardsWithBrokenFeedConnection: {}, + personalCardsWithBrokenConnection: {}, + })), +); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'test@example.com'}))); + +jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: false}))); + +function TimeSensitiveSection() { + const items = useTimeSensitiveItems(); + return {items}; +} + +const renderTimeSensitiveSection = () => + render( + + + , + ); + +describe('TimeSensitiveSection - AddDepositAccount', () => { + const mockedUseTimeSensitiveAddDepositAccount = jest.mocked(useTimeSensitiveAddDepositAccount); + const mockedUseTimeSensitiveAddBankAccount = jest.mocked(useTimeSensitiveAddBankAccount); + const mockedUseTimeSensitiveAddPaymentCard = jest.mocked(useTimeSensitiveAddPaymentCard); + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockedUseTimeSensitiveAddDepositAccount.mockReturnValue({ + shouldShowAddDepositAccount: true, + }); + mockedUseTimeSensitiveAddBankAccount.mockReturnValue({ + shouldShowAddBankAccount: false, + }); + mockedUseTimeSensitiveAddPaymentCard.mockReturnValue({ + shouldShowAddPaymentCard: false, + }); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('renders the task and opens the deposit account setup flow', async () => { + await Onyx.set(ONYXKEYS.ACCOUNT, {validated: true}); + await waitForBatchedUpdates(); + + renderTimeSensitiveSection(); + fireEvent.press(screen.getByText('homePage.timeSensitiveSection.ctaFix')); + + expect(screen.getByText('homePage.timeSensitiveSection.addDepositAccount.title')).toBeTruthy(); + // Opens the collect deposit account flow, the same one the Wallet page opens for a collecting member + expect(Navigation.navigate).toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.COLLECT_DEPOSIT_ACCOUNT.getRoute())); + }); + + it('yields to the queued-payment task so the same flow is not offered twice', async () => { + mockedUseTimeSensitiveAddBankAccount.mockReturnValue({ + shouldShowAddBankAccount: true, + }); + await Onyx.set(ONYXKEYS.ACCOUNT, {validated: true}); + await waitForBatchedUpdates(); + + renderTimeSensitiveSection(); + + expect(screen.getByText('homePage.timeSensitiveSection.addBankAccount.title')).toBeTruthy(); + expect(screen.queryByText('homePage.timeSensitiveSection.addDepositAccount.title')).toBeNull(); + }); +}); diff --git a/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx index cdeabd424061..295d1736df3e 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx @@ -4,10 +4,20 @@ import swapBackgroundTabForRHPTarget from '@libs/Navigation/helpers/swapBackgrou import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; +import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; +import ONYXKEYS from '@src/ONYXKEYS'; +import HomeTaskGroup from '@src/pages/home/HomeTaskGroup'; import AddHomeAddress from '@src/pages/home/TimeSensitiveSection/items/AddHomeAddress'; +import useTimeSensitiveItems from '@src/pages/home/TimeSensitiveSection/useTimeSensitiveItems'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; +import type * as NativeNavigation from '@react-navigation/native'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../../../../utils/waitForBatchedUpdates'; + const mockRootState: ReturnType = { key: 'root', index: 0, @@ -17,15 +27,15 @@ const mockRootState: ReturnType = { stale: false, }; -jest.mock('@libs/Navigation/Navigation', () => ({ - __esModule: true, - default: { - navigate: jest.fn(), - }, -})); +jest.mock('@libs/Navigation/Navigation'); jest.mock('@libs/Navigation/helpers/swapBackgroundTabForRHPTarget'); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useFocusEffect: jest.fn(), +})); + jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: jest.fn((key: string) => key)}))); jest.mock('@hooks/useLazyAsset', () => ({ @@ -34,8 +44,44 @@ jest.mock('@hooks/useLazyAsset', () => ({ })), })); +jest.mock('@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddBankAccount', () => + jest.fn(() => ({ + shouldShowAddBankAccount: false, + })), +); + +jest.mock('@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddPaymentCard', () => + jest.fn(() => ({ + shouldShowAddPaymentCard: false, + })), +); + +jest.mock('@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards', () => + jest.fn(() => ({ + shouldShowAddShippingAddress: false, + shouldShowActivateCard: false, + shouldShowReviewCardFraud: false, + shouldShowAddVirtualCardPersonalDetails: false, + cardsNeedingShippingAddress: [], + cardsNeedingActivation: [], + cardsWithFraud: [], + virtualCardsNeedingPersonalDetails: [], + })), +); + +jest.mock('@hooks/useCardFeedErrors', () => + jest.fn(() => ({ + cardsWithBrokenFeedConnection: {}, + personalCardsWithBrokenConnection: {}, + })), +); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'test@example.com'}))); + jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: false}))); + jest.mock('@hooks/useTheme', () => jest.fn(() => ({white: '#fff'}))); + jest.mock('@hooks/useThemeStyles', () => jest.fn( () => @@ -48,7 +94,68 @@ jest.mock('@hooks/useThemeStyles', () => ), ); -describe('AddHomeAddress', () => { +function TimeSensitiveSection() { + return ( + + ); +} + +const renderTimeSensitiveSection = () => + render( + + + , + ); + +describe('TimeSensitiveSection - AddHomeAddress', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('renders AddHomeAddress when an active workspace uses home and office commuter exclusions', async () => { + await Onyx.set(ONYXKEYS.ACCOUNT, {validated: true}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, { + id: '1', + name: 'Boulder Development', + areDistanceRatesEnabled: true, + commuterExclusions: {method: 'homeAndOffice'}, + }); + await waitForBatchedUpdates(); + + renderTimeSensitiveSection(); + + expect(screen.getByText('homePage.timeSensitiveSection.addHomeAddress.title')).toBeTruthy(); + }); + + it('renders AddHomeAddress when distance rates are disabled but commuter exclusions remain active', async () => { + // Given the workspace still has a home and office commuter exclusion, which requires a home address even if Distance rates are off + await Onyx.set(ONYXKEYS.ACCOUNT, {validated: true}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, { + id: '1', + name: 'Boulder Development', + areDistanceRatesEnabled: false, + commuterExclusions: {method: 'homeAndOffice'}, + }); + await waitForBatchedUpdates(); + + // When the time sensitive section renders for the member + renderTimeSensitiveSection(); + + // Then the home address task remains visible because the saved commuter exclusion is still enforced + expect(screen.getByText('homePage.timeSensitiveSection.addHomeAddress.title')).toBeTruthy(); + }); +}); + +describe('AddHomeAddress navigation', () => { beforeEach(() => { jest.mocked(Navigation.navigate).mockClear(); jest.mocked(swapBackgroundTabForRHPTarget).mockClear(); diff --git a/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx index 076bb81d59be..c1e5307233e7 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx @@ -60,6 +60,7 @@ jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLay jest.mock('@libs/actions/BankAccounts', () => ({ pressLockedBankAccount: jest.fn(), + openDepositAccountSetup: jest.fn(), })); jest.mock('@libs/actions/Report', () => ({ @@ -368,6 +369,7 @@ describe('TimeSensitiveSection - UnlockBankAccount', () => { introSelected: undefined, currentUserAccountID: ADMIN_ACCOUNT_ID, isSelfTourViewed: false, + hasCompletedGuidedSetupFlow: true, // Narrowed to Concierge only, which is not in the personal details list here conciergePersonalDetails: {}, }); diff --git a/tests/unit/pages/settings/AddAgentAvatarPageTest.tsx b/tests/unit/pages/settings/AddAgentAvatarPageTest.tsx index ab6b3329b79f..57b6fe79c879 100644 --- a/tests/unit/pages/settings/AddAgentAvatarPageTest.tsx +++ b/tests/unit/pages/settings/AddAgentAvatarPageTest.tsx @@ -2,13 +2,14 @@ import {render, waitFor} from '@testing-library/react-native'; import useOnyx from '@hooks/useOnyx'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; -import AddAgentAvatarPage from '@pages/settings/Agents/Fields/AddAgentAvatarPage'; -import type {OnSaveParams} from '@pages/settings/Agents/Fields/EditAgentAvatarPage'; +import AddAgentAvatarPage from '@pages/settings/Agents/Fields/DynamicAddAgentAvatarPage'; +import type {OnSaveParams} from '@pages/settings/Agents/Fields/DynamicEditAgentAvatarPage'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as ReactNavigationNative from '@react-navigation/native'; @@ -43,7 +44,7 @@ jest.mock('@hooks/useOnyx', () => ({ let mockEditAgentAvatarOnSave: jest.Mock | undefined; -jest.mock('@pages/settings/Agents/Fields/EditAgentAvatarPage', () => ({ +jest.mock('@pages/settings/Agents/Fields/DynamicEditAgentAvatarPage', () => ({ EditAgentAvatarContent: jest.fn((props: {onSave?: (params: OnSaveParams) => void; initialPresetID?: string}) => { mockEditAgentAvatarOnSave = props.onSave ? jest.fn(props.onSave) : undefined; return null; @@ -54,6 +55,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), goBack: jest.fn(), })); +jest.mock('@hooks/useDynamicBackPath', () => jest.fn(() => 'custom')); const mockGoBack = jest.mocked(Navigation.goBack); const mockUseOnyx = jest.mocked(useOnyx); @@ -70,7 +72,7 @@ describe('AddAgentAvatarPage', () => { }); it('passes the persisted preset ID from the Onyx draft to EditAgentAvatarContent', () => { - const {EditAgentAvatarContent} = jest.requireMock<{EditAgentAvatarContent: jest.Mock}>('@pages/settings/Agents/Fields/EditAgentAvatarPage'); + const {EditAgentAvatarContent} = jest.requireMock<{EditAgentAvatarContent: jest.Mock}>('@pages/settings/Agents/Fields/DynamicEditAgentAvatarPage'); render(); @@ -90,7 +92,7 @@ describe('AddAgentAvatarPage', () => { expect(mockSetNewAgentAvatarPreset).toHaveBeenCalledWith('bot-avatar--blue'); expect(mockSetNewAgentUploadedAvatar).not.toHaveBeenCalled(); - await waitFor(() => expect(mockGoBack).toHaveBeenCalledWith(ROUTES.SETTINGS_AGENTS_ADD.getRoute())); + await waitFor(() => expect(mockGoBack).toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD.getRoute()))); }); it('serializes the uploaded file into the Onyx draft and navigates back when save is called with a file', async () => { @@ -100,7 +102,7 @@ describe('AddAgentAvatarPage', () => { expect(mockSetNewAgentUploadedAvatar).toHaveBeenCalledWith(MOCK_FILE); expect(mockSetNewAgentAvatarPreset).not.toHaveBeenCalled(); - await waitFor(() => expect(mockGoBack).toHaveBeenCalledWith(ROUTES.SETTINGS_AGENTS_ADD.getRoute())); + await waitFor(() => expect(mockGoBack).toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD.getRoute()))); }); it('waits for the uploaded-photo write to persist before navigating back', async () => { @@ -119,7 +121,7 @@ describe('AddAgentAvatarPage', () => { resolveWrite(); - await waitFor(() => expect(mockGoBack).toHaveBeenCalledWith(ROUTES.SETTINGS_AGENTS_ADD.getRoute())); + await waitFor(() => expect(mockGoBack).toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD.getRoute()))); }); it('logs a warning and still navigates back when the draft write fails', async () => { @@ -128,9 +130,9 @@ describe('AddAgentAvatarPage', () => { render(); mockEditAgentAvatarOnSave?.({file: MOCK_FILE, uri: 'file://photo.jpg'}); - await waitFor(() => expect(mockGoBack).toHaveBeenCalledWith(ROUTES.SETTINGS_AGENTS_ADD.getRoute())); - expect(mockLogWarn).toHaveBeenCalledTimes(1); - expect(mockLogWarn.mock.calls.at(0)?.at(0)).toBe('Failed to persist the new-agent avatar draft'); + await waitFor(() => expect(mockGoBack).toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD.getRoute()))); + expect(mockLogWarn).toHaveBeenCalled(); + expect(mockLogWarn.mock.calls.at(1)?.at(0)).toBe('Failed to persist the new-agent avatar draft'); }); it('skips navigation when the user already left the screen while the write was pending', async () => { diff --git a/tests/unit/pages/settings/AddAgentPageTest.tsx b/tests/unit/pages/settings/AddAgentPageTest.tsx index c4a0982ae8af..9740c812bd64 100644 --- a/tests/unit/pages/settings/AddAgentPageTest.tsx +++ b/tests/unit/pages/settings/AddAgentPageTest.tsx @@ -4,17 +4,21 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useOnyx from '@hooks/useOnyx'; import {AGENT_AVATARS} from '@libs/Avatars/AgentAvatarCatalog'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import AddAgentPage from '@pages/settings/Agents/AddAgentPage'; +import AddAgentPage from '@pages/settings/Agents/DynamicAddAgentPage'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type {PolicyEmployeeList} from '@src/types/onyx'; import type HybridAppModuleType from '@expensify/react-native-hybrid-app/src/types'; +import type {ValueOf} from 'type-fest'; import React from 'react'; @@ -34,6 +38,16 @@ const mockCreateAgent = jest.fn<{optimisticAccountID: number; avatarURI: string resolveOptimisticPersonalDetail = resolve; }), })); +const mockCreateCompanyAgent = jest.fn<{optimisticAccountID: number; avatarURI: string | undefined; optimisticReportID: string; optimisticPersonalDetailPromise: Promise}, unknown[]>( + () => ({ + optimisticAccountID: -123456, + avatarURI: undefined, + optimisticReportID: OPTIMISTIC_REPORT_ID, + optimisticPersonalDetailPromise: new Promise((resolve) => { + resolveOptimisticPersonalDetail = resolve; + }), + }), +); const mockSetNewAgentAvatarPreset = jest.fn(); const mockClearNewAgentAvatarDraft = jest.fn(); const mockClearNewAgentTemplate = jest.fn(); @@ -41,9 +55,11 @@ let mockAvatarDraft: {customExpensifyAvatarID?: string; uploadedAvatar?: {uri: s let mockAvatarDraftStatus: 'loading' | 'loaded' = 'loaded'; let mockIsNarrowLayout = true; let mockTemplate: {name: string; prompt: string; avatarID: string} | undefined; +let mockPolicy: {id: string; policyAccountID: number; employeeList: PolicyEmployeeList} | undefined; jest.mock('@userActions/Agent', () => ({ createAgent: (...args: unknown[]) => mockCreateAgent(...args), + createCompanyAgent: (...args: unknown[]) => mockCreateCompanyAgent(...args), setNewAgentAvatarPreset: (...args: unknown[]) => mockSetNewAgentAvatarPreset(...args), clearNewAgentAvatarDraft: (...args: unknown[]) => mockClearNewAgentAvatarDraft(...args), clearNewAgentTemplate: (...args: unknown[]) => mockClearNewAgentTemplate(...args), @@ -90,6 +106,9 @@ jest.mock('@hooks/useOnyx', () => { if (key === onyxKeys.NEW_AGENT_TEMPLATE) { return [mockTemplate, {status: 'loaded'}] as const; } + if (key.startsWith(onyxKeys.COLLECTION.POLICY)) { + return [mockPolicy, {status: 'loaded'}] as const; + } return [undefined, {status: 'loaded'}] as const; }), }; @@ -147,7 +166,7 @@ jest.mock('@components/HeaderWithBackButton', () => { return MockHeader; }); -let mockFormOnSubmit: ((values: {firstName: string; prompt: string}) => void) | undefined; +let mockFormOnSubmit: ((values: {firstName: string; prompt: string; role?: ValueOf}) => void) | undefined; jest.mock('@components/Form/FormProvider', () => { function MockFormProvider({children, onSubmit}: {children: React.ReactNode; onSubmit: (values: {firstName: string; prompt: string}) => void}) { @@ -187,10 +206,10 @@ function getRevealAfterTransition(): () => void { return afterTransition; } -type AddAgentRouteProp = PlatformStackRouteProp; +type AddAgentRouteProp = PlatformStackRouteProp; function makeRoute(params: AddAgentRouteProp['params'] = {}): AddAgentRouteProp { - return createMock({name: SCREENS.SETTINGS.AGENTS.ADD, key: '', params}); + return createMock({name: SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD, key: '', params}); } function renderAddAgentPage(routeParams: AddAgentRouteProp['params'] = {}) { @@ -209,6 +228,7 @@ describe('AddAgentPage', () => { mockAvatarDraftStatus = 'loaded'; mockIsNarrowLayout = true; mockTemplate = undefined; + mockPolicy = undefined; mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: OWNER_ACCOUNT_ID, login: OWNER_LOGIN}); mockAvatarOnPress = undefined; resolveOptimisticPersonalDetail = undefined; @@ -271,7 +291,7 @@ describe('AddAgentPage', () => { mockAvatarOnPress?.(); expect(mockSetNewAgentAvatarPreset).not.toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_AGENTS_ADD_AVATAR); + expect(mockNavigate).toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD_AVATAR.getRoute())); }); it('does not seed a default while the draft is still loading (avoids clobbering a saved avatar on refresh)', () => { @@ -338,7 +358,7 @@ describe('AddAgentPage', () => { mockFormOnSubmit?.({firstName: 'Bot', prompt: 'Reject gambling.'}); - expect(mockCreateAgent).toHaveBeenCalledWith('Bot', 'Reject gambling.', OWNER_ACCOUNT_ID, OWNER_LOGIN, 'bot-avatar--blue', undefined, undefined, undefined); + expect(mockCreateAgent).toHaveBeenCalledWith('Bot', 'Reject gambling.', OWNER_ACCOUNT_ID, OWNER_LOGIN, 'bot-avatar--blue', undefined, undefined); expect(mockClearNewAgentTemplate).toHaveBeenCalledTimes(1); expect(mockClearNewAgentAvatarDraft).not.toHaveBeenCalled(); expect(mockRevealRouteBeforeDismissingModal).not.toHaveBeenCalled(); @@ -353,16 +373,28 @@ describe('AddAgentPage', () => { expect(mockClearNewAgentAvatarDraft).toHaveBeenCalledTimes(1); }); - it('forwards policyID from route params to createAgent', async () => { - renderAddAgentPage({policyID: 'POL_42'}); + it('forwards policyID from route params to createCompanyAgent', async () => { + mockPolicy = {id: 'POL_42', policyAccountID: 5444, employeeList: {[OWNER_LOGIN]: {role: CONST.POLICY.ROLE.ADMIN}}}; - mockFormOnSubmit?.({firstName: 'Bot', prompt: 'Reject gambling.'}); + renderAddAgentPage({policyID: 'POL_42'}); - expect(mockCreateAgent).toHaveBeenCalledWith('Bot', 'Reject gambling.', OWNER_ACCOUNT_ID, OWNER_LOGIN, 'bot-avatar--blue', undefined, undefined, 'POL_42'); + mockFormOnSubmit?.({firstName: 'Bot', prompt: 'Reject gambling.', role: CONST.POLICY.ROLE.ADMIN}); + + expect(mockCreateCompanyAgent).toHaveBeenCalledWith( + OWNER_ACCOUNT_ID, + OWNER_LOGIN, + 'Reject gambling.', + mockPolicy, + CONST.POLICY.ROLE.ADMIN, + 'Bot', + 'bot-avatar--blue', + undefined, + undefined, + ); resolveOptimisticPersonalDetail?.(); await Promise.resolve(); expect(mockRevealRouteBeforeDismissingModal).toHaveBeenCalledWith( - ROUTES.REPORT_WITH_ID.getRoute(OPTIMISTIC_REPORT_ID, undefined, undefined, ROUTES.SETTINGS_AGENTS), + ROUTES.REPORT_WITH_ID.getRoute(OPTIMISTIC_REPORT_ID, undefined, undefined, ROUTES.WORKSPACE_AGENTS.getRoute('POL_42')), expect.anything(), ); expect(getRevealAfterTransition()).toEqual(expect.any(Function)); @@ -414,7 +446,7 @@ describe('AddAgentPage', () => { mockFormOnSubmit?.({firstName: 'Bot', prompt: 'Reject gambling.'}); - expect(mockCreateAgent).toHaveBeenCalledWith('Bot', 'Reject gambling.', OWNER_ACCOUNT_ID, OWNER_LOGIN, 'bot-avatar--blue', undefined, undefined, undefined); + expect(mockCreateAgent).toHaveBeenCalledWith('Bot', 'Reject gambling.', OWNER_ACCOUNT_ID, OWNER_LOGIN, 'bot-avatar--blue', undefined, undefined); }); it('creates the agent with the reconstructed uploaded file when a photo was uploaded', () => { diff --git a/tests/unit/pages/settings/AgentsPageTest.tsx b/tests/unit/pages/settings/AgentsPageTest.tsx index 6a3b7c6a5a9c..c14c37a14639 100644 --- a/tests/unit/pages/settings/AgentsPageTest.tsx +++ b/tests/unit/pages/settings/AgentsPageTest.tsx @@ -70,6 +70,7 @@ jest.mock('@hooks/useLazyAsset', () => ({ })); jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined, {status: 'loaded'}])); +jest.mock('@hooks/usePrevious', () => jest.fn(() => [])); jest.mock('@components/OnyxListItemProvider', () => ({ usePersonalDetails: jest.fn(() => ({})), @@ -171,8 +172,8 @@ describe('AgentsPage', () => { it('shows agent list when agents exist in Onyx', () => { const TEST_ACCOUNT_ID = 12345; mockUseOnyx.mockImplementation((key) => { - if (key === ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT) { - return [{[`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`]: {prompt: 'Test prompt'}}, {status: 'loaded'}]; + if (key === ONYXKEYS.COLLECTION.AGENT) { + return [{[`${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`]: {prompt: 'Test prompt'}}, {status: 'loaded'}]; } return [undefined, {status: 'loaded'}]; }); @@ -194,8 +195,8 @@ describe('AgentsPage', () => { it('excludes agents whose personal details are missing from the list', () => { const TEST_ACCOUNT_ID = 12345; mockUseOnyx.mockImplementation((key) => { - if (key === ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT) { - return [{[`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`]: {prompt: 'Test prompt'}}, {status: 'loaded'}]; + if (key === ONYXKEYS.COLLECTION.AGENT) { + return [{[`${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`]: {prompt: 'Test prompt'}}, {status: 'loaded'}]; } return [undefined, {status: 'loaded'}]; }); diff --git a/tests/unit/pages/settings/AgentsTableRowTest.tsx b/tests/unit/pages/settings/AgentsTableRowTest.tsx index b650bfae984d..01cabd64ec5e 100644 --- a/tests/unit/pages/settings/AgentsTableRowTest.tsx +++ b/tests/unit/pages/settings/AgentsTableRowTest.tsx @@ -5,9 +5,10 @@ import AgentsTableRow from '@components/Tables/AgentsTable/AgentsTableRow'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type ReactNative from 'react-native'; @@ -195,7 +196,8 @@ const BASE_ITEM: AgentRowData = { accountID: TEST_ACCOUNT_ID, displayName: 'Test Agent', login: 'agent@example.com', - action: () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(TEST_ACCOUNT_ID)), + canManage: true, + action: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT.getRoute(TEST_ACCOUNT_ID))), onChatPress: mockOnChatPress, onCopilotPress: mockOnCopilotPress, dismissError: jest.fn(), @@ -235,7 +237,7 @@ describe('AgentsTableRow', () => { fireEvent.press(screen.getByLabelText('Test Agent, agent@example.com')); - expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(TEST_ACCOUNT_ID)); + expect(mockNavigate).toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT.getRoute(TEST_ACCOUNT_ID))); }); it('shows action buttons on wide layout', () => { @@ -277,7 +279,7 @@ describe('AgentsTableRow', () => { fireEvent.press(screen.getByText('common.edit')); - expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(TEST_ACCOUNT_ID)); + expect(mockNavigate).toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT.getRoute(TEST_ACCOUNT_ID))); }); it('pressing Chat button calls onChatPress', () => { diff --git a/tests/unit/pages/settings/EditAgentPageTest.tsx b/tests/unit/pages/settings/EditAgentPageTest.tsx index 3d7bde35c08a..ac68422811e3 100644 --- a/tests/unit/pages/settings/EditAgentPageTest.tsx +++ b/tests/unit/pages/settings/EditAgentPageTest.tsx @@ -8,7 +8,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; -import EditAgentPage from '@pages/settings/Agents/EditAgentPage'; +import DynamicEditAgentPage from '@pages/settings/Agents/DynamicEditAgentPage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -148,20 +148,20 @@ const mockUseOnyx = jest.mocked(useOnyx); const TEST_ACCOUNT_ID = 12345; -type EditAgentPageRoute = PlatformStackScreenProps['route']; -type EditAgentPageNavigation = PlatformStackScreenProps['navigation']; +type DynamicEditAgentPageRoute = PlatformStackScreenProps['route']; +type DynamicEditAgentPageNavigation = PlatformStackScreenProps['navigation']; -const mockRoute = createMock({params: {accountID: TEST_ACCOUNT_ID}}); -const mockNavigation = createMock({}); +const mockRoute = createMock({params: {accountID: TEST_ACCOUNT_ID}}); +const mockNavigation = createMock({}); -describe('EditAgentPage', () => { +describe('DynamicEditAgentPage', () => { beforeEach(() => { jest.clearAllMocks(); mockUseOnyx.mockImplementation((key, options) => { if (key === ONYXKEYS.PERSONAL_DETAILS_LIST && options?.selector) { return [{displayName: 'Default Agent'}, {status: 'loaded'}]; } - if (typeof key === 'string' && key.startsWith(ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT)) { + if (typeof key === 'string' && key.startsWith(ONYXKEYS.COLLECTION.AGENT)) { return [{prompt: 'Default prompt'}, {status: 'loaded'}]; } return [undefined, {status: 'loaded'}]; @@ -177,7 +177,7 @@ describe('EditAgentPage', () => { }); const {toJSON} = render( - , @@ -188,14 +188,14 @@ describe('EditAgentPage', () => { it('renders prompt from agent Onyx key', () => { mockUseOnyx.mockImplementation((key) => { - if (key === `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`) { + if (key === `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`) { return [{prompt: 'Reject all gambling expenses.'}, {status: 'loaded'}]; } return [undefined, {status: 'loaded'}]; }); const {toJSON} = render( - , @@ -206,7 +206,7 @@ describe('EditAgentPage', () => { it('renders delete agent menu item', () => { const {toJSON} = render( - , @@ -224,7 +224,7 @@ describe('EditAgentPage', () => { }); render( - , @@ -241,14 +241,14 @@ describe('EditAgentPage', () => { it('shows error text when agent has nameErrors', () => { mockUseOnyx.mockImplementation((key) => { - if (key === `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`) { + if (key === `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`) { return [{prompt: 'Some prompt', nameErrors: {someKey: 'agentsPage.error.updateName'}}, {status: 'loaded'}]; } return [undefined, {status: 'loaded'}]; }); const {toJSON} = render( - , @@ -259,14 +259,14 @@ describe('EditAgentPage', () => { it('shows error text when agent has promptErrors', () => { mockUseOnyx.mockImplementation((key) => { - if (key === `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`) { + if (key === `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`) { return [{prompt: 'Some prompt', promptErrors: {someKey: 'agentsPage.error.updatePrompt'}}, {status: 'loaded'}]; } return [undefined, {status: 'loaded'}]; }); const {toJSON} = render( - , @@ -284,7 +284,7 @@ describe('EditAgentPage', () => { }); const {toJSON} = render( - , @@ -299,7 +299,7 @@ describe('EditAgentPage', () => { mockUseOnyx.mockReturnValue([undefined, {status: 'loading'}]); const {toJSON} = render( - , diff --git a/tests/unit/pages/settings/EditNamePageTest.tsx b/tests/unit/pages/settings/EditNamePageTest.tsx index 044048544d3e..a2683836d702 100644 --- a/tests/unit/pages/settings/EditNamePageTest.tsx +++ b/tests/unit/pages/settings/EditNamePageTest.tsx @@ -5,7 +5,7 @@ import useOnyx from '@hooks/useOnyx'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import EditNamePage from '@pages/settings/Agents/Fields/EditNamePage'; +import EditNamePage from '@pages/settings/Agents/Fields/DynamicEditNamePage'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -88,8 +88,8 @@ const mockUseOnyx = jest.mocked(useOnyx); const TEST_ACCOUNT_ID = 12345; -type EditNamePageRoute = PlatformStackScreenProps['route']; -type EditNamePageNavigation = PlatformStackScreenProps['navigation']; +type EditNamePageRoute = PlatformStackScreenProps['route']; +type EditNamePageNavigation = PlatformStackScreenProps['navigation']; const mockRoute = createMock({params: {accountID: TEST_ACCOUNT_ID}}); const mockNavigation = createMock({}); diff --git a/tests/unit/pages/settings/EditPromptPageTest.tsx b/tests/unit/pages/settings/EditPromptPageTest.tsx index 99329748bcfa..4797947201c6 100644 --- a/tests/unit/pages/settings/EditPromptPageTest.tsx +++ b/tests/unit/pages/settings/EditPromptPageTest.tsx @@ -5,7 +5,7 @@ import useOnyx from '@hooks/useOnyx'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import EditPromptPage from '@pages/settings/Agents/Fields/EditPromptPage'; +import EditPromptPage from '@pages/settings/Agents/Fields/DynamicEditPromptPage'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -86,8 +86,8 @@ const mockUseOnyx = jest.mocked(useOnyx); const TEST_ACCOUNT_ID = 12345; -type EditPromptPageRoute = PlatformStackScreenProps['route']; -type EditPromptPageNavigation = PlatformStackScreenProps['navigation']; +type EditPromptPageRoute = PlatformStackScreenProps['route']; +type EditPromptPageNavigation = PlatformStackScreenProps['navigation']; const mockRoute = createMock({params: {accountID: TEST_ACCOUNT_ID}}); const mockNavigation = createMock({}); @@ -111,7 +111,7 @@ describe('EditPromptPage', () => { it('renders prompt as default value in InputWrapper', () => { mockUseOnyx.mockImplementation((key) => { - if (key === `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${TEST_ACCOUNT_ID}`) { + if (key === `${ONYXKEYS.COLLECTION.AGENT}${TEST_ACCOUNT_ID}`) { return [{prompt: 'Old prompt'}, {status: 'loaded'}]; } return [undefined, {status: 'loaded'}]; diff --git a/tests/unit/pages/settings/NewAgentPageTest.tsx b/tests/unit/pages/settings/NewAgentPageTest.tsx index 792cafe25edf..fbae8eeaf686 100644 --- a/tests/unit/pages/settings/NewAgentPageTest.tsx +++ b/tests/unit/pages/settings/NewAgentPageTest.tsx @@ -1,23 +1,30 @@ import {fireEvent, render, screen, waitFor} from '@testing-library/react-native'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; import useSuggestedAgents from '@hooks/useSuggestedAgents'; import {AGENT_AVATARS} from '@libs/Avatars/AgentAvatarCatalog'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp, PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import NewAgentPage from '@pages/settings/Agents/NewAgentPage'; +import NewAgentPage from '@pages/settings/Agents/DynamicNewAgentPage'; import {clearNewAgentAvatarDraft, clearNewAgentTemplate, setNewAgentTemplate} from '@userActions/Agent'; -import ROUTES from '@src/ROUTES'; +import CONST from '@src/CONST'; +import type ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type {PolicyEmployeeList} from '@src/types/onyx'; import type SuggestedAgent from '@src/types/onyx/SuggestedAgent'; import React from 'react'; +let mockPolicy: {id: string; policyAccountID: number; employeeList: PolicyEmployeeList} | undefined; + jest.mock('@hooks/useLazyAsset', () => ({ useMemoizedLazyExpensifyIcons: jest.fn(() => ({Bot: 'Bot'})), useMemoizedLazyIllustrations: jest.fn(() => ({Lightbulb: 'Lightbulb'})), @@ -99,18 +106,34 @@ jest.mock('@components/HeaderWithBackButton', () => { return MockHeader; }); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({}))); + +jest.mock('@hooks/useOnyx', () => { + const onyxKeys = jest.requireActual<{default: typeof ONYXKEYS}>('@src/ONYXKEYS').default; + return { + __esModule: true, + default: jest.fn((key: string) => { + if (key.startsWith(onyxKeys.COLLECTION.POLICY)) { + return [mockPolicy, {status: 'loaded'}] as const; + } + return [undefined, {status: 'loaded'}] as const; + }), + }; +}); + const mockedUseSuggestedAgents = jest.mocked(useSuggestedAgents); const mockedUseNetwork = jest.mocked(useNetwork); const mockNavigate = jest.mocked(Navigation.navigate); const mockSetNewAgentTemplate = jest.mocked(setNewAgentTemplate); const mockClearNewAgentTemplate = jest.mocked(clearNewAgentTemplate); const mockClearNewAgentAvatarDraft = jest.mocked(clearNewAgentAvatarDraft); +const mockUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDetails); -type NewAgentRouteProp = PlatformStackRouteProp; -type NewAgentNavigationProp = PlatformStackScreenProps['navigation']; +type NewAgentRouteProp = PlatformStackRouteProp; +type NewAgentNavigationProp = PlatformStackScreenProps['navigation']; function makeRoute(params: NewAgentRouteProp['params'] = {}): NewAgentRouteProp { - return {key: 'Settings_Agents_New-test', name: SCREENS.SETTINGS.AGENTS.NEW, params}; + return {key: 'Settings_Agents_New-test', name: SCREENS.SETTINGS.AGENTS.DYNAMIC_NEW, params}; } function renderNewAgentPage(routeParams: NewAgentRouteProp['params'] = {}) { @@ -123,6 +146,9 @@ function renderNewAgentPage(routeParams: NewAgentRouteProp['params'] = {}) { ); } +const OWNER_ACCOUNT_ID = 999; +const OWNER_LOGIN = 'owner@test.com'; + const TEMPLATES: SuggestedAgent[] = [ {id: 't1', name: 'Cheapskate Charlie', description: 'Watches spend.', prompt: 'Reject pricey expenses.'}, {id: 't2', name: 'Enforcer Eliza', description: 'Keeps records clean.', prompt: 'Require receipts.'}, @@ -136,6 +162,8 @@ describe('NewAgentPage', () => { mockSetNewAgentTemplate.mockResolvedValue(undefined); mockClearNewAgentTemplate.mockResolvedValue(undefined); mockClearNewAgentAvatarDraft.mockResolvedValue(undefined); + mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: OWNER_ACCOUNT_ID, login: OWNER_LOGIN}); + mockPolicy = undefined; }); it('always renders the build custom agent button', () => { @@ -206,7 +234,7 @@ describe('NewAgentPage', () => { }); expect(mockClearNewAgentAvatarDraft).toHaveBeenCalledTimes(1); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_AGENTS_ADD.getRoute()); + expect(mockNavigate).toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD.getRoute())); }); }); @@ -219,17 +247,19 @@ describe('NewAgentPage', () => { expect(mockClearNewAgentAvatarDraft).toHaveBeenCalledTimes(1); expect(mockSetNewAgentTemplate).not.toHaveBeenCalled(); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_AGENTS_ADD.getRoute()); + expect(mockNavigate).toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD.getRoute())); }); }); it('forwards the policyID to the builder route when present', async () => { + mockPolicy = {id: 'POL_42', policyAccountID: 5444, employeeList: {[OWNER_LOGIN]: {role: CONST.POLICY.ROLE.ADMIN}}}; + renderNewAgentPage({policyID: 'POL_42'}); fireEvent.press(screen.getByText('newAgentPage.buildCustomAgent')); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_AGENTS_ADD.getRoute({policyID: 'POL_42'})); + expect(mockNavigate).toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD.getRoute({policyID: 'POL_42'}))); }); }); }); diff --git a/tests/unit/pages/workspace/accounting/certinia/CertiniaFxExpenseAccountSelectPageTest.tsx b/tests/unit/pages/workspace/accounting/certinia/CertiniaFxExpenseAccountSelectPageTest.tsx index 5ddab67c1cf8..51d94f534f3d 100644 --- a/tests/unit/pages/workspace/accounting/certinia/CertiniaFxExpenseAccountSelectPageTest.tsx +++ b/tests/unit/pages/workspace/accounting/certinia/CertiniaFxExpenseAccountSelectPageTest.tsx @@ -186,6 +186,7 @@ describe('CertiniaFxExpenseAccountSelectPage', () => { jest.mocked(useSelectionListSearch).mockImplementationOnce(() => ({ filteredData: [], textInputOptions: {label: undefined, value: 'zzzz', onChangeText: jest.fn(), headerMessage: undefined}, + pinnedValues: [], })); // When: the picker is opened with a chart of accounts. diff --git a/tests/unit/pages/workspace/accounting/netsuite/netsuiteOAuthMigrationUtilsTest.ts b/tests/unit/pages/workspace/accounting/netsuite/netsuiteOAuthMigrationUtilsTest.ts new file mode 100644 index 000000000000..1f97c428ddbe --- /dev/null +++ b/tests/unit/pages/workspace/accounting/netsuite/netsuiteOAuthMigrationUtilsTest.ts @@ -0,0 +1,310 @@ +import {getNetSuiteOAuthMigrationSeverity, shouldShowNetSuiteOAuthMigrationNudge} from '@pages/workspace/accounting/netsuite/utils'; + +import CONST from '@src/CONST'; +import type Policy from '@src/types/onyx/Policy'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import createMock from '../../../../../utils/createMock'; + +type NetSuiteMigrationOverrides = { + accountID?: string; + verified?: boolean; + tokenID?: string; + tokenSecret?: string; + authType?: 'tba' | 'oauth2'; + shouldIncludeSyncOptions?: boolean; + syncTax?: boolean; + syncReimbursedReports?: boolean; + syncCustomSegments?: boolean; + syncCustomLists?: boolean; + customSegments?: Array<{segmentName: string; internalID: string; scriptID: string; mapping: 'TAG' | 'REPORT_FIELD' | ''}>; + customLists?: Array<{listName: string; internalID: string; transactionFieldID: string; mapping: 'TAG' | 'REPORT_FIELD' | ''}>; +}; + +function makeNetSuitePolicy(overrides: NetSuiteMigrationOverrides): Policy { + const shouldIncludeSyncOptions = overrides.shouldIncludeSyncOptions ?? true; + return createMock({ + role: CONST.POLICY.ROLE.ADMIN, + connections: { + netsuite: { + accountID: 'accountID' in overrides ? overrides.accountID : 'account123', + verified: 'verified' in overrides ? overrides.verified : true, + tokenID: 'tokenID' in overrides ? overrides.tokenID : 'token123', + tokenSecret: 'tokenSecret' in overrides ? overrides.tokenSecret : 'secret456', + config: overrides.authType !== undefined ? {authType: overrides.authType} : undefined, + options: shouldIncludeSyncOptions + ? { + config: { + syncOptions: { + syncTax: overrides.syncTax ?? false, + syncReimbursedReports: overrides.syncReimbursedReports ?? false, + syncCustomSegments: overrides.syncCustomSegments ?? false, + syncCustomLists: overrides.syncCustomLists ?? false, + customSegments: overrides.customSegments ?? [], + customLists: overrides.customLists ?? [], + }, + }, + } + : undefined, + }, + }, + }); +} + +function makePolicyCollection(...policies: Policy[]): OnyxCollection { + return Object.fromEntries(policies.map((policy, index) => [`policy_${index}`, policy])); +} + +describe('shouldShowNetSuiteOAuthMigrationNudge', () => { + describe('when the nudge SHOULD be shown', () => { + it('returns true for a verified TBA connection with no authType set', () => { + // Given a policy with a verified NetSuite TBA connection and no authType recorded + const policy = makeNetSuitePolicy({verified: true, tokenID: 'token', tokenSecret: 'secret', authType: undefined}); + + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(policy, makePolicyCollection(policy)); + + // Then the nudge should be shown because the connection is TBA and not yet migrated + expect(result).toBe(true); + }); + + it('returns true when authType is tba', () => { + // Given a policy with authType explicitly set to tba + const policy = makeNetSuitePolicy({verified: true, tokenID: 'token', tokenSecret: 'secret', authType: 'tba'}); + + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(policy, makePolicyCollection(policy)); + + // Then the nudge should be shown because the connection is not yet on OAuth 2.0 + expect(result).toBe(true); + }); + }); + + describe('when the nudge should NOT be shown', () => { + it('returns false when authType is oauth2 (already migrated)', () => { + // Given a policy where the NetSuite connection has already migrated to OAuth 2.0 + const policy = makeNetSuitePolicy({verified: true, tokenID: 'token', tokenSecret: 'secret', authType: 'oauth2'}); + + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(policy, makePolicyCollection(policy)); + + // Then the nudge should not be shown because the migration is already complete + expect(result).toBe(false); + }); + + it('returns false when connection is not verified', () => { + // Given a policy with a TBA connection that has not been verified yet + const policy = makeNetSuitePolicy({verified: false, tokenID: 'token', tokenSecret: 'secret'}); + + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(policy, makePolicyCollection(policy)); + + // Then the nudge should not be shown because the connection is unverified + expect(result).toBe(false); + }); + + it('returns false when tokenID is missing', () => { + // Given a policy without a tokenID (not a TBA connection) + const policy = makeNetSuitePolicy({verified: true, tokenID: undefined, tokenSecret: 'secret'}); + + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(policy, makePolicyCollection(policy)); + + // Then the nudge should not be shown because there is no TBA token to migrate + expect(result).toBe(false); + }); + + it('returns false when tokenSecret is missing', () => { + // Given a policy without a tokenSecret (incomplete TBA credentials) + const policy = makeNetSuitePolicy({verified: true, tokenID: 'token', tokenSecret: undefined}); + + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(policy, makePolicyCollection(policy)); + + // Then the nudge should not be shown because the TBA credential pair is incomplete + expect(result).toBe(false); + }); + + it('returns false when the policy has no netsuite connection', () => { + // Given a policy with no NetSuite connection at all + const policy = createMock({connections: {}}); + + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(policy, makePolicyCollection(policy)); + + // Then the nudge should not be shown + expect(result).toBe(false); + }); + + it('returns false when policy is undefined', () => { + // Given no policy + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(undefined, {}); + + // Then the nudge should not be shown + expect(result).toBe(false); + }); + + it('returns false when the connection settings are unavailable', () => { + // Given a TBA connection whose sync settings are not loaded + const policy = makeNetSuitePolicy({shouldIncludeSyncOptions: false}); + + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(policy, makePolicyCollection(policy)); + + // Then the nudge should not be shown because migration eligibility is unknown + expect(result).toBe(false); + }); + + it('returns false when the policy collection is unavailable', () => { + // Given a simple TBA connection but no policy collection + const policy = makeNetSuitePolicy({}); + + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(policy, undefined); + + // Then the nudge should not be shown because shared connection eligibility is unknown + expect(result).toBe(false); + }); + + it('returns false when a TBA sibling policy for the same account has an excluded setting', () => { + // Given two TBA policies share an account and the sibling imports tax + const policy = makeNetSuitePolicy({}); + const siblingPolicy = makeNetSuitePolicy({syncTax: true}); + + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(policy, makePolicyCollection(policy, siblingPolicy)); + + // Then the nudge should not migrate the sibling policy indirectly + expect(result).toBe(false); + }); + + it('ignores an excluded sibling policy that is already on OAuth 2.0', () => { + // Given a simple TBA policy and an OAuth sibling policy for the same account + const policy = makeNetSuitePolicy({}); + const siblingPolicy = makeNetSuitePolicy({authType: 'oauth2', syncTax: true}); + + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(policy, makePolicyCollection(policy, siblingPolicy)); + + // Then the nudge can be shown because the sibling will not switch transports + expect(result).toBe(true); + }); + + it('ignores an excluded sibling policy for another NetSuite account', () => { + // Given a simple TBA policy and a complex TBA policy for another account + const policy = makeNetSuitePolicy({}); + const siblingPolicy = makeNetSuitePolicy({accountID: 'anotherAccount', syncTax: true}); + + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(policy, makePolicyCollection(policy, siblingPolicy)); + + // Then the unrelated sibling does not block this account's nudge + expect(result).toBe(true); + }); + + it.each([ + ['tax import', {syncTax: true}], + ['reimbursed report sync', {syncReimbursedReports: true}], + ['custom segment import', {syncCustomSegments: true}], + ['custom list import', {syncCustomLists: true}], + ] satisfies Array<[string, NetSuiteMigrationOverrides]>)('returns false when %s is enabled', (_settingName, settings) => { + // Given a TBA connection with a complex sync setting enabled + const policy = makeNetSuitePolicy(settings); + + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(policy, makePolicyCollection(policy)); + + // Then the nudge should not be shown during the limited rollout + expect(result).toBe(false); + }); + + it('returns false when custom segments are configured', () => { + // Given a TBA connection with a configured custom segment or custom record + const policy = makeNetSuitePolicy({ + customSegments: [{segmentName: 'Region', internalID: '1', scriptID: 'region', mapping: 'TAG'}], + }); + + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(policy, makePolicyCollection(policy)); + + // Then the nudge should not be shown during the limited rollout + expect(result).toBe(false); + }); + + it('returns false when custom lists are configured', () => { + // Given a TBA connection with a configured custom list + const policy = makeNetSuitePolicy({ + customLists: [{listName: 'Project', internalID: '2', transactionFieldID: 'project', mapping: 'REPORT_FIELD'}], + }); + + // When checking whether to show the migration nudge + const result = shouldShowNetSuiteOAuthMigrationNudge(policy, makePolicyCollection(policy)); + + // Then the nudge should not be shown during the limited rollout + expect(result).toBe(false); + }); + }); +}); + +describe('getNetSuiteOAuthMigrationSeverity', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns danger when fewer than 30 days remain before the deadline', () => { + // Given today is 20 days before the Feb 2027 deadline + jest.useFakeTimers({now: new Date('2027-01-12').getTime()}); + + // When computing the severity + const severity = getNetSuiteOAuthMigrationSeverity(); + + // Then severity is danger because the deadline is imminent + expect(severity).toBe('danger'); + }); + + it('returns danger on the exact 30-day boundary', () => { + // Given today is exactly 30 days before the deadline (boundary is <= 30 → danger) + jest.useFakeTimers({now: new Date('2027-01-02').getTime()}); + + // When computing the severity + const severity = getNetSuiteOAuthMigrationSeverity(); + + // Then severity is danger + expect(severity).toBe('danger'); + }); + + it('returns warning when between 31 and 90 days remain', () => { + // Given today is 60 days before the Feb 2027 deadline + jest.useFakeTimers({now: new Date('2026-12-03').getTime()}); + + // When computing the severity + const severity = getNetSuiteOAuthMigrationSeverity(); + + // Then severity is warning because the deadline is approaching + expect(severity).toBe('warning'); + }); + + it('returns warning on the exact 90-day boundary', () => { + // Given today is exactly 90 days before the deadline (boundary is <= 90 → warning) + jest.useFakeTimers({now: new Date('2026-11-03').getTime()}); + + // When computing the severity + const severity = getNetSuiteOAuthMigrationSeverity(); + + // Then severity is warning + expect(severity).toBe('warning'); + }); + + it('returns info when more than 90 days remain', () => { + // Given today is 200 days before the Feb 2027 deadline + jest.useFakeTimers({now: new Date('2026-07-16').getTime()}); + + // When computing the severity + const severity = getNetSuiteOAuthMigrationSeverity(); + + // Then severity is info because the deadline is far away + expect(severity).toBe('info'); + }); +}); diff --git a/tests/unit/pages/workspace/accounting/qbd/DynamicQuickbooksDesktopFxExpenseAccountSelectPageTest.tsx b/tests/unit/pages/workspace/accounting/qbd/DynamicQuickbooksDesktopFxExpenseAccountSelectPageTest.tsx index f37555d2d5aa..277f0c6405da 100644 --- a/tests/unit/pages/workspace/accounting/qbd/DynamicQuickbooksDesktopFxExpenseAccountSelectPageTest.tsx +++ b/tests/unit/pages/workspace/accounting/qbd/DynamicQuickbooksDesktopFxExpenseAccountSelectPageTest.tsx @@ -181,6 +181,7 @@ describe('DynamicQuickbooksDesktopFxExpenseAccountSelectPage', () => { jest.mocked(useSelectionListSearch).mockImplementationOnce(() => ({ filteredData: [], textInputOptions: {label: undefined, value: 'zzzz', onChangeText: jest.fn(), headerMessage: undefined}, + pinnedValues: [], })); // When: the picker is opened with expense accounts. diff --git a/tests/unit/pinnedChartTooltipTest.ts b/tests/unit/pinnedChartTooltipTest.ts new file mode 100644 index 000000000000..e7889cab17bd --- /dev/null +++ b/tests/unit/pinnedChartTooltipTest.ts @@ -0,0 +1,157 @@ +import {dismissPinnedChartTooltip, markChartTouch, pinChartTooltip, unpinChartTooltip} from '@components/Charts/utils/pinnedChartTooltip'; + +describe('pinnedChartTooltip', () => { + beforeEach(() => { + // The pinned tooltip lives in module state, so each test starts with nothing pinned and no chart touched + dismissPinnedChartTooltip(); + }); + + describe('pinChartTooltip', () => { + it('hides the tooltip of another chart when a new one is pinned', () => { + // Given a tooltip pinned on one chart + const firstDismiss = jest.fn(); + const secondDismiss = jest.fn(); + pinChartTooltip(firstDismiss); + + // When a tooltip is pinned on another chart + pinChartTooltip(secondDismiss); + + // Then only one tooltip stays visible on the screen + expect(firstDismiss).toHaveBeenCalledTimes(1); + expect(secondDismiss).not.toHaveBeenCalled(); + }); + + it('keeps the tooltip visible when the same one is pinned again', () => { + // Given a pinned tooltip + const dismiss = jest.fn(); + pinChartTooltip(dismiss); + + // When it is pinned again, as happens when its chart re-renders + pinChartTooltip(dismiss); + + // Then it is not hidden by its own re-registration + expect(dismiss).not.toHaveBeenCalled(); + }); + }); + + describe('unpinChartTooltip', () => { + it('stops hiding a tooltip on screen touches once it is unpinned', () => { + // Given a pinned tooltip that its chart already hid by itself + const dismiss = jest.fn(); + pinChartTooltip(dismiss); + unpinChartTooltip(dismiss); + + // When the screen is touched + dismissPinnedChartTooltip(); + + // Then the tooltip is not hidden a second time + expect(dismiss).not.toHaveBeenCalled(); + }); + + it('keeps the pinned tooltip when a tooltip that was replaced earlier is unpinned', () => { + // Given a tooltip that was replaced by one pinned on another chart + const firstDismiss = jest.fn(); + const secondDismiss = jest.fn(); + pinChartTooltip(firstDismiss); + pinChartTooltip(secondDismiss); + + // When the replaced tooltip cleans up after itself + unpinChartTooltip(firstDismiss); + dismissPinnedChartTooltip(); + + // Then the current tooltip is still registered and gets hidden by the screen touch + expect(secondDismiss).toHaveBeenCalledTimes(1); + expect(firstDismiss).toHaveBeenCalledTimes(1); + }); + }); + + describe('dismissPinnedChartTooltip', () => { + it('hides the pinned tooltip on a touch outside of any chart', () => { + // Given a pinned tooltip + const dismiss = jest.fn(); + pinChartTooltip(dismiss); + + // When the screen is touched outside of any chart + dismissPinnedChartTooltip(); + + // Then the tooltip is hidden + expect(dismiss).toHaveBeenCalledTimes(1); + }); + + it('keeps the pinned tooltip on a touch on its own chart', () => { + // Given a pinned tooltip + const dismiss = jest.fn(); + pinChartTooltip(dismiss); + + // When its own chart is touched, so the chart decides what happens with the tooltip + markChartTouch(dismiss); + dismissPinnedChartTooltip(); + + // Then the screen touch does not hide it + expect(dismiss).not.toHaveBeenCalled(); + }); + + it('hides the pinned tooltip on a touch on another chart with a pinned tooltip', () => { + // Given a pinned tooltip + const dismiss = jest.fn(); + const otherDismiss = jest.fn(); + pinChartTooltip(dismiss); + + // When a chart other than the one showing the tooltip is touched + markChartTouch(otherDismiss); + dismissPinnedChartTooltip(); + + // Then the tooltip is hidden + expect(dismiss).toHaveBeenCalledTimes(1); + expect(otherDismiss).not.toHaveBeenCalled(); + }); + + it('hides the pinned tooltip on a touch on a chart with nothing pinned', () => { + // Given a pinned tooltip + const dismiss = jest.fn(); + pinChartTooltip(dismiss); + + // When a chart without a pinned tooltip is touched + markChartTouch(undefined); + dismissPinnedChartTooltip(); + + // Then the tooltip is hidden + expect(dismiss).toHaveBeenCalledTimes(1); + }); + + it('hides the pinned tooltip on a touch outside after a touch on its own chart', () => { + // Given a pinned tooltip whose own chart was touched once + const dismiss = jest.fn(); + pinChartTooltip(dismiss); + markChartTouch(dismiss); + dismissPinnedChartTooltip(); + + // When the screen is touched outside of any chart + dismissPinnedChartTooltip(); + + // Then the touch on the chart only protected the tooltip from the touch it belonged to + expect(dismiss).toHaveBeenCalledTimes(1); + }); + + it('hides the pinned tooltip only once', () => { + // Given a pinned tooltip hidden by a screen touch + const dismiss = jest.fn(); + pinChartTooltip(dismiss); + dismissPinnedChartTooltip(); + + // When the screen is touched again + dismissPinnedChartTooltip(); + + // Then the tooltip is not hidden again + expect(dismiss).toHaveBeenCalledTimes(1); + }); + + it('does nothing on a touch when no tooltip is pinned', () => { + // Given no pinned tooltip + + // When the screen is touched + // Then nothing breaks + expect(() => dismissPinnedChartTooltip()).not.toThrow(); + }); + }); +}); diff --git a/tests/unit/resolveDashboardStateTest.ts b/tests/unit/resolveDashboardStateTest.ts index e810c2e17d43..97c44a4a1bf1 100644 --- a/tests/unit/resolveDashboardStateTest.ts +++ b/tests/unit/resolveDashboardStateTest.ts @@ -37,6 +37,7 @@ function makeSnapshot(data: SearchResults['data']): SearchResults { const GROUP_KEY = `${CONST.SEARCH.GROUP_PREFIX}1` as const; const SNAPSHOT_WITH_ROWS = makeSnapshot({[GROUP_KEY]: {count: 1, total: 1000, currency: 'USD', year: 2026, month: 1}}); const SNAPSHOT_WITHOUT_ROWS = makeSnapshot({}); +const SNAPSHOT_WITH_EMPTY_BUCKETS = makeSnapshot({[GROUP_KEY]: {count: 0, total: 0, currency: 'USD', year: 2026, month: 1}}); const FAILED_SNAPSHOT: SearchResults = {...makeSnapshot({}), errors: ERRORS}; function makeCharts(headline: SearchResults | undefined, supporting: SearchResults | undefined): InsightsDashboardChart[] { @@ -182,6 +183,24 @@ describe('getDashboardState', () => { expect(state).toBe(INSIGHTS_DASHBOARD_STATE.EMPTY); }); + it('says the account has no expenses when every bucket came back empty', () => { + // Given an account with no expenses whose snapshots hold only the empty buckets GetInsights sends for the range + // When the page's state is resolved + const state = getDashboardState({...LOADED_DASHBOARD, hasResults: false}, false, makeCharts(SNAPSHOT_WITH_EMPTY_BUCKETS, SNAPSHOT_WITH_EMPTY_BUCKETS)); + + // Then the page says the user has no expenses + expect(state).toBe(INSIGHTS_DASHBOARD_STATE.NO_EXPENSES); + }); + + it('says the filters matched nothing when every bucket came back empty', () => { + // Given an account with expenses whose snapshots hold only empty buckets + // When the page's state is resolved + const state = getDashboardState(LOADED_DASHBOARD, false, makeCharts(SNAPSHOT_WITH_EMPTY_BUCKETS, SNAPSHOT_WITH_EMPTY_BUCKETS)); + + // Then the page shows an empty state + expect(state).toBe(INSIGHTS_DASHBOARD_STATE.EMPTY); + }); + it('draws the charts when any of them has rows', () => { // Given a headline snapshot with no rows, and a supporting chart that has some // When the page's state is resolved diff --git a/tests/unit/resolveDraggedColumnWidthsTest.ts b/tests/unit/resolveDraggedColumnWidthsTest.ts new file mode 100644 index 000000000000..411a103862b4 --- /dev/null +++ b/tests/unit/resolveDraggedColumnWidthsTest.ts @@ -0,0 +1,109 @@ +import type {ResizableColumn} from '@components/Table/columnResize/resolveDraggedColumnWidths'; +import resolveDraggedColumnWidths from '@components/Table/columnResize/resolveDraggedColumnWidths'; + +import CONST from '@src/CONST'; + +const {MIN_WIDTH} = CONST.TABLES.COLUMN_RESIZE; + +/** A headed column sized from its content, which absorbs other columns' resizes down to its minimum width. */ +function contentColumn(key: string, minWidth = 0, maxWidth = Number.POSITIVE_INFINITY): ResizableColumn { + return {key, label: key, declaredWidth: undefined, contentWidth: 0, minWidth, maxWidth}; +} + +describe('resolveDraggedColumnWidths', () => { + it('takes a widened column out of the content-sized columns after it, equally', () => { + // Given a 600px row with a fixed status column and a headless arrow among the columns after the first + const columns = [contentColumn('name'), {...contentColumn('status'), declaredWidth: 80}, contentColumn('email'), contentColumn('role'), {...contentColumn('arrow'), label: ''}]; + const startWidths = {name: 200, status: 80, email: 130, role: 150, arrow: 40}; + + // When the first column is dragged 60px wider + const columnWidths = resolveDraggedColumnWidths({columns, startWidths, resizedColumnKeys: [], availableColumnsWidth: 600, columnKey: 'name', width: 260}); + + // Then only the content-sized columns give way, 30px each, so the row still fits and fixed-size content keeps its size + expect(columnWidths).toEqual({name: 260, email: 100, role: 120}); + }); + + it('leaves a column the user dragged before alone and splits the resize between the auto-sized ones', () => { + // Given four columns that can each shrink to 100px, where the user dragged the third to 180px + const columns = ['a', 'b', 'c', 'd'].map((key) => contentColumn(key, 100)); + const startWidths = {a: 150, b: 150, c: 180, d: 120}; + + // When the first column is dragged 60px wider + const columnWidths = resolveDraggedColumnWidths({columns, startWidths, resizedColumnKeys: ['c'], availableColumnsWidth: 600, columnKey: 'a', width: 210}); + + // Then the auto-sized columns give all of it, the last down to its minimum width, so the row fits without scrolling + expect(columnWidths).toEqual({a: 210, b: 110, d: 100}); + }); + + it('never moves the columns before the dragged one', () => { + // Given four columns in a row that fits + const columns = ['a', 'b', 'c', 'd'].map((key) => contentColumn(key)); + const startWidths = {a: 260, b: 180, c: 180, d: 180}; + + // When the third column is dragged 20px wider + const columnWidths = resolveDraggedColumnWidths({columns, startWidths, resizedColumnKeys: [], availableColumnsWidth: 800, columnKey: 'c', width: 200}); + + // Then only the column after it gives way, so the dragged edge stays under the pointer + expect(columnWidths).toEqual({c: 200, d: 160}); + }); + + it("moves an absorber's share to the rest once it reaches its minimum width, and overflows what's left", () => { + // Given a 600px row whose second column can give 10px and whose third can give 40px + const columns = [contentColumn('name'), contentColumn('email', 190), contentColumn('role', 160)]; + const startWidths = {name: 200, email: 200, role: 200}; + + // When the first column is dragged 100px wider + const columnWidths = resolveDraggedColumnWidths({columns, startWidths, resizedColumnKeys: [], availableColumnsWidth: 600, columnKey: 'name', width: 300}); + + // Then both stop at their minimum width, and the 50px nobody can give scrolls + expect(columnWidths).toEqual({name: 300, email: 190, role: 160}); + }); + + it("moves an absorber's share to the rest once it reaches its maximum width", () => { + // Given a 560px row whose second column is capped 20px above its width, so it truncates long values + const columns = [contentColumn('member'), contentColumn('card', undefined, 180), contentColumn('account')]; + const startWidths = {member: 200, card: 160, account: 200}; + + // When the first column is dragged 60px narrower + const columnWidths = resolveDraggedColumnWidths({columns, startWidths, resizedColumnKeys: [], availableColumnsWidth: 560, columnKey: 'member', width: 140}); + + // Then the capped column stops at its maximum and the uncapped one takes the rest, so the row still fits + expect(columnWidths).toEqual({member: 140, card: 180, account: 240}); + }); + + it('never squeezes an absorber below the drag bound, even when its content is narrower', () => { + // Given a column after the first whose content needs less than the drag bound + const columns = [contentColumn('name'), contentColumn('flag', 10)]; + + // When the first column is dragged far wider + const columnWidths = resolveDraggedColumnWidths({ + columns, + startWidths: {name: 200, flag: 100}, + resizedColumnKeys: [], + availableColumnsWidth: 300, + columnKey: 'name', + width: 400, + }); + + // Then it stops at the drag bound, so its own edge stays within reach + expect(columnWidths.flag).toBe(MIN_WIDTH); + }); + + it('takes back overflow before growing the columns after a narrowed one', () => { + // Given a row 40px wider than the table + const columns = [contentColumn('name'), contentColumn('email')]; + + // When the first column is dragged 60px narrower + const columnWidths = resolveDraggedColumnWidths({ + columns, + startWidths: {name: 340, email: 300}, + resizedColumnKeys: [], + availableColumnsWidth: 600, + columnKey: 'name', + width: 280, + }); + + // Then the first 40px only shrink the scroll width, and the column after it takes the remaining 20px + expect(columnWidths).toEqual({name: 280, email: 320}); + }); +}); diff --git a/tests/unit/resolveInsightsChartDataTest.ts b/tests/unit/resolveInsightsChartDataTest.ts index 0b995bc0bacb..4feda1fb9d22 100644 --- a/tests/unit/resolveInsightsChartDataTest.ts +++ b/tests/unit/resolveInsightsChartDataTest.ts @@ -29,15 +29,15 @@ function makeSnapshot(overrides: Partial = {}): SearchResults { }; } -function makeRows(count: number): GroupedItem[] { - return Array.from({length: count}, (_, index) => ({ +function makeRows(rowCount: number, expenseCount = 1): GroupedItem[] { + return Array.from({length: rowCount}, (_, index) => ({ keyForList: String(index), transactions: [], groupedBy: CONST.SEARCH.GROUP_BY.MONTH, year: 2026, month: index + 1, - count: 1, - total: 100, + count: expenseCount, + total: expenseCount ? 100 : 0, currency: CONST.CURRENCY.USD, formattedMonth: `Month ${index + 1}`, shortFormattedMonth: `M${index + 1}`, @@ -90,6 +90,16 @@ describe('resolveInsightsChartData', () => { expect(state).toBe(INSIGHTS_CHART_STATE.EMPTY); }); + it('is empty when every bucket has no expenses', () => { + // Given a settled snapshot holding only the empty buckets GetInsights sends for the range + // When the chart is resolved + const {data, state} = resolveInsightsChartData({snapshot: makeSnapshot(), queryJSON: QUERY_JSON, sortedData: makeRows(3, 0)}); + + // Then the chart is empty instead of drawing a flat line + expect(state).toBe(INSIGHTS_CHART_STATE.EMPTY); + expect(data).toEqual([]); + }); + it('is empty when the snapshot arrived with no rows in it', () => { // Given a settled snapshot holding data that groups into nothing // When the chart is resolved diff --git a/tests/unit/sanitizeLogParamsTest.ts b/tests/unit/sanitizeLogParamsTest.ts index fc6cbe8a23a7..166a7d308768 100644 --- a/tests/unit/sanitizeLogParamsTest.ts +++ b/tests/unit/sanitizeLogParamsTest.ts @@ -1,6 +1,29 @@ import sanitizeLogParams, {sanitizeUrlForLogging} from '@libs/sanitizeLogParams'; describe('sanitizeLogParams', () => { + it('redacts wallet authentication and provisioning material from API logs', () => { + // Given signed wallet request fields and encrypted provisioning fields can pass through the API logger. + const params = { + command: 'CreateDigitalWallet', + cardID: 123, + data: { + signedChallenge: '{"signature":"secret"}', + nonce: 'nonce', + nonceSignature: 'signature', + certificates: '["certificate"]', + encryptedPassData: 'encrypted', + activationData: 'activation', + ephemeralPublicKey: 'ephemeral', + opaquePaymentCard: 'opaque', + }, + }; + // When the shared API logging boundary sanitizes these parameters. + const result = sanitizeLogParams(params); + // Then secrets are redacted without changing the actual wire input or nonsensitive identifiers. + expect(result).toEqual({command: 'CreateDigitalWallet', cardID: 123, data: Object.fromEntries(Object.keys(params.data).map((key) => [key, '']))}); + expect(params.data.signedChallenge).toBe('{"signature":"secret"}'); + }); + it('redacts known sensitive keys', () => { const result = sanitizeLogParams({authToken: 'secret', password: 'hunter2', token: 'abc'}); expect(result).toEqual({authToken: '', password: '', token: ''}); diff --git a/tests/unit/selectors/AgentZeroChatTest.ts b/tests/unit/selectors/AgentZeroChatTest.ts new file mode 100644 index 000000000000..ffda9f2b8179 --- /dev/null +++ b/tests/unit/selectors/AgentZeroChatTest.ts @@ -0,0 +1,33 @@ +import type {PersonalDetailsList} from '@src/types/onyx'; + +import {getCustomAgentAccountIDs} from '@selectors/AgentZeroChat'; + +const FIRST_AGENT_ACCOUNT_ID = 1001; +const HUMAN_ACCOUNT_ID = 2002; +const SECOND_AGENT_ACCOUNT_ID = 3003; + +describe('getCustomAgentAccountIDs', () => { + it('returns the custom agents only, sorted by accountID', () => { + // Given personal details where two custom agents surround a human, out of order + const personalDetails: PersonalDetailsList = { + [SECOND_AGENT_ACCOUNT_ID]: {accountID: SECOND_AGENT_ACCOUNT_ID, isCustomAgent: true}, + [HUMAN_ACCOUNT_ID]: {accountID: HUMAN_ACCOUNT_ID}, + [FIRST_AGENT_ACCOUNT_ID]: {accountID: FIRST_AGENT_ACCOUNT_ID, isCustomAgent: true}, + }; + + // When the custom agents are picked out + const customAgentAccountIDs = getCustomAgentAccountIDs(personalDetails); + + // Then only the agents come back in a stable order, so the selector result compares equal between renders + expect(customAgentAccountIDs).toEqual([FIRST_AGENT_ACCOUNT_ID, SECOND_AGENT_ACCOUNT_ID]); + }); + + it('returns an empty list when no custom agent is present', () => { + // Given personal details for humans only + const personalDetails: PersonalDetailsList = {[HUMAN_ACCOUNT_ID]: {accountID: HUMAN_ACCOUNT_ID}}; + + // When the custom agents are picked out + // Then nothing comes back + expect(getCustomAgentAccountIDs(personalDetails)).toEqual([]); + }); +}); diff --git a/tests/unit/selectors/ReportActionTest.ts b/tests/unit/selectors/ReportActionTest.ts index b0d3a304a286..916076705eed 100644 --- a/tests/unit/selectors/ReportActionTest.ts +++ b/tests/unit/selectors/ReportActionTest.ts @@ -1,7 +1,7 @@ import CONST from '@src/CONST'; import type {ReportAction, ReportActions} from '@src/types/onyx'; -import {getReceiptScanFailedIOUActionDataSelector} from '@selectors/ReportAction'; +import {getLatestFeedbackActionIDByAuthorSelector, getReceiptScanFailedIOUActionDataSelector} from '@selectors/ReportAction'; const CURRENT_USER_ACCOUNT_ID = 123456789; const OTHER_USER_ACCOUNT_ID = 987654321; @@ -146,3 +146,36 @@ describe('getReceiptScanFailedIOUActionDataSelector', () => { }); }); }); + +describe('getLatestFeedbackActionIDByAuthorSelector', () => { + const AGENT_ACCOUNT_ID = 1001; + + const createComment = (reportActionID: string, actorAccountID: number, created: string): ReportAction => ({ + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID, + created, + message: [{type: 'COMMENT', html: 'hi', text: 'hi'}], + originalMessage: {html: 'hi', whisperedTo: []}, + }); + + const reportActions: ReportActions = { + concierge: createComment('concierge', CONST.ACCOUNT_ID.CONCIERGE, '2026-09-01 00:00:00.000'), + olderAgent: createComment('olderAgent', AGENT_ACCOUNT_ID, '2026-09-02 00:00:00.000'), + newerAgent: createComment('newerAgent', AGENT_ACCOUNT_ID, '2026-09-03 00:00:00.000'), + }; + + it('returns the newest comment of the given author', () => { + // Given a parent report where a custom agent wrote twice after Concierge + // When the selector runs for the agent + // Then it picks the agent's newest comment, and Concierge's comment does not count for the agent + expect(getLatestFeedbackActionIDByAuthorSelector(AGENT_ACCOUNT_ID)(reportActions)).toBe('newerAgent'); + }); + + it('returns undefined when the author is not known', () => { + // Given a parent action whose author is not loaded yet + // When the selector runs without an author + // Then it returns nothing, so the selector does not fall back to Concierge's newest comment + expect(getLatestFeedbackActionIDByAuthorSelector(undefined)(reportActions)).toBeUndefined(); + }); +}); diff --git a/tests/unit/selectors/ReportTest.ts b/tests/unit/selectors/ReportTest.ts index e8ec24e9c382..67d74d70d7de 100644 --- a/tests/unit/selectors/ReportTest.ts +++ b/tests/unit/selectors/ReportTest.ts @@ -1,7 +1,8 @@ import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; -import {reportAvatarFieldsSelector, reportAvatarKindSelector, reportPolicyFieldsSelector} from '@selectors/Report'; +import {reportAvatarFieldsSelector, reportAvatarKindSelector, reportParentChainSelector, reportPolicyFieldsSelector} from '@selectors/Report'; const REPORT_ID = 'report123'; const PARENT_REPORT_ID = 'parentReport123'; @@ -104,3 +105,64 @@ describe('reportPolicyFieldsSelector', () => { expect(reportPolicyFieldsSelector(undefined)).toBeUndefined(); }); }); + +describe('reportParentChainSelector', () => { + const GRANDPARENT_REPORT_ID = 'grandparentReport123'; + const GRANDPARENT_REPORT_ACTION_ID = 'grandparentReportAction123'; + const UNRELATED_REPORT_ID = 'unrelatedReport123'; + + const reportKey = (reportID: string) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}` as const; + + it('should return the parent links of the report and every ancestor, and nothing else', () => { + // Given a thread -> parent -> grandparent chain plus an unrelated report in the collection + const reports = { + [reportKey(REPORT_ID)]: createReport({...threadFields, lastMessageText: 'Hello'}), + [reportKey(PARENT_REPORT_ID)]: createReport({reportID: PARENT_REPORT_ID, parentReportID: GRANDPARENT_REPORT_ID, parentReportActionID: GRANDPARENT_REPORT_ACTION_ID}), + [reportKey(GRANDPARENT_REPORT_ID)]: createReport({reportID: GRANDPARENT_REPORT_ID}), + [reportKey(UNRELATED_REPORT_ID)]: createReport({reportID: UNRELATED_REPORT_ID, ...threadFields}), + }; + + // When the selector is applied for the thread + const result = reportParentChainSelector(REPORT_ID)(reports); + + // Then only the chain entries are returned, projected to the parent link fields, so unrelated reports cannot affect the result + expect(result).toEqual({ + [reportKey(REPORT_ID)]: threadFields, + [reportKey(PARENT_REPORT_ID)]: {parentReportID: GRANDPARENT_REPORT_ID, parentReportActionID: GRANDPARENT_REPORT_ACTION_ID}, + [reportKey(GRANDPARENT_REPORT_ID)]: {parentReportID: undefined, parentReportActionID: undefined}, + }); + }); + + it('should stop at the first ancestor that is not in Onyx', () => { + // Given a thread whose parent report is not loaded + const reports = {[reportKey(REPORT_ID)]: createReport(threadFields)}; + + // When the selector is applied for the thread + const result = reportParentChainSelector(REPORT_ID)(reports); + + // Then only the thread's own links are returned, which is all the error-clearing walk can use + expect(result).toEqual({[reportKey(REPORT_ID)]: threadFields}); + }); + + it('should terminate when the parent links form a cycle', () => { + // Given two reports that list each other as parent (corrupt data must not hang the UI thread) + const reports = { + [reportKey(REPORT_ID)]: createReport(threadFields), + [reportKey(PARENT_REPORT_ID)]: createReport({reportID: PARENT_REPORT_ID, parentReportID: REPORT_ID, parentReportActionID: 'action1'}), + }; + + // When the selector is applied for the thread + const result = reportParentChainSelector(REPORT_ID)(reports); + + // Then each report on the cycle appears once + expect(Object.keys(result ?? {})).toEqual([reportKey(REPORT_ID), reportKey(PARENT_REPORT_ID)]); + }); + + it('should return an empty collection when the report ID is missing or the collection is not loaded', () => { + // Given no report ID, or no report collection + // When the selector is applied + // Then nothing is returned, so the caller's walk does not fall back to stale data + expect(reportParentChainSelector(undefined)({[reportKey(REPORT_ID)]: createReport(threadFields)})).toEqual({}); + expect(reportParentChainSelector(REPORT_ID)(undefined)).toEqual({}); + }); +}); diff --git a/tests/unit/selectors/TableColumnWidthsTest.ts b/tests/unit/selectors/TableColumnWidthsTest.ts deleted file mode 100644 index f8e2dd1ffed5..000000000000 --- a/tests/unit/selectors/TableColumnWidthsTest.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {tableColumnWidthsSelector} from '@src/selectors/TableColumnWidths'; - -const TAGS_TABLE_ID = 'workspaceTags'; -const TAXES_TABLE_ID = 'workspaceTaxes'; - -describe('tableColumnWidthsSelector', () => { - const storedWidths = { - [TAGS_TABLE_ID]: {name: 240, enabled: 120}, - [TAXES_TABLE_ID]: {name: 300}, - }; - - // Given a table that has opted into resizing - // When its widths are selected out of the record holding every table's - // Then it gets only its own, so the table never lays out from another table's columns - it('picks out the widths stored for the table', () => { - expect(tableColumnWidthsSelector(TAGS_TABLE_ID)(storedWidths)).toEqual({name: 240, enabled: 120}); - }); - - // Given a resizable table the user has never dragged - // When its widths are selected - // Then there are none, so it lays out from its column defaults instead of another table's widths - it('returns nothing for a table with no stored widths', () => { - expect(tableColumnWidthsSelector('workspaceCategories')(storedWidths)).toBeUndefined(); - }); -}); diff --git a/tests/unit/startNavigateToInboxTabSpanTest.ts b/tests/unit/startNavigateToInboxTabSpanTest.ts new file mode 100644 index 000000000000..7c41987878db --- /dev/null +++ b/tests/unit/startNavigateToInboxTabSpanTest.ts @@ -0,0 +1,93 @@ +import {startSpan} from '@libs/telemetry/activeSpans'; +import {markReportsTabPreloadDispatched} from '@libs/telemetry/reportsTabPreloadTime'; +import {startNavigateToInboxTabSpan} from '@libs/telemetry/startTabNavigationSpans'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +jest.mock('@libs/telemetry/activeSpans', () => ({startSpan: jest.fn()})); +jest.mock('@libs/telemetry/navigateToReportsSpans', () => ({startNavigateToReportsSpans: jest.fn()})); +let mockRootState: unknown; +jest.mock('@navigation/navigationRef', () => ({getRootState: () => mockRootState})); + +const REPORTS_ROUTE_KEY = 'reports-key'; +const TAB_NAVIGATOR_KEY = 'tab-key'; + +function setRootState({focusedIndex, preloadedRouteKeys}: {focusedIndex: number; preloadedRouteKeys: string[]}) { + mockRootState = { + routes: [ + { + name: NAVIGATORS.TAB_NAVIGATOR, + state: { + key: TAB_NAVIGATOR_KEY, + index: focusedIndex, + preloadedRouteKeys, + routes: [ + {name: SCREENS.HOME, key: 'home-key'}, + {name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, key: 'search-key'}, + {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, key: REPORTS_ROUTE_KEY}, + ], + }, + }, + ], + }; +} + +function getStartedAttributes() { + return jest.mocked(startSpan).mock.calls.at(-1)?.[1].attributes ?? {}; +} + +describe('startNavigateToInboxTabSpan', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('records the tab being left and how long the preloaded tab waited', () => { + // Given the user is on Search and the Reports tab was preloaded 5 seconds ago + const nowSpy = jest.spyOn(performance, 'now').mockReturnValue(1000); + markReportsTabPreloadDispatched(TAB_NAVIGATOR_KEY); + nowSpy.mockReturnValue(6000); + setRootState({focusedIndex: 1, preloadedRouteKeys: [REPORTS_ROUTE_KEY]}); + + // When the Inbox tab is tapped, before the switch is dispatched, so the span still sees the tab being left + startNavigateToInboxTabSpan({isWideLayout: true}); + + // Then the span says where the tap came from and how long the frozen tab waited, so slow taps can be split by both + expect(getStartedAttributes()).toMatchObject({ + [CONST.TELEMETRY.ATTRIBUTE_SOURCE_TAB]: 'SEARCH', + [CONST.TELEMETRY.ATTRIBUTE_IS_PRELOADED]: true, + [CONST.TELEMETRY.ATTRIBUTE_MS_SINCE_PRELOAD]: 5000, + }); + nowSpy.mockRestore(); + }); + + it('leaves out the preload wait when the tab is not preloaded', () => { + // Given a preload happened earlier, but the Reports tab is not preloaded anymore and the user is on Home + markReportsTabPreloadDispatched(TAB_NAVIGATOR_KEY); + setRootState({focusedIndex: 0, preloadedRouteKeys: []}); + + // When the Inbox tab is tapped, before the switch is dispatched, so the span still sees the tab being left + startNavigateToInboxTabSpan({isWideLayout: false}); + + // Then no wait is reported, because an old preload time would describe a tab that is no longer frozen + const attributes = getStartedAttributes(); + expect(attributes[CONST.TELEMETRY.ATTRIBUTE_SOURCE_TAB]).toBe('HOME'); + expect(attributes[CONST.TELEMETRY.ATTRIBUTE_IS_PRELOADED]).toBe(false); + expect(attributes).not.toHaveProperty(CONST.TELEMETRY.ATTRIBUTE_MS_SINCE_PRELOAD); + }); + + it('leaves out the preload wait when the last preload went to another tab navigator', () => { + // Given a cross-tab deep link pushed a second tab navigator whose preload overwrote the saved time, and the user went back to the first one + markReportsTabPreloadDispatched('pushed-tab-key'); + setRootState({focusedIndex: 1, preloadedRouteKeys: [REPORTS_ROUTE_KEY]}); + + // When the Inbox tab is tapped on the first tab navigator, whose Reports tab is still preloaded + startNavigateToInboxTabSpan({isWideLayout: true}); + + // Then no wait is reported, because the saved time belongs to the other navigator's preload and would understate the wait + const attributes = getStartedAttributes(); + expect(attributes[CONST.TELEMETRY.ATTRIBUTE_IS_PRELOADED]).toBe(true); + expect(attributes).not.toHaveProperty(CONST.TELEMETRY.ATTRIBUTE_MS_SINCE_PRELOAD); + }); +}); diff --git a/tests/unit/useAccountIDToNameMapTest.tsx b/tests/unit/useAccountIDToNameMapTest.tsx index 17ec926b35ff..73ce175a5d2f 100644 --- a/tests/unit/useAccountIDToNameMapTest.tsx +++ b/tests/unit/useAccountIDToNameMapTest.tsx @@ -1,6 +1,6 @@ import {act, renderHook} from '@testing-library/react-native'; -import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap'; +import useAccountIDToNameMap, {useMentionedAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; @@ -34,16 +34,16 @@ const renderAccountIDToNameMap = async () => { return hook; }; -describe('useAccountIDToNameMap', () => { - beforeAll(() => { - Onyx.init({keys: ONYXKEYS}); - }); +beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); +}); - beforeEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdates(); - }); +beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); +}); +describe('useAccountIDToNameMap', () => { it('returns an empty object when personalDetailsList is not set', async () => { const {result} = await renderAccountIDToNameMap(); expect(result.current).toEqual({}); @@ -111,3 +111,44 @@ describe('useAccountIDToNameMap', () => { expect(result.current).toBe(firstResult); }); }); + +describe('useMentionedAccountIDToNameMap', () => { + it('returns only mentioned account IDs when html is provided', async () => { + await Onyx.set( + ONYXKEYS.PERSONAL_DETAILS_LIST, + buildList([ + [1001, createPersonalDetails(1001, {login: 'alice@expensify.com', displayName: 'Alice'})], + [1002, createPersonalDetails(1002, {login: 'bob@expensify.com', displayName: 'Bob'})], + [1003, createPersonalDetails(1003, {login: undefined, displayName: 'Carol'})], + ]), + ); + + const html = 'Review PR with and '; + const hook = renderHook(() => useMentionedAccountIDToNameMap(html)); + await act(async () => { + await waitForBatchedUpdates(); + }); + + // Given only account IDs 1001 and 1003 are mentioned in the HTML + // Then the map should only contain those two accounts + expect(hook.result.current['1001']).toBe('alice@expensify.com'); + expect(hook.result.current['1003']).toBe('Carol'); + expect(Object.keys(hook.result.current)).toHaveLength(2); + // When account 1002 is not mentioned + // Then it should not appear in the map + expect(hook.result.current['1002']).toBeUndefined(); + }); + + it('returns an empty map when html has no mentions', async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, buildList([[1, createPersonalDetails(1, {login: 'alice@example.com'})]])); + + const hook = renderHook(() => useMentionedAccountIDToNameMap('Buy milk')); + await act(async () => { + await waitForBatchedUpdates(); + }); + + // Given the HTML contains no mention-user tags + // Then the map should be empty + expect(hook.result.current).toEqual({}); + }); +}); diff --git a/tests/unit/useAccountTabIndicatorStatusTest.ts b/tests/unit/useAccountTabIndicatorStatusTest.ts index c06c5a354adf..331f4c360498 100644 --- a/tests/unit/useAccountTabIndicatorStatusTest.ts +++ b/tests/unit/useAccountTabIndicatorStatusTest.ts @@ -185,6 +185,7 @@ const getMockForTestCase = ({name, status}: IndicatorTestCase) => owner: name === accountCardFeedTestCases.admin.name ? 'johndoe12@expensify.com' : 'otheruser@expensify.com', role: name === accountCardFeedTestCases.admin.name ? 'admin' : 'user', policyAccountID: cardFeed.policyAccountID, + areDistanceRatesEnabled: status === CONST.INDICATOR_STATUS.HAS_HOME_ADDRESS_INFO, commuterExclusions: status === CONST.INDICATOR_STATUS.HAS_HOME_ADDRESS_INFO ? {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE} : undefined, }, [ONYXKEYS.CARD_LIST]: { diff --git a/tests/unit/useAccountingConnectionListingsTest.ts b/tests/unit/useAccountingConnectionListingsTest.ts index 9e67e7a85e35..d4f06c0af32a 100644 --- a/tests/unit/useAccountingConnectionListingsTest.ts +++ b/tests/unit/useAccountingConnectionListingsTest.ts @@ -21,9 +21,13 @@ jest.mock('@hooks/useThemeStyles', () => () => ({})); jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({})})); jest.mock('@hooks/usePolicyFeatureWriteAccess', () => () => ({canWrite: mockCanWrite, showReadOnlyModal: mockShowReadOnlyModal})); jest.mock('@libs/actions/connections', () => ({isConnectionInProgress: () => false, isConnectionUnverified: () => false})); -jest.mock('@libs/actions/connections/QuickbooksOnline', () => ({shouldShowQBOReimbursableExportDestinationAccountError: () => false})); + jest.mock('@libs/Navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn()}})); -jest.mock('@libs/PolicyUtils', () => ({getConnectedIntegration: () => mockConnectedIntegration, getIntegrationLastSuccessfulDate: () => undefined})); +jest.mock('@libs/PolicyUtils', () => ({ + getConnectedIntegration: () => mockConnectedIntegration, + getIntegrationLastSuccessfulDate: () => undefined, + shouldShowQBOReimbursableExportDestinationAccountError: () => false, +})); jest.mock('@pages/workspace/accounting/AccountingContext', () => ({ useAccountingActions: () => ({startIntegrationFlow: mockStartIntegrationFlow}), useAccountingState: () => ({activeIntegration: undefined, popoverAnchorRefs: {current: {}}}), @@ -53,7 +57,7 @@ describe('useAccountingConnectionListings', () => { const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO); // When the admin connects Xero - xero?.onConnect(); + xero?.onConnect?.(); // Then Xero's flow starts with nothing to disconnect expect(mockStartIntegrationFlow).toHaveBeenCalledWith({name: CONST.POLICY.CONNECTIONS.NAME.XERO, isIntuitEnterpriseSuite: undefined}); @@ -65,7 +69,7 @@ describe('useAccountingConnectionListings', () => { const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO); // When the admin connects Xero - xero?.onConnect(); + xero?.onConnect?.(); // Then the flow is told to disconnect QuickBooks Online first, which asks the admin before replacing it expect(mockStartIntegrationFlow).toHaveBeenCalledWith({ @@ -82,7 +86,7 @@ describe('useAccountingConnectionListings', () => { const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO); // When they try to connect Xero - xero?.onConnect(); + xero?.onConnect?.(); // Then they are told they can't, and no flow starts expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1); diff --git a/tests/unit/useArrowKeyFocusManagerTest.ts b/tests/unit/useArrowKeyFocusManagerTest.ts index ecd2d989a32f..386d76cc0155 100644 --- a/tests/unit/useArrowKeyFocusManagerTest.ts +++ b/tests/unit/useArrowKeyFocusManagerTest.ts @@ -20,6 +20,15 @@ function pressArrowDown() { }); } +function pressArrowUp() { + act(() => { + if (!shortcuts.ArrowUp?.isActive) { + return; + } + shortcuts.ArrowUp.callback(); + }); +} + describe('useArrowKeyFocusManager', () => { afterEach(() => { for (const key of Object.keys(shortcuts)) { @@ -174,4 +183,61 @@ describe('useArrowKeyFocusManager', () => { expect(onFocusedIndexChange).toHaveBeenCalledTimes(1); }); + + it('moves ArrowUp past a disabled header at index 0 to -1 when cyclic traversal is off and negative indexes are allowed', () => { + // Given a sectioned list (header at index 0) that lets ArrowUp leave the list for the search input + const {result} = renderHook(() => + useArrowKeyFocusManager({ + maxIndex: 3, + initialFocusedIndex: 1, + disabledIndexes: [0], + disableCyclicTraversal: true, + allowNegativeIndexes: true, + isActive: true, + }), + ); + + // When ArrowUp is pressed on the first row + pressArrowUp(); + + // Then focus leaves the rows instead of wrapping to the last row + expect(result.current[0]).toBe(-1); + + // When ArrowDown is pressed from the search input + pressArrowDown(); + + // Then focus returns to the first row, skipping the header + expect(result.current[0]).toBe(1); + }); + + it('moves ArrowUp from the first row to -1 but still wraps ArrowDown from the last row when only negative indexes are allowed', () => { + // Given a sectioned list (header at index 0) that lets ArrowUp leave the list but keeps cyclic traversal on + const {result} = renderHook(() => + useArrowKeyFocusManager({ + maxIndex: 3, + initialFocusedIndex: 3, + disabledIndexes: [0], + allowNegativeIndexes: true, + isActive: true, + }), + ); + + // When ArrowDown is pressed on the last row + pressArrowDown(); + + // Then focus wraps to the first row, skipping the header + expect(result.current[0]).toBe(1); + + // When ArrowUp is pressed on the first row + pressArrowUp(); + + // Then focus leaves the rows for the search input instead of wrapping to the last row + expect(result.current[0]).toBe(-1); + + // When ArrowDown is pressed from the search input + pressArrowDown(); + + // Then focus returns to the first row + expect(result.current[0]).toBe(1); + }); }); diff --git a/tests/unit/useBulkActionBarFocusTest.ts b/tests/unit/useBulkActionBarFocusTest.ts new file mode 100644 index 000000000000..aa96bdef2d8a --- /dev/null +++ b/tests/unit/useBulkActionBarFocusTest.ts @@ -0,0 +1,185 @@ +/** + * Tests for useBulkActionBarFocus (web), the hand-back of focus when the bulk action bar unmounts with the + * selection it describes. Runs against real DOM nodes in jsdom rather than a rendered bar, because the hook only + * ever reads the bar through a ref. + */ + +import {act, renderHook} from '@testing-library/react-native'; + +import type {RefObject} from 'react'; + +// Jest resolves index.native.ts by default, and that one is a no-op. +const useBulkActionBarFocus = require<{ + default: (barRef: RefObject, isCoveredByScreen: boolean) => {suppressStrayFocusRing: () => void; isFocusInsideBar: boolean}; +}>('../../src/components/BulkActionBar/useBulkActionBarFocus/index.ts').default; +const {setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests} = require<{ + setActivePopoverLauncher: (element: HTMLElement) => void; + markActivePopoverLauncherDeactivated: (element?: HTMLElement) => void; + resetLauncherStackForTests: () => void; +}>('../../src/libs/LauncherStack.ts'); + +function createButton(label: string) { + const button = document.createElement('button'); + button.textContent = label; + document.body.appendChild(button); + return button; +} + +/** jsdom fires focusin for `focus()`, which is what the hook listens on, so the real event is enough here. */ +function focus(element: HTMLElement) { + act(() => element.focus()); +} + +afterEach(() => { + resetLauncherStackForTests(); + document.body.innerHTML = ''; +}); + +describe('useBulkActionBarFocus', () => { + it('hands focus back to the table when the bar unmounts holding it', () => { + // Given a row that was focused before the bar appeared, and a bar that has since taken focus + const row = createButton('Row'); + const bar = document.createElement('div'); + const barButton = createButton('Change role'); + bar.appendChild(barButton); + document.body.appendChild(bar); + + const {unmount} = renderHook(() => useBulkActionBarFocus({current: bar}, false)); + + focus(row); + focus(barButton); + + // When the action clears the selection and the bar goes away under the focused button + unmount(); + bar.remove(); + + // Then focus is back on the row rather than lost to the document + expect(document.activeElement).toBe(row); + }); + + it('hands focus back to a checkbox that was already focused when the bar appeared', () => { + // Given the row checkbox that made the selection, which held focus before the bar existed and so fires no + // focusin the bar can see + const row = createButton('Row'); + focus(row); + + const bar = document.createElement('div'); + const barButton = createButton('Clear selection'); + bar.appendChild(barButton); + document.body.appendChild(bar); + + const {unmount} = renderHook(() => useBulkActionBarFocus({current: bar}, false)); + + focus(barButton); + + // When the action clears the selection and the bar goes away under the focused button + unmount(); + bar.remove(); + + // Then focus is back on the checkbox rather than lost to the document + expect(document.activeElement).toBe(row); + }); + + it('hands focus back when an action run from another screen has already dropped it to the document', () => { + // Given a row focused on the table, and a screen opened over it that takes focus to its own Save button + const row = createButton('Row'); + const bar = document.createElement('div'); + document.body.appendChild(bar); + const save = createButton('Save'); + + const {rerender, unmount} = renderHook(({isCoveredByScreen}) => useBulkActionBarFocus({current: bar}, isCoveredByScreen), {initialProps: {isCoveredByScreen: false}}); + + focus(row); + + rerender({isCoveredByScreen: true}); + focus(save); + + // When that screen saves and leaves, so its own controls are gone and focus has fallen to the document + // before the bar unmounts with the selection + act(() => save.remove()); + expect(document.activeElement).toBe(document.body); + + unmount(); + bar.remove(); + + // Then focus is back on the row, not left on the document + expect(document.activeElement).toBe(row); + }); + + it('leaves focus alone when the bar unmounts without holding it', () => { + // Given a bar that unmounts while focus sits on a control of the table behind it + const row = createButton('Row'); + const other = createButton('Other'); + const bar = document.createElement('div'); + document.body.appendChild(bar); + + const {unmount} = renderHook(() => useBulkActionBarFocus({current: bar}, false)); + + focus(row); + focus(other); + + // When the last row is deselected and the bar goes away + unmount(); + bar.remove(); + + // Then the control the user is actually on keeps focus + expect(document.activeElement).toBe(other); + }); + + it('keeps the table target when focus passes through the bar\'s own "More" menu', () => { + // Given a row focused on the table, then the bar's "More" menu opened over it. The menu renders in a popover + // portalled to the body, so its items are outside the bar in the DOM but on the same screen. + const row = createButton('Row'); + const bar = document.createElement('div'); + document.body.appendChild(bar); + const moreButton = createButton('More'); + bar.appendChild(moreButton); + const menuItem = createButton('Remove members'); + + const {unmount} = renderHook(() => useBulkActionBarFocus({current: bar}, false)); + + focus(row); + focus(moreButton); + act(() => setActivePopoverLauncher(moreButton)); + focus(menuItem); + + // When the chosen action clears the selection, so the menu has closed and the bar unmounts with it + act(() => { + markActivePopoverLauncherDeactivated(moreButton); + menuItem.remove(); + }); + + unmount(); + bar.remove(); + + // Then focus is back on the row, rather than lost because the menu item was remembered and is now gone + expect(document.activeElement).toBe(row); + }); + + it('hands focus back when the bar unmounts while its own "More" menu still holds focus', () => { + // Given a row focused on the table, then an action chosen from the bar's "More" menu. The action clears the + // selection from the menu item, so the bar unmounts before the popover has finished closing and focus is + // still on the item rather than back on the table. + const row = createButton('Row'); + const bar = document.createElement('div'); + document.body.appendChild(bar); + const moreButton = createButton('More'); + bar.appendChild(moreButton); + const menuItem = createButton('Duplicate expenses'); + + const {unmount} = renderHook(() => useBulkActionBarFocus({current: bar}, false)); + + focus(row); + focus(moreButton); + act(() => setActivePopoverLauncher(moreButton)); + focus(menuItem); + + // When the bar goes away with the selection, taking the launcher the popover would otherwise return to + unmount(); + bar.remove(); + act(() => menuItem.remove()); + + // Then focus is back on the row, rather than aimed at a "More" button that left with the bar + expect(document.activeElement).toBe(row); + }); +}); diff --git a/tests/unit/useClearReportActionDraftsOnReportChangeTest.tsx b/tests/unit/useClearReportActionDraftsOnReportChangeTest.tsx new file mode 100644 index 000000000000..8acdc0192107 --- /dev/null +++ b/tests/unit/useClearReportActionDraftsOnReportChangeTest.tsx @@ -0,0 +1,93 @@ +import {renderHook} from '@testing-library/react-native'; + +import {IsInSidePanelContext} from '@hooks/useIsInSidePanel'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import {clearAllReportActionDrafts, clearReportActionDrafts} from '@libs/actions/Report'; + +import useClearReportActionDraftsOnReportChange from '@pages/inbox/report/useClearReportActionDraftsOnReportChange'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); + +jest.mock('@libs/actions/Report', () => ({ + clearAllReportActionDrafts: jest.fn(), + clearReportActionDrafts: jest.fn(), +})); + +const mockUseResponsiveLayout = jest.mocked(useResponsiveLayout); +const mockClearAllReportActionDrafts = jest.mocked(clearAllReportActionDrafts); +const mockClearReportActionDrafts = jest.mocked(clearReportActionDrafts); + +function SidePanelWrapper({children}: {children: React.ReactNode}) { + return {children}; +} + +function mockScreenWidth(isExtraLargeScreenWidth: boolean) { + mockUseResponsiveLayout.mockReturnValue({ + ...CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE, + isExtraLargeScreenWidth, + }); +} + +describe('useClearReportActionDraftsOnReportChange', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockScreenWidth(true); + }); + + it('clears drafts on mount, on report change and on unmount for the central report', () => { + // Given the hook is rendered for a report outside the side panel + const {rerender, unmount} = renderHook(({reportID}: {reportID: string}) => useClearReportActionDraftsOnReportChange(reportID), {initialProps: {reportID: '1'}}); + + // Then drafts are cleared on mount so a draft from another report doesn't stay in edit mode + expect(mockClearAllReportActionDrafts).toHaveBeenCalledTimes(1); + + // When the user switches to another report + rerender({reportID: '2'}); + + // Then drafts are cleared for the report being left and for the new report + expect(mockClearAllReportActionDrafts).toHaveBeenCalledTimes(3); + + // When the report screen unmounts + unmount(); + + // Then drafts are cleared again so leaving the report discards the edit + expect(mockClearAllReportActionDrafts).toHaveBeenCalledTimes(4); + }); + + it('clears only the side panel report drafts when the side panel is docked', () => { + // Given the hook is rendered for the Concierge report inside the docked side panel, next to a central report that may be editing a message + const {unmount} = renderHook(() => useClearReportActionDraftsOnReportChange('1'), {wrapper: SidePanelWrapper}); + + // Then only the side panel report's drafts are cleared on mount + expect(mockClearReportActionDrafts).toHaveBeenCalledTimes(1); + expect(mockClearReportActionDrafts).toHaveBeenLastCalledWith('1'); + + // When the side panel is closed and its report unmounts + unmount(); + + // Then the side panel's own edit draft is discarded, as on any other report + expect(mockClearReportActionDrafts).toHaveBeenCalledTimes(2); + expect(mockClearReportActionDrafts).toHaveBeenLastCalledWith('1'); + + // And the central report's edit draft is kept + expect(mockClearAllReportActionDrafts).not.toHaveBeenCalled(); + }); + + it('clears all drafts when the side panel is a narrow modal', () => { + // Given the screen is too narrow to dock the side panel, so it opens as a modal that covers the central report like a new page + mockScreenWidth(false); + + // When the hook is rendered for the report inside the side panel and then unmounts + const {unmount} = renderHook(() => useClearReportActionDraftsOnReportChange('1'), {wrapper: SidePanelWrapper}); + unmount(); + + // Then all drafts are cleared on mount and unmount, keeping the existing behavior when leaving a report + expect(mockClearAllReportActionDrafts).toHaveBeenCalledTimes(2); + expect(mockClearReportActionDrafts).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/useComposerSubmitClearValueTest.tsx b/tests/unit/useComposerSubmitClearValueTest.tsx new file mode 100644 index 000000000000..2fd970500fba --- /dev/null +++ b/tests/unit/useComposerSubmitClearValueTest.tsx @@ -0,0 +1,128 @@ +/** Exercises optional clear values at the actual submission hook. */ +import {renderHook} from '@testing-library/react-native'; + +import type * as UseSidePanelActionsModule from '@hooks/useSidePanelActions'; + +import type * as ReportModule from '@libs/actions/Report'; +import {addAttachmentWithComment, addComment} from '@libs/actions/Report'; +import {createTaskFromMarkdown} from '@libs/actions/Task'; +import type * as ActiveSpansModule from '@libs/telemetry/activeSpans'; + +import type * as ComposerContextModule from '@pages/inbox/report/ReportActionCompose/ComposerContext'; +import useComposerSubmit from '@pages/inbox/report/ReportActionCompose/useComposerSubmit'; + +import CONST from '@src/CONST'; + +import createMock from '../utils/createMock'; + +const mockPublishDraft = jest.fn(); +const mockClearComposer = jest.fn(); +const mockMeta = createMock>({composerRef: {current: null}, attachmentFileRef: {current: null}, textRef: {current: ''}}); +const mockEditState = createMock>({ + isEditingInComposer: false, + didResetComposerHeightWhileEditing: false, + effectiveDraft: '', + editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF, +}); + +jest.mock('@hooks/useAncestors', () => () => []); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({accountID: 1})); +jest.mock('@hooks/useDelegateAccountID', () => () => undefined); +jest.mock('@hooks/useIsInSidePanel', () => () => false); +jest.mock('@hooks/useOnyx', () => () => [undefined]); +jest.mock('@hooks/usePermissions', () => () => ({isBetaEnabled: () => false})); +jest.mock('@hooks/useReportIsArchived', () => () => false); +jest.mock('@hooks/useSidePanelActions', () => () => ({ + openSidePanel: jest.fn< + ReturnType['openSidePanel']>, + Parameters['openSidePanel']> + >(), +})); +jest.mock('@react-navigation/native', () => ({useRoute: () => ({name: 'Report'})})); +jest.mock('@pages/inbox/ActionListContext', () => ({useActionListContext: () => ({scrollOffsetRef: {current: 100000}})})); +jest.mock('@pages/inbox/report/ReportActionCompose/ComposerContext', () => ({ + useComposerMeta: () => mockMeta, + useComposerActions: () => ({clearComposer: mockClearComposer}), + useComposerSendState: () => ({isSendDisabled: false}), + useComposerEditState: () => mockEditState, + useComposerEditActions: () => ({ + publishDraft: mockPublishDraft, + setDidResetComposerHeightWhileEditing: jest.fn< + ReturnType['setDidResetComposerHeightWhileEditing']>, + Parameters['setDidResetComposerHeightWhileEditing']> + >(), + }), +})); +jest.mock('@pages/inbox/report/ReportActionCompose/useComposerReportData', () => () => ({report: undefined, effectiveTransactionThreadReportID: undefined})); +jest.mock('@pages/inbox/report/ReportActionCompose/useSidePanelContext', () => () => undefined); +jest.mock('@libs/actions/Report', () => ({ + addAttachmentWithComment: jest.fn, Parameters>(), + addComment: jest.fn, Parameters>(), + clearAgentZeroProcessingIndicator: jest.fn, Parameters>(), + setIsComposerFullSize: jest.fn, Parameters>(), +})); +jest.mock('@libs/actions/Task', () => ({createTaskFromMarkdown: jest.fn, Parameters>(() => false)})); +jest.mock('@libs/ReportActionsUtils', () => ({getAllReportActions: () => []})); +jest.mock('@libs/ReportUtils', () => ({isConciergeChatReport: () => false, canUserPerformWriteAction: () => true, generateReportID: () => '2'})); +jest.mock('@libs/NumberUtils', () => ({rand64: () => '3'})); +jest.mock('@libs/telemetry/activeSpans', () => ({ + startSpan: jest.fn, Parameters>(), +})); +jest.mock('@libs/telemetry/getSendMessageListWeight', () => () => ({reportActionCount: 0, moneyRequestPreviewCount: 0})); +jest.mock('@libs/telemetry/getSendMessageSource', () => () => 'report'); +jest.mock('@libs/telemetry/sendMessageSpans', () => ({getSendMessageVisibleSpanID: () => 'span'})); + +describe('useComposerSubmit clear values', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockMeta.attachmentFileRef.current = null; + mockEditState.isEditingInComposer = false; + }); + + it.each(['comment', 'edit', 'attachment'] as const)('handles normalized empty input in the %s branch', (submission) => { + // Given web Composer normalizes an omitted live-markdown value to an empty string. + mockEditState.isEditingInComposer = submission === 'edit'; + if (submission === 'attachment') { + mockMeta.attachmentFileRef.current = {uri: 'file:///photo.png', name: 'photo.png', type: 'image/png'}; + } + const {result} = renderHook(() => useComposerSubmit('1')); + + // When the normalized clear value reaches the actual submission hook. + expect(() => result.current.validateAndSubmitDraft('')).not.toThrow(); + + // Then empty comments are skipped, while edits and attachments retain their existing empty-text behavior. + expect(addComment).not.toHaveBeenCalled(); + expect(createTaskFromMarkdown).not.toHaveBeenCalled(); + expect(mockClearComposer).not.toHaveBeenCalled(); + if (submission === 'edit') { + expect(mockPublishDraft).toHaveBeenCalledWith(''); + expect(addAttachmentWithComment).not.toHaveBeenCalled(); + } else if (submission === 'attachment') { + expect(addAttachmentWithComment).toHaveBeenCalledWith(expect.objectContaining({text: ''})); + expect(mockPublishDraft).not.toHaveBeenCalled(); + } else { + expect(mockPublishDraft).not.toHaveBeenCalled(); + expect(addAttachmentWithComment).not.toHaveBeenCalled(); + } + expect(mockMeta.attachmentFileRef.current).toBeNull(); + }); + + it('retains trimmed strings in the edit, comment and attachment branches', () => { + // Given each existing valid branch receives an actual string. + const {result, rerender} = renderHook(() => useComposerSubmit('1')); + + // When ordinary strings pass through the same submission interface. + result.current.validateAndSubmitDraft(' hello '); + mockEditState.isEditingInComposer = true; + rerender({}); + result.current.validateAndSubmitDraft(' edited '); + mockMeta.attachmentFileRef.current = {uri: 'file:///photo.png', name: 'photo.png', type: 'image/png'}; + result.current.validateAndSubmitDraft(' attached '); + + // Then existing processing retains trimming and branch ownership. + expect(addComment).toHaveBeenCalledWith(expect.objectContaining({text: 'hello'})); + expect(mockPublishDraft).toHaveBeenCalledWith('edited'); + expect(addAttachmentWithComment).toHaveBeenCalledWith(expect.objectContaining({text: 'attached'})); + expect(mockMeta.attachmentFileRef.current).toBeNull(); + }); +}); diff --git a/tests/unit/useConnectedAccountingIntegrationTest.ts b/tests/unit/useConnectedAccountingIntegrationTest.ts new file mode 100644 index 000000000000..854f4b8c3cf7 --- /dev/null +++ b/tests/unit/useConnectedAccountingIntegrationTest.ts @@ -0,0 +1,122 @@ +import {renderHook} from '@testing-library/react-native'; + +import useConnectedAccountingIntegration from '@pages/workspace/accounting/useConnectedAccountingIntegration'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; +import type {ConnectionName} from '@src/types/onyx/Policy'; + +import createRandomPolicy from '../utils/collections/policies'; + +const mockSyncConnection = jest.fn(); +let mockConnectedIntegration: ConnectionName | undefined; + +jest.mock('@hooks/useCardFeeds', () => () => [undefined]); +jest.mock('@hooks/useCardsLists', () => () => [undefined]); +jest.mock('@hooks/useConfirmModal', () => () => ({showConfirmModal: jest.fn()})); +jest.mock('@hooks/useEnvironment', () => () => ({environment: 'development'})); +jest.mock('@hooks/useExpensifyCardFeeds', () => () => ({})); +jest.mock('@hooks/useHasReusablePoliciesConnectedTo', () => () => false); +jest.mock('@hooks/useIsUnifiedConnectionsBetaEnabled', () => () => false); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({})})); +jest.mock('@hooks/useLocalize', () => () => ({ + translate: (key: string) => key, + datetimeToRelative: () => '', + getLocalDateFromDatetime: jest.fn(), + dateFnsLocale: undefined, +})); +jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); +jest.mock('@hooks/useOnyx', () => () => [undefined]); +jest.mock('@hooks/usePermissions', () => () => ({isBetaEnabled: () => false})); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => () => ({canWrite: true, showReadOnlyModal: jest.fn()})); +jest.mock('@hooks/useThemeStyles', () => () => ({})); +jest.mock('@hooks/useWorkspaceAccountID', () => () => undefined); +jest.mock('@libs/AccountingUtils', () => ({ + getQBORefreshTokenExpiryDate: () => undefined, + getQBORefreshTokenExpiryStatus: () => undefined, +})); +jest.mock('@libs/actions/connections', () => ({ + isAuthenticationError: () => false, + isConnectionInProgress: () => false, + isConnectionUnverified: () => false, + removePolicyConnection: jest.fn(), + // Lazy wrapper so mockSyncConnection is resolved at call time + syncConnection: (...args: unknown[]) => { + mockSyncConnection(...args); + }, +})); +jest.mock('@libs/CardUtils', () => ({isExpensifyCardFullySetUp: () => false})); +jest.mock('@libs/DateUtils', () => ({formatWithUTCTimeZone: jest.fn()})); +jest.mock('@libs/Environment/Environment', () => ({ + getEnvironmentURL: () => Promise.resolve(''), + getOldDotURLFromEnvironment: () => '', +})); +jest.mock('@components/TextLink', () => ({__esModule: true, default: () => null})); +jest.mock('@libs/PolicyUtils', () => ({ + areSettingsInErrorFields: () => false, + findCurrentXeroOrganization: () => undefined, + getConnectedIntegration: () => mockConnectedIntegration, + getCurrentSageIntacctEntityName: () => '', + getCurrentXeroOrganizationName: () => '', + getIntegrationLastSuccessfulDate: () => undefined, + getXeroTenants: () => [], + hasAccountingConnections: () => true, + hasSupportedOnlyOnOldDotIntegration: () => false, + settingsPendingAction: () => undefined, + shouldShowQBOReimbursableExportDestinationAccountError: () => false, + shouldShowSyncError: () => false, +})); +jest.mock('@navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn()}})); +jest.mock('@userActions/Policy/Policy', () => ({openPolicyExpensifyCardsPage: jest.fn()})); +jest.mock('@pages/workspace/accounting/AccountingContext', () => ({ + useAccountingActions: () => ({startIntegrationFlow: jest.fn()}), + useAccountingState: () => ({activeIntegration: undefined}), +})); +jest.mock('@pages/workspace/accounting/certinia/utils', () => ({ + getCertiniaSelectedCompanyID: () => undefined, + isCertiniaFFAConnection: () => false, +})); +jest.mock('@pages/workspace/accounting/utils', () => ({ + getAccountingIntegrationData: () => undefined, + getAccountingIntegrationDisplayName: () => '', + getSynchronizationErrorMessage: () => undefined, + isIntuitEnterpriseSuiteConnection: () => false, +})); + +const policy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), id: 'policy-1'}; + +function getSyncNowItem() { + const {result} = renderHook(() => useConnectedAccountingIntegration(policy)); + return result.current.overflowMenu.find((item) => item.text === 'workspace.accounting.syncNow'); +} + +describe('useConnectedAccountingIntegration', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockConnectedIntegration = undefined; + }); + + describe('Sync now menu item', () => { + it('should call syncConnection with forceDataRefresh true when connected to QBD', () => { + // Given a workspace connected to QuickBooks Desktop + mockConnectedIntegration = CONST.POLICY.CONNECTIONS.NAME.QBD; + + // When the user selects "Sync now" + getSyncNowItem()?.onSelected?.(); + + // Then syncConnection is called with forceDataRefresh true so IS forces a full data refresh from Codat + expect(mockSyncConnection).toHaveBeenCalledWith(policy, CONST.POLICY.CONNECTIONS.NAME.QBD, true); + }); + + it('should call syncConnection with forceDataRefresh false for non-QBD integrations', () => { + // Given a workspace connected to QuickBooks Online + mockConnectedIntegration = CONST.POLICY.CONNECTIONS.NAME.QBO; + + // When the user selects "Sync now" + getSyncNowItem()?.onSelected?.(); + + // Then syncConnection is called with forceDataRefresh false since only QBD uses Codat's cached data + expect(mockSyncConnection).toHaveBeenCalledWith(policy, CONST.POLICY.CONNECTIONS.NAME.QBO, false); + }); + }); +}); diff --git a/tests/unit/useCreateNavigationSuggestionsTest.ts b/tests/unit/useCreateNavigationSuggestionsTest.ts index c0dcfd84b0e4..244be8892eb7 100644 --- a/tests/unit/useCreateNavigationSuggestionsTest.ts +++ b/tests/unit/useCreateNavigationSuggestionsTest.ts @@ -15,8 +15,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; type MockUseCreateReportParams = { - onCreateReport: (shouldDismissEmptyReportsConfirmation?: boolean) => void; - groupPoliciesWithChatEnabled: unknown[] | readonly never[]; + onCreateReport: (policy: unknown, shouldDismissEmptyReportsConfirmation?: boolean) => void; onNavigateToWorkspaceSelection: () => void; shouldHandleNavigationBack: boolean; shouldSkipEmptyReportConfirmation?: boolean; @@ -37,8 +36,6 @@ const mockUseOnyx = jest.fn const isBetaEnabledByDefault = (beta: string) => beta !== CONST.BETAS.PREVENT_SPOTNANA_TRAVEL; const mockIsBetaEnabled = jest.fn(isBetaEnabledByDefault); const mockCanSendInvoice = jest.fn(() => false); -const mockGetDefaultChatEnabledPolicy = jest.fn((policies: unknown[]) => (policies.length === 1 ? policies.at(0) : undefined)); -const mockGetGroupPoliciesWhereReportCanBeCreated = jest.fn(); const mockShouldShowPolicy = jest.fn(() => true); const mockHasAcceptedTravelTerms = jest.fn(() => false); const mockIsPaidGroupPolicy = jest.fn(() => false); @@ -165,8 +162,6 @@ jest.mock('@libs/openTravelDotLink', () => ({ jest.mock('@libs/PolicyUtils', () => ({ canSendInvoice: (...args: unknown[]) => mockCanSendInvoice(...args), - getDefaultChatEnabledPolicy: (policies: unknown[]) => mockGetDefaultChatEnabledPolicy(policies), - getGroupPoliciesWhereReportCanBeCreated: (policies: unknown, currentUserLogin?: string) => mockGetGroupPoliciesWhereReportCanBeCreated(policies, currentUserLogin), hasAcceptedTravelTerms: () => mockHasAcceptedTravelTerms(), isPaidGroupPolicy: () => mockIsPaidGroupPolicy(), shouldShowPolicy: (...args: unknown[]) => mockShouldShowPolicy(...args), @@ -220,24 +215,21 @@ describe('useCreateNavigationSuggestions', () => { mockHasAcceptedTravelTerms.mockReturnValue(false); mockIsPaidGroupPolicy.mockReturnValue(false); mockIsBetaEnabled.mockImplementation(isBetaEnabledByDefault); - mockGetGroupPoliciesWhereReportCanBeCreated.mockReturnValue([]); mockIsOnSearchMoneyRequestReportPage.mockReturnValue(false); mockIsRestrictedPolicyCreation = false; mockCreateReportIsVisible = true; jest.mocked(Navigation.isTopmostRouteModalScreen).mockReturnValue(false); }); - it('uses shared report policy eligibility and renders only available Create actions', () => { + it('renders only available Create actions', () => { + // Given useCreateReport reports "Create report" as not visible (e.g. policies or the domain lock still loading) mockCreateReportIsVisible = false; + + // When the Search router builds its Create suggestions const {result} = renderHook(() => useCreateNavigationSuggestions()); - expect(mockGetGroupPoliciesWhereReportCanBeCreated).toHaveBeenCalledWith(policies, session.email); - expect(mockUseCreateReport).toHaveBeenCalledWith( - expect.objectContaining({ - groupPoliciesWithChatEnabled: [], - shouldHandleNavigationBack: false, - }), - ); + // Then "Create report" is left out and the remaining actions keep their order + expect(mockUseCreateReport).toHaveBeenCalledWith(expect.objectContaining({shouldHandleNavigationBack: false})); expect(result.current.map((item) => item.keyForList)).toEqual(['create_expense', 'create_trackDistance', 'create_chat']); }); @@ -279,11 +271,14 @@ describe('useCreateNavigationSuggestions', () => { }); it('does not create a report without a default policy', () => { + // Given the Search router has handed its create callback to useCreateReport renderHook(() => useCreateNavigationSuggestions()); - const onCreateReport = mockUseCreateReport.mock.calls.at(0)?.at(0)?.onCreateReport; - act(() => onCreateReport?.()); + // When the hook resolves no workspace and calls back with undefined + act(() => onCreateReport?.(undefined)); + + // Then no report is created and nothing navigates, rather than creating a report with no workspace expect(createNewReport).not.toHaveBeenCalled(); expect(Navigation.navigate).not.toHaveBeenCalled(); }); @@ -377,15 +372,15 @@ describe('useCreateNavigationSuggestions', () => { expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.TRAVEL_MY_TRIPS.getRoute(submitPolicy.id)); }); - it('passes Submit eligibility and exposes permission-gated actions', () => { - mockGetGroupPoliciesWhereReportCanBeCreated.mockReturnValue([submitPolicy]); + it('exposes permission-gated actions', () => { + // Given a user who can send invoices and has no workspace of their own yet mockCanSendInvoice.mockReturnValue(true); mockShouldShowPolicy.mockReturnValue(false); + // When the Search router builds its Create suggestions and the invoice and workspace actions are run const {result} = renderHook(() => useCreateNavigationSuggestions()); - expect(mockGetGroupPoliciesWhereReportCanBeCreated).toHaveBeenCalledWith(policies, session.email); - expect(mockUseCreateReport).toHaveBeenCalledWith(expect.objectContaining({groupPoliciesWithChatEnabled: [submitPolicy]})); + // Then both gated actions are listed alongside the always-available ones and start their own flows expect(result.current.map((item) => item.keyForList)).toEqual(['create_expense', 'create_report', 'create_trackDistance', 'create_chat', 'create_invoice', 'create_workspace']); act(() => result.current.find((item) => item.keyForList === 'create_invoice')?.action?.()); @@ -417,12 +412,14 @@ describe('useCreateNavigationSuggestions', () => { }); it('creates a report and navigates through the Reports root', () => { - mockGetGroupPoliciesWhereReportCanBeCreated.mockReturnValue([submitPolicy]); + // Given the Search router has handed its create callback to useCreateReport renderHook(() => useCreateNavigationSuggestions()); - const onCreateReport = mockUseCreateReport.mock.calls.at(0)?.at(0)?.onCreateReport; - act(() => onCreateReport?.(true)); + // When the hook resolves a workspace and calls back with it + act(() => onCreateReport?.(submitPolicy, true)); + + // Then the report is created on that workspace, and the Reports root is pushed first so back returns to the list expect(createNewReport).toHaveBeenCalledWith(expect.anything(), false, true, submitPolicy, false, mockGetCurrencyDecimals, undefined, false, true); expect(clearLastSearchParams).not.toHaveBeenCalled(); expect(Navigation.navigate).toHaveBeenNthCalledWith(1, 'reports', {forceReplace: false}); @@ -430,16 +427,17 @@ describe('useCreateNavigationSuggestions', () => { }); it('reads the underlying search report route when Create report actions run', () => { - mockGetGroupPoliciesWhereReportCanBeCreated.mockReturnValue([submitPolicy]); + // Given the router rendered without checking the route, and the user is now on a Search report page renderHook(() => useCreateNavigationSuggestions()); - expect(mockIsOnSearchMoneyRequestReportPage).not.toHaveBeenCalled(); mockIsOnSearchMoneyRequestReportPage.mockReturnValue(true); + // When the create and workspace-selector callbacks run const createReportParams = mockUseCreateReport.mock.calls.at(0)?.at(0); - act(() => createReportParams?.onCreateReport()); + act(() => createReportParams?.onCreateReport(submitPolicy)); act(() => createReportParams?.onNavigateToWorkspaceSelection()); + // Then the route is read at action time, so both replace the Search report page instead of stacking on it expect(clearLastSearchParams).toHaveBeenCalledTimes(1); expect(Navigation.navigate).toHaveBeenNthCalledWith(1, 'reports', {forceReplace: true}); expect(Navigation.navigate).toHaveBeenNthCalledWith(2, 'report/created-report', {forceReplace: true}); diff --git a/tests/unit/useCreateReportPreferredPolicyTest.tsx b/tests/unit/useCreateReportPreferredPolicyTest.tsx new file mode 100644 index 000000000000..8b339a27fbe4 --- /dev/null +++ b/tests/unit/useCreateReportPreferredPolicyTest.tsx @@ -0,0 +1,189 @@ +import {act, renderHook, waitFor} from '@testing-library/react-native'; + +import {SearchQueryContext} from '@components/Search/SearchContext'; +import {SearchScopeProvider} from '@components/Search/SearchScopeProvider'; +import type {SearchQueryContextValue} from '@components/Search/types'; + +import useCreateReport from '@hooks/useCreateReport'; + +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +import type {PropsWithChildren, ReactNode} from 'react'; + +import Onyx from 'react-native-onyx'; + +import createMock from '../utils/createMock'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +/** + * End-to-end check of the domain "preferred workspace" lock in useCreateReport, driven by real Onyx data through the real + * usePreferredPolicy / useUserSecurityGroup chain. The mocked-useOnyx suite in useCreateReportTest cannot catch a broken + * link in that chain, which is how #102130 shipped. + */ + +// Plain literals on purpose: USER_EMAIL is read by a jest.mock factory, so babel hoists its declaration above the +// others and deriving it from USER_DOMAIN would evaluate to `user@undefined`. +const USER_DOMAIN = 'example.com'; +const USER_EMAIL = 'user@example.com'; +const USER_ACCOUNT_ID = 1; +const SECURITY_GROUP_ID = '123456'; +const DOMAIN_ACCOUNT_ID = 42; +const ACTIVE_POLICY_ID = 'active-1'; +const PREFERRED_POLICY_ID = 'preferred-1'; +const OTHER_POLICY_ID = 'other-1'; +const SEARCH_HASH = 987654; + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: () => ({accountID: USER_ACCOUNT_ID, login: USER_EMAIL}), +})); + +jest.mock('@hooks/useShouldShowEmptyReportConfirmation', () => jest.fn(() => false)); + +jest.mock('@hooks/useCreateEmptyReportConfirmation', () => + jest.fn(() => ({ + openCreateReportConfirmation: jest.fn(), + CreateReportConfirmationModal: null, + })), +); + +jest.mock('@libs/interceptAnonymousUser', () => jest.fn((cb: () => void) => cb())); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + getActiveRoute: jest.fn(() => ''), +})); + +function makeTeamPolicy(id: string): Policy { + return createMock({ + id, + name: `${id} workspace`, + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.TEAM, + owner: 'owner@example.com', + ownerAccountID: 99, + outputCurrency: CONST.CURRENCY.USD, + employeeList: {}, + }); +} + +type SecurityGroupFixture = { + /** Whether the membership entry uses the object form (sharedNVP key) or the legacy string form (securityGroup_ key) */ + membershipShape: 'sharedNVP' | 'legacy'; + isRestrictionEnabled: boolean; +}; + +async function setSecurityGroup({membershipShape, isRestrictionEnabled}: SecurityGroupFixture) { + const securityGroup = {enableRestrictedPrimaryPolicy: isRestrictionEnabled, restrictedPrimaryPolicyID: PREFERRED_POLICY_ID}; + if (membershipShape === 'sharedNVP') { + await Onyx.set(ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS, {[USER_DOMAIN]: {securityGroupID: SECURITY_GROUP_ID, ownerAccountID: DOMAIN_ACCOUNT_ID}}); + await Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_SECURITY_GROUP}${SECURITY_GROUP_ID}_${DOMAIN_ACCOUNT_ID}`, securityGroup); + return; + } + await Onyx.set(ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS, {[USER_DOMAIN]: SECURITY_GROUP_ID}); + await Onyx.set(`${ONYXKEYS.COLLECTION.SECURITY_GROUP}${SECURITY_GROUP_ID}`, securityGroup); +} + +async function setWorkspaces(preferredPolicy: Policy) { + const activePolicy = makeTeamPolicy(ACTIVE_POLICY_ID); + const otherPolicy = makeTeamPolicy(OTHER_POLICY_ID); + await Onyx.set(ONYXKEYS.SESSION, {email: USER_EMAIL, accountID: USER_ACCOUNT_ID}); + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, ACTIVE_POLICY_ID); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${ACTIVE_POLICY_ID}`, activePolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${OTHER_POLICY_ID}`, otherPolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${PREFERRED_POLICY_ID}`, preferredPolicy); +} + +/** Mirrors the Reports empty view, which renders the create flow inside SearchScopeProvider with an active search hash */ +function SearchScopeWrapper({children}: PropsWithChildren) { + return ( + + ({currentSearchHash: SEARCH_HASH})}>{children} + + ); +} + +async function renderAndPress(wrapper?: (props: PropsWithChildren) => ReactNode) { + const onCreateReport = jest.fn(); + const {result} = renderHook(() => useCreateReport({onCreateReport}), {wrapper}); + await waitFor(() => expect(result.current.isVisible).toBe(true)); + + act(() => { + result.current.createReport(); + }); + return onCreateReport; +} + +describe('useCreateReport domain preferred workspace (real Onyx)', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + return waitForBatchedUpdates(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + }); + + it.each([ + ['the security group lives under the sharedNVP key', 'sharedNVP'], + ['the security group lives under the legacy securityGroup_ key', 'legacy'], + ] as const)('creates on the preferred workspace instead of the active one when %s', async (_description, membershipShape) => { + // Given a member of three workspaces whose domain security group locks them to a workspace that is not their active one + await setSecurityGroup({membershipShape, isRestrictionEnabled: true}); + await setWorkspaces(makeTeamPolicy(PREFERRED_POLICY_ID)); + + // When they press "Create report" + const onCreateReport = await renderAndPress(); + + // Then the report goes to the preferred workspace and no selector opens + expect(onCreateReport).toHaveBeenCalledWith(expect.objectContaining({id: PREFERRED_POLICY_ID}), false); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('creates on the active workspace when the security group does not enable the restriction', async () => { + // Given a security group that names a preferred workspace but has the restriction switched off + await setSecurityGroup({membershipShape: 'sharedNVP', isRestrictionEnabled: false}); + await setWorkspaces(makeTeamPolicy(PREFERRED_POLICY_ID)); + + // When they press "Create report" + const onCreateReport = await renderAndPress(); + + // Then the user's own default workspace is used + expect(onCreateReport).toHaveBeenCalledWith(expect.objectContaining({id: ACTIVE_POLICY_ID}), false); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('falls back to the active workspace when the user is not a member of the preferred workspace', async () => { + // Given the restriction points at a workspace the user is not in, so Onyx only holds the backend's {id, name} stub for it + await setSecurityGroup({membershipShape: 'sharedNVP', isRestrictionEnabled: true}); + await setWorkspaces(createMock({id: PREFERRED_POLICY_ID, name: 'Preferred'})); + + // When they press "Create report" + const onCreateReport = await renderAndPress(); + + // Then the lock cannot be honoured and the normal rules apply: the eligible active workspace is used + expect(onCreateReport).toHaveBeenCalledWith(expect.objectContaining({id: ACTIVE_POLICY_ID}), false); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('creates on the preferred workspace when rendered inside the Search scope whose snapshot lacks it', async () => { + // Given a domain-locked user, and a Search snapshot (what the Reports empty view reads from) that only holds the active + // workspace, so a snapshot-backed read would miss the preferred workspace and silently drop the lock + await setSecurityGroup({membershipShape: 'sharedNVP', isRestrictionEnabled: true}); + await setWorkspaces(makeTeamPolicy(PREFERRED_POLICY_ID)); + const activePolicySnapshotKey = `${ONYXKEYS.COLLECTION.POLICY}${ACTIVE_POLICY_ID}` as const; + await Onyx.set(`${ONYXKEYS.COLLECTION.SNAPSHOT}${SEARCH_HASH}`, {data: {[activePolicySnapshotKey]: makeTeamPolicy(ACTIVE_POLICY_ID)}}); + + // When they press "Create report" from inside the Search scope + const onCreateReport = await renderAndPress(SearchScopeWrapper); + + // Then the hook reads live policies and the report still goes to the preferred workspace + expect(onCreateReport).toHaveBeenCalledWith(expect.objectContaining({id: PREFERRED_POLICY_ID}), false); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/useCreateReportTest.tsx b/tests/unit/useCreateReportTest.tsx index 7aed632c96da..64c1daf55c60 100644 --- a/tests/unit/useCreateReportTest.tsx +++ b/tests/unit/useCreateReportTest.tsx @@ -17,11 +17,20 @@ import createMock from '../utils/createMock'; // ── Mocks ────────────────────────────────────────────────────────────────────── -jest.mock('@hooks/useLocalize', () => () => ({translate: jest.fn((key: string) => key)})); +jest.mock('@hooks/useLocalize', () => () => ({ + translate: jest.fn((key: string) => key), +})); jest.mock('@hooks/useOnyx', () => jest.fn()); const mockUseOnyx = jest.mocked(useOnyx); +// The hook reads policies through the snapshot-free useOnyx from react-native-onyx; serve them from the same mock. +jest.mock('react-native-onyx', () => ({ + __esModule: true, + ...jest.requireActual>('react-native-onyx'), + useOnyx: (...args: Parameters) => mockUseOnyx(...args), +})); + jest.mock('@hooks/useShouldShowEmptyReportConfirmation', () => jest.fn(() => false)); const mockUseShouldShowEmptyReportConfirmation = jest.mocked(useShouldShowEmptyReportConfirmation); @@ -38,23 +47,13 @@ jest.mock('@libs/PolicyUtils', () => { // `requireActual` inside the factory because jest hoists `jest.mock` above the top-level `CONST` import. const CONSTANTS = jest.requireActual<{default: typeof CONST}>('@src/CONST').default; return { - getDefaultChatEnabledPolicy: jest.fn((policies: Array>, activePolicy: OnyxEntry) => { - // Mirror the real helper: prefer activePolicy when it is a group workspace from the provided create-report candidates; otherwise use the only candidate. - if ( - activePolicy && - (activePolicy.type === CONSTANTS.POLICY.TYPE.TEAM || activePolicy.type === CONSTANTS.POLICY.TYPE.CORPORATE || activePolicy.type === CONSTANTS.POLICY.TYPE.SUBMIT) - ) { - return activePolicy; - } - if (policies.length === 1) { - return policies.at(0); - } - return undefined; - }), isPaidGroupPolicy: jest.fn((policy: OnyxEntry) => policy?.type === CONSTANTS.POLICY.TYPE.TEAM || policy?.type === CONSTANTS.POLICY.TYPE.CORPORATE), isGroupPolicy: jest.fn( (policy: OnyxEntry) => policy?.type === CONSTANTS.POLICY.TYPE.TEAM || policy?.type === CONSTANTS.POLICY.TYPE.CORPORATE || policy?.type === CONSTANTS.POLICY.TYPE.SUBMIT, ), + canCreateReportOnPolicy: jest.fn( + (policy: OnyxEntry) => policy?.type === CONSTANTS.POLICY.TYPE.TEAM || policy?.type === CONSTANTS.POLICY.TYPE.CORPORATE || policy?.type === CONSTANTS.POLICY.TYPE.SUBMIT, + ), }; }); @@ -75,6 +74,14 @@ jest.mock('@libs/SubscriptionUtils', () => ({ shouldRestrictUserBillableActions: (...args: Parameters) => mockShouldRestrictUserBillableActions(...args), })); +const mockUsePreferredPolicy = jest.fn(() => ({ + isRestrictedToPreferredPolicy: false, + preferredPolicyID: undefined as string | undefined, + isRestrictedPolicyCreation: false, + isLoadingPreferredPolicy: false, +})); +jest.mock('@hooks/usePreferredPolicy', () => () => mockUsePreferredPolicy()); + // ── Helpers ──────────────────────────────────────────────────────────────────── const POLICY_ID = 'policy-123'; @@ -97,47 +104,201 @@ function makeSubmitPolicy(id = POLICY_ID): Policy { return {...policy, type: CONST.POLICY.TYPE.SUBMIT}; } -function setupUseCreateReportOnyx({activePolicy, emptyReportsConfirmationDismissed}: {activePolicy?: OnyxEntry; emptyReportsConfirmationDismissed?: boolean} = {}) { +/** Report-eligible workspaces the mocked policy collection selector resolves over; reset per test */ +const eligiblePolicies = {value: [] as Array>}; + +function setEligiblePolicies(policies: Array>) { + eligiblePolicies.value = policies; +} + +function isCreateReportPolicyType(policy: OnyxEntry) { + return policy?.type === CONST.POLICY.TYPE.TEAM || policy?.type === CONST.POLICY.TYPE.CORPORATE || policy?.type === CONST.POLICY.TYPE.SUBMIT; +} + +/** Mirrors getDefaultChatEnabledPolicySelection: prefer a group active policy, otherwise the only eligible workspace */ +function getMockSelection(activePolicy: OnyxEntry) { + const policies = eligiblePolicies.value; + let defaultPolicy: OnyxEntry; + if (isCreateReportPolicyType(activePolicy)) { + defaultPolicy = activePolicy; + } else if (policies.length === 1) { + defaultPolicy = policies.at(0); + } + return { + defaultChatEnabledPolicyID: defaultPolicy?.id, + hasChatEnabledPolicies: policies.length > 0, + hasMultipleChatEnabledPolicies: policies.length > 1, + }; +} + +type SetupUseCreateReportOnyxParams = { + activePolicy?: OnyxEntry; + /** Served under its own `policy_` key, the way the hook reads the domain's preferred workspace */ + preferredPolicy?: OnyxEntry; + /** Load status reported for the preferred workspace key */ + preferredPolicyLoadStatus?: 'loaded' | 'loading'; + emptyReportsConfirmationDismissed?: boolean; +}; + +function setupUseCreateReportOnyx({activePolicy, preferredPolicy, preferredPolicyLoadStatus = 'loaded', emptyReportsConfirmationDismissed}: SetupUseCreateReportOnyxParams = {}) { mockUseOnyx.mockImplementation((key) => { if (key === ONYXKEYS.NVP_ACTIVE_POLICY_ID) { return [activePolicy?.id, {status: 'loaded'}]; } - if (key === `${ONYXKEYS.COLLECTION.POLICY}${activePolicy?.id}`) { + if (activePolicy && key === `${ONYXKEYS.COLLECTION.POLICY}${activePolicy.id}`) { return [activePolicy, {status: 'loaded'}]; } + if (preferredPolicy && key === `${ONYXKEYS.COLLECTION.POLICY}${preferredPolicy.id}`) { + return [preferredPolicy, {status: preferredPolicyLoadStatus}]; + } if (key === ONYXKEYS.NVP_EMPTY_REPORTS_CONFIRMATION_DISMISSED) { return [emptyReportsConfirmationDismissed, {status: 'loaded'}]; } + if (key === ONYXKEYS.COLLECTION.POLICY) { + return [getMockSelection(activePolicy), {status: 'loaded'}]; + } + const eligiblePolicy = eligiblePolicies.value.find((policy) => !!policy && key === `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + if (eligiblePolicy) { + return [eligiblePolicy, {status: 'loaded'}]; + } return [undefined, {status: 'loaded'}]; }); } +function setPreferredPolicyRestriction(preferredPolicyID: string | undefined, isLoadingPreferredPolicy = false) { + mockUsePreferredPolicy.mockReturnValue({ + isRestrictedToPreferredPolicy: true, + preferredPolicyID, + isRestrictedPolicyCreation: false, + isLoadingPreferredPolicy, + }); +} + // ── Tests ────────────────────────────────────────────────────────────────────── describe('useCreateReport', () => { beforeEach(() => { jest.clearAllMocks(); reportIDCounter.value = 100; + setEligiblePolicies([]); mockShouldRestrictUserBillableActions.mockReturnValue(false); + mockUsePreferredPolicy.mockReturnValue({ + isRestrictedToPreferredPolicy: false, + preferredPolicyID: undefined, + isRestrictedPolicyCreation: false, + isLoadingPreferredPolicy: false, + }); mockUseShouldShowEmptyReportConfirmation.mockReturnValue(false); setupUseCreateReportOnyx(); }); + describe('domain preferred workspace restriction', () => { + const personalPolicy: OnyxEntry = { + ...makePaidPolicy('personal-1'), + type: CONST.POLICY.TYPE.PERSONAL, + }; + const preferredPolicy = makePaidPolicy('preferred-1'); + // What the backend sends for a preferred workspace the user is not a member of: no type, no role. + const preferredPolicyStub = createMock({ + id: 'preferred-1', + name: 'Preferred', + }); + + it.each([ + ['creates on the preferred workspace instead of the active one', makePaidPolicy('p1'), preferredPolicy, false, 'create'], + ['creates on the preferred workspace when the active one is personal and multiple workspaces exist', personalPolicy, preferredPolicy, false, 'create'], + ['shows the billing restriction page instead of the selector when the preferred workspace is billing-restricted', makePaidPolicy('p1'), preferredPolicy, true, 'restricted'], + ['falls back to the normal rules when the preferred workspace is only a membership-less stub', personalPolicy, preferredPolicyStub, false, 'selector'], + ['falls back to the normal rules when the preferred workspace is not in Onyx at all', personalPolicy, undefined, false, 'selector'], + ])('%s', (_description, activePolicy, restrictedPolicy, isBillingRestricted, expected) => { + // Given a domain lock on `preferred-1` and the active workspace / preferred workspace data in Onyx + setupUseCreateReportOnyx({ + activePolicy, + preferredPolicy: restrictedPolicy, + }); + setPreferredPolicyRestriction('preferred-1'); + mockShouldRestrictUserBillableActions.mockReturnValue(isBillingRestricted); + const onCreateReport = jest.fn(); + // The caller's eligible list never contains the preferred workspace, so the hook must resolve it by key on its own + const policies = [makePaidPolicy('p1'), makePaidPolicy('p2')]; + + setEligiblePolicies(policies); + const {result} = renderHook(() => + useCreateReport({ + onCreateReport, + }), + ); + + // When the user presses "Create report" + act(() => { + result.current.createReport(); + }); + + // Then an eligible preferred workspace wins without a selector, and anything else falls back to the normal rules + const selectorRoute = DYNAMIC_ROUTES.NEW_REPORT_WORKSPACE_SELECTION.getRoute(); + if (expected === 'create') { + expect(onCreateReport).toHaveBeenCalledWith(expect.objectContaining({id: 'preferred-1'}), false); + expect(Navigation.navigate).not.toHaveBeenCalled(); + } else if (expected === 'restricted') { + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RESTRICTED_ACTION.getRoute('preferred-1')); + expect(Navigation.navigate).not.toHaveBeenCalledWith(selectorRoute); + expect(onCreateReport).not.toHaveBeenCalled(); + } else { + expect(Navigation.navigate).toHaveBeenCalledWith(selectorRoute); + expect(onCreateReport).not.toHaveBeenCalled(); + } + }); + + it.each([ + ['the security group is still loading', true, 'loaded'], + ['the preferred workspace is still loading', false, 'loading'], + ] as const)('hides the entry point and does nothing on press while %s', (_description, isLoadingPreferredPolicy, preferredPolicyLoadStatus) => { + // Given the domain lock cannot be evaluated yet because its Onyx inputs have not resolved + setupUseCreateReportOnyx({ + activePolicy: makePaidPolicy('p1'), + preferredPolicy, + preferredPolicyLoadStatus, + }); + setPreferredPolicyRestriction('preferred-1', isLoadingPreferredPolicy); + const onCreateReport = jest.fn(); + + setEligiblePolicies([makePaidPolicy('p1'), makePaidPolicy('p2')]); + const {result} = renderHook(() => + useCreateReport({ + onCreateReport, + }), + ); + + // When the user presses "Create report" anyway + act(() => { + result.current.createReport(); + }); + + // Then nothing is created or navigated, instead of silently falling back to the active workspace + expect(result.current.isVisible).toBe(false); + expect(onCreateReport).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + }); + describe('upgrade path (no policies)', () => { it('navigates to upgrade path when user has no group policies', () => { + // Given a user with no workspace they can create a report on const onCreateReport = jest.fn(); + setEligiblePolicies([]); const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: [], }), ); + // When they press "Create report" act(() => { result.current.createReport(); }); + // Then they are sent to the upgrade flow for reports, since there is nowhere to create the report yet expect(Navigation.navigate).toHaveBeenCalledTimes(1); const navigateArg = jest.mocked(Navigation.navigate).mock.calls.at(0)?.at(0); expect(navigateArg).toEqual(expect.stringContaining('upgrade')); @@ -148,49 +309,53 @@ describe('useCreateReport', () => { describe('workspace selection', () => { it('navigates to workspace selector when default policy ID is not available', () => { + // Given an eligible workspace exists but no default workspace ID can be resolved const onCreateReport = jest.fn(); - // Pass array with undefined so getDefaultChatEnabledPolicy returns undefined const policies = [undefined]; + setEligiblePolicies(policies); const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: policies, }), ); + // When they press "Create report" act(() => { result.current.createReport(); }); + // Then the selector opens so the user picks a workspace instead of the hook guessing expect(Navigation.navigate).toHaveBeenCalledWith(DYNAMIC_ROUTES.NEW_REPORT_WORKSPACE_SELECTION.getRoute()); expect(onCreateReport).not.toHaveBeenCalled(); }); it('navigates to workspace selector when restricted with multiple workspaces', () => { - // Set activePolicy to a non-personal paid policy so isDefaultPersonal is false; the selector - // should fire purely on the billing-restricted safety net branch. + // Given a non-personal default that is billing-restricted and another workspace to choose, so only the + // billing safety net (not the personal-default rule) can open the selector setupUseCreateReportOnyx({activePolicy: makePaidPolicy('p1')}); mockShouldRestrictUserBillableActions.mockReturnValue(true); const onCreateReport = jest.fn(); const policies = [makePaidPolicy('p1'), makePaidPolicy('p2')]; + setEligiblePolicies(policies); const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: policies, }), ); + // When they press "Create report" act(() => { result.current.createReport(); }); + // Then the selector opens so the user isn't dead-ended on the restricted-action page expect(Navigation.navigate).toHaveBeenCalledWith(DYNAMIC_ROUTES.NEW_REPORT_WORKSPACE_SELECTION.getRoute()); }); it('navigates to workspace selector when default is personal and there are 2+ non-personal workspaces', () => { - // Per spec: selector shows iff default workspace is personal AND user has 2+ non-personal options. + // Given a personal default and two non-personal workspaces; per spec the selector shows iff both hold const personalPolicy: OnyxEntry = { ...makePaidPolicy('personal-1'), type: CONST.POLICY.TYPE.PERSONAL, @@ -199,66 +364,71 @@ describe('useCreateReport', () => { const onCreateReport = jest.fn(); const policies = [makePaidPolicy('p1'), makePaidPolicy('p2')]; + setEligiblePolicies(policies); const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: policies, }), ); + // When they press "Create report" act(() => { result.current.createReport(); }); + // Then the selector opens because there is no meaningful default to create on expect(Navigation.navigate).toHaveBeenCalledWith(DYNAMIC_ROUTES.NEW_REPORT_WORKSPACE_SELECTION.getRoute()); expect(onCreateReport).not.toHaveBeenCalled(); }); it('does NOT show selector when default is non-personal, even with multiple non-personal workspaces', () => { - // Per spec: if default is already non-personal, just create in default — no selector. + // Given a non-personal default among three non-personal workspaces setupUseCreateReportOnyx({activePolicy: makePaidPolicy('p1')}); const onCreateReport = jest.fn(); const policies = [makePaidPolicy('p1'), makePaidPolicy('p2'), makePaidPolicy('p3')]; + setEligiblePolicies(policies); const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: policies, }), ); + // When they press "Create report" act(() => { result.current.createReport(); }); + // Then per spec the report goes straight to the default, with no selector expect(Navigation.navigate).not.toHaveBeenCalledWith(DYNAMIC_ROUTES.NEW_REPORT_WORKSPACE_SELECTION.getRoute()); - expect(onCreateReport).toHaveBeenCalledWith(false); + expect(onCreateReport).toHaveBeenCalledWith(expect.anything(), false); }); it('does NOT show selector when default is a Submit workspace, even with 2+ Submit workspaces', () => { - // Regression: a Submit workspace is a valid non-personal default, so creating a report - // should go straight to it instead of opening the workspace selector. + // Given a Submit default among two Submit workspaces; Submit is a valid non-personal default (regression) setupUseCreateReportOnyx({activePolicy: makeSubmitPolicy('p1')}); const onCreateReport = jest.fn(); const policies = [makeSubmitPolicy('p1'), makeSubmitPolicy('p2')]; + setEligiblePolicies(policies); const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: policies, }), ); + // When they press "Create report" act(() => { result.current.createReport(); }); + // Then the report goes straight to the Submit default instead of opening the selector expect(Navigation.navigate).not.toHaveBeenCalledWith(DYNAMIC_ROUTES.NEW_REPORT_WORKSPACE_SELECTION.getRoute()); - expect(onCreateReport).toHaveBeenCalledWith(false); + expect(onCreateReport).toHaveBeenCalledWith(expect.anything(), false); }); it('does NOT show selector when default is personal but only 1 non-personal workspace exists', () => { - // Per spec: with a single non-personal candidate, just create there — no selector. + // Given a personal default and a single non-personal workspace const personalPolicy: OnyxEntry = { ...makePaidPolicy('personal-1'), type: CONST.POLICY.TYPE.PERSONAL, @@ -267,58 +437,66 @@ describe('useCreateReport', () => { const onCreateReport = jest.fn(); const policies = [makePaidPolicy('p1')]; + setEligiblePolicies(policies); const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: policies, }), ); + // When they press "Create report" act(() => { result.current.createReport(); }); + // Then per spec the report goes to the only candidate, with nothing to choose between expect(Navigation.navigate).not.toHaveBeenCalledWith(DYNAMIC_ROUTES.NEW_REPORT_WORKSPACE_SELECTION.getRoute()); - expect(onCreateReport).toHaveBeenCalledWith(false); + expect(onCreateReport).toHaveBeenCalledWith(expect.anything(), false); }); }); describe('direct report creation', () => { it('calls onCreateReport directly when workspace is valid and no confirmation needed', () => { + // Given a single unrestricted workspace with no empty report to warn about const onCreateReport = jest.fn(); const policies = [makePaidPolicy()]; + setEligiblePolicies(policies); const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: policies, }), ); + // When they press "Create report" act(() => { result.current.createReport(); }); - expect(onCreateReport).toHaveBeenCalledWith(false); + // Then the report is created right away without any navigation + expect(onCreateReport).toHaveBeenCalledWith(expect.anything(), false); expect(Navigation.navigate).not.toHaveBeenCalled(); }); it('opens empty report confirmation when policy has empty reports', () => { + // Given the workspace already has an empty report the user should be warned about mockUseShouldShowEmptyReportConfirmation.mockReturnValue(true); const onCreateReport = jest.fn(); const policies = [makePaidPolicy()]; + setEligiblePolicies(policies); const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: policies, }), ); + // When they press "Create report" act(() => { result.current.createReport(); }); + // Then the confirmation opens first, so the user doesn't pile up empty reports by accident expect(mockOpenCreateReportConfirmation).toHaveBeenCalledTimes(1); expect(onCreateReport).not.toHaveBeenCalled(); }); @@ -326,21 +504,24 @@ describe('useCreateReport', () => { describe('restricted action', () => { it('navigates to restricted action when single workspace is billing-restricted', () => { + // Given the user's only workspace is billing-restricted, so there is no alternative to offer mockShouldRestrictUserBillableActions.mockReturnValue(true); const onCreateReport = jest.fn(); const policies = [makePaidPolicy()]; + setEligiblePolicies(policies); const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: policies, }), ); + // When they press "Create report" act(() => { result.current.createReport(); }); + // Then the billing restriction page opens for that workspace expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RESTRICTED_ACTION.getRoute(POLICY_ID)); expect(onCreateReport).not.toHaveBeenCalled(); }); @@ -348,19 +529,22 @@ describe('useCreateReport', () => { describe('decision flow priority', () => { it('upgrade path takes priority over workspace selection when no policies exist', () => { + // Given no eligible workspaces, which would also leave no default workspace const onCreateReport = jest.fn(); + setEligiblePolicies([]); const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: [], }), ); + // When they press "Create report" act(() => { result.current.createReport(); }); + // Then the upgrade flow wins over the selector, since an empty selector would be a dead end const navigateArg = jest.mocked(Navigation.navigate).mock.calls.at(0)?.at(0); expect(navigateArg).toEqual(expect.stringContaining('upgrade')); }); @@ -368,22 +552,24 @@ describe('useCreateReport', () => { describe('policies loaded with valid workspace', () => { it('does not navigate to upgrade path when user has a workspace', () => { + // Given the user has one eligible workspace const onCreateReport = jest.fn(); const policies = [makePaidPolicy()]; + setEligiblePolicies(policies); const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: policies, }), ); + // When they press "Create report" act(() => { result.current.createReport(); }); - // Should call onCreateReport directly, not navigate to upgrade - expect(onCreateReport).toHaveBeenCalledWith(false); + // Then the report is created directly and the upgrade flow is never opened + expect(onCreateReport).toHaveBeenCalledWith(expect.anything(), false); const calls = jest.mocked(Navigation.navigate).mock.calls; const navigatedToUpgrade = calls.some((call) => { const firstArg = call.at(0); @@ -395,91 +581,107 @@ describe('useCreateReport', () => { describe('empty report confirmation dismissed', () => { it('calls onCreateReport directly when confirmation was previously dismissed', () => { + // Given the user previously chose not to see the empty-report confirmation again mockUseShouldShowEmptyReportConfirmation.mockReturnValue(false); setupUseCreateReportOnyx({emptyReportsConfirmationDismissed: true}); const onCreateReport = jest.fn(); const policies = [makePaidPolicy()]; + setEligiblePolicies(policies); const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: policies, }), ); + // When they press "Create report" act(() => { result.current.createReport(); }); - expect(onCreateReport).toHaveBeenCalledWith(false); + // Then the report is created without asking again + expect(onCreateReport).toHaveBeenCalledWith(expect.anything(), false); expect(mockOpenCreateReportConfirmation).not.toHaveBeenCalled(); }); }); describe('returns', () => { it('returns createReport function and isVisible flag', () => { + // Given any user state const onCreateReport = jest.fn(); + setEligiblePolicies([]); + + // When the hook renders const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: [], }), ); + // Then callers get both the press handler and the visibility flag they render with expect(typeof result.current.createReport).toBe('function'); expect(typeof result.current.isVisible).toBe('boolean'); }); it('isVisible is true when policies exist', () => { + // Given policies have loaded and the user has an eligible workspace const onCreateReport = jest.fn(); + setEligiblePolicies([makePaidPolicy()]); + + // When the hook renders const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: [makePaidPolicy()], }), ); + // Then the entry point is shown expect(result.current.isVisible).toBe(true); }); }); describe('policy hydration gate', () => { it('isVisible is false while the policy collection is still loading', () => { + // Given Onyx has not hydrated the policy collection yet mockUseOnyx.mockReturnValue([undefined, {status: 'loading'}]); const onCreateReport = jest.fn(); + setEligiblePolicies([]); + + // When the hook renders const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: [], }), ); + // Then the entry point stays hidden until the decision can be made on real data expect(result.current.isVisible).toBe(false); }); it('does not navigate to upgrade path when the user actually has policies but Onyx is still loading', () => { - // Simulates cold start: consumer defaulted groupPoliciesWithChatEnabled to [] because Onyx - // hasn't hydrated yet. The hook should NOT treat this as "no policies" and navigate to upgrade. + // Given a cold start: the policy selection reports no workspaces only because Onyx hasn't hydrated yet mockUseOnyx.mockReturnValue([undefined, {status: 'loading'}]); const onCreateReport = jest.fn(); + setEligiblePolicies([]); const {result} = renderHook(() => useCreateReport({ onCreateReport, - groupPoliciesWithChatEnabled: [], }), ); + // When they press "Create report" anyway act(() => { result.current.createReport(); }); + // Then nothing happens, rather than treating "not loaded" as "no policies" and opening the upgrade flow expect(Navigation.navigate).not.toHaveBeenCalled(); expect(onCreateReport).not.toHaveBeenCalled(); }); diff --git a/tests/unit/useDownloadAttachmentTest.ts b/tests/unit/useDownloadAttachmentTest.ts index 4ffeb44fd8a7..420dd733ecaf 100644 --- a/tests/unit/useDownloadAttachmentTest.ts +++ b/tests/unit/useDownloadAttachmentTest.ts @@ -6,6 +6,8 @@ import fileDownload from '@libs/fileDownload'; import useDownloadAttachment from '@pages/media/AttachmentModalScreen/routes/hooks/useDownloadAttachment'; +import CONFIG from '@src/CONFIG'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; @@ -15,7 +17,8 @@ jest.mock('@libs/fileDownload'); const mockFileDownload = jest.mocked(fileDownload); const ENCRYPTED_TOKEN = 'testEncryptedToken123'; -const FILE_URL = 'https://example.com/file.jpg'; +const FILE_URL = `${CONFIG.EXPENSIFY.EXPENSIFY_URL}chat-attachments/123/file.jpg`; +const ATTACKER_FILE_URL = 'https://attacker.example/file.pdf'; describe('useDownloadAttachment', () => { beforeAll(() => { @@ -58,4 +61,22 @@ describe('useDownloadAttachment', () => { expect(mockFileDownload).toHaveBeenCalledWith(expect.anything(), FILE_URL, 'file.jpg', undefined, undefined, undefined, undefined, undefined, true); }); + + it('should NOT add encryptedAuthToken to a search attachment URL outside Expensify, even when isAuthTokenRequired is true', async () => { + // Given a search attachment whose route params ask for the token but whose source is on another host + const {result} = renderHook( + () => + useDownloadAttachment({ + isAuthTokenRequired: true, + type: CONST.ATTACHMENT_TYPE.SEARCH, + }), + {wrapper: OnyxListItemProvider}, + ); + + // When downloading the attachment + result.current({source: ATTACKER_FILE_URL, file: {name: 'file.pdf'}}); + + // Then the download opens the URL unchanged, so the token never reaches that host + expect(mockFileDownload).toHaveBeenCalledWith(expect.anything(), ATTACKER_FILE_URL, 'file.pdf', undefined, undefined, undefined, undefined, undefined, true); + }); }); diff --git a/tests/unit/useExpenseEducationalModalsTest.tsx b/tests/unit/useExpenseEducationalModalsTest.tsx new file mode 100644 index 000000000000..375d20c73825 --- /dev/null +++ b/tests/unit/useExpenseEducationalModalsTest.tsx @@ -0,0 +1,261 @@ +import {act, render, renderHook} from '@testing-library/react-native'; + +import {ModalProvider} from '@components/Modal/Global/ModalContext'; + +import useHoldRejectEducationalModal from '@hooks/useHoldRejectEducationalModal'; +import useHoldSubmitterEducationalModal from '@hooks/useHoldSubmitterEducationalModal'; + +import variables from '@styles/variables'; + +import React from 'react'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +type MockFeatureTrainingModalProps = { + onClose?: () => void; + onConfirm?: (willShowAgain: boolean) => void; + shouldCloseOnConfirm?: boolean; + shouldUseScrollView?: boolean; + width?: number; + children?: React.ReactNode; +}; + +let mockFeatureTrainingModalProps: MockFeatureTrainingModalProps | undefined; +let mockRenderedContent: string | undefined; +let mockHoldSubmitterIsDM: boolean | undefined; + +jest.mock('@components/FeatureTrainingModal', () => ({ + __esModule: true, + default: (props: MockFeatureTrainingModalProps) => { + mockFeatureTrainingModalProps = props; + return props.children ?? null; + }, +})); + +jest.mock('@components/HoldOrRejectEducationalModalContent', () => ({ + __esModule: true, + default: () => { + mockRenderedContent = 'HoldOrRejectEducationalModalContent'; + return null; + }, +})); + +jest.mock('@components/HoldSubmitterEducationalModalContent', () => ({ + __esModule: true, + default: ({isDM}: {isDM?: boolean}) => { + mockRenderedContent = 'HoldSubmitterEducationalModalContent'; + mockHoldSubmitterIsDM = isDM; + return null; + }, +})); + +function renderEducationalModalHooks() { + return renderHook( + () => ({ + ...useHoldRejectEducationalModal(), + ...useHoldSubmitterEducationalModal(), + }), + { + wrapper: ({children}: {children: React.ReactNode}) => {children}, + }, + ); +} + +describe('expense educational modal hooks', () => { + beforeEach(() => { + mockFeatureTrainingModalProps = undefined; + mockRenderedContent = undefined; + mockHoldSubmitterIsDM = undefined; + }); + + it.each(['close', 'confirm'] as const)('lets the caller chain its follow-up when the user chooses to %s the hold/reject modal', async (action) => { + // Given a caller that chains its own follow-up onto the modal promise + const onDismiss = jest.fn(); + const {result} = renderEducationalModalHooks(); + await act(async () => { + result.current.showHoldOrRejectEducationalModal().finally(onDismiss); + await waitForBatchedUpdates(); + }); + + // Then the modal keeps the existing content and layout, and the follow-up waits for dismissal + expect(mockRenderedContent).toBe('HoldOrRejectEducationalModalContent'); + expect(mockFeatureTrainingModalProps?.width).toBe(variables.holdEducationModalWidth); + expect(mockFeatureTrainingModalProps?.shouldUseScrollView).toBe(true); + expect(mockFeatureTrainingModalProps?.shouldCloseOnConfirm).toBe(false); + expect(onDismiss).not.toHaveBeenCalled(); + + // When the user closes or confirms the modal + await act(async () => { + if (action === 'close') { + mockFeatureTrainingModalProps?.onClose?.(); + } else { + mockFeatureTrainingModalProps?.onConfirm?.(false); + } + await waitForBatchedUpdates(); + }); + + // Then the caller-owned follow-up runs exactly once for either dismissal path + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it.each([true, false, undefined])('passes isDM=%s to the submitter content and returns its dismissal promise', async (isDM) => { + // Given a submitter modal opened with the caller's DM context + const onDismiss = jest.fn(); + const {result} = renderEducationalModalHooks(); + await act(async () => { + result.current.showHoldSubmitterEducationalModal(isDM === undefined ? undefined : {isDM}).finally(onDismiss); + await waitForBatchedUpdates(); + }); + expect(mockRenderedContent).toBe('HoldSubmitterEducationalModalContent'); + expect(mockHoldSubmitterIsDM).toBe(isDM); + expect(onDismiss).not.toHaveBeenCalled(); + + // When the user confirms the modal + await act(async () => { + mockFeatureTrainingModalProps?.onConfirm?.(false); + await waitForBatchedUpdates(); + }); + + // Then the promise settles and the caller's follow-up runs + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('keeps composed modal promises independent', async () => { + // Given a hold/reject modal with its caller-owned follow-up + const onRejectDismiss = jest.fn(); + const onSubmitterDismiss = jest.fn(); + const {result} = renderEducationalModalHooks(); + await act(async () => { + result.current.showHoldOrRejectEducationalModal().finally(onRejectDismiss); + await waitForBatchedUpdates(); + }); + + // When a submitter modal opens above it and the user confirms it + await act(async () => { + result.current.showHoldSubmitterEducationalModal().finally(onSubmitterDismiss); + await waitForBatchedUpdates(); + }); + expect(mockRenderedContent).toBe('HoldSubmitterEducationalModalContent'); + await act(async () => { + mockFeatureTrainingModalProps?.onConfirm?.(false); + await waitForBatchedUpdates(); + }); + + // Then only that promise settles and the hold/reject modal remains on the stack + expect(onSubmitterDismiss).toHaveBeenCalledTimes(1); + expect(onRejectDismiss).not.toHaveBeenCalled(); + expect(mockRenderedContent).toBe('HoldOrRejectEducationalModalContent'); + + // When the remaining modal closes + await act(async () => { + mockFeatureTrainingModalProps?.onClose?.(); + await waitForBatchedUpdates(); + }); + + // Then its promise settles independently + expect(onRejectDismiss).toHaveBeenCalledTimes(1); + expect(onSubmitterDismiss).toHaveBeenCalledTimes(1); + }); + + it('returns a separate promise for each opening', async () => { + // Given two invocations from the same caller + const firstDismiss = jest.fn(); + const secondDismiss = jest.fn(); + const {result} = renderEducationalModalHooks(); + await act(async () => { + result.current.showHoldOrRejectEducationalModal().finally(firstDismiss); + result.current.showHoldOrRejectEducationalModal().finally(secondDismiss); + await waitForBatchedUpdates(); + }); + + // When the top modal closes + await act(async () => { + mockFeatureTrainingModalProps?.onClose?.(); + await waitForBatchedUpdates(); + }); + + // Then only the second invocation settles, following the global stack's normal behavior + expect(secondDismiss).toHaveBeenCalledTimes(1); + expect(firstDismiss).not.toHaveBeenCalled(); + + // When the first modal closes + await act(async () => { + mockFeatureTrainingModalProps?.onClose?.(); + await waitForBatchedUpdates(); + }); + + // Then its follow-up runs once too + expect(firstDismiss).toHaveBeenCalledTimes(1); + expect(secondDismiss).toHaveBeenCalledTimes(1); + }); + + it('leaves the opening event in control of its follow-up across re-renders', async () => { + // Given a caller that captures its follow-up when the opening event occurs + const originalFollowUp = jest.fn(); + const replacementFollowUp = jest.fn(); + const {result, rerender} = renderHook( + ({onDismiss}: {onDismiss: () => void}) => { + const {showHoldOrRejectEducationalModal} = useHoldRejectEducationalModal(); + return () => showHoldOrRejectEducationalModal().finally(onDismiss); + }, + { + initialProps: {onDismiss: originalFollowUp}, + wrapper: ({children}: {children: React.ReactNode}) => {children}, + }, + ); + await act(async () => { + result.current(); + await waitForBatchedUpdates(); + }); + + // When the caller re-renders before the user dismisses the modal + rerender({onDismiss: replacementFollowUp}); + await act(async () => { + mockFeatureTrainingModalProps?.onClose?.(); + await waitForBatchedUpdates(); + }); + + // Then ordinary promise closure semantics apply, with no hidden callback replacement by the hook + expect(originalFollowUp).toHaveBeenCalledTimes(1); + expect(replacementFollowUp).not.toHaveBeenCalled(); + }); + + it('leaves the global modal and its promise independent of the opening component lifecycle', async () => { + // Given a caller that opens a modal on the global stack + const onDismiss = jest.fn(); + function Caller() { + const {showHoldOrRejectEducationalModal} = useHoldRejectEducationalModal(); + React.useEffect(() => { + showHoldOrRejectEducationalModal().finally(onDismiss); + // Simulate a single opening event rather than another modal on every stack update. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return null; + } + function Screen({isCallerMounted}: {isCallerMounted: boolean}) { + return {isCallerMounted && }; + } + const {rerender} = render(); + await act(async () => { + await waitForBatchedUpdates(); + }); + + // When the opening component unmounts while the global modal provider remains mounted + rerender(); + await act(async () => { + await waitForBatchedUpdates(); + }); + + // Then it does not dismiss the modal or settle its promise automatically + expect(onDismiss).not.toHaveBeenCalled(); + + // When the user confirms the still-open modal + await act(async () => { + mockFeatureTrainingModalProps?.onConfirm?.(false); + await waitForBatchedUpdates(); + }); + + // Then the caller-owned promise settles normally + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/useHasTabBeenShownTest.ts b/tests/unit/useHasTabBeenShownTest.ts new file mode 100644 index 000000000000..ab5d0105203b --- /dev/null +++ b/tests/unit/useHasTabBeenShownTest.ts @@ -0,0 +1,37 @@ +import {renderHook} from '@testing-library/react-native'; + +import useHasTabBeenShown from '@hooks/useHasTabBeenShown'; +import useRootNavigationState from '@hooks/useRootNavigationState'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationState} from '@react-navigation/native'; + +jest.mock('@hooks/useRootNavigationState', () => jest.fn()); + +function buildRootState(selectedTabIndex: number): NavigationState { + const routeNames = [SCREENS.HOME, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]; + const tabState: NavigationState = {stale: false, type: 'tab', key: 'tab', index: selectedTabIndex, routeNames, routes: routeNames.map((name) => ({key: name, name}))}; + return {stale: false, type: 'stack', key: 'root', index: 0, routeNames: [NAVIGATORS.TAB_NAVIGATOR], routes: [{key: 'tabs', name: NAVIGATORS.TAB_NAVIGATOR, state: tabState}]}; +} + +describe('useHasTabBeenShown', () => { + it('stays false for a background tab and true once the tab has been selected', () => { + // Given native tabs that mounted Spend at startup while Home is selected + let rootState = buildRootState(0); + jest.mocked(useRootNavigationState).mockImplementation((selector) => selector(rootState)); + const {result, rerender} = renderHook(() => useHasTabBeenShown(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)); + const wasShownInBackground = result.current; + + // When the user selects Spend and then goes back to Home + rootState = buildRootState(1); + rerender({}); + rootState = buildRootState(0); + rerender({}); + + // Then Spend held back its mount work in the background and keeps it running after the user leaves it + expect(wasShownInBackground).toBe(false); + expect(result.current).toBe(true); + }); +}); diff --git a/tests/unit/useIsPastDateTest.ts b/tests/unit/useIsPastDateTest.ts new file mode 100644 index 000000000000..7d6f78d6807b --- /dev/null +++ b/tests/unit/useIsPastDateTest.ts @@ -0,0 +1,109 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useIsPastDate from '@hooks/useIsPastDate'; + +import CONST from '@src/CONST'; + +const ONE_MINUTE_MS = 60 * 1000; + +function getDateFromNow(offsetMs: number) { + return new Date(Date.now() + offsetMs).toISOString(); +} + +describe('useIsPastDate', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns false until the date passes and true right after', () => { + // Given a date one minute in the future + const date = getDateFromNow(ONE_MINUTE_MS); + const {result} = renderHook(() => useIsPastDate(date)); + expect(result.current).toBe(false); + + // When time moves to just before the date + act(() => { + jest.advanceTimersByTime(ONE_MINUTE_MS - 1000); + }); + + // Then it still reports the date as upcoming + expect(result.current).toBe(false); + + // When time moves past the date without any other re-render trigger + act(() => { + jest.advanceTimersByTime(1000); + }); + + // Then it flips to true on its own, because the hook schedules a re-render for the moment the date passes + expect(result.current).toBe(true); + }); + + it('resets to false when a passed date is replaced by a future one', () => { + // Given a date that has already been reached while the hook is mounted + const {result, rerender} = renderHook(({date}: {date: string}) => useIsPastDate(date), {initialProps: {date: getDateFromNow(ONE_MINUTE_MS)}}); + act(() => { + jest.advanceTimersByTime(ONE_MINUTE_MS); + }); + expect(result.current).toBe(true); + + // When the date is replaced by a new future one, like a call rescheduled after the previous one passed + rerender({date: getDateFromNow(ONE_MINUTE_MS)}); + + // Then it reports the new date as upcoming, because the passed state belongs to the old date only + expect(result.current).toBe(false); + + // When the new date passes + act(() => { + jest.advanceTimersByTime(ONE_MINUTE_MS); + }); + + // Then it flips to true again + expect(result.current).toBe(true); + }); + + it('returns true immediately for a date that already passed', () => { + // Given a date one minute in the past + const date = getDateFromNow(-ONE_MINUTE_MS); + + // When the hook renders + const {result} = renderHook(() => useIsPastDate(date)); + + // Then it reports the date as passed without waiting for a timer + expect(result.current).toBe(true); + }); + + it('returns false when there is no date', () => { + // Given no date + // When the hook renders + const {result} = renderHook(() => useIsPastDate(undefined)); + + // Then it reports false, so callers treat a missing date as not passed + expect(result.current).toBe(false); + }); + + it('flips at the date even when it is further away than the maximum setTimeout delay', () => { + // Given a date beyond CONST.LIMIT_TIMEOUT, where a single setTimeout would overflow and fire immediately + const date = getDateFromNow(CONST.LIMIT_TIMEOUT + ONE_MINUTE_MS); + const {result} = renderHook(() => useIsPastDate(date)); + + // When the first LIMIT_TIMEOUT step elapses + act(() => { + jest.advanceTimersByTime(CONST.LIMIT_TIMEOUT); + }); + + // Then it is still upcoming, because the hook re-arms instead of firing early + expect(result.current).toBe(false); + + // When the remaining time elapses + act(() => { + jest.advanceTimersByTime(ONE_MINUTE_MS); + }); + + // Then it flips exactly at the date + expect(result.current).toBe(true); + }); +}); diff --git a/tests/unit/useListKeyboardNavTest.ts b/tests/unit/useListKeyboardNavTest.ts index cd8421f6219e..c06ebb0e96d9 100644 --- a/tests/unit/useListKeyboardNavTest.ts +++ b/tests/unit/useListKeyboardNavTest.ts @@ -1,23 +1,28 @@ import {act, renderHook} from '@testing-library/react-native'; +import type useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useListKeyboardNav from '@hooks/useListKeyboardNav'; -import type Navigation from '@libs/Navigation/Navigation'; +import type * as ReactNavigation from '@react-navigation/native'; +import type {ComponentRef} from 'react'; +import type {View} from 'react-native'; -type ShortcutCallback = () => void; -type ShortcutConfig = {isActive?: boolean}; +import createMock from '../utils/createMock'; + +type ShortcutCallback = Parameters[1]; +type ShortcutConfig = Parameters[2]; const shortcuts: Record = {}; jest.mock('@react-navigation/native', () => { - const actualNav = jest.requireActual('@react-navigation/native'); + const actualNav = jest.requireActual('@react-navigation/native'); return { ...actualNav, useIsFocused: () => true, }; }); -jest.mock('@hooks/useKeyboardShortcut', () => (key: {shortcutKey: string}, callback: ShortcutCallback, config?: ShortcutConfig) => { +jest.mock('@hooks/useKeyboardShortcut', () => (key: Parameters[0], callback: ShortcutCallback, config?: ShortcutConfig) => { shortcuts[key.shortcutKey] = {callback, isActive: config?.isActive ?? true}; }); @@ -59,6 +64,7 @@ function simulateFocusOut(container: HTMLElement, relatedTarget: EventTarget | n describe('useListKeyboardNav', () => { afterEach(() => { + jest.restoreAllMocks(); for (const key of Object.keys(shortcuts)) { delete shortcuts[key]; } @@ -357,4 +363,131 @@ describe('useListKeyboardNav', () => { cleanup(); }); + + it('keeps focus active when moving between children in the container', () => { + // Given two rows in the same list, focusout must not disable internal keyboard navigation. + const {ref, cleanup} = createContainerRef(); + const first = document.createElement('button'); + const second = document.createElement('button'); + first.id = 'a'; + second.id = 'b'; + ref.current.append(first, second); + const {result} = renderHook(() => useListKeyboardNav({isActive: true, itemKeys: ['a', 'b', 'c'], disabledIndexes: [], containerRef: ref})); + // When the browser transfers focus to another row, the related target is still contained. + act(() => { + first.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + first.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget: second})); + }); + pressArrowDown(); + // Then the next arrow must advance instead of becoming inactive or resetting the index. + expect(result.current.focusedIndex).toBe(1); + cleanup(); + }); + + it('updates focus state for unmatched and non-element targets without selecting a row', () => { + // Given a list that previously lost focus, target-id matching cannot gate focus-state updates. + const {ref, cleanup} = createContainerRef(); + const unmatched = document.createElement('button'); + unmatched.id = 'unmatched'; + const text = document.createTextNode('nested text'); + ref.current.append(unmatched, text); + const {result} = renderHook(() => useListKeyboardNav({isActive: true, itemKeys: ['a', 'b'], disabledIndexes: [], containerRef: ref})); + simulateFocusIn(ref.current); + simulateFocusOut(ref.current); + // When an unmatched row and then a text node receive focus, both still activate the list. + act(() => { + unmatched.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + }); + expect(result.current.focusedIndex).toBe(-1); + pressArrowDown(); + expect(result.current.focusedIndex).toBe(0); + simulateFocusOut(ref.current); + act(() => { + text.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + }); + pressArrowDown(); + // Then the list recovers arrow navigation without interpreting either target as a known row. + expect(result.current.focusedIndex).toBe(0); + cleanup(); + }); + + it('handles a null focus target and removes the exact DOM listeners on unmount', () => { + // Given a browser container, retain the actual registered callback rather than replacing the hook. + const {ref, cleanup} = createContainerRef(); + const addListener = jest.spyOn(ref.current, 'addEventListener'); + const removeListener = jest.spyOn(ref.current, 'removeEventListener'); + const {result, unmount} = renderHook(() => useListKeyboardNav({isActive: true, itemKeys: ['a'], disabledIndexes: [], containerRef: ref})); + const focusIn = addListener.mock.calls.find(([name]) => name === 'focusin'); + const focusOut = addListener.mock.calls.find(([name]) => name === 'focusout'); + expect(focusIn).toBeDefined(); + expect(focusOut).toBeDefined(); + if (!focusIn || !focusOut || typeof focusIn[1] !== 'function') { + throw new Error('The list must register its DOM focus listeners'); + } + // When a newly constructed event has no target, it still represents focus activation. + const notifyFocusIn = focusIn[1]; + act(() => notifyFocusIn(new FocusEvent('focusin'))); + pressArrowDown(); + unmount(); + // Then navigation remains usable and both exact callbacks are released. + expect(result.current.focusedIndex).toBe(0); + expect(removeListener).toHaveBeenCalledWith('focusin', focusIn[1]); + expect(removeListener).toHaveBeenCalledWith('focusout', focusOut[1]); + cleanup(); + }); + + it('retains native View ownership without attaching DOM listeners', () => { + // Given a real native View contract, no browser methods are part of the container. + const nativeView = createMock>({focus: jest.fn()}); + const containerRef = {current: nativeView}; + // When the generic hook receives a native container, traversal remains independent of DOM focus. + const {result, unmount} = renderHook(() => useListKeyboardNav({isActive: true, itemKeys: ['a'], disabledIndexes: [], containerRef})); + pressArrowDown(); + // Then native ownership is unchanged and the DOM listener branch is bypassed. + expect(result.current.focusedIndex).toBe(0); + unmount(); + }); + + it.each([ + {itemKeys: [], disabledIndexes: []}, + {itemKeys: ['a', 'b'], disabledIndexes: [0, 1]}, + ])('does not select an item when no enabled row exists: %j', ({itemKeys, disabledIndexes}) => { + // Given an empty or fully disabled list, neither direction may manufacture an enabled row. + const {ref, cleanup} = createContainerRef(); + const {result} = renderHook(() => useListKeyboardNav({isActive: true, itemKeys, disabledIndexes, containerRef: ref})); + // When traversal is requested in both directions, execute the real arrow manager. + pressArrowDown(); + pressArrowUp(); + // Then the unfocused marker remains intact, including the all-disabled loop termination. + expect(result.current.focusedIndex).toBe(-1); + cleanup(); + }); + + it('uses the latest item order for focus events and adjusts after shrinking', () => { + // Given focus on the last row, a reorder changes the id-to-index correspondence. + const {ref, cleanup} = createContainerRef(); + const child = document.createElement('button'); + child.id = 'c'; + ref.current.appendChild(child); + const {result, rerender} = renderHook(({itemKeys}: {itemKeys: string[]}) => useListKeyboardNav({isActive: true, itemKeys, disabledIndexes: [], containerRef: ref}), { + initialProps: {itemKeys: ['a', 'b', 'c']}, + }); + act(() => { + child.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + }); + expect(result.current.focusedIndex).toBe(2); + // When the order changes, the layout effect must update the keys before the next focus event. + rerender({itemKeys: ['c', 'a', 'b']}); + act(() => { + child.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + }); + expect(result.current.focusedIndex).toBe(0); + act(() => result.current.setFocusedIndex(2)); + rerender({itemKeys: ['c']}); + // Then shrinking clamps to the surviving row and an empty list restores the unfocused marker. + expect(result.current.focusedIndex).toBe(0); + rerender({itemKeys: []}); + expect(result.current.focusedIndex).toBe(-1); + cleanup(); + }); }); diff --git a/tests/unit/useMCPConnectionListingsTest.ts b/tests/unit/useMCPConnectionListingsTest.ts index 29c2d0e01c55..21118031e957 100644 --- a/tests/unit/useMCPConnectionListingsTest.ts +++ b/tests/unit/useMCPConnectionListingsTest.ts @@ -33,7 +33,7 @@ describe('useMCPConnectionListings', () => { const claude = result.current.find((listing) => listing.key === 'claude'); // When they connect Claude - claude?.onConnect(); + claude?.onConnect?.(); // Then MCP is turned on and Claude's connect page opens, since Connections has no MCP toggle expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED); @@ -46,7 +46,7 @@ describe('useMCPConnectionListings', () => { const mcpGuide = result.current.find((listing) => listing.key === 'mcp'); // When the admin presses "+" on it - mcpGuide?.onConnect(); + mcpGuide?.onConnect?.(); // Then the guide opens, since the client is set up by following it rather than by connecting from here expect(openExternalLink).toHaveBeenCalledWith(CONST.MCP_HELP_URL); @@ -59,7 +59,7 @@ describe('useMCPConnectionListings', () => { const {result} = renderHook(() => useMCPConnectionListings(policy)); // When they try to connect an assistant - result.current.at(0)?.onConnect(); + result.current.at(0)?.onConnect?.(); // Then they are told they can't, and nothing is turned on or opened expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1); diff --git a/tests/unit/useMergeConnectionListingsTest.ts b/tests/unit/useMergeConnectionListingsTest.ts index 653a95419651..5fd63941924c 100644 --- a/tests/unit/useMergeConnectionListingsTest.ts +++ b/tests/unit/useMergeConnectionListingsTest.ts @@ -63,7 +63,7 @@ describe('useMergeConnectionListings', () => { const {listings, onStartSetup} = renderListings(); // When the admin connects an HR provider - listings.at(0)?.onConnect(); + listings.at(0)?.onConnect?.(); // Then the upgrade page opens instead of the provider's setup expect(Navigation.navigate).toHaveBeenCalledTimes(1); @@ -75,7 +75,7 @@ describe('useMergeConnectionListings', () => { const {listings, onStartSetup} = renderListings(); // When the admin connects Gusto - listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.GUSTO)?.onConnect(); + listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.GUSTO)?.onConnect?.(); // Then HR is turned on and Gusto's setup starts, with nothing to replace expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED); @@ -91,7 +91,7 @@ describe('useMergeConnectionListings', () => { // When the admin connects another provider and cancels the Replace prompt await act(async () => { - listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.ZENEFITS)?.onConnect(); + listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.ZENEFITS)?.onConnect?.(); await waitForBatchedUpdates(); }); diff --git a/tests/unit/useParentReportActionTest.tsx b/tests/unit/useParentReportActionTest.tsx new file mode 100644 index 000000000000..05a39fbd80d6 --- /dev/null +++ b/tests/unit/useParentReportActionTest.tsx @@ -0,0 +1,77 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useParentReportAction from '@hooks/useParentReportAction'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const PARENT_REPORT_ID = '1'; +const PARENT_REPORT_ACTION_ID = '100'; + +const threadReport = {reportID: '2', parentReportID: PARENT_REPORT_ID, parentReportActionID: PARENT_REPORT_ACTION_ID} as Report; + +const parentReportAction = { + reportActionID: PARENT_REPORT_ACTION_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + created: '2026-10-08 10:00:00.000', + childReportID: threadReport.reportID, + childVisibleActionCount: 1, + childLastVisibleActionCreated: '2026-10-08 10:00:00.000', +} as ReportAction; + +describe('useParentReportAction', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT_ID}`, {[PARENT_REPORT_ACTION_ID]: parentReportAction}); + await waitForBatchedUpdates(); + }); + + const replyInThread = async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT_ID}`, { + [PARENT_REPORT_ACTION_ID]: {childVisibleActionCount: 2, childLastVisibleActionCreated: '2026-10-08 11:00:00.000'}, + }); + await waitForBatchedUpdates(); + }); + }; + + it('should keep the parent action identity on a reply when thread replies are ignored', async () => { + // Given a thread screen that only renders its parent message + const {result} = renderHook(() => useParentReportAction(threadReport, {shouldIgnoreThreadReplies: true})); + await act(async () => { + await waitForBatchedUpdates(); + }); + const firstResult = result.current; + expect(firstResult).toEqual(expect.objectContaining({reportActionID: PARENT_REPORT_ACTION_ID})); + expect(firstResult).not.toHaveProperty('childVisibleActionCount'); + + // When someone replies in the thread + await replyInThread(); + + // Then the parent action keeps its identity, so the caller does not re-render for every reply + expect(result.current).toBe(firstResult); + }); + + it('should return the reply counters by default', async () => { + // Given a caller that needs the reply counters (e.g. the thread replies row) + const {result} = renderHook(() => useParentReportAction(threadReport)); + await act(async () => { + await waitForBatchedUpdates(); + }); + + // When someone replies in the thread + await replyInThread(); + + // Then the parent action carries the updated counters + expect(result.current?.childVisibleActionCount).toBe(2); + }); +}); diff --git a/tests/unit/useProactiveAppReviewTest.ts b/tests/unit/useProactiveAppReviewTest.ts index c571fcee3e95..877e7044b78a 100644 --- a/tests/unit/useProactiveAppReviewTest.ts +++ b/tests/unit/useProactiveAppReviewTest.ts @@ -1,5 +1,7 @@ import {renderHook, waitFor} from '@testing-library/react-native'; +import DateUtils from '@libs/DateUtils'; + import CONST from '@src/CONST'; import useProactiveAppReview from '@src/hooks/useProactiveAppReview'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -49,6 +51,97 @@ describe('useProactiveAppReview', () => { const {result} = renderHook(() => useProactiveAppReview()); + await waitFor(() => { + expect(result.current.shouldShowModal).toBe(false); + }); + }); + it('returns shouldShowModal false when a negative response is recorded but the server NVP carries no lastPrompt', async () => { + // This is the authoritative payload observed on the wire for #99564 + await Onyx.merge(ONYXKEYS.NVP_APP_REVIEW, {trigger: 'submit', response: 'negative'}); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useProactiveAppReview()); + + await waitFor(() => { + expect(result.current.shouldShowModal).toBe(false); + }); + }); + + it('returns shouldShowModal false when a skip response is recorded but the server NVP carries no lastPrompt', async () => { + await Onyx.merge(ONYXKEYS.NVP_APP_REVIEW, {trigger: 'submit', response: 'skip'}); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useProactiveAppReview()); + + await waitFor(() => { + expect(result.current.shouldShowModal).toBe(false); + }); + }); + + it('returns shouldShowModal false when the server lastPrompt is stale but this client prompted recently (deploy blocker #94824 shape)', async () => { + await Onyx.merge(ONYXKEYS.SESSION, {accountID: 1}); + await Onyx.merge(ONYXKEYS.NVP_APP_REVIEW, {trigger: 'submit', response: 'negative', lastPrompt: '2026-01-29 17:16:19.172'}); + await Onyx.set(ONYXKEYS.APP_REVIEW_LAST_PROMPT, {accountID: 1, lastPrompt: DateUtils.getDBTime()}); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useProactiveAppReview()); + + await waitFor(() => { + expect(result.current.shouldShowModal).toBe(false); + }); + }); + + it('re-prompts a negative responder once 30 days have passed since the local prompt record', async () => { + const thirtyOneDaysAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: 1}); + await Onyx.merge(ONYXKEYS.NVP_APP_REVIEW, {trigger: 'submit', response: 'negative'}); + await Onyx.set(ONYXKEYS.APP_REVIEW_LAST_PROMPT, {accountID: 1, lastPrompt: DateUtils.getDBTime(thirtyOneDaysAgo.valueOf())}); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useProactiveAppReview()); + + await waitFor(() => { + expect(result.current.shouldShowModal).toBe(true); + }); + }); + + it('ignores a prompt record left behind by a different account on this device', async () => { + const fortyDaysAgo = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: 2}); + await Onyx.merge(ONYXKEYS.NVP_APP_REVIEW, {trigger: 'submit', lastPrompt: DateUtils.getDBTime(fortyDaysAgo.valueOf())}); + // Account 1 answered on this device; account 2 is signed in now and must not inherit that record. + await Onyx.set(ONYXKEYS.APP_REVIEW_LAST_PROMPT, {accountID: 1, lastPrompt: DateUtils.getDBTime()}); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useProactiveAppReview()); + + await waitFor(() => { + expect(result.current.shouldShowModal).toBe(true); + }); + }); + + it('stays closed on the next sign-in when the server lastPrompt is still the one that triggered the prompt (#99564)', async () => { + // The exact account state reproduced on web: prompted and answered 40 days ago per the server, + // answered again since, and the server copy never moved. + const fortyDaysAgo = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: 1}); + await Onyx.merge(ONYXKEYS.NVP_APP_REVIEW, {trigger: 'submit', response: 'negative', lastPrompt: DateUtils.getDBTime(fortyDaysAgo.valueOf())}); + await Onyx.set(ONYXKEYS.APP_REVIEW_LAST_PROMPT, {accountID: 1, lastPrompt: DateUtils.getDBTime()}); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useProactiveAppReview()); + + await waitFor(() => { + expect(result.current.shouldShowModal).toBe(false); + }); + }); + + it('still re-prompts a positive responder never', async () => { + await Onyx.merge(ONYXKEYS.NVP_APP_REVIEW, {trigger: 'submit', response: 'positive'}); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useProactiveAppReview()); + await waitFor(() => { expect(result.current.shouldShowModal).toBe(false); }); diff --git a/tests/unit/useReceiptPartnerConnectionListingsTest.ts b/tests/unit/useReceiptPartnerConnectionListingsTest.ts index b8db5cebbc8e..e00bfe80f14d 100644 --- a/tests/unit/useReceiptPartnerConnectionListingsTest.ts +++ b/tests/unit/useReceiptPartnerConnectionListingsTest.ts @@ -58,7 +58,7 @@ describe('useReceiptPartnerConnectionListings', () => { const {result} = renderHook(() => useReceiptPartnerConnectionListings(policy)); // When the admin presses "+" on Uber - act(() => result.current.at(0)?.onConnect()); + act(() => result.current.at(0)?.onConnect?.()); // Then the feature is turned on and the Uber connect page opens, since Connections has no toggle for the feature expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED); @@ -77,7 +77,7 @@ describe('useReceiptPartnerConnectionListings', () => { // When the admin then connects Uber from the page and the connection shows up mockUberState = {...mockUberState, isUberConnected: false}; rerender({}); - act(() => result.current.at(0)?.onConnect()); + act(() => result.current.at(0)?.onConnect?.()); mockUberState = {...mockUberState, isUberConnected: true}; rerender({}); diff --git a/tests/unit/useReportActionsNewActionLiveTailTest.ts b/tests/unit/useReportActionsNewActionLiveTailTest.ts index 84f426155b17..3b2f63e6df8b 100644 --- a/tests/unit/useReportActionsNewActionLiveTailTest.ts +++ b/tests/unit/useReportActionsNewActionLiveTailTest.ts @@ -209,6 +209,41 @@ describe('useReportActionsNewActionLiveTail', () => { expect(mockNavigationSetParams).not.toHaveBeenCalled(); expect(setTreatAsNoPaginationAnchor).toHaveBeenCalledWith(true); }); + + it('scrolls straight to the bottom when an unread marker is set but the newest actions are already loaded', () => { + // Given a report scrolled up with an unread marker from another user's comment, and the newest actions already loaded + const setIsFloatingMessageCounterVisible = jest.fn(); + const {result} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), { + initialProps: buildParams({unreadMarkerReportActionID: '2', hasNewerActions: false, hasNewestReportAction: true, setIsFloatingMessageCounterVisible}), + }); + + // When the current user sends a comment + act(() => { + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + }); + + // Then the list scrolls to the bottom directly, without an openReport round trip + expect(mockOpenReport).not.toHaveBeenCalled(); + expect(setIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); + expect(reportScrollManager.scrollToBottom).toHaveBeenCalledTimes(1); + expect(result.current.isScrollToBottomEnabled).toBe(true); + }); + + it('still loads the live tail when an unread marker is set and the newest actions are not loaded', () => { + // Given an unread marker and a report whose newest actions are not loaded yet + renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), { + initialProps: buildParams({unreadMarkerReportActionID: '2', hasNewerActions: false, hasNewestReportAction: false}), + }); + + // When the current user sends a comment + act(() => { + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + }); + + // Then openReport fetches the newest actions before the list scrolls + expect(mockOpenReport).toHaveBeenCalledTimes(1); + expect(reportScrollManager.scrollToBottom).not.toHaveBeenCalled(); + }); }); describe('withNavigationFallback stub', () => { diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index c3e78bccf919..af87b65e8e5a 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -45,19 +45,25 @@ jest.mock('@hooks/useReportScrollManager', () => ({ // --- useReportUnreadMessageScrollTracking --- const mockSetIsFloatingMessageCounterVisible = jest.fn(); +const mockSetIsActionBadgeAboveViewport = jest.fn(); const mockTrackVerticalScrolling = jest.fn(); const mockOnViewableItemsChanged = jest.fn(); let mockIsFloatingMessageCounterVisible = false; let mockIsActionBadgeAboveViewport = false; +let mockShouldDisablePillTracking: boolean | undefined; jest.mock('@pages/inbox/report/useReportUnreadMessageScrollTracking', () => ({ __esModule: true, - default: () => ({ - isFloatingMessageCounterVisible: mockIsFloatingMessageCounterVisible, - setIsFloatingMessageCounterVisible: mockSetIsFloatingMessageCounterVisible, - isActionBadgeAboveViewport: mockIsActionBadgeAboveViewport, - trackVerticalScrolling: mockTrackVerticalScrolling, - onViewableItemsChanged: mockOnViewableItemsChanged, - }), + default: ({shouldDisablePillTracking}: {shouldDisablePillTracking?: boolean}) => { + mockShouldDisablePillTracking = shouldDisablePillTracking; + return { + isFloatingMessageCounterVisible: mockIsFloatingMessageCounterVisible, + setIsFloatingMessageCounterVisible: mockSetIsFloatingMessageCounterVisible, + isActionBadgeAboveViewport: mockIsActionBadgeAboveViewport, + setIsActionBadgeAboveViewport: mockSetIsActionBadgeAboveViewport, + trackVerticalScrolling: mockTrackVerticalScrolling, + onViewableItemsChanged: mockOnViewableItemsChanged, + }; + }, })); // --- useReportActionsNewActionLiveTail --- @@ -239,6 +245,7 @@ describe('useReportActionsScroll', () => { mockReportRHPActiveRoute = undefined; mockIsFloatingMessageCounterVisible = false; mockIsActionBadgeAboveViewport = false; + mockShouldDisablePillTracking = undefined; mockIsScrollToBottomEnabled = false; mockIsTransactionThread = false; mockIsSentMoneyReportAction = false; @@ -405,6 +412,59 @@ describe('useReportActionsScroll', () => { expect(mockScrollToIndex).toHaveBeenCalledWith(5, {animated: true, viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); + + it('leaves the pills to viewability tracking when the composer is not expanded', async () => { + // Given a valid action badge target with the composer at its normal size + const {result} = await renderScroll({actionBadgeTargetIndex: 5}); + + // When the action badge pill is pressed + act(() => { + result.current.scrollToActionBadgeTarget(); + }); + + // Then the pills are not hidden directly because the target scrolls into view and viewability tracking hides the pill + expect(mockSetIsActionBadgeAboveViewport).not.toHaveBeenCalled(); + expect(mockSetIsFloatingMessageCounterVisible).not.toHaveBeenCalled(); + }); + + it('hides both pills on press when the composer is expanded', async () => { + // Given a valid action badge target while the expanded composer covers the list + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${REPORT_ID}`, true); + const {result} = await renderScroll({actionBadgeTargetIndex: 5}); + + // When the action badge pill is pressed + act(() => { + result.current.scrollToActionBadgeTarget(); + }); + + // Then both pills are hidden directly because the target can never become viewable to hide them, and the list still scrolls + expect(mockSetIsActionBadgeAboveViewport).toHaveBeenCalledWith(false); + expect(mockSetIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); + expect(mockScrollToIndex).toHaveBeenCalledWith(5, {animated: true, viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + }); + }); + + describe('pill tracking', () => { + it('keeps scroll-based pill tracking enabled when the composer is not expanded', async () => { + // Given the composer at its normal size and no linked message to position + + // When the hook renders + await renderScroll(); + + // Then scroll-based pill tracking stays enabled so the Latest messages pill follows scrolling + expect(mockShouldDisablePillTracking).toBe(false); + }); + + it('disables scroll-based pill tracking while the composer is expanded', async () => { + // Given the expanded composer covers the list + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${REPORT_ID}`, true); + + // When the hook renders + await renderScroll(); + + // Then scroll-based pill tracking is disabled so scrolling the hidden list cannot bring the Latest messages pill back + expect(mockShouldDisablePillTracking).toBe(true); + }); }); describe('flushPendingScrollToBottom', () => { diff --git a/tests/unit/useReportsParentHierarchyTest.ts b/tests/unit/useReportsParentHierarchyTest.ts new file mode 100644 index 000000000000..b30affdf27d8 --- /dev/null +++ b/tests/unit/useReportsParentHierarchyTest.ts @@ -0,0 +1,89 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const THREAD_REPORT_ID = '1'; +const PARENT_REPORT_ID = '2'; +const UNRELATED_REPORT_ID = '3'; +const OTHER_THREAD_REPORT_ID = '4'; +const PARENT_REPORT_ACTION_ID = '100'; + +const reportKey = (reportID: string) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}` as const; + +describe('useReportsParentHierarchy', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + it('should keep the same reference across report changes and re-renders that do not touch the chain', async () => { + // Given a thread and its parent in Onyx, and the hook mounted for the thread + await act(async () => { + await Onyx.merge(reportKey(THREAD_REPORT_ID), {reportID: THREAD_REPORT_ID, parentReportID: PARENT_REPORT_ID, parentReportActionID: PARENT_REPORT_ACTION_ID}); + await Onyx.merge(reportKey(PARENT_REPORT_ID), {reportID: PARENT_REPORT_ID}); + }); + const {result, rerender} = renderHook(() => useReportsParentHierarchy(THREAD_REPORT_ID)); + await waitForBatchedUpdatesWithAct(); + const initialHierarchy = result.current; + + // When an unrelated report is added, a chain report changes a field that is not a parent link, and the list re-renders + await act(async () => { + await Onyx.merge(reportKey(UNRELATED_REPORT_ID), {reportID: UNRELATED_REPORT_ID}); + await Onyx.merge(reportKey(THREAD_REPORT_ID), {lastMessageText: 'new message'}); + }); + await waitForBatchedUpdatesWithAct(); + rerender({}); + + // Then the hook returns the same object, so the list that shares it with every row does not re-render them + expect(initialHierarchy).toEqual({ + [reportKey(THREAD_REPORT_ID)]: {parentReportID: PARENT_REPORT_ID, parentReportActionID: PARENT_REPORT_ACTION_ID}, + [reportKey(PARENT_REPORT_ID)]: {parentReportID: undefined, parentReportActionID: undefined}, + }); + expect(result.current).toBe(initialHierarchy); + }); + + it('should include a parent report that loads after the hook is mounted', async () => { + // Given a thread whose parent report is not in Onyx yet + await act(async () => { + await Onyx.merge(reportKey(THREAD_REPORT_ID), {reportID: THREAD_REPORT_ID, parentReportID: PARENT_REPORT_ID, parentReportActionID: PARENT_REPORT_ACTION_ID}); + }); + const {result} = renderHook(() => useReportsParentHierarchy(THREAD_REPORT_ID)); + await waitForBatchedUpdatesWithAct(); + expect(Object.keys(result.current ?? {})).toEqual([reportKey(THREAD_REPORT_ID)]); + + // When the parent report arrives, e.g. from an API response + await act(async () => { + await Onyx.merge(reportKey(PARENT_REPORT_ID), {reportID: PARENT_REPORT_ID}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the chain includes it, so dismissing an error can still walk up to the parent + expect(Object.keys(result.current ?? {})).toEqual([reportKey(THREAD_REPORT_ID), reportKey(PARENT_REPORT_ID)]); + }); + + it('should switch to the new chain when the reportID changes', async () => { + // Given two unrelated threads in Onyx and the hook mounted for the first one + await act(async () => { + await Onyx.merge(reportKey(THREAD_REPORT_ID), {reportID: THREAD_REPORT_ID, parentReportID: PARENT_REPORT_ID, parentReportActionID: PARENT_REPORT_ACTION_ID}); + await Onyx.merge(reportKey(OTHER_THREAD_REPORT_ID), {reportID: OTHER_THREAD_REPORT_ID}); + }); + const {result, rerender} = renderHook(({reportID}: {reportID: string}) => useReportsParentHierarchy(reportID), {initialProps: {reportID: THREAD_REPORT_ID}}); + await waitForBatchedUpdatesWithAct(); + + // When the hook is re-rendered for the other thread + rerender({reportID: OTHER_THREAD_REPORT_ID}); + await waitForBatchedUpdatesWithAct(); + + // Then only the other thread's chain is returned, so no stale links from the first thread leak into the walk + expect(Object.keys(result.current ?? {})).toEqual([reportKey(OTHER_THREAD_REPORT_ID)]); + }); +}); diff --git a/tests/unit/useResetIOUTypeTabSelectionTest.tsx b/tests/unit/useResetIOUTypeTabSelectionTest.tsx new file mode 100644 index 000000000000..4a14a66245ec --- /dev/null +++ b/tests/unit/useResetIOUTypeTabSelectionTest.tsx @@ -0,0 +1,121 @@ +/** Covers valid tab names through reset and the actual odometer hydrator. */ +import {act, renderHook} from '@testing-library/react-native'; + +import type * as UseDefaultParticipantsModule from '@hooks/useDefaultParticipants'; +import type * as UseOnyxModule from '@hooks/useOnyx'; +import useResetIOUType from '@hooks/useResetIOUType'; + +import {initMoneyRequest} from '@userActions/IOU/MoneyRequest'; +import {hydrateOdometerDraftIntoTransaction} from '@userActions/OdometerTransactionUtils'; +import {setTransactionReport} from '@userActions/Transaction'; + +import CONST from '@src/CONST'; +import mockOnyxKeys from '@src/ONYXKEYS'; +import type {OdometerDraft, Transaction} from '@src/types/onyx'; + +import type * as NativeModule from '@react-navigation/native'; + +import {Keyboard} from 'react-native'; + +import createMock from '../utils/createMock'; + +type ResetIOUProps = Parameters[0]; +const mockParticipants: ReturnType['participants'] = [{accountID: 2, selected: true}]; +const mockOdometerDraft = createMock({}); + +jest.mock('@hooks/useOnyx', () => (key: Parameters[0]) => { + if (key === mockOnyxKeys.ODOMETER_DRAFT) { + return [mockOdometerDraft]; + } + return [undefined]; +}); +jest.mock('@hooks/useDefaultParticipants', () => () => ({participants: mockParticipants})); +jest.mock('@hooks/usePersonalPolicy', () => () => ({id: 'personal'})); +jest.mock('@react-navigation/native', () => ({...jest.requireActual('@react-navigation/native'), useFocusEffect: jest.fn()})); +jest.mock('@libs/IOUUtils', () => ({resolveEarlyReportID: jest.fn(() => 'early-report')})); +jest.mock('@libs/TransactionUtils', () => ({getIsFromGlobalCreate: () => true})); +jest.mock('@userActions/IOU/MoneyRequest', () => ({initMoneyRequest: jest.fn, Parameters>()})); +jest.mock('@userActions/Transaction', () => ({setTransactionReport: jest.fn, Parameters>()})); +jest.mock('@userActions/OdometerTransactionUtils', () => ({ + hydrateOdometerDraftIntoTransaction: jest.fn, Parameters>(), + isOdometerDraftPendingHydration: () => true, +})); + +function renderTabReset(overrides: Partial = {}) { + const transaction = createMock({transactionID: 'transaction', reportID: 'old-report', iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL}); + return renderHook(() => useResetIOUType({reportID: '1', report: undefined, transaction, transactionRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, ...overrides})); +} + +describe('useResetIOUType tab selection', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Keyboard, 'dismiss').mockImplementation(() => {}); + }); + afterEach(() => jest.restoreAllMocks()); + + it.each([CONST.IOU.REQUEST_TYPE.SCAN, CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER])('preserves initialization, report and hydration effects for %s', (tab) => { + // Given a manual draft and global-create participants that resolve a destination report. + const {result} = renderTabReset(); + + // When a valid changed tab reaches the actual callback. + act(() => result.current(tab)); + + // Then the changed tab resets the draft, while only odometer names hydrate its fields. + expect(Keyboard.dismiss).toHaveBeenCalledTimes(1); + expect(initMoneyRequest).toHaveBeenCalledWith( + expect.objectContaining({newIouRequestType: tab, currentIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, defaultParticipants: mockParticipants, reportID: '1'}), + ); + expect(setTransactionReport).toHaveBeenCalledWith(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: 'early-report'}, true); + const earlierCallOrder1 = jest.mocked(Keyboard.dismiss).mock.invocationCallOrder.at(0); + const laterCallOrder1 = jest.mocked(initMoneyRequest).mock.invocationCallOrder.at(0); + if (earlierCallOrder1 === undefined || laterCallOrder1 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder1).toBeLessThan(laterCallOrder1); + const earlierCallOrder2 = jest.mocked(initMoneyRequest).mock.invocationCallOrder.at(0); + const laterCallOrder2 = jest.mocked(setTransactionReport).mock.invocationCallOrder.at(0); + if (earlierCallOrder2 === undefined || laterCallOrder2 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder2).toBeLessThan(laterCallOrder2); + if (tab === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) { + expect(hydrateOdometerDraftIntoTransaction).toHaveBeenCalledWith('transaction', mockOdometerDraft, undefined); + const earlierCallOrder3 = jest.mocked(setTransactionReport).mock.invocationCallOrder.at(0); + const laterCallOrder3 = jest.mocked(hydrateOdometerDraftIntoTransaction).mock.invocationCallOrder.at(0); + if (earlierCallOrder3 === undefined || laterCallOrder3 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder3).toBeLessThan(laterCallOrder3); + } else { + expect(hydrateOdometerDraftIntoTransaction).not.toHaveBeenCalled(); + } + }); + + it('does not reset an unchanged request type', () => { + // Given the draft already uses the selected request type. + const valid = renderTabReset(); + + // When the callback receives the current request type. + act(() => valid.result.current(CONST.IOU.REQUEST_TYPE.MANUAL)); + + // Then equality precedes keyboard, initialization, destination and hydration effects. + expect(Keyboard.dismiss).not.toHaveBeenCalled(); + expect(initMoneyRequest).not.toHaveBeenCalled(); + expect(setTransactionReport).not.toHaveBeenCalled(); + expect(hydrateOdometerDraftIntoTransaction).not.toHaveBeenCalled(); + }); + + it('retains the per-diem keyboard exception without skipping initialization', () => { + // Given the page opts into keeping the keyboard behavior for per-diem selection. + const {result} = renderTabReset({skipKeyboardDismissForPerDiem: true}); + + // When the selected tab becomes per diem. + act(() => result.current(CONST.IOU.REQUEST_TYPE.PER_DIEM)); + + // Then only keyboard dismissal is excluded, with the reset still observable. + expect(Keyboard.dismiss).not.toHaveBeenCalled(); + expect(initMoneyRequest).toHaveBeenCalledWith(expect.objectContaining({newIouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM})); + expect(setTransactionReport).toHaveBeenCalledTimes(1); + expect(hydrateOdometerDraftIntoTransaction).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/useSidebarOrderedReportsTest.tsx b/tests/unit/useSidebarOrderedReportsTest.tsx index 25a233e49436..006d9540b713 100644 --- a/tests/unit/useSidebarOrderedReportsTest.tsx +++ b/tests/unit/useSidebarOrderedReportsTest.tsx @@ -10,6 +10,7 @@ import SidebarUtils from '@libs/SidebarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report, ReportAttributesDerivedValue} from '@src/types/onyx'; +import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; import type {OnyxMultiSetInput} from 'react-native-onyx'; @@ -542,4 +543,64 @@ describe('useSidebarOrderedReports', () => { const callForReport2 = mockSidebarUtils.updateReportsToDisplayInLHN.mock.calls.find((call) => call[0]?.updatedReportsKeys.includes(`${ONYXKEYS.COLLECTION.REPORT}2`)); expect(callForReport2?.[0]?.updatedReportsKeys).toEqual([`${ONYXKEYS.COLLECTION.REPORT}2`]); }); + + describe('reportAttributes changes', () => { + const createReportAttributes = (attributes: Partial = {}): ReportAttributes => ({ + reportName: 'Chat', + isEmpty: false, + brickRoadStatus: undefined, + requiresAttention: false, + reportErrors: {}, + ...attributes, + }); + + const displayedReportID = '1'; + const newDMReportID = '2'; + + // Renders the LHN with one displayed report and a new DM that is filtered out because its attributes are not ready yet. + const renderWithExcludedReport = async () => { + const displayedReports = createMockReports({ + report1: {reportName: 'Chat A'}, + }); + // The LHN cache is keyed by the report's Onyx key + mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue({[`${ONYXKEYS.COLLECTION.REPORT}${displayedReportID}`]: displayedReports[displayedReportID]}); + mockSidebarUtils.updateReportsToDisplayInLHN.mockImplementation(({displayedReports: reports}) => reports); + + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${displayedReportID}`, displayedReports[displayedReportID]); + await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {reports: {[displayedReportID]: createReportAttributes()}, locale: 'en'}); + }); + + renderHook(() => useSidebarOrderedReports(), { + wrapper: TestWrapper, + }); + await waitForBatchedUpdatesWithAct(); + + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${newDMReportID}`, {reportID: newDMReportID, type: CONST.REPORT.TYPE.CHAT} as Report); + }); + await waitForBatchedUpdatesWithAct(); + }; + + const getUpdatedReportsKeys = () => mockSidebarUtils.updateReportsToDisplayInLHN.mock.calls.flatMap((call) => call[0]?.updatedReportsKeys ?? []); + + it('should recheck a report that is not displayed when its attributes make it require attention', async () => { + // Given the LHN cache is built and a new DM was filtered out before its attributes were calculated + await renderWithExcludedReport(); + mockSidebarUtils.updateReportsToDisplayInLHN.mockClear(); + + // When the derived attributes later mark the DM as requiring attention, with no change to the report itself + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { + reports: {[displayedReportID]: createReportAttributes(), [newDMReportID]: createReportAttributes({requiresAttention: true})}, + locale: 'en', + }); + }); + await waitForBatchedUpdatesWithAct(); + + // Then only the DM is rechecked, so it can be added to the LHN without a full rebuild. The displayed report's + // attributes did not change, so it is not rechecked + expect(getUpdatedReportsKeys()).toEqual([`${ONYXKEYS.COLLECTION.REPORT}${newDMReportID}`]); + }); + }); }); diff --git a/tests/unit/useSubmitLockTest.ts b/tests/unit/useSubmitLockTest.ts new file mode 100644 index 000000000000..c8209b478dcc --- /dev/null +++ b/tests/unit/useSubmitLockTest.ts @@ -0,0 +1,62 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useSubmitLock from '@pages/iou/request/step/confirmation/submission/useSubmitLock'; + +describe('useSubmitLock', () => { + it('locks the first submission and marks the expense confirmed', () => { + // Given a confirmation that hasn't been submitted yet + const {result} = renderHook(() => useSubmitLock()); + + // When the first submission takes the lock + let didLock = false; + act(() => { + didLock = result.current.lockSubmission(); + }); + + // Then it goes through, and the confirm button reads as confirmed so it can't be pressed again + expect(didLock).toBe(true); + expect(result.current.isConfirmed).toBe(true); + expect(result.current.formHasBeenSubmitted.current).toBe(true); + }); + + it('refuses a second submission while the first still holds the lock', () => { + // Given a submission that already took the lock, e.g. while the navigator is still moving away + const {result} = renderHook(() => useSubmitLock()); + act(() => { + result.current.lockSubmission(); + }); + + // When another tap tries to submit + let didLockAgain = true; + act(() => { + didLockAgain = result.current.lockSubmission(); + }); + + // Then it is refused, so the expense isn't created twice + expect(didLockAgain).toBe(false); + expect(result.current.isConfirmed).toBe(true); + }); + + it('lets the page submit again once a submission that wrote nothing unlocks it', () => { + // Given a submission that took the lock but returned without writing (e.g. a scan split with no receipt yet) + const {result} = renderHook(() => useSubmitLock()); + act(() => { + result.current.lockSubmission(); + }); + + // When it hands the page back + act(() => { + result.current.unlockSubmission(); + }); + + // Then the confirm button is active again and the next tap can submit + expect(result.current.isConfirmed).toBe(false); + expect(result.current.formHasBeenSubmitted.current).toBe(false); + + let didLock = false; + act(() => { + didLock = result.current.lockSubmission(); + }); + expect(didLock).toBe(true); + }); +}); diff --git a/tests/unit/useWalletPhoneMagicCodeTest.tsx b/tests/unit/useWalletPhoneMagicCodeTest.tsx index 64ad7464f514..cea56914cb2b 100644 --- a/tests/unit/useWalletPhoneMagicCodeTest.tsx +++ b/tests/unit/useWalletPhoneMagicCodeTest.tsx @@ -10,6 +10,7 @@ import ROUTES from '@src/ROUTES'; import Onyx from 'react-native-onyx'; +import getOnyxValue from '../utils/getOnyxValue'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const mockUpdatePersonalDetails = jest.fn(); @@ -110,6 +111,38 @@ describe('useWalletPhoneValidateCode', () => { expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_ENABLE_PAYMENTS_CONFIRM_VALIDATE_CODE.getRoute()); }); + it('saves every submitted detail to the form draft before routing to the validateCode screen', async () => { + // Given a stored phone number and a form draft that only holds the field the user edited, since pre-filled + // values (e.g. the legal name) are never drafted + await act(async () => { + await setStoredPhoneNumber(STORED_PHONE_NUMBER_WITH_COUNTRY_CODE); + await Onyx.set(ONYXKEYS.FORMS.WALLET_ADDITIONAL_DETAILS_DRAFT, {phoneNumber: CHANGED_PHONE_NUMBER}); + await waitForBatchedUpdates(); + }); + const {result} = await renderWalletPhoneValidateCode(); + + // When the phone number is changed, which holds the submission until the validateCode is entered + await act(async () => { + result.current.submitPersonalDetails(buildParams({phoneNumber: CHANGED_PHONE_NUMBER})); + await waitForBatchedUpdates(); + }); + + // Then the draft holds every submitted detail, so the validateCode screen sends the complete request + const draft = await getOnyxValue(ONYXKEYS.FORMS.WALLET_ADDITIONAL_DETAILS_DRAFT); + expect(draft).toEqual({ + legalFirstName: 'Alberta', + legalLastName: 'Charleson', + addressStreet: '123 Main St', + addressCity: 'City', + addressState: 'CA', + addressZipCode: '90001', + dob: '2000-08-08', + ssn: '123456789', + phoneNumber: CHANGED_PHONE_NUMBER, + }); + expect(mockUpdatePersonalDetails).not.toHaveBeenCalled(); + }); + it('routes to the validateCode screen of the calling flow when it provides its own route', async () => { // Given the IOU pay flow, which must open the validateCode screen on top of itself rather than the Settings wallet one const payFlowRoute: Route = ROUTES.ENABLE_PAYMENTS; diff --git a/tests/unit/useWindowDimensionsTest.tsx b/tests/unit/useWindowDimensionsTest.tsx new file mode 100644 index 000000000000..2c39358178e0 --- /dev/null +++ b/tests/unit/useWindowDimensionsTest.tsx @@ -0,0 +1,259 @@ +/** Exercises viewport caching and fullscreen locks through the production hook with controlled browser inputs. */ +import {act, renderHook} from '@testing-library/react-native'; + +import {FullScreenActionsContext, FullScreenStateContext} from '@components/VideoPlayerContexts/FullScreenContextProvider'; +import type {FullScreenActionsContextType, FullScreenStateContextType, ResponsiveLayoutProperties} from '@components/VideoPlayerContexts/FullScreenContextProvider/types'; + +import type * as WindowDimensionsModule from '@hooks/useWindowDimensions/index'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +import type {PropsWithChildren} from 'react'; +import type {ScaledSize} from 'react-native'; +import type ReactNative from 'react-native'; + +import React from 'react'; +import {Dimensions} from 'react-native'; + +import createMock from '../utils/createMock'; + +const originalMatchMedia = Object.getOwnPropertyDescriptor(window, 'matchMedia'); +const initialHeight = window.visualViewport?.height ?? window.innerHeight; +let mockViewport: ScaledSize = {width: 390, height: initialHeight, scale: 1, fontScale: 1}; +let mockIsWebKit = true; +jest.mock('@libs/Browser', () => ({isMobile: () => true, isMobileWebKit: () => mockIsWebKit})); + +const ReactNativeRuntime = jest.requireMock('react-native'); +const useWindowDimensions = jest.requireActual('@hooks/useWindowDimensions/index.ts').default; + +/** Mirrors the production provider's ref mutations so render-time lock ordering remains observable. */ +function createFullscreenContexts(isFullScreen: boolean) { + const state: FullScreenStateContextType = {isFullScreen, isFullScreenRef: {current: isFullScreen}, lockedWindowDimensionsRef: {current: null}}; + const lock = jest.fn((dimensions) => { + state.lockedWindowDimensionsRef.current = dimensions; + }); + const unlock = jest.fn(() => { + state.lockedWindowDimensionsRef.current = null; + }); + const actions: FullScreenActionsContextType = {lockWindowDimensions: lock, unlockWindowDimensions: unlock, setIsFullScreen: jest.fn()}; + function Wrapper({children}: PropsWithChildren) { + return ( + + {children} + + ); + } + return {state, lock, unlock, Wrapper}; +} + +function settleResize() { + act(() => { + jest.advanceTimersByTime(CONST.TIMING.RESIZE_DEBOUNCE_TIME); + }); +} + +beforeEach(() => { + mockViewport = {width: 390, height: initialHeight, scale: 1, fontScale: 1}; + mockIsWebKit = true; + jest.useFakeTimers(); + jest.spyOn(ReactNativeRuntime, 'useWindowDimensions').mockImplementation(() => mockViewport); + // Keep the physical screen above the height breakpoint so width expectations do not depend on the browser environment. + jest.spyOn(Dimensions, 'get').mockReturnValue({width: 390, height: 900, scale: 1, fontScale: 1}); + Object.defineProperty(window, 'matchMedia', {configurable: true, writable: true, value: jest.fn(() => createMock({matches: true}))}); +}); +afterEach(() => { + jest.restoreAllMocks(); + if (originalMatchMedia) { + Object.defineProperty(window, 'matchMedia', originalMatchMedia); + } else { + Reflect.deleteProperty(window, 'matchMedia'); + } + jest.useRealTimers(); +}); + +describe('useWindowDimensions', () => { + it.each(['input', 'textarea'])('restores cached keyboard and initial heights for %s with debounce', (tagName) => { + // Given a portrait WebKit viewport, the keyboard height must survive a later resize. + const input = document.createElement(tagName); + document.body.appendChild(input); + const {result, rerender} = renderHook(() => useWindowDimensions(true)); + mockViewport = {...mockViewport, height: initialHeight - 200}; + rerender(undefined); + expect(result.current.windowHeight).toBe(initialHeight); + settleResize(); + expect(result.current.windowHeight).toBe(initialHeight - 200); + mockViewport = {...mockViewport, height: initialHeight}; + rerender(undefined); + settleResize(); + expect(result.current.windowHeight).toBe(initialHeight); + // When focus reopens the keyboard, the saved height is used only after the debounce interval. + act(() => { + input.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + }); + expect(result.current.windowHeight).toBe(initialHeight); + settleResize(); + expect(result.current.windowHeight).toBe(initialHeight - 200); + act(() => { + input.dispatchEvent(new FocusEvent('focusout', {bubbles: true})); + }); + // Then focusout restores the original viewport rather than the keyboard measurement. + expect(result.current.windowHeight).toBe(initialHeight - 200); + settleResize(); + expect(result.current.windowHeight).toBe(initialHeight); + input.remove(); + }); + + it('ignores non-input, non-element and null focus targets and cleans up exact listeners', () => { + // Given a reduced viewport, unrelated focus events must not restore the initial height. + const addListener = jest.spyOn(window, 'addEventListener'); + const removeListener = jest.spyOn(window, 'removeEventListener'); + const {result, rerender, unmount} = renderHook(() => useWindowDimensions(true)); + mockViewport = {...mockViewport, height: initialHeight - 180}; + rerender(undefined); + settleResize(); + const focusIn = addListener.mock.calls.find(([name]) => name === 'focusin'); + const focusOut = addListener.mock.calls.find(([name]) => name === 'focusout'); + expect(focusIn).toBeDefined(); + expect(focusOut).toBeDefined(); + if (!focusIn || !focusOut || typeof focusIn[1] !== 'function' || typeof focusOut[1] !== 'function') { + throw new Error('Cached viewport listeners must be installed'); + } + const notifyFocusIn = focusIn[1]; + const notifyFocusOut = focusOut[1]; + const nonInput = document.createElement('button'); + const text = document.createTextNode('text'); + document.body.append(nonInput, text); + // When focus moves outside keyboard-producing elements, including an event without a target, no cache update applies. + act(() => { + nonInput.dispatchEvent(new FocusEvent('focusout', {bubbles: true})); + text.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + text.dispatchEvent(new FocusEvent('focusout', {bubbles: true})); + notifyFocusIn(new FocusEvent('focusin')); + notifyFocusOut(new FocusEvent('focusout')); + }); + settleResize(); + // Then the keyboard height remains and unmount removes the same listeners. + expect(result.current.windowHeight).toBe(initialHeight - 180); + unmount(); + expect(removeListener).toHaveBeenCalledWith('focusin', notifyFocusIn); + expect(removeListener).toHaveBeenCalledWith('focusout', notifyFocusOut); + nonInput.remove(); + text.remove(); + }); + + it.each([ + {useCache: false, isWebKit: true}, + {useCache: true, isWebKit: false}, + ])('returns live dimensions without keyboard caching: %j', ({useCache, isWebKit}) => { + // Given an uncached platform, viewport changes should be visible immediately. + mockIsWebKit = isWebKit; + const addListener = jest.spyOn(window, 'addEventListener'); + const {result, rerender} = renderHook(() => useWindowDimensions(useCache)); + // When the platform reports a smaller viewport, no debounced height substitutes for it. + mockViewport = {...mockViewport, width: 800, height: 400}; + rerender(undefined); + // Then the actual dimensions are returned and no keyboard listeners were registered. + expect(result.current).toMatchObject({windowWidth: 800, windowHeight: 400}); + expect(addListener.mock.calls.filter(([name]) => name === 'focusin' || name === 'focusout')).toHaveLength(0); + }); + + it.each([ + { + width: variables.extraSmallMobileResponsiveWidthBreakpoint, + expectedResponsiveLayout: { + isSmallScreenWidth: true, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: true, + isMediumScreenWidth: false, + isLargeScreenWidth: false, + isSmallScreen: true, + }, + }, + { + width: variables.mobileResponsiveWidthBreakpoint, + expectedResponsiveLayout: { + isSmallScreenWidth: true, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: false, + isMediumScreenWidth: false, + isLargeScreenWidth: false, + isSmallScreen: true, + }, + }, + { + width: variables.mobileResponsiveWidthBreakpoint + 1, + expectedResponsiveLayout: { + isSmallScreenWidth: false, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: false, + isMediumScreenWidth: true, + isLargeScreenWidth: false, + isSmallScreen: false, + }, + }, + { + width: variables.tabletResponsiveWidthBreakpoint, + expectedResponsiveLayout: { + isSmallScreenWidth: false, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: false, + isMediumScreenWidth: true, + isLargeScreenWidth: false, + isSmallScreen: false, + }, + }, + { + width: variables.tabletResponsiveWidthBreakpoint + 1, + expectedResponsiveLayout: { + isSmallScreenWidth: false, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: false, + isMediumScreenWidth: false, + isLargeScreenWidth: true, + isSmallScreen: false, + }, + }, + ])('keeps responsive width thresholds at $width', ({width, expectedResponsiveLayout}) => { + // Given a breakpoint-adjacent viewport, equality belongs to the smaller width category. + mockViewport = {...mockViewport, width, height: 900}; + const fullscreen = createFullscreenContexts(true); + // When fullscreen begins, the hook sends the same computed layout to the provider. + const {result} = renderHook(() => useWindowDimensions(), {wrapper: fullscreen.Wrapper}); + // Then the lock carries responsive results, including screen-height and lower-dimension semantics. + expect(fullscreen.lock).toHaveBeenCalledTimes(1); + expect(fullscreen.lock).toHaveBeenCalledWith({ + windowWidth: width, + windowHeight: 900, + responsiveLayoutResults: expectedResponsiveLayout, + }); + expect(result.current).toMatchObject({windowWidth: width, windowHeight: 900}); + }); + + it('locks initially, locks again on orientation change and returns the last lock before unlocking', () => { + // Given fullscreen on a phone, browser chrome changes must not replace the saved dimensions. + mockViewport = {...mockViewport, width: 390, height: 800}; + const fullscreen = createFullscreenContexts(true); + const {result, rerender} = renderHook(() => useWindowDimensions(), {wrapper: fullscreen.Wrapper}); + expect(fullscreen.lock).toHaveBeenCalledTimes(1); + mockViewport = {...mockViewport, height: 700}; + rerender(undefined); + expect(result.current).toEqual({windowWidth: 390, windowHeight: 800}); + expect(fullscreen.lock).toHaveBeenCalledTimes(1); + // When width and height exchange their responsive orientation, the current dimensions become the next lock. + mockViewport = {...mockViewport, width: 800, height: 390}; + rerender(undefined); + expect(fullscreen.lock).toHaveBeenCalledTimes(2); + expect(result.current).toMatchObject({windowWidth: 800, windowHeight: 390}); + fullscreen.state.isFullScreen = false; + mockViewport = {...mockViewport, width: 780}; + rerender(undefined); + // Then exit returns the last locked dimensions once before subsequent renders resume live values. + expect(fullscreen.unlock).toHaveBeenCalledTimes(1); + expect(result.current).toEqual({windowWidth: 800, windowHeight: 390}); + expect(fullscreen.state.lockedWindowDimensionsRef.current).toBeNull(); + rerender(undefined); + expect(result.current).toMatchObject({windowWidth: 780, windowHeight: 390}); + }); +}); diff --git a/tests/utils/NumericInputLayoutTestUtils.tsx b/tests/utils/NumericInputLayoutTestUtils.tsx new file mode 100644 index 000000000000..939344fc7f5d --- /dev/null +++ b/tests/utils/NumericInputLayoutTestUtils.tsx @@ -0,0 +1,50 @@ +import {render, renderHook} from '@testing-library/react-native'; + +import Button from '@components/Button'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import NumericInput from '@components/NumericInput'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +/** Test identifier given to the NumericInput root, which the layout suffixes for each of its parts */ +const ROOT_TEST_ID = 'layout'; +const BODY_TEST_ID = `${ROOT_TEST_ID}-body`; +const MAIN_TEST_ID = `${ROOT_TEST_ID}-main`; +const AMOUNT_TEST_ID = `${ROOT_TEST_ID}-amount`; +const PAD_TEST_ID = `${ROOT_TEST_ID}-pad`; +const FOOTER_TEST_ID = `${ROOT_TEST_ID}-footer`; +const INPUT_TEST_ID = 'numeric-text-input'; +const FLIP_TEST_ID = 'flip-button'; + +function Providers({children}: {children: React.ReactNode}) { + return {children}; +} + +/** Styles the layout parts render with, read through the same hook. Call it before rendering the layout, since it updates `screen`. */ +function getLayoutStyles() { + return renderHook(() => useThemeStyles()).result.current; +} + +/** Renders the NumericInput layout with the providers its parts read from, kept around the tree on rerender */ +function renderWithProviders(children: React.ReactElement) { + return render(children, {wrapper: Providers}); +} + +/** Renders a standard single-column NumericInput with a footer button and basic text input */ +function renderLayout() { + return renderWithProviders( + Save} + > + + , + ); +} + +export {AMOUNT_TEST_ID, BODY_TEST_ID, FLIP_TEST_ID, FOOTER_TEST_ID, INPUT_TEST_ID, MAIN_TEST_ID, PAD_TEST_ID, ROOT_TEST_ID, getLayoutStyles, renderLayout, renderWithProviders}; diff --git a/tests/utils/TestHelper.ts b/tests/utils/TestHelper.ts index 675143fec395..b89d578a0512 100644 --- a/tests/utils/TestHelper.ts +++ b/tests/utils/TestHelper.ts @@ -38,7 +38,9 @@ import waitForBatchedUpdatesWithAct from './waitForBatchedUpdatesWithAct'; // must be registered here too. Idempotent, so this doesn't conflict with the appSetup() call inside setupApp(). registerMiddlewares(); -type MockFetch = jest.Mock, Parameters> & { +type FetchMockParameters = [input: Parameters[0] | URL, init?: Parameters[1]]; + +type MockFetch = jest.Mock, FetchMockParameters> & { pause: () => void; fail: () => void; succeed: () => void; @@ -61,8 +63,8 @@ type APIWriteOnyxUpdateWithObjectValue = type QueueItem = { resolve: (value: Response | PromiseLike) => void; - input: Parameters[0]; - options?: RequestInit; + input: FetchMockParameters[0]; + options?: FetchMockParameters[1]; }; type FormData = { @@ -323,7 +325,7 @@ function createGlobalFetchMock(mockResponse?: Partial): MockFetch { let isPaused = false; let shouldFail = false; - const getResponse = (input: Parameters[0], options?: RequestInit): Response => + const getResponse = (input: FetchMockParameters[0], options?: FetchMockParameters[1]): Response => shouldFail ? createMock({ ok: true, @@ -347,7 +349,7 @@ function createGlobalFetchMock(mockResponse?: Partial): MockFetch { }); const mockFetch = Object.assign( - jest.fn, Parameters>().mockImplementation((input: Parameters[0], options: Parameters[1]) => { + jest.fn, FetchMockParameters>().mockImplementation((input: FetchMockParameters[0], options?: FetchMockParameters[1]) => { if (!isPaused) { return Promise.resolve(getResponse(input, options)); } @@ -392,22 +394,22 @@ function getGlobalFetchMock(mockResponse?: Partial): MockFetch { function setupGlobalFetchMock(): MockFetch { const mockFetch = getGlobalFetchMock(); - const originalFetch = global.fetch; + const originalFetch = globalThis.fetch; - global.fetch = mockFetch; + globalThis.fetch = mockFetch; afterAll(() => { - global.fetch = originalFetch; + globalThis.fetch = originalFetch; }); return mockFetch; } function getFetchMockCalls(commandName: ApiCommand) { - if (!jest.isMockFunction(global.fetch)) { + if (!jest.isMockFunction(globalThis.fetch)) { throw new Error('Expected global.fetch to be a Jest mock function.'); } - return jest.mocked(global.fetch).mock.calls.filter((c) => c[0] === `https://www.expensify.com.dev/api/${commandName}?`); + return jest.mocked(globalThis.fetch).mock.calls.filter((c) => c[0] === `https://www.expensify.com.dev/api/${commandName}?`); } /** @@ -425,7 +427,14 @@ function expectAPICommandToHaveBeenCalledWith(comma expect(call).toBeTruthy(); const body = call?.[1]?.body; const params = body instanceof FormData ? Object.fromEntries(body) : {}; - expect(params).toEqual(expect.objectContaining(expectedParams)); + + // FormData coerces every value to a string, so a number or boolean in the expectation could never match. + const expectedEntries = Object.entries((expectedParams ?? {}) as Record).map(([key, value]) => [ + key, + typeof value === 'number' || typeof value === 'boolean' ? String(value) : value, + ]); + + expect(params).toEqual(expect.objectContaining(Object.fromEntries(expectedEntries))); } function setPersonalDetails(login: string, accountID: number) { diff --git a/tests/utils/findAncestorWithStyle.ts b/tests/utils/findAncestorWithStyle.ts new file mode 100644 index 000000000000..0d74e4fe868a --- /dev/null +++ b/tests/utils/findAncestorWithStyle.ts @@ -0,0 +1,31 @@ +import type {screen} from '@testing-library/react-native'; + +type TestInstance = ReturnType; + +/** Value a style prop ends up with for one property, read from the last style that sets it, the way React Native applies them. */ +function getStyleProperty(style: unknown, property: string): unknown { + if (Array.isArray(style)) { + return style.reduce((value, nestedStyle) => getStyleProperty(nestedStyle, property) ?? value, undefined); + } + if (typeof style !== 'object' || style === null) { + return undefined; + } + return Reflect.get(style, property); +} + +/** + * Walks up from a rendered node to the closest host element above it whose style sets the given property to the given value, + * so a layout test can find the column or container that actually wraps an element instead of asserting on a hand-built tree. + */ +function findAncestorWithStyle(node: TestInstance, property: string, value: unknown): TestInstance | undefined { + let current: TestInstance | null = node.parent; + while (current) { + if (typeof current.type === 'string' && getStyleProperty(current.props.style, property) === value) { + return current; + } + current = current.parent; + } + return undefined; +} + +export default findAncestorWithStyle; diff --git a/web/cloudflare-worker/README.md b/web/cloudflare-worker/README.md index 240e22d88fcd..c2cd4be84b00 100644 --- a/web/cloudflare-worker/README.md +++ b/web/cloudflare-worker/README.md @@ -14,7 +14,7 @@ Every request runs the Worker first (`run_worker_first: true`), which reads the | HTML (`/`, `/index.html`, any SPA deep link) | Each `